Files
minibusservice.no/src/lib/components/listbox/Listbox.svelte
nub31 3bf281c7a7 ...
2026-03-16 22:00:24 +01:00

124 lines
3.4 KiB
Svelte

<script lang="ts" generics="T">
import ListboxItem from './ListboxItem.svelte';
import { writable, type Writable } from 'svelte/store';
import { setListboxContext, type ListboxItemAbstract } from './context';
import { twMerge } from 'tailwind-merge';
/** @description Determine if multiple items can be selected */
export let allowMultiple = true;
/** @description Determine if multiple items can be selected */
export let items: T[];
/** @description Determine if multiple items can be selected */
export let defaultSelectedItems: T[];
/** @description Function called when the selected items changes */
export let onChange: (items: T[]) => void;
let selectedItems: Writable<ListboxItemAbstract<T>[]> = writable([]);
let registeredItems: Writable<ListboxItemAbstract<T>[]> = writable([]);
let focusedItem: Writable<ListboxItemAbstract<T> | null> = writable(null);
function toggleItemSelected(item: ListboxItemAbstract<T>): void {
if (item) {
setFocus(item);
if ($selectedItems.includes(item)) {
if (allowMultiple) {
$selectedItems = $selectedItems.filter((x) => x !== item);
} else {
$selectedItems = [];
}
} else {
if (allowMultiple) {
$selectedItems.push(item);
$selectedItems = $selectedItems;
} else {
$selectedItems = [item];
}
}
}
onChange($selectedItems.map((x) => x.item));
}
function setFocus(item: ListboxItemAbstract<T>) {
const itemEl = document.getElementById(item.id);
$focusedItem = item;
itemEl?.focus();
}
function onKeyDown(e: KeyboardEvent, item: ListboxItemAbstract<T>) {
const index = $registeredItems.findIndex((x) => x === item);
if (index === -1) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if ($registeredItems.length - 1 === index) {
setFocus($registeredItems[0]);
} else {
setFocus($registeredItems[index + 1]);
}
break;
case 'ArrowUp':
e.preventDefault();
if (index === 0) {
setFocus($registeredItems[$registeredItems.length - 1]);
} else {
setFocus($registeredItems[index - 1]);
}
break;
case 'Enter':
case ' ':
e.preventDefault();
toggleItemSelected(item);
break;
case 'Home':
setFocus($registeredItems[0]);
break;
case 'End':
setFocus($registeredItems[$registeredItems.length - 1]);
break;
}
}
function registerItem(item: ListboxItemAbstract<T>): void {
$registeredItems.push(item);
$registeredItems = $registeredItems;
if (defaultSelectedItems.includes(item.item)) {
$selectedItems.push(item);
$selectedItems = $selectedItems;
}
if ($registeredItems.length === 1) {
$focusedItem = $registeredItems[0];
}
}
function unregisterItem(item: ListboxItemAbstract<T>): void {
$registeredItems = $registeredItems.filter((x) => x.id !== item.id);
$selectedItems = $selectedItems.filter((x) => x.id !== item.id);
}
setListboxContext<T>({
registerItem: registerItem,
unregisterItem: unregisterItem,
selectedItems: selectedItems,
focusedItem: focusedItem
});
</script>
<ul
class={twMerge('flex flex-col gap-2 p-4', $$restProps['class'])}
role="listbox"
aria-activedescendant={$focusedItem?.id}
aria-multiselectable={allowMultiple}
tabindex={0}
>
{#each items as item}
<ListboxItem {onKeyDown} onClick={(e, x) => toggleItemSelected(x)} {item}>
<slot {item} />
</ListboxItem>
{/each}
</ul>