@human-kit/svelte-components 1.0.0-alpha.1

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 (140) hide show
  1. package/dist/combobox/TODO.md +175 -0
  2. package/dist/combobox/button/combobox-button.svelte +57 -0
  3. package/dist/combobox/button/combobox-button.svelte.d.ts +9 -0
  4. package/dist/combobox/index.d.ts +14 -0
  5. package/dist/combobox/index.js +18 -0
  6. package/dist/combobox/index.parts.d.ts +10 -0
  7. package/dist/combobox/index.parts.js +11 -0
  8. package/dist/combobox/input/combobox-input.svelte +98 -0
  9. package/dist/combobox/input/combobox-input.svelte.d.ts +13 -0
  10. package/dist/combobox/item/combobox-item-implicit-text-test.svelte +21 -0
  11. package/dist/combobox/item/combobox-item-implicit-text-test.svelte.d.ts +3 -0
  12. package/dist/combobox/item/combobox-listboxitem.svelte +136 -0
  13. package/dist/combobox/item/combobox-listboxitem.svelte.d.ts +18 -0
  14. package/dist/combobox/item-indicator/combobox-item-indicator.svelte +63 -0
  15. package/dist/combobox/item-indicator/combobox-item-indicator.svelte.d.ts +17 -0
  16. package/dist/combobox/list/combobox-listbox.svelte +76 -0
  17. package/dist/combobox/list/combobox-listbox.svelte.d.ts +47 -0
  18. package/dist/combobox/popover/combobox-popover.svelte +69 -0
  19. package/dist/combobox/popover/combobox-popover.svelte.d.ts +12 -0
  20. package/dist/combobox/root/combobox-filtered-test.svelte +51 -0
  21. package/dist/combobox/root/combobox-filtered-test.svelte.d.ts +7 -0
  22. package/dist/combobox/root/combobox-multiselect-test.svelte +76 -0
  23. package/dist/combobox/root/combobox-multiselect-test.svelte.d.ts +13 -0
  24. package/dist/combobox/root/combobox-numeric-string-id-test.svelte +20 -0
  25. package/dist/combobox/root/combobox-numeric-string-id-test.svelte.d.ts +3 -0
  26. package/dist/combobox/root/combobox-test.svelte +43 -0
  27. package/dist/combobox/root/combobox-test.svelte.d.ts +9 -0
  28. package/dist/combobox/root/combobox.svelte +696 -0
  29. package/dist/combobox/root/combobox.svelte.d.ts +58 -0
  30. package/dist/combobox/root/context.d.ts +90 -0
  31. package/dist/combobox/root/context.js +15 -0
  32. package/dist/combobox/tag/combobox-tag.svelte +58 -0
  33. package/dist/combobox/tag/combobox-tag.svelte.d.ts +22 -0
  34. package/dist/combobox/tag/tag-context-provider.svelte +36 -0
  35. package/dist/combobox/tag/tag-context-provider.svelte.d.ts +14 -0
  36. package/dist/combobox/tag-remove/combobox-tag-remove.svelte +53 -0
  37. package/dist/combobox/tag-remove/combobox-tag-remove.svelte.d.ts +14 -0
  38. package/dist/combobox/tags/combobox-tags.svelte +50 -0
  39. package/dist/combobox/tags/combobox-tags.svelte.d.ts +20 -0
  40. package/dist/dialog/content/dialog-content.svelte +121 -0
  41. package/dist/dialog/content/dialog-content.svelte.d.ts +19 -0
  42. package/dist/dialog/index.d.ts +10 -0
  43. package/dist/dialog/index.js +15 -0
  44. package/dist/dialog/index.parts.d.ts +5 -0
  45. package/dist/dialog/index.parts.js +6 -0
  46. package/dist/dialog/overlay/dialog-overlay.svelte +39 -0
  47. package/dist/dialog/overlay/dialog-overlay.svelte.d.ts +12 -0
  48. package/dist/dialog/portal/dialog-portal.svelte +32 -0
  49. package/dist/dialog/portal/dialog-portal.svelte.d.ts +12 -0
  50. package/dist/dialog/root/context.d.ts +25 -0
  51. package/dist/dialog/root/context.js +8 -0
  52. package/dist/dialog/root/dialog-root.svelte +99 -0
  53. package/dist/dialog/root/dialog-root.svelte.d.ts +21 -0
  54. package/dist/dialog/root/dialog-stack.d.ts +32 -0
  55. package/dist/dialog/root/dialog-stack.js +55 -0
  56. package/dist/dialog/root/dialog-test.svelte +38 -0
  57. package/dist/dialog/root/dialog-test.svelte.d.ts +10 -0
  58. package/dist/dialog/root/dialog-with-combobox-test.svelte +61 -0
  59. package/dist/dialog/root/dialog-with-combobox-test.svelte.d.ts +7 -0
  60. package/dist/dialog/root/nested-dialog-test.svelte +63 -0
  61. package/dist/dialog/root/nested-dialog-test.svelte.d.ts +8 -0
  62. package/dist/dialog/root/types.d.ts +10 -0
  63. package/dist/dialog/root/types.js +1 -0
  64. package/dist/dialog/trigger/dialog-trigger.svelte +71 -0
  65. package/dist/dialog/trigger/dialog-trigger.svelte.d.ts +12 -0
  66. package/dist/hooks/use-virtual-focus.svelte.d.ts +55 -0
  67. package/dist/hooks/use-virtual-focus.svelte.js +201 -0
  68. package/dist/index.d.ts +13 -0
  69. package/dist/index.js +19 -0
  70. package/dist/input/index.d.ts +3 -0
  71. package/dist/input/index.js +3 -0
  72. package/dist/input/input.svelte +19 -0
  73. package/dist/input/input.svelte.d.ts +8 -0
  74. package/dist/label/index.d.ts +3 -0
  75. package/dist/label/index.js +3 -0
  76. package/dist/label/label.svelte +21 -0
  77. package/dist/label/label.svelte.d.ts +8 -0
  78. package/dist/listbox/index.d.ts +6 -0
  79. package/dist/listbox/index.js +10 -0
  80. package/dist/listbox/index.parts.d.ts +2 -0
  81. package/dist/listbox/index.parts.js +3 -0
  82. package/dist/listbox/item/listbox-item.svelte +186 -0
  83. package/dist/listbox/item/listbox-item.svelte.d.ts +34 -0
  84. package/dist/listbox/root/context.d.ts +73 -0
  85. package/dist/listbox/root/context.js +249 -0
  86. package/dist/listbox/root/listbox-numeric-id-test.svelte +18 -0
  87. package/dist/listbox/root/listbox-numeric-id-test.svelte.d.ts +3 -0
  88. package/dist/listbox/root/listbox-test.svelte +27 -0
  89. package/dist/listbox/root/listbox-test.svelte.d.ts +8 -0
  90. package/dist/listbox/root/listbox.svelte +146 -0
  91. package/dist/listbox/root/listbox.svelte.d.ts +54 -0
  92. package/dist/popover/content/popover-content-test.svelte +43 -0
  93. package/dist/popover/content/popover-content-test.svelte.d.ts +12 -0
  94. package/dist/popover/content/popover-content.svelte +167 -0
  95. package/dist/popover/content/popover-content.svelte.d.ts +38 -0
  96. package/dist/popover/index.d.ts +8 -0
  97. package/dist/popover/index.js +14 -0
  98. package/dist/popover/index.parts.d.ts +4 -0
  99. package/dist/popover/index.parts.js +5 -0
  100. package/dist/popover/root/context.d.ts +24 -0
  101. package/dist/popover/root/context.js +10 -0
  102. package/dist/popover/root/popover-root.svelte +87 -0
  103. package/dist/popover/root/popover-root.svelte.d.ts +20 -0
  104. package/dist/popover/root/popover-test.svelte +40 -0
  105. package/dist/popover/root/popover-test.svelte.d.ts +11 -0
  106. package/dist/popover/trigger/popover-trigger-button.svelte +42 -0
  107. package/dist/popover/trigger/popover-trigger-button.svelte.d.ts +12 -0
  108. package/dist/popover/trigger/popover-trigger-in-dialog-test.svelte +29 -0
  109. package/dist/popover/trigger/popover-trigger-in-dialog-test.svelte.d.ts +18 -0
  110. package/dist/popover/trigger/popover-trigger.svelte +71 -0
  111. package/dist/popover/trigger/popover-trigger.svelte.d.ts +12 -0
  112. package/dist/portal/index.d.ts +1 -0
  113. package/dist/portal/index.js +1 -0
  114. package/dist/portal/portal.svelte +44 -0
  115. package/dist/portal/portal.svelte.d.ts +10 -0
  116. package/dist/primitives/aria-hide-outside.d.ts +38 -0
  117. package/dist/primitives/aria-hide-outside.js +152 -0
  118. package/dist/primitives/click-outside.d.ts +26 -0
  119. package/dist/primitives/click-outside.js +66 -0
  120. package/dist/primitives/floating.d.ts +57 -0
  121. package/dist/primitives/floating.js +179 -0
  122. package/dist/primitives/focus-trap.d.ts +19 -0
  123. package/dist/primitives/focus-trap.js +102 -0
  124. package/dist/primitives/index.d.ts +6 -0
  125. package/dist/primitives/index.js +7 -0
  126. package/dist/primitives/keyboard-navigation.d.ts +88 -0
  127. package/dist/primitives/keyboard-navigation.js +274 -0
  128. package/dist/primitives/scroll-lock.d.ts +19 -0
  129. package/dist/primitives/scroll-lock.js +62 -0
  130. package/dist/test-mocks/app-environment.d.ts +7 -0
  131. package/dist/test-mocks/app-environment.js +7 -0
  132. package/dist/test-mocks/app-navigation.d.ts +11 -0
  133. package/dist/test-mocks/app-navigation.js +11 -0
  134. package/dist/test-mocks/app-stores.d.ts +16 -0
  135. package/dist/test-mocks/app-stores.js +18 -0
  136. package/dist/utils/cn.d.ts +2 -0
  137. package/dist/utils/cn.js +5 -0
  138. package/dist/utils/index.d.ts +1 -0
  139. package/dist/utils/index.js +1 -0
  140. package/package.json +99 -0
