web/src/lib/components/Listbox.svelte (94 lines of code) (raw):
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { createListbox } from 'svelte-headlessui';
import { fade } from 'svelte/transition';
import Icon from './Icon.svelte';
import { twMerge } from 'tailwind-merge';
type GenericOption = { value: string; name: string };
interface Props<T extends GenericOption> {
options: readonly T[];
selectedValue: T['value'];
selectedOptionPrefix?: string;
label?: string | undefined;
id?: string | undefined;
errorMessage?: string | undefined;
}
let {
options,
selectedValue = $bindable(),
selectedOptionPrefix = '',
label = undefined,
id = undefined,
errorMessage = undefined
}: Props<GenericOption> = $props();
let listbox = $state(createListbox({
label: 'Actions',
selected: options.find((option) => option.value === selectedValue) ?? options[0]
}));
$effect(() => {
const newSelectedOption = options.find((option) => option.value === selectedValue);
if (newSelectedOption) {
listbox.set({ selected: newSelectedOption });
}
});
const dispatch = createEventDispatcher<{ selectedValue: string }>();
function onSelect(e: Event) {
const selected = (e as CustomEvent).detail.selected.value;
selectedValue = selected;
dispatch('selectedValue', selected);
}
</script>
<div class="flex flex-col gap-2">
{#if label}
<label for={id} class="text-sm ml-1 text-color">
{label}
</label>
{/if}
<div class="relative w-full">
<button
use:listbox.button
onselect={onSelect}
class={twMerge(
'rounded-md dark:bg-shadeD400 w-full px-4 ring-1 ring-gray-300 dark:ring-gray-500 flex items-center h-[40px] text-color focus-within:ring-2 focus-within:ring-gray-400 transition group relative',
$listbox.expanded && 'ring-creator-gray4',
errorMessage && '!ring-red-600 ring-2'
)}
>
<span class="block truncate text-sm">{selectedOptionPrefix} {$listbox.selected.name}</span>
<Icon
name="chevronDown"
class={twMerge(
' pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 w-[18px] transition-transform',
$listbox.expanded && '-rotate-180'
)}
/>
</button>
{#if $listbox.expanded}
<ul
use:listbox.items
transition:fade={{ duration: 100 }}
class="absolute mt-1 max-h-60 py-1 z-20 w-full border overflow-auto dark:bg-shadeD400 bg-shadeL200 rounded-md text-sm shadow-lg text-color"
>
{#each options as option, idx (idx)}
{@const selected = $listbox.selected === option}
<li
class={twMerge(
'relative cursor-default select-none py-2 pr-4 transition-colors p-[10px] hoverable',
selected && 'dark:!bg-shadeD170 !bg-shadeL600'
)}
use:listbox.item={{ value: option }}
>
<span class="block truncate {selected ? 'font-medium' : 'font-normal'}"
>{option.name}</span
>
</li>
{/each}
</ul>
{/if}
</div>
{#if errorMessage}
<span class="text-red-600 text-xs font-medium ml-1">
{errorMessage}
</span>
{/if}
</div>