~james/core

core/web/src/lib/components/ui/Select.svelte -rw-r--r-- 3.4 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
<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}