Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/docs/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
mdiDotsCircle,
mdiFormatHeaderPound,
mdiFormDropdown,
mdiFormSelect,
mdiFormTextbox,
mdiFormTextboxPassword,
mdiHelpBox,
Expand Down Expand Up @@ -93,6 +94,7 @@ export const componentGroups = [
},
{ name: 'PasswordInput', icon: mdiFormTextboxPassword },
{ name: 'Select', icon: mdiFormDropdown },
{ name: 'Dropdown', icon: mdiFormSelect },
{ name: 'Switch', icon: mdiToggleSwitchOutline, activeIcon: mdiToggleSwitch },
],
},
Expand Down
134 changes: 134 additions & 0 deletions src/lib/components/Dropdown/Dropdown.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<script lang="ts" module>
import { type SelectItem, type Variants } from '@immich/ui';
export type DropdownItem = SelectItem & {
icon?: string;
};

type T = DropdownItem;
</script>

<script lang="ts" generics="T extends DropdownItem">
import { Button, type SelectProps } from '@immich/ui';
import { mdiCheck } from '@mdi/js';
import { Popover } from 'bits-ui';
import { fly } from 'svelte/transition';
import Icon from '../Icon/Icon.svelte';
import type { Snippet } from 'svelte';
import Text from '../Text/Text.svelte';

type Props = SelectProps<T> & {
fullWidth?: boolean;
position?: 'bottom-left' | 'bottom-right';
variant?: Variants;
trigger?: Snippet<[{ props: Record<string, unknown>; selectedIcon?: string | undefined }]>;
hideTextOnSmallScreen?: boolean;
};

let {
data,
class: className,
color = 'primary',
size = 'small',
onChange,
placeholder,
shape,
hideTextOnSmallScreen = true,
value = $bindable(),
fullWidth,
variant,
position = 'bottom-right',
trigger,
}: Props = $props();

const handleSelectOption = (option: T) => {
onChange?.(option);
value = option;
showDropdown = false;
};

const asOptions = (items: string[] | T[]) => {
return items.map((item) => {
if (typeof item === 'string') {
return { value: item, label: item } as T;
}

const label = item.label ?? item.value;
return { ...item, label };
});
};

const options = $derived(asOptions(data));

let showDropdown = $state(false);
</script>

