<script lang="ts">
import ChevronDown from "$icon/chevron-down";
import type { Component, Snippet } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import Combobox from "#lib/components/ui/Combobox.svelte";
import {
selectField,
type SelectAlign,
type SelectOption
} from "#lib/components/ui/selectField.ts";
interface Props {
options: SelectOption[];
value?: string;
rich?: boolean;
onSelect?: (value: string) => void;
placeholder?: string;
label?: string;
icon?: Component<SvelteHTMLElements["svg"]>;
error?: boolean;
disabled?: boolean;
id?: string;
class?: string;
searchPlaceholder?: string;
emptyLabel?: string;
/** take filtering over, e.g. to query a server: `options` is then rendered as given */
onSearch?: (query: string) => void;
loading?: boolean;
align?: SelectAlign;
panelClass?: string;
footer?: Snippet;
testid?: string;
}
let {
options,
value = $bindable(""),
rich = false,
onSelect,
placeholder,
label,
icon: Icon,
error = false,
disabled = false,
id,
class: className,
searchPlaceholder,
emptyLabel,
onSearch,
loading,
align,
panelClass,
footer,
testid
}: Props = $props();
const groups = $derived(
options.reduce<{ label?: string; options: SelectOption[] }[]>((groups, option) => {
const last = groups.at(-1);
if (last && last.label === option.group) last.options.push(option);
else groups.push({ label: option.group, options: [option] });
return groups;
}, [])
);
const field = $derived(selectField({ error, disabled }));
// `pl-7` has to be able to beat this `px-xs`, so it is merged after rather than interpolated
// into the same string — prettier's class sorter would hoist an inline conditional to the
// front, where tailwind-merge resolves it the other way and the label lands under the icon
const nativeBox = "min-h-8 appearance-none px-xs py-2xs pr-2xl pointer-coarse:text-[1rem]";
const onchange = (event: Event) => {
onSelect?.((event.currentTarget as HTMLSelectElement).value);
};
</script>
{#snippet nativeOptions(items: SelectOption[])}
{#each items as option (option.value)}
<option value={option.value} disabled={option.disabled}
>{option.label ?? option.value}</option
>
{/each}
{/snippet}
{#if rich}
<Combobox
{options}
bind:value
{onSelect}
{placeholder}
{label}
icon={Icon}
{error}
{disabled}
{id}
{searchPlaceholder}
{emptyLabel}
{onSearch}
{loading}
{align}
{panelClass}
{footer}
{testid}
class={className}
/>
{:else}
<div class="relative {className ?? ''}">
{#if Icon}
<Icon
class="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-foreground-default"
aria-hidden="true"
/>
{/if}
<select
bind:value
data-testid={testid}
{id}
{disabled}
{onchange}
aria-label={label}
aria-invalid={error}
class={field.box({ class: [nativeBox, Icon && "pl-7"] })}
>
{#if placeholder}
<option value="">{placeholder}</option>
{/if}
{#each groups as group, i (i)}
{#if group.label}
<optgroup label={group.label}>{@render nativeOptions(group.options)}</optgroup>
{:else}
{@render nativeOptions(group.options)}
{/if}
{/each}
</select>
<ChevronDown
class={field.chevron({
class: "pointer-events-none absolute top-1/2 right-2 size-3.5 -translate-y-1/2"
})}
aria-hidden="true"
/>
</div>
{/if}