@classic-homes/theme-svelte 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/lib/components/CardHeader.svelte +22 -2
  2. package/dist/lib/components/CardHeader.svelte.d.ts +5 -4
  3. package/dist/lib/components/Combobox.svelte +187 -0
  4. package/dist/lib/components/Combobox.svelte.d.ts +38 -0
  5. package/dist/lib/components/DateTimePicker.svelte +415 -0
  6. package/dist/lib/components/DateTimePicker.svelte.d.ts +31 -0
  7. package/dist/lib/components/HeaderSearch.svelte +340 -0
  8. package/dist/lib/components/HeaderSearch.svelte.d.ts +37 -0
  9. package/dist/lib/components/MultiSelect.svelte +244 -0
  10. package/dist/lib/components/MultiSelect.svelte.d.ts +40 -0
  11. package/dist/lib/components/NumberInput.svelte +205 -0
  12. package/dist/lib/components/NumberInput.svelte.d.ts +33 -0
  13. package/dist/lib/components/OTPInput.svelte +213 -0
  14. package/dist/lib/components/OTPInput.svelte.d.ts +23 -0
  15. package/dist/lib/components/PageHeader.svelte +6 -0
  16. package/dist/lib/components/PageHeader.svelte.d.ts +1 -1
  17. package/dist/lib/components/RadioGroup.svelte +124 -0
  18. package/dist/lib/components/RadioGroup.svelte.d.ts +31 -0
  19. package/dist/lib/components/Signature.svelte +1070 -0
  20. package/dist/lib/components/Signature.svelte.d.ts +74 -0
  21. package/dist/lib/components/Slider.svelte +136 -0
  22. package/dist/lib/components/Slider.svelte.d.ts +30 -0
  23. package/dist/lib/components/layout/AuthLayout.svelte +133 -0
  24. package/dist/lib/components/layout/AuthLayout.svelte.d.ts +48 -0
  25. package/dist/lib/components/layout/DashboardLayout.svelte +100 -74
  26. package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +17 -10
  27. package/dist/lib/components/layout/ErrorLayout.svelte +206 -0
  28. package/dist/lib/components/layout/ErrorLayout.svelte.d.ts +52 -0
  29. package/dist/lib/components/layout/FormPageLayout.svelte +2 -8
  30. package/dist/lib/components/layout/Header.svelte +232 -41
  31. package/dist/lib/components/layout/Header.svelte.d.ts +71 -5
  32. package/dist/lib/components/layout/PublicLayout.svelte +54 -80
  33. package/dist/lib/components/layout/PublicLayout.svelte.d.ts +3 -1
  34. package/dist/lib/components/layout/QuickLinks.svelte +49 -29
  35. package/dist/lib/components/layout/QuickLinks.svelte.d.ts +4 -2
  36. package/dist/lib/components/layout/Sidebar.svelte +345 -86
  37. package/dist/lib/components/layout/Sidebar.svelte.d.ts +12 -0
  38. package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +182 -0
  39. package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte.d.ts +18 -0
  40. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +378 -0
  41. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte.d.ts +25 -0
  42. package/dist/lib/components/layout/sidebar/SidebarSearch.svelte +121 -0
  43. package/dist/lib/components/layout/sidebar/SidebarSearch.svelte.d.ts +17 -0
  44. package/dist/lib/components/layout/sidebar/SidebarSection.svelte +144 -0
  45. package/dist/lib/components/layout/sidebar/SidebarSection.svelte.d.ts +25 -0
  46. package/dist/lib/components/layout/sidebar/index.d.ts +10 -0
  47. package/dist/lib/components/layout/sidebar/index.js +10 -0
  48. package/dist/lib/index.d.ts +13 -2
  49. package/dist/lib/index.js +11 -0
  50. package/dist/lib/schemas/auth.d.ts +6 -6
  51. package/dist/lib/stores/sidebar.svelte.d.ts +54 -0
  52. package/dist/lib/stores/sidebar.svelte.js +171 -1
  53. package/dist/lib/types/components.d.ts +105 -0
  54. package/dist/lib/types/layout.d.ts +203 -3
  55. package/package.json +1 -1