@@ -0,0 +1,696 @@
1
+ <script lang="ts" generics="T extends object = object">
2
+ import { untrack, type Snippet } from 'svelte';
3
+ import { setComboBoxContext, type ComboBoxContext } from './context';
4
+ import type { ListBoxContext } from '../../listbox/root/context';
5
+ import { useVirtualFocus } from '../../hooks/use-virtual-focus.svelte';
6
+
7
+ type ComboBoxProps<T> = {
8
+ /** Stable ID used to generate internal ARIA IDs (recommended for SSR). */
9
+ id?: string;
10
+ isDisabled?: boolean;
11
+ isReadOnly?: boolean;
12
+ /** Selected value(s). Single value for single mode, array for multiple mode. Can be bound with bind:value */
13
+ value?: string | number | (string | number)[];
14
+ defaultValue?: string | number | (string | number)[];
15
+ /** Current input value. Can be bound with bind:inputValue */
16
+ inputValue?: string;
17
+ defaultInputValue?: string;
18
+ selectionBehavior?: 'toggle' | 'replace';
19
+ selectionMode?: 'single' | 'multiple';
20
+ /** Whether to close popover after selection. Default: true for single, false for multiple */
21
+ closeOnSelect?: boolean;
22
+ /** Whether the popover is open. Can be bound with bind:isOpen */
23
+ isOpen?: boolean;
24
+ /** How the popover opens: 'focus' | 'input' | 'press'. Default: 'press' */
25
+ trigger?: 'focus' | 'input' | 'press';
26
+ onInputChange?: (value: string) => void;
27
+ onOpenChange?: (open: boolean) => void;
28
+ onChange?: (value: string | number | (string | number)[] | undefined) => void;
29
+ /** Optional: Array of items for dynamic rendering */
30
+ items?: T[];
31
+ /** Optional: Snippet to render each item (used with items prop) */
32
+ renderItem?: Snippet<[T]>;
33
+ children?: Snippet;
34
+ class?: string;
35
+ /** Accessible label for the combobox group */
36
+ 'aria-label'?: string;
37
+ /** ID of element that labels this combobox group */
38
+ 'aria-labelledby'?: string;
39
+ };
40
+
41
+ const generatedInstanceId = $props.id();
42
+
43
+ let {
44
+ id: rootId,
45
+ isDisabled = false,
46
+ isReadOnly = false,
47
+ value = $bindable(),
48
+ defaultValue,
49
+ inputValue = $bindable(),
50
+ defaultInputValue = '',
51
+ selectionBehavior,
52
+ selectionMode = 'single',
53
+ closeOnSelect,
54
+ isOpen = $bindable(),
55
+ trigger = 'press',
56
+ onInputChange,
57
+ onOpenChange,
58
+ onChange,
59
+ items,
60
+ renderItem,
61
+ children,
62
+ class: className = '',
63
+ 'aria-label': ariaLabel,
64
+ 'aria-labelledby': ariaLabelledby
65
+ }: ComboBoxProps<T> = $props();
66
+
67
+ const instanceId = untrack(() => rootId) ?? generatedInstanceId;
68
+
69
+ // Track if selectionBehavior was explicitly passed (for dev warning)
70
+ const selectionBehaviorExplicit = $derived(selectionBehavior !== undefined);
71
+ // Apply default if not provided
72
+ const effectiveSelectionBehavior = $derived(selectionBehavior ?? 'toggle');
73
+ // Default closeOnSelect based on selectionMode
74
+ const effectiveCloseOnSelect = $derived(closeOnSelect ?? selectionMode === 'single');
75
+
76
+ let wrapperRef: HTMLElement | null = $state(null);
77
+ let inputRef: HTMLElement | null = $state(null);
78
+ let triggerRef: HTMLElement | null = $state(null);
79
+ let listboxCtxRef: ListBoxContext | null = $state(null);
80
+ let listboxRef: HTMLElement | null = $state(null);
81
+
82
+ let isOpenInternal = $state(false);
83
+ // Use function to capture initial value only (not reactive)
84
+ let inputValueInternal = $state((() => defaultInputValue)());
85
+ let selectedInternal = $state<Set<string | number>>((() => parseSelection(defaultValue))());
86
+
87
+ // Use virtual focus hook for navigation
88
+ const navigation = useVirtualFocus({
89
+ instanceId,
90
+ containerRef: () => listboxRef
91
+ });
92
+
93
+ // Persistent label of the selected item (for restore on blur/escape)
94
+ let selectedLabel: string = $state('');
95
+
96
+ // Persistent labels for selected items in multiple mode (not cleared on unregister)
97
+ let selectedLabels = $state(new Map<string | number, string>());
98
+
99
+ // Virtual focus for tag navigation in multiple mode
100
+ let focusedTagId: string | number | null = $state(null);
101
+
102
+ // Flag to control whether inputValue should be used for filtering
103
+ // When false, all items are shown regardless of inputValue
104
+ let shouldFilter: boolean = $state(true);
105
+
106
+ // Dev-mode prop validation warnings
107
+ if (import.meta.env.DEV) {
108
+ $effect(() => {
109
+ // Only warn if user explicitly passed selectionBehavior="toggle"
110
+ if (
111
+ selectionBehaviorExplicit &&
112
+ effectiveSelectionBehavior === 'toggle' &&
113
+ selectionMode === 'single'
114
+ ) {
115
+ console.warn(
116
+ '[ComboBox]: selectionBehavior="toggle" has no effect with selectionMode="single". ' +
117
+ 'Toggle behavior is only meaningful for multiple selection.'
118
+ );
119
+ }
120
+ if (value !== undefined && defaultValue !== undefined) {
121
+ console.warn(
122
+ '[ComboBox]: Both "value" and "defaultValue" are provided. ' +
123
+ 'Use "value" for controlled mode or "defaultValue" for uncontrolled mode, not both.'
124
+ );
125
+ }
126
+ });
127
+ }
128
+
129
+ function parseSelection(
130
+ val: string | number | (string | number)[] | undefined
131
+ ): Set<string | number> {
132
+ if (val === undefined) return new Set();
133
+ if (Array.isArray(val)) return new Set(val);
134
+ return new Set([val]);
135
+ }
136
+
137
+ // Convert internal Set back to external value based on selectionMode
138
+ function toExternalValue(
139
+ internalSet: Set<string | number>
140
+ ): string | number | (string | number)[] | undefined {
141
+ if (selectionMode === 'single') {
142
+ const arr = Array.from(internalSet);
143
+ return arr.length > 0 ? arr[0] : undefined;
144
+ }
145
+ return Array.from(internalSet);
146
+ }
147
+
148
+ // Reactive controlled mode checks - if prop changes from undefined to defined, behavior updates
149
+ const isOpenControlled = $derived(isOpen !== undefined);
150
+ const isInputControlled = $derived(inputValue !== undefined);
151
+ const isSelectionControlled = $derived(value !== undefined);
152
+
153
+ const currentIsOpen = $derived(isOpenControlled ? isOpen! : isOpenInternal);
154
+ const currentInputValue = $derived(isInputControlled ? inputValue! : inputValueInternal);
155
+ const currentSelection = $derived(
156
+ isSelectionControlled ? parseSelection(value) : selectedInternal
157
+ );
158
+
159
+ // Input value used for filtering - empty when shouldFilter is false
160
+ const filterValue = $derived(shouldFilter ? currentInputValue : '');
161
+
162
+ function setIsOpen(open: boolean) {
163
+ isOpenInternal = open;
164
+ isOpen = open; // Update bindable prop
165
+ onOpenChange?.(open);
166
+ // Reset focus and pending when closing
167
+ if (!open) {
168
+ navigation.setFocused(null);
169
+ navigation.setPendingDirection(null);
170
+ }
171
+ }
172
+
173
+ function setInputValueHandler(val: string) {
174
+ // Clear tag virtual focus when typing
175
+ focusedTagId = null;
176
+ inputValueInternal = val;
177
+ inputValue = val; // Update bindable prop
178
+ onInputChange?.(val); // Notify parent of input change
179
+ // Reset focus when filter changes (user typing)
180
+ navigation.setFocused(null);
181
+
182
+ // Re-enable filtering when user starts typing/editing
183
+ if (!shouldFilter) {
184
+ shouldFilter = true;
185
+ }
186
+
187
+ // Instant deselection when input is cleared (single mode only)
188
+ // In multiple mode, selections are managed via tags, not input
189
+ if (selectionMode === 'single' && val.trim() === '' && currentSelection.size > 0) {
190
+ const emptySelection = new Set<string | number>();
191
+ if (isSelectionControlled) {
192
+ onChange?.(toExternalValue(emptySelection));
193
+ } else {
194
+ selectedInternal = emptySelection;
195
+ onChange?.(toExternalValue(emptySelection));
196
+ }
197
+ value = toExternalValue(emptySelection);
198
+ selectedLabel = '';
199
+ }
200
+ }
201
+
202
+ function selectItem(id: string | number, label: string) {
203
+ let newSelection: Set<string | number>;
204
+
205
+ if (selectionMode === 'single') {
206
+ newSelection = new Set([id]);
207
+ // Save the label persistently for restore on blur/escape
208
+ selectedLabel = label;
209
+ // Update input directly without triggering deselection
210
+ inputValueInternal = label;
211
+ inputValue = label;
212
+ onInputChange?.(label);
213
+ if (effectiveCloseOnSelect) {
214
+ closePopover(true); // Close and keep focus on input
215
+ }
216
+ } else {
217
+ const isTogglingOff = effectiveSelectionBehavior === 'toggle' && currentSelection.has(id);
218
+
219
+ if (isTogglingOff) {
220
+ newSelection = new Set(
221
+ Array.from(currentSelection).filter((selectedId) => selectedId !== id)
222
+ );
223
+ // Remove from persistent labels
224
+ selectedLabels.delete(id);
225
+ } else {
226
+ newSelection = new Set([...currentSelection, id]);
227
+ // Save label persistently for tags display
228
+ selectedLabels.set(id, label);
229
+ }
230
+ // Clear input after selection in multiple mode (to continue searching)
231
+ inputValueInternal = '';
232
+ inputValue = '';
233
+ onInputChange?.('');
234
+ if (effectiveCloseOnSelect) {
235
+ closePopover(true); // Close and keep focus on input
236
+ }
237
+ }
238
+
239
+ if (isSelectionControlled) {
240
+ onChange?.(toExternalValue(newSelection));
241
+ } else {
242
+ selectedInternal = newSelection;
243
+ onChange?.(toExternalValue(newSelection));
244
+ }
245
+ // Update bindable value
246
+ value = toExternalValue(newSelection);
247
+ }
248
+
249
+ function removeItem(id: string | number) {
250
+ // If removing the focused tag, clear virtual focus
251
+ if (focusedTagId === id) {
252
+ focusedTagId = null;
253
+ }
254
+ const newSelection = new Set(
255
+ Array.from(currentSelection).filter((selectedId) => selectedId !== id)
256
+ );
257
+ // Remove from persistent labels
258
+ selectedLabels.delete(id);
259
+
260
+ if (isSelectionControlled) {
261
+ onChange?.(toExternalValue(newSelection));
262
+ } else {
263
+ selectedInternal = newSelection;
264
+ onChange?.(toExternalValue(newSelection));
265
+ }
266
+ value = toExternalValue(newSelection);
267
+
268
+ // Clear selectedLabel if we removed the last item
269
+ if (newSelection.size === 0) {
270
+ selectedLabel = '';
271
+ }
272
+ }
273
+
274
+ function clearSelection() {
275
+ const emptySelection = new Set<string | number>();
276
+
277
+ if (isSelectionControlled) {
278
+ onChange?.(toExternalValue(emptySelection));
279
+ } else {
280
+ selectedInternal = emptySelection;
281
+ onChange?.(toExternalValue(emptySelection));
282
+ }
283
+ value = toExternalValue(emptySelection);
284
+ selectedLabel = '';
285
+
286
+ // Also clear the input
287
+ inputValueInternal = '';
288
+ inputValue = '';
289
+ onInputChange?.('');
290
+ }
291
+
292
+ function openPopover() {
293
+ if (!isDisabled && !isReadOnly) {
294
+ // Don't open if triggerRef is not set yet (prevents race condition with focus trap)
295
+ if (!triggerRef) {
296
+ return;
297
+ }
298
+ // If opening with a selection, disable filtering to show all options
299
+ if (currentSelection.size > 0 && selectionMode === 'single') {
300
+ shouldFilter = false;
301
+ // Only reset filter if user didn't type (input matches selection)
302
+ if (currentInputValue === selectedLabel) {
303
+ onInputChange?.('');
304
+ }
305
+ // Otherwise user typed, keep their filter
306
+ }
307
+ setIsOpen(true);
308
+ // Auto-focus the selected item when opening with a selection
309
+ // This way the first arrow key press will navigate from the selection
310
+ if (currentSelection.size > 0 && selectionMode === 'single') {
311
+ const selectedId = Array.from(currentSelection)[0];
312
+ navigation.setFocused(selectedId);
313
+ }
314
+ }
315
+ }
316
+
317
+ function closePopover(refocusInput = false) {
318
+ setIsOpen(false);
319
+ // Reset navigation state
320
+ navigation.reset();
321
+ // Re-enable filtering for next open
322
+ shouldFilter = true;
323
+ // Only refocus input when explicitly requested (e.g., after selection)
324
+ // Never refocus in focus mode to prevent re-opening
325
+ if (refocusInput && trigger !== 'focus') {
326
+ inputRef?.focus();
327
+ }
328
+ }
329
+
330
+ function togglePopover() {
331
+ if (currentIsOpen) {
332
+ closePopover();
333
+ } else {
334
+ openPopover();
335
+ }
336
+ }
337
+
338
+ // Use navigation hook methods for keyboard navigation
339
+ function selectFocusedItem() {
340
+ if (navigation.focusedId !== null) {
341
+ const label = navigation.itemLabels.get(navigation.focusedId) ?? String(navigation.focusedId);
342
+ selectItem(navigation.focusedId, label);
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Handle input blur or escape - restore selection label or clear if no selection
348
+ */
349
+ function handleInputBlur() {
350
+ // Clear tag virtual focus
351
+ focusedTagId = null;
352
+ // Close popover first to prevent flash of options when clearing input
353
+ closePopover();
354
+
355
+ // In multiple mode, always clear the input on blur
356
+ if (selectionMode === 'multiple') {
357
+ if (currentInputValue.trim() !== '') {
358
+ inputValueInternal = '';
359
+ inputValue = '';
360
+ onInputChange?.('');
361
+ }
362
+ return;
363
+ }
364
+
365
+ // Single mode: If there's no selection and input has content, clear it
366
+ if (currentSelection.size === 0) {
367
+ if (currentInputValue.trim() !== '') {
368
+ inputValueInternal = '';
369
+ inputValue = '';
370
+ onInputChange?.('');
371
+ }
372
+ return;
373
+ }
374
+
375
+ // If there's a selection, restore its label (using persistent selectedLabel)
376
+ if (currentSelection.size > 0 && selectedLabel) {
377
+ if (selectedLabel !== currentInputValue) {
378
+ // Restore the selected label
379
+ inputValueInternal = selectedLabel;
380
+ inputValue = selectedLabel;
381
+ onInputChange?.(selectedLabel);
382
+ }
383
+ }
384
+ }
385
+
386
+ function handleKeydown(event: KeyboardEvent) {
387
+ if (isDisabled) return;
388
+
389
+ // Handle tag virtual focus navigation in multiple mode
390
+ if (focusedTagId !== null && selectionMode === 'multiple') {
391
+ const selectedIds = Array.from(currentSelection);
392
+ const currentIndex = selectedIds.indexOf(focusedTagId);
393
+
394
+ switch (event.key) {
395
+ case 'ArrowLeft': {
396
+ if (currentIndex > 0) {
397
+ focusedTagId = selectedIds[currentIndex - 1];
398
+ }
399
+ event.preventDefault();
400
+ return;
401
+ }
402
+ case 'ArrowRight': {
403
+ if (currentIndex < selectedIds.length - 1) {
404
+ focusedTagId = selectedIds[currentIndex + 1];
405
+ } else {
406
+ // Past last tag, return to input
407
+ focusedTagId = null;
408
+ }
409
+ event.preventDefault();
410
+ return;
411
+ }
412
+ case 'ArrowUp': {
413
+ focusedTagId = null;
414
+ if (!currentIsOpen) {
415
+ openPopover();
416
+ navigation.setPendingDirection('last');
417
+ } else {
418
+ navigation.previous();
419
+ }
420
+ event.preventDefault();
421
+ return;
422
+ }
423
+ case 'ArrowDown': {
424
+ focusedTagId = null;
425
+ if (!currentIsOpen) {
426
+ openPopover();
427
+ navigation.setPendingDirection('first');
428
+ } else {
429
+ navigation.next();
430
+ }
431
+ event.preventDefault();
432
+ return;
433
+ }
434
+ case 'Delete':
435
+ case 'Backspace': {
436
+ const prevId = currentIndex > 0 ? selectedIds[currentIndex - 1] : null;
437
+ const nextId =
438
+ currentIndex < selectedIds.length - 1 ? selectedIds[currentIndex + 1] : null;
439
+
440
+ removeItem(focusedTagId);
441
+
442
+ if (nextId !== null) {
443
+ focusedTagId = nextId;
444
+ } else if (prevId !== null) {
445
+ focusedTagId = prevId;
446
+ } else {
447
+ focusedTagId = null;
448
+ }
449
+ event.preventDefault();
450
+ return;
451
+ }
452
+ case 'Escape': {
453
+ focusedTagId = null;
454
+ break; // Fall through to normal escape handling
455
+ }
456
+ default: {
457
+ // Character keys: clear tag focus, let character go to input
458
+ if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
459
+ focusedTagId = null;
460
+ // Don't prevent default - character will be typed in input
461
+ return;
462
+ }
463
+ break;
464
+ }
465
+ }
466
+ }
467
+
468
+ switch (event.key) {
469
+ case 'ArrowDown':
470
+ if (!currentIsOpen) {
471
+ openPopover();
472
+ // If there's no selection, set pending direction to focus first item
473
+ if (currentSelection.size === 0 || selectionMode !== 'single') {
474
+ navigation.setPendingDirection('first');
475
+ }
476
+ // If there's a selection, openPopover already focused it
477
+ } else {
478
+ navigation.next();
479
+ }
480
+ event.preventDefault();
481
+ break;
482
+ case 'ArrowUp':
483
+ if (!currentIsOpen) {
484
+ openPopover();
485
+ // If there's no selection, set pending direction to focus last item
486
+ if (currentSelection.size === 0 || selectionMode !== 'single') {
487
+ navigation.setPendingDirection('last');
488
+ }
489
+ // If there's a selection, openPopover already focused it
490
+ } else {
491
+ navigation.previous();
492
+ }
493
+ event.preventDefault();
494
+ break;
495
+ case 'ArrowLeft':
496
+ // In multiple mode, navigate to last tag when cursor is at start
497
+ if (selectionMode === 'multiple' && currentSelection.size > 0) {
498
+ const input = inputRef as HTMLInputElement | null;
499
+ if (input && input.selectionStart === 0 && input.selectionEnd === 0) {
500
+ // Close popover when navigating to tags
501
+ if (currentIsOpen) {
502
+ closePopover();
503
+ }
504
+ // Set virtual focus on last tag
505
+ const selectedIds = Array.from(currentSelection);
506
+ focusedTagId = selectedIds[selectedIds.length - 1];
507
+ event.preventDefault();
508
+ break;
509
+ }
510
+ }
511
+ if (currentIsOpen) {
512
+ // Reset focus when using horizontal arrows, but allow cursor movement
513
+ navigation.setFocused(null);
514
+ // Don't prevent default - let the cursor move in the input
515
+ }
516
+ break;
517
+ case 'ArrowRight':
518
+ if (currentIsOpen) {
519
+ // Reset focus when using horizontal arrows, but allow cursor movement
520
+ navigation.setFocused(null);
521
+ // Don't prevent default - let the cursor move in the input
522
+ }
523
+ break;
524
+ case 'Home':
525
+ if (currentIsOpen) {
526
+ navigation.first();
527
+ event.preventDefault();
528
+ }
529
+ break;
530
+ case 'End':
531
+ if (currentIsOpen) {
532
+ navigation.last();
533
+ event.preventDefault();
534
+ }
535
+ break;
536
+ case 'PageUp':
537
+ if (currentIsOpen) {
538
+ navigation.pageUp();
539
+ event.preventDefault();
540
+ }
541
+ break;
542
+ case 'PageDown':
543
+ if (currentIsOpen) {
544
+ navigation.pageDown();
545
+ event.preventDefault();
546
+ }
547
+ break;
548
+ case 'Enter':
549
+ if (currentIsOpen && navigation.focusedId !== null) {
550
+ selectFocusedItem();
551
+ event.preventDefault();
552
+ }
553
+ break;
554
+ case 'Escape':
555
+ if (currentIsOpen) {
556
+ closePopover(true); // Keep focus on input after Escape
557
+ // Stop propagation so parent dialogs don't also close
558
+ event.stopPropagation();
559
+ event.stopImmediatePropagation();
560
+ }
561
+ handleInputBlur();
562
+ event.preventDefault();
563
+ break;
564
+ case 'Backspace':
565
+ // In multiple mode, remove last tag when input is empty
566
+ if (selectionMode === 'multiple' && currentInputValue === '' && currentSelection.size > 0) {
567
+ const lastId = Array.from(currentSelection).pop();
568
+ if (lastId !== undefined) {
569
+ removeItem(lastId);
570
+ }
571
+ }
572
+ // Don't prevent default - let backspace work normally in input
573
+ break;
574
+ }
575
+ }
576
+
577
+ function setWrapperAsTrigger(node: HTMLElement) {
578
+ triggerRef = node;
579
+ return {};
580
+ }
581
+
582
+ const ctx: ComboBoxContext<T> = {
583
+ get instanceId() {
584
+ return instanceId;
585
+ },
586
+ get inputValue() {
587
+ return filterValue; // Returns empty string when shouldFilter is false
588
+ },
589
+ get displayValue() {
590
+ return currentInputValue; // Always returns the actual input value
591
+ },
592
+ get isOpen() {
593
+ return currentIsOpen;
594
+ },
595
+ get inputRef() {
596
+ return inputRef;
597
+ },
598
+ get triggerRef() {
599
+ return triggerRef;
600
+ },
601
+ get selectedValue() {
602
+ return currentSelection;
603
+ },
604
+ get isDisabled() {
605
+ return isDisabled;
606
+ },
607
+ get isReadOnly() {
608
+ return isReadOnly;
609
+ },
610
+ get selectionMode() {
611
+ return selectionMode;
612
+ },
613
+ get trigger() {
614
+ return trigger;
615
+ },
616
+ get shouldFilter() {
617
+ return shouldFilter;
618
+ },
619
+ get focusedItemId() {
620
+ return navigation.focusedId;
621
+ },
622
+ get itemIds() {
623
+ return navigation.itemIds;
624
+ },
625
+ get itemLabels() {
626
+ return navigation.itemLabels;
627
+ },
628
+ get selectedLabels() {
629
+ return selectedLabels;
630
+ },
631
+ get pendingFocusDirection() {
632
+ return navigation.pendingFocusDirection;
633
+ },
634
+ get listboxCtx() {
635
+ return listboxCtxRef;
636
+ },
637
+ get listboxRef() {
638
+ return listboxRef;
639
+ },
640
+ get items() {
641
+ return items;
642
+ },
643
+ get renderItem() {
644
+ return renderItem;
645
+ },
646
+ setInputRef: (el) => {
647
+ inputRef = el;
648
+ },
649
+ setTriggerRef: (el) => {
650
+ triggerRef = el;
651
+ },
652
+ setListboxCtx: (ctx) => {
653
+ listboxCtxRef = ctx;
654
+ },
655
+ setListboxRef: (el) => {
656
+ listboxRef = el;
657
+ },
658
+ setInputValue: setInputValueHandler,
659
+ open: openPopover,
660
+ close: closePopover,
661
+ toggle: togglePopover,
662
+ select: selectItem,
663
+ removeItem,
664
+ clearSelection,
665
+ onOpenChange: setIsOpen,
666
+ setFocusedItemId: navigation.setFocused,
667
+ registerItem: navigation.register,
668
+ unregisterItem: navigation.unregister,
669
+ handleKeydown,
670
+ handleInputBlur,
671
+ get focusedTagId() {
672
+ return focusedTagId;
673
+ },
674
+ setFocusedTagId: (id: string | number | null) => {
675
+ focusedTagId = id;
676
+ }
677
+ };
678
+
679
+ setComboBoxContext(ctx);
680
+ </script>
681
+
682
+ <div
683
+ bind:this={wrapperRef}
684
+ role="group"
685
+ aria-label={ariaLabel}
686
+ aria-labelledby={ariaLabelledby}
687
+ class={className}
688
+ data-combobox
689
+ data-disabled={isDisabled || undefined}
690
+ data-readonly={isReadOnly || undefined}
691
+ use:setWrapperAsTrigger
692
+ >
693
+ {#if children}
694
+ {@render children()}
695
+ {/if}
696
+ </div>