<!-- BUTTON TITLE -->
<Popover.Root bind:open={showDropdown}>
<Popover.Trigger>
{#snippet child({ props })}
{#if trigger}
{@render trigger({ props, selectedIcon: value?.icon })}
{:else}
<Button {...props} {fullWidth} title={placeholder} {color} {shape} {variant} {size}>
{#if value?.icon}
<Icon icon={value.icon} />
{/if}

<Text class={hideTextOnSmallScreen ? 'hidden sm:block' : ''}>
{#if !value}
{placeholder}
{:else}
{value?.label}
{/if}
Comment on lines +78 to +82
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would probably do this in JS land with {value?.label || placeholder}. That's definitely exclusively personal preference though

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh yeah agreed, that's a remnant of what I had there before

</Text>
</Button>
{/if}
{/snippet}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content align={position === 'bottom-left' ? 'start' : 'end'} forceMount>
{#snippet child({ props, wrapperProps, open })}
<!-- DROP DOWN MENU -->
{#if open}
<div {...wrapperProps}>
<div
{...props}
class={[
'flex max-h-[50vh] min-w-[250px] flex-col overflow-y-auto rounded-2xl bg-gray-100 py-2 text-sm font-medium text-black shadow-lg dark:bg-gray-700 dark:text-white',
className,
props.class,
]}
transition:fly={{ y: -30, duration: 250 }}
>
{#each options as option (option.value)}
{@const buttonStyle = option.disabled
? ''
: 'transition-all hover:bg-gray-300 dark:hover:bg-gray-800'}
<button
type="button"
class="grid grid-cols-[36px_1fr] place-items-center p-2 disabled:opacity-40 {buttonStyle}"
disabled={option.disabled}
onclick={() => !option.disabled && handleSelectOption(option)}
>
{#if value?.value === option.value}
<div class="text-primary">
<Icon icon={mdiCheck} />
</div>
<p class="text-primary justify-self-start">
{option.label}
</p>
{:else}
<div></div>
<p class="justify-self-start">
{option.label}
</p>
{/if}
</button>
{/each}
</div>
</div>
{/if}
{/snippet}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
35 changes: 35 additions & 0 deletions src/routes/components/dropdown/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<script lang="ts">
import ComponentDescription from '$docs/components/ComponentDescription.svelte';
import ComponentExamples from '$docs/components/ComponentExamples.svelte';
import ComponentPage from '$docs/components/ComponentPage.svelte';
import BasicExample from './BasicExample.svelte';
import basicExample from './BasicExample.svelte?raw';
import OutlineButton from './OutlineButton.svelte';
import outlineButton from './OutlineButton.svelte?raw';
import BigList from './BigList.svelte';
import bigList from './BigList.svelte?raw';
import CustomTrigger from './CustomTrigger.svelte';
import customTrigger from './CustomTrigger.svelte?raw';
</script>

<ComponentPage name="Dropdown">
<ComponentDescription>
Allows the user to select a single option from a dropdown list that is triggered by a button
</ComponentDescription>
<ComponentExamples
examples={[
{ title: 'Basic', code: basicExample, component: BasicExample },
{ title: 'Outline Button', code: outlineButton, component: OutlineButton },
{
title: 'Big List',
code: bigList,
component: BigList,
},
{
title: 'Custom Trigger',
code: customTrigger,
component: CustomTrigger,
},
]}
/>
</ComponentPage>
17 changes: 17 additions & 0 deletions src/routes/components/dropdown/BasicExample.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script lang="ts">
import { Stack } from '@immich/ui';
import Dropdown from '@immich/ui/components/Dropdown/Dropdown.svelte';
import { mdiPartyPopper, mdiTrashCan, mdiReact } from '@mdi/js';
</script>

<Stack class="mb-8 max-w-[250px]" gap={8}>
<Dropdown
placeholder="Select a framework"
data={[
{ label: 'Svelte', value: 'svelte', icon: mdiPartyPopper },
{ label: 'React', value: 'react', icon: mdiReact },
{ label: 'Angular', value: 'angular', icon: mdiTrashCan },
]}
onChange={() => {}}
/>
</Stack>
37 changes: 37 additions & 0 deletions src/routes/components/dropdown/BigList.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script lang="ts">
import { Stack } from '@immich/ui';
import Dropdown from '@immich/ui/components/Dropdown/Dropdown.svelte';
</script>

<Stack class="mb-8 max-w-[250px]" gap={8}>
<Dropdown
placeholder="Select a fruit"
data={[
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Cherry', value: 'cherry' },
{ label: 'Date', value: 'date' },
{ label: 'Elderberry', value: 'elderberry' },
{ label: 'Fig', value: 'fig' },
{ label: 'Grape', value: 'grape' },
{ label: 'Honeydew', value: 'honeydew' },
{ label: 'Kiwi', value: 'kiwi' },
{ label: 'Lemon', value: 'lemon' },
{ label: 'Mango', value: 'mango' },
{ label: 'Nectarine', value: 'nectarine' },
{ label: 'Orange', value: 'orange' },
{ label: 'Papaya', value: 'papaya' },
{ label: 'Quince', value: 'quince' },
{ label: 'Raspberry', value: 'raspberry' },
{ label: 'Strawberry', value: 'strawberry' },
{ label: 'Tangerine', value: 'tangerine' },
{ label: 'Ugli fruit', value: 'ugli-fruit' },
{ label: 'Vanilla bean', value: 'vanilla-bean' },
{ label: 'Watermelon', value: 'watermelon' },
{ label: 'Xigua', value: 'xigua' },
{ label: 'Yellow passion fruit', value: 'yellow-passion-fruit' },
{ label: 'Zucchini', value: 'zucchini' },
]}
onChange={() => {}}
/>
</Stack>
25 changes: 25 additions & 0 deletions src/routes/components/dropdown/CustomTrigger.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script lang="ts">
import { Button, Stack } from '@immich/ui';
import Dropdown, { type DropdownItem } from '@immich/ui/components/Dropdown/Dropdown.svelte';
import Icon from '@immich/ui/components/Icon/Icon.svelte';

let options = [
{ label: 'Svelte', value: 'svelte' },
{ label: 'React', value: 'react' },
{ label: 'Angular', value: 'angular' },
];
let selectedOption = $state<DropdownItem | null>(options[0]);
</script>

<Stack class="mb-8 max-w-[250px]" gap={8}>
<Dropdown placeholder="Select a framework" data={options}>
{#snippet trigger({ props, selectedIcon })}
<Button {...props} color="success" variant="filled" fullWidth size="large" shape="round">
{selectedOption?.value || 'Select an option'}
{#if selectedIcon}
<Icon icon={selectedIcon} />
{/if}
</Button>
{/snippet}
</Dropdown>
</Stack>
17 changes: 17 additions & 0 deletions src/routes/components/dropdown/OutlineButton.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script lang="ts">
import { Stack } from '@immich/ui';
import Dropdown from '@immich/ui/components/Dropdown/Dropdown.svelte';
</script>

<Stack class="mb-8 max-w-[250px]" gap={8}>
<Dropdown
placeholder="Select a framework"
color="secondary"
variant="outline"
data={[
{ label: 'Svelte', value: 'svelte' },
{ label: 'React', value: 'react' },
{ label: 'Angular', value: 'angular' },
]}
/>
</Stack>
Loading