@@ -0,0 +1,31 @@
1
+ interface Props {
2
+ /** Current date/time value */
3
+ value?: Date | null;
4
+ /** Callback when value changes */
5
+ onValueChange?: (date: Date | null) => void;
6
+ /** Placeholder text */
7
+ placeholder?: string;
8
+ /** Whether the picker is disabled */
9
+ disabled?: boolean;
10
+ /** Whether selection is required */
11
+ required?: boolean;
12
+ /** Minimum selectable date */
13
+ min?: Date;
14
+ /** Maximum selectable date */
15
+ max?: Date;
16
+ /** Time format (12h or 24h) */
17
+ timeFormat?: '12h' | '24h';
18
+ /** Minute step interval (default: 15) */
19
+ minuteStep?: number;
20
+ /** Name attribute for form submission */
21
+ name?: string;
22
+ /** Element ID */
23
+ id?: string;
24
+ /** Error message to display */
25
+ error?: string;
26
+ /** Additional class for the trigger */
27
+ class?: string;
28
+ }
29
+ declare const DateTimePicker: import("svelte").Component<Props, {}, "value">;
30
+ type DateTimePicker = ReturnType<typeof DateTimePicker>;
31
+ export default DateTimePicker;
@@ -0,0 +1,340 @@
1
+ <script lang="ts">
2
+ /**
3
+ * HeaderSearch - Docusaurus-style search component for the header
4
+ *
5
+ * A flexible, configurable search component that displays a trigger button
6
+ * in the header and opens a modal dialog with search input and results.
7
+ *
8
+ * Features:
9
+ * - Backend-agnostic (callback-based integration)
10
+ * - Keyboard shortcut support (Cmd/Ctrl+K)
11
+ * - Full keyboard navigation in results
12
+ * - Custom result rendering via snippets
13
+ * - Loading and empty states
14
+ *
15
+ * @example Basic usage:
16
+ * ```svelte
17
+ * <HeaderSearch
18
+ * enabled={true}
19
+ * placeholder="Search..."
20
+ * onSearch={(query) => handleSearch(query)}
21
+ * onSelect={(item) => goto(item.href)}
22
+ * results={searchResults}
23
+ * loading={isSearching}
24
+ * />
25
+ * ```
26
+ */
27
+ import { Dialog as DialogPrimitive } from 'bits-ui';
28
+ import { cn } from '../utils.js';
29
+ import type { Snippet } from 'svelte';
30
+ import type { SearchResultItem, SearchResultGroup } from '../types/layout.js';
31
+ import Spinner from './Spinner.svelte';
32
+
33
+ interface Props<T = SearchResultItem> {
34
+ /** Whether search is enabled/visible */
35
+ enabled?: boolean;
36
+ /** Placeholder text for search input */
37
+ placeholder?: string;
38
+ /** Called when user types (debounced). App should update results prop. */
39
+ onSearch?: (query: string) => void;
40
+ /** Called when user selects a result */
41
+ onSelect?: (item: T) => void;
42
+ /** Called when search dialog opens/closes */
43
+ onOpenChange?: (open: boolean) => void;
44
+ /** Search results to display (flat list or grouped) */
45
+ results?: T[] | SearchResultGroup<T>[];
46
+ /** Whether results are currently loading */
47
+ loading?: boolean;
48
+ /** Message when no results found */
49
+ emptyMessage?: string;
50
+ /** Enable Cmd/Ctrl+K shortcut (default: true) */
51
+ enableShortcut?: boolean;
52
+ /** Custom shortcut key (default: 'k') */
53
+ shortcutKey?: string;
54
+ /** Trigger button variant: 'default' shows full search box, 'icon' shows icon-only button */
55
+ variant?: 'default' | 'icon';
56
+ /** Size variant for trigger button */
57
+ size?: 'sm' | 'md';
58
+ /** Custom result item renderer - receives (item, isSelected) */
59
+ renderResult?: Snippet<[T, boolean]>;
60
+ /** Custom empty state renderer */
61
+ renderEmpty?: Snippet;
62
+ /** Additional classes for trigger button */
63
+ class?: string;
64
+ }
65
+
66
+ let {
67
+ enabled = true,
68
+ placeholder = 'Search...',
69
+ onSearch,
70
+ onSelect,
71
+ onOpenChange,
72
+ results = [],
73
+ loading = false,
74
+ emptyMessage = 'No results found.',
75
+ enableShortcut = true,
76
+ shortcutKey = 'k',
77
+ variant = 'default',
78
+ size = 'md',
79
+ renderResult,
80
+ renderEmpty,
81
+ class: className,
82
+ }: Props = $props();
83
+
84
+ // State
85
+ let open = $state(false);
86
+ let query = $state('');
87
+ let selectedIndex = $state(0);
88
+ let inputRef: HTMLInputElement | undefined = $state();
89
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
90
+
91
+ // Flatten results for keyboard navigation
92
+ const flatResults = $derived.by(() => {
93
+ if (!results || results.length === 0) return [];
94
+ // Check if grouped results
95
+ if (results.length > 0 && 'items' in results[0]) {
96
+ return (results as SearchResultGroup[]).flatMap((g) => g.items);
97
+ }
98
+ return results as SearchResultItem[];
99
+ });
100
+
101
+ // Keyboard shortcut handler
102
+ $effect(() => {
103
+ if (!enableShortcut || typeof window === 'undefined') return;
104
+
105
+ const handleKeydown = (e: KeyboardEvent) => {
106
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === shortcutKey) {
107
+ e.preventDefault();
108
+ open = true;
109
+ }
110
+ };
111
+
112
+ window.addEventListener('keydown', handleKeydown);
113
+ return () => window.removeEventListener('keydown', handleKeydown);
114
+ });
115
+
116
+ // Focus input when dialog opens
117
+ $effect(() => {
118
+ if (open && inputRef) {
119
+ requestAnimationFrame(() => inputRef?.focus());
120
+ }
121
+ });
122
+
123
+ // Debounced search
124
+ function handleInput(value: string) {
125
+ query = value;
126
+ selectedIndex = 0;
127
+
128
+ if (debounceTimer) clearTimeout(debounceTimer);
129
+ debounceTimer = setTimeout(() => {
130
+ onSearch?.(value);
131
+ }, 200);
132
+ }
133
+
134
+ // Keyboard navigation within results
135
+ function handleKeydown(e: KeyboardEvent) {
136
+ switch (e.key) {
137
+ case 'ArrowDown':
138
+ e.preventDefault();
139
+ selectedIndex = Math.min(selectedIndex + 1, flatResults.length - 1);
140
+ break;
141
+ case 'ArrowUp':
142
+ e.preventDefault();
143
+ selectedIndex = Math.max(selectedIndex - 1, 0);
144
+ break;
145
+ case 'Enter':
146
+ e.preventDefault();
147
+ if (flatResults[selectedIndex]) {
148
+ handleSelect(flatResults[selectedIndex]);
149
+ }
150
+ break;
151
+ }
152
+ }
153
+
154
+ function handleSelect(item: SearchResultItem) {
155
+ onSelect?.(item);
156
+ open = false;
157
+ query = '';
158
+ }
159
+
160
+ function handleOpenChange(isOpen: boolean) {
161
+ open = isOpen;
162
+ onOpenChange?.(isOpen);
163
+ if (!isOpen) {
164
+ query = '';
165
+ selectedIndex = 0;
166
+ }
167
+ }
168
+ </script>
169
+
170
+ {#if enabled}
171
+ <!-- Trigger Button -->
172
+ {#if variant === 'icon'}
173
+ <!-- Icon-only variant -->
174
+ <button
175
+ type="button"
176
+ onclick={() => (open = true)}
177
+ class={cn(
178
+ 'inline-flex items-center justify-center rounded-md border border-input bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
179
+ size === 'sm' ? 'h-8 w-8' : 'h-9 w-9',
180
+ className
181
+ )}
182
+ aria-label="Search"
183
+ title={enableShortcut ? `Search (⌘/${shortcutKey.toUpperCase()})` : 'Search'}
184
+ >
185
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
186
+ <path
187
+ stroke-linecap="round"
188
+ stroke-linejoin="round"
189
+ stroke-width="2"
190
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
191
+ />
192
+ </svg>
193
+ </button>
194
+ {:else}
195
+ <!-- Default full search box variant -->
196
+ <button
197
+ type="button"
198
+ onclick={() => (open = true)}
199
+ class={cn(
200
+ 'flex items-center gap-2 rounded-md border border-input bg-background px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
201
+ size === 'sm' ? 'h-8' : 'h-9',
202
+ className
203
+ )}
204
+ aria-label="Search"
205
+ >
206
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
207
+ <path
208
+ stroke-linecap="round"
209
+ stroke-linejoin="round"
210
+ stroke-width="2"
211
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
212
+ />
213
+ </svg>
214
+ <span class="hidden sm:inline">{placeholder}</span>
215
+ {#if enableShortcut}
216
+ <kbd
217
+ class="pointer-events-none hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground sm:inline-flex"
218
+ >
219
+ <span class="text-xs">⌘</span>{shortcutKey.toUpperCase()}
220
+ </kbd>
221
+ {/if}
222
+ </button>
223
+ {/if}
224
+
225
+ <!-- Search Dialog -->
226
+ <DialogPrimitive.Root bind:open onOpenChange={handleOpenChange}>
227
+ <DialogPrimitive.Portal>
228
+ <DialogPrimitive.Overlay
229
+ class="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
230
+ />
231
+ <DialogPrimitive.Content
232
+ class="fixed left-[50%] top-[20%] z-50 w-full max-w-lg translate-x-[-50%] overflow-hidden rounded-lg border bg-background shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:top-[15%]"
233
+ aria-describedby={undefined}
234
+ >
235
+ <!-- Search Input -->
236
+ <div class="flex items-center border-b px-3">
237
+ <svg
238
+ class="h-4 w-4 shrink-0 text-muted-foreground"
239
+ fill="none"
240
+ viewBox="0 0 24 24"
241
+ stroke="currentColor"
242
+ >
243
+ <path
244
+ stroke-linecap="round"
245
+ stroke-linejoin="round"
246
+ stroke-width="2"
247
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
248
+ />
249
+ </svg>
250
+ <input
251
+ bind:this={inputRef}
252
+ type="search"
253
+ {placeholder}
254
+ value={query}
255
+ oninput={(e) => handleInput(e.currentTarget.value)}
256
+ onkeydown={handleKeydown}
257
+ class="flex h-12 w-full bg-transparent px-3 py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
258
+ autocomplete="off"
259
+ autocorrect="off"
260
+ spellcheck="false"
261
+ />
262
+ {#if loading}
263
+ <Spinner size="sm" class="shrink-0" />
264
+ {/if}
265
+ </div>
266
+
267
+ <!-- Results Area -->
268
+ <div class="max-h-[300px] overflow-y-auto p-2" role="listbox">
269
+ {#if loading && flatResults.length === 0}
270
+ <div class="flex items-center justify-center py-6">
271
+ <Spinner size="sm" />
272
+ <span class="ml-2 text-sm text-muted-foreground">Searching...</span>
273
+ </div>
274
+ {:else if flatResults.length === 0 && query}
275
+ {#if renderEmpty}
276
+ {@render renderEmpty()}
277
+ {:else}
278
+ <div class="py-6 text-center text-sm text-muted-foreground">
279
+ {emptyMessage}
280
+ </div>
281
+ {/if}
282
+ {:else if flatResults.length === 0}
283
+ <div class="py-6 text-center text-sm text-muted-foreground">Type to search...</div>
284
+ {:else}
285
+ {#each flatResults as item, index (item.id)}
286
+ <button
287
+ type="button"
288
+ onclick={() => handleSelect(item)}
289
+ class={cn(
290
+ 'flex w-full cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors',
291
+ index === selectedIndex
292
+ ? 'bg-accent text-accent-foreground'
293
+ : 'hover:bg-accent/50'
294
+ )}
295
+ role="option"
296
+ aria-selected={index === selectedIndex}
297
+ >
298
+ {#if renderResult}
299
+ {@render renderResult(item, index === selectedIndex)}
300
+ {:else}
301
+ <div class="min-w-0 flex-1">
302
+ <div class="truncate font-medium">{item.title}</div>
303
+ {#if item.description}
304
+ <div class="truncate text-xs text-muted-foreground">
305
+ {item.description}
306
+ </div>
307
+ {/if}
308
+ </div>
309
+ {#if item.category}
310
+ <span class="shrink-0 text-xs text-muted-foreground">
311
+ {item.category}
312
+ </span>
313
+ {/if}
314
+ {/if}
315
+ </button>
316
+ {/each}
317
+ {/if}
318
+ </div>
319
+
320
+ <!-- Footer with keyboard hints -->
321
+ <div
322
+ class="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground"
323
+ >
324
+ <div class="flex items-center gap-1">
325
+ <kbd class="rounded border bg-muted px-1.5 py-0.5">↑↓</kbd>
326
+ <span>Navigate</span>
327
+ </div>
328
+ <div class="flex items-center gap-1">
329
+ <kbd class="rounded border bg-muted px-1.5 py-0.5">↵</kbd>
330
+ <span>Select</span>
331
+ </div>
332
+ <div class="flex items-center gap-1">
333
+ <kbd class="rounded border bg-muted px-1.5 py-0.5">esc</kbd>
334
+ <span>Close</span>
335
+ </div>
336
+ </div>
337
+ </DialogPrimitive.Content>
338
+ </DialogPrimitive.Portal>
339
+ </DialogPrimitive.Root>
340
+ {/if}
@@ -0,0 +1,37 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { SearchResultItem, SearchResultGroup } from '../types/layout.js';
3
+ interface Props<T = SearchResultItem> {
4
+ /** Whether search is enabled/visible */
5
+ enabled?: boolean;
6
+ /** Placeholder text for search input */
7
+ placeholder?: string;
8
+ /** Called when user types (debounced). App should update results prop. */
9
+ onSearch?: (query: string) => void;
10
+ /** Called when user selects a result */
11
+ onSelect?: (item: T) => void;
12
+ /** Called when search dialog opens/closes */
13
+ onOpenChange?: (open: boolean) => void;
14
+ /** Search results to display (flat list or grouped) */
15
+ results?: T[] | SearchResultGroup<T>[];
16
+ /** Whether results are currently loading */
17
+ loading?: boolean;
18
+ /** Message when no results found */
19
+ emptyMessage?: string;
20
+ /** Enable Cmd/Ctrl+K shortcut (default: true) */
21
+ enableShortcut?: boolean;
22
+ /** Custom shortcut key (default: 'k') */
23
+ shortcutKey?: string;
24
+ /** Trigger button variant: 'default' shows full search box, 'icon' shows icon-only button */
25
+ variant?: 'default' | 'icon';
26
+ /** Size variant for trigger button */
27
+ size?: 'sm' | 'md';
28
+ /** Custom result item renderer - receives (item, isSelected) */
29
+ renderResult?: Snippet<[T, boolean]>;
30
+ /** Custom empty state renderer */
31
+ renderEmpty?: Snippet;
32
+ /** Additional classes for trigger button */
33
+ class?: string;
34
+ }
35
+ declare const HeaderSearch: import("svelte").Component<Props<SearchResultItem>, {}, "">;
36
+ type HeaderSearch = ReturnType<typeof HeaderSearch>;
37
+ export default HeaderSearch;
@@ -0,0 +1,244 @@
1
+ <script lang="ts">
2
+ import { Combobox as ComboboxPrimitive } from 'bits-ui';
3
+ import { cn } from '../utils.js';
4
+ import Spinner from './Spinner.svelte';
5
+
6
+ export interface MultiSelectOption {
7
+ value: string;
8
+ label: string;
9
+ disabled?: boolean;
10
+ }
11
+
12
+ interface Props {
13
+ /** Currently selected values */
14
+ value?: string[];
15
+ /** Callback when values change */
16
+ onValueChange?: (values: string[]) => void;
17
+ /** Array of selectable options */
18
+ options: MultiSelectOption[];
19
+ /** Placeholder text for the input */
20
+ placeholder?: string;
21
+ /** Message to display when no results found */
22
+ emptyMessage?: string;
23
+ /** Whether the multiselect is disabled */
24
+ disabled?: boolean;
25
+ /** Whether selection is required */
26
+ required?: boolean;
27
+ /** Name attribute for form submission */
28
+ name?: string;
29
+ /** Element ID */
30
+ id?: string;
31
+ /** Error message to display */
32
+ error?: string;
33
+ /** Maximum number of selections allowed */
34
+ max?: number;
35
+ /** Whether async data is loading */
36
+ loading?: boolean;
37
+ /** Callback when search query changes (for async loading) */
38
+ onSearch?: (query: string) => void;
39
+ /** Debounce delay in ms for search callback (default: 300) */
40
+ debounceMs?: number;
41
+ /** Additional class for the trigger */
42
+ class?: string;
43
+ }
44
+
45
+ let {
46
+ value = $bindable([]),
47
+ onValueChange,
48
+ options,
49
+ placeholder = 'Select options...',
50
+ emptyMessage = 'No results found.',
51
+ disabled = false,
52
+ required = false,
53
+ name,
54
+ id,
55
+ error,
56
+ max,
57
+ loading = false,
58
+ onSearch,
59
+ debounceMs = 300,
60
+ class: className,
61
+ }: Props = $props();
62
+
63
+ let searchQuery = $state('');
64
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
65
+ let open = $state(false);
66
+
67
+ // Get labels for selected values
68
+ const selectedLabels = $derived(
69
+ value.map((v) => options.find((opt) => opt.value === v)?.label).filter(Boolean) as string[]
70
+ );
71
+
72
+ // Always filter options locally based on search query
73
+ const filteredOptions = $derived.by(() => {
74
+ if (!searchQuery) return options;
75
+ const query = searchQuery.toLowerCase();
76
+ return options.filter((opt) => opt.label.toLowerCase().includes(query));
77
+ });
78
+
79
+ // Check if max selections reached
80
+ const maxReached = $derived(max !== undefined && value.length >= max);
81
+
82
+ function handleSearchInput(e: Event) {
83
+ const target = e.target as HTMLInputElement;
84
+ searchQuery = target.value;
85
+
86
+ if (onSearch) {
87
+ // Debounce the search callback
88
+ if (debounceTimer) clearTimeout(debounceTimer);
89
+ debounceTimer = setTimeout(() => {
90
+ onSearch(searchQuery);
91
+ }, debounceMs);
92
+ }
93
+ }
94
+
95
+ function handleValueChange(newValues: string[] | undefined) {
96
+ if (newValues !== undefined) {
97
+ value = newValues;
98
+ onValueChange?.(newValues);
99
+ }
100
+ }
101
+
102
+ function removeValue(valueToRemove: string) {
103
+ const newValues = value.filter((v) => v !== valueToRemove);
104
+ value = newValues;
105
+ onValueChange?.(newValues);
106
+ }
107
+
108
+ function handleOpenChange(isOpen: boolean) {
109
+ open = isOpen;
110
+ if (!isOpen) {
111
+ searchQuery = '';
112
+ }
113
+ }
114
+
115
+ // Cleanup debounce timer on unmount
116
+ $effect(() => {
117
+ return () => {
118
+ if (debounceTimer) clearTimeout(debounceTimer);
119
+ };
120
+ });
121
+ </script>
122
+
123
+ <div class={cn('w-full', className)}>
124
+ <!-- Selected tags -->
125
+ {#if selectedLabels.length > 0}
126
+ <div class="flex flex-wrap gap-1 mb-2">
127
+ {#each value as selectedValue}
128
+ {@const label = options.find((o) => o.value === selectedValue)?.label}
129
+ {#if label}
130
+ <span
131
+ class="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-medium text-secondary-foreground"
132
+ >
133
+ {label}
134
+ <button
135
+ type="button"
136
+ onclick={() => removeValue(selectedValue)}
137
+ class="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
138
+ {disabled}
139
+ aria-label={`Remove ${label}`}
140
+ >
141
+ <svg
142
+ xmlns="http://www.w3.org/2000/svg"
143
+ width="14"
144
+ height="14"
145
+ viewBox="0 0 24 24"
146
+ fill="none"
147
+ stroke="currentColor"
148
+ stroke-width="2"
149
+ stroke-linecap="round"
150
+ stroke-linejoin="round"
151
+ >
152
+ <path d="M18 6 6 18" />
153
+ <path d="m6 6 12 12" />
154
+ </svg>
155
+ </button>
156
+ </span>
157
+ {/if}
158
+ {/each}
159
+ </div>
160
+ {/if}
161
+
162
+ <ComboboxPrimitive.Root
163
+ type="multiple"
164
+ {disabled}
165
+ {required}
166
+ {name}
167
+ bind:open
168
+ onOpenChange={handleOpenChange}
169
+ onValueChange={handleValueChange}
170
+ {value}
171
+ >
172
+ <div class="relative">
173
+ <ComboboxPrimitive.Input
174
+ {id}
175
+ placeholder={value.length > 0 ? `${value.length} selected` : placeholder}
176
+ oninput={handleSearchInput}
177
+ onfocus={() => (open = true)}
178
+ onclick={() => (open = true)}
179
+ class={cn(
180
+ 'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
181
+ error && 'border-destructive focus:ring-destructive'
182
+ )}
183
+ aria-invalid={error ? 'true' : undefined}
184
+ />
185
+ </div>
186
+
187
+ <ComboboxPrimitive.Content
188
+ class="relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
189
+ sideOffset={4}
190
+ >
191
+ <div class="p-1 max-h-[300px] overflow-y-auto">
192
+ {#if loading}
193
+ <div class="flex items-center justify-center py-6">
194
+ <Spinner size="sm" />
195
+ <span class="ml-2 text-sm text-muted-foreground">Loading...</span>
196
+ </div>
197
+ {:else if filteredOptions.length === 0}
198
+ <div class="py-6 text-center text-sm text-muted-foreground">
199
+ {emptyMessage}
200
+ </div>
201
+ {:else}
202
+ {#each filteredOptions as option}
203
+ {@const isSelected = value.includes(option.value)}
204
+ {@const isDisabledByMax = maxReached && !isSelected}
205
+ <ComboboxPrimitive.Item
206
+ value={option.value}
207
+ label={option.label}
208
+ disabled={option.disabled || isDisabledByMax}
209
+ class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
210
+ >
211
+ <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
212
+ {#if isSelected}
213
+ <svg
214
+ xmlns="http://www.w3.org/2000/svg"
215
+ width="16"
216
+ height="16"
217
+ viewBox="0 0 24 24"
218
+ fill="none"
219
+ stroke="currentColor"
220
+ stroke-width="2"
221
+ stroke-linecap="round"
222
+ stroke-linejoin="round"
223
+ class="h-4 w-4"
224
+ >
225
+ <polyline points="20 6 9 17 4 12" />
226
+ </svg>
227
+ {:else}
228
+ <span class="h-3.5 w-3.5 rounded-sm border border-primary"></span>
229
+ {/if}
230
+ </span>
231
+ {option.label}
232
+ </ComboboxPrimitive.Item>
233
+ {/each}
234
+ {/if}
235
+ </div>
236
+ </ComboboxPrimitive.Content>
237
+ </ComboboxPrimitive.Root>
238
+ </div>
239
+
240
+ {#if error}
241
+ <p class="text-sm text-destructive mt-1.5" role="alert">
242
+ {error}
243
+ </p>
244
+ {/if}
@@ -0,0 +1,40 @@
1
+ export interface MultiSelectOption {
2
+ value: string;
3
+ label: string;
4
+ disabled?: boolean;
5
+ }
6
+ interface Props {
7
+ /** Currently selected values */
8
+ value?: string[];
9
+ /** Callback when values change */
10
+ onValueChange?: (values: string[]) => void;
11
+ /** Array of selectable options */
12
+ options: MultiSelectOption[];
13
+ /** Placeholder text for the input */
14
+ placeholder?: string;
15
+ /** Message to display when no results found */
16
+ emptyMessage?: string;
17
+ /** Whether the multiselect is disabled */
18
+ disabled?: boolean;
19
+ /** Whether selection is required */
20
+ required?: boolean;
21
+ /** Name attribute for form submission */
22
+ name?: string;
23
+ /** Element ID */
24
+ id?: string;
25
+ /** Error message to display */
26
+ error?: string;
27
+ /** Maximum number of selections allowed */
28
+ max?: number;
29
+ /** Whether async data is loading */
30
+ loading?: boolean;
31
+ /** Callback when search query changes (for async loading) */
32
+ onSearch?: (query: string) => void;
33
+ /** Debounce delay in ms for search callback (default: 300) */
34
+ debounceMs?: number;
35
+ /** Additional class for the trigger */
36
+ class?: string;
37
+ }
38
+ declare const MultiSelect: import("svelte").Component<Props, {}, "value">;
39
+ type MultiSelect = ReturnType<typeof MultiSelect>;
40
+ export default MultiSelect;