@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.
- package/dist/combobox/TODO.md +175 -0
- package/dist/combobox/button/combobox-button.svelte +57 -0
- package/dist/combobox/button/combobox-button.svelte.d.ts +9 -0
- package/dist/combobox/index.d.ts +14 -0
- package/dist/combobox/index.js +18 -0
- package/dist/combobox/index.parts.d.ts +10 -0
- package/dist/combobox/index.parts.js +11 -0
- package/dist/combobox/input/combobox-input.svelte +98 -0
- package/dist/combobox/input/combobox-input.svelte.d.ts +13 -0
- package/dist/combobox/item/combobox-item-implicit-text-test.svelte +21 -0
- package/dist/combobox/item/combobox-item-implicit-text-test.svelte.d.ts +3 -0
- package/dist/combobox/item/combobox-listboxitem.svelte +136 -0
- package/dist/combobox/item/combobox-listboxitem.svelte.d.ts +18 -0
- package/dist/combobox/item-indicator/combobox-item-indicator.svelte +63 -0
- package/dist/combobox/item-indicator/combobox-item-indicator.svelte.d.ts +17 -0
- package/dist/combobox/list/combobox-listbox.svelte +76 -0
- package/dist/combobox/list/combobox-listbox.svelte.d.ts +47 -0
- package/dist/combobox/popover/combobox-popover.svelte +69 -0
- package/dist/combobox/popover/combobox-popover.svelte.d.ts +12 -0
- package/dist/combobox/root/combobox-filtered-test.svelte +51 -0
- package/dist/combobox/root/combobox-filtered-test.svelte.d.ts +7 -0
- package/dist/combobox/root/combobox-multiselect-test.svelte +76 -0
- package/dist/combobox/root/combobox-multiselect-test.svelte.d.ts +13 -0
- package/dist/combobox/root/combobox-numeric-string-id-test.svelte +20 -0
- package/dist/combobox/root/combobox-numeric-string-id-test.svelte.d.ts +3 -0
- package/dist/combobox/root/combobox-test.svelte +43 -0
- package/dist/combobox/root/combobox-test.svelte.d.ts +9 -0
- package/dist/combobox/root/combobox.svelte +696 -0
- package/dist/combobox/root/combobox.svelte.d.ts +58 -0
- package/dist/combobox/root/context.d.ts +90 -0
- package/dist/combobox/root/context.js +15 -0
- package/dist/combobox/tag/combobox-tag.svelte +58 -0
- package/dist/combobox/tag/combobox-tag.svelte.d.ts +22 -0
- package/dist/combobox/tag/tag-context-provider.svelte +36 -0
- package/dist/combobox/tag/tag-context-provider.svelte.d.ts +14 -0
- package/dist/combobox/tag-remove/combobox-tag-remove.svelte +53 -0
- package/dist/combobox/tag-remove/combobox-tag-remove.svelte.d.ts +14 -0
- package/dist/combobox/tags/combobox-tags.svelte +50 -0
- package/dist/combobox/tags/combobox-tags.svelte.d.ts +20 -0
- package/dist/dialog/content/dialog-content.svelte +121 -0
- package/dist/dialog/content/dialog-content.svelte.d.ts +19 -0
- package/dist/dialog/index.d.ts +10 -0
- package/dist/dialog/index.js +15 -0
- package/dist/dialog/index.parts.d.ts +5 -0
- package/dist/dialog/index.parts.js +6 -0
- package/dist/dialog/overlay/dialog-overlay.svelte +39 -0
- package/dist/dialog/overlay/dialog-overlay.svelte.d.ts +12 -0
- package/dist/dialog/portal/dialog-portal.svelte +32 -0
- package/dist/dialog/portal/dialog-portal.svelte.d.ts +12 -0
- package/dist/dialog/root/context.d.ts +25 -0
- package/dist/dialog/root/context.js +8 -0
- package/dist/dialog/root/dialog-root.svelte +99 -0
- package/dist/dialog/root/dialog-root.svelte.d.ts +21 -0
- package/dist/dialog/root/dialog-stack.d.ts +32 -0
- package/dist/dialog/root/dialog-stack.js +55 -0
- package/dist/dialog/root/dialog-test.svelte +38 -0
- package/dist/dialog/root/dialog-test.svelte.d.ts +10 -0
- package/dist/dialog/root/dialog-with-combobox-test.svelte +61 -0
- package/dist/dialog/root/dialog-with-combobox-test.svelte.d.ts +7 -0
- package/dist/dialog/root/nested-dialog-test.svelte +63 -0
- package/dist/dialog/root/nested-dialog-test.svelte.d.ts +8 -0
- package/dist/dialog/root/types.d.ts +10 -0
- package/dist/dialog/root/types.js +1 -0
- package/dist/dialog/trigger/dialog-trigger.svelte +71 -0
- package/dist/dialog/trigger/dialog-trigger.svelte.d.ts +12 -0
- package/dist/hooks/use-virtual-focus.svelte.d.ts +55 -0
- package/dist/hooks/use-virtual-focus.svelte.js +201 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +19 -0
- package/dist/input/index.d.ts +3 -0
- package/dist/input/index.js +3 -0
- package/dist/input/input.svelte +19 -0
- package/dist/input/input.svelte.d.ts +8 -0
- package/dist/label/index.d.ts +3 -0
- package/dist/label/index.js +3 -0
- package/dist/label/label.svelte +21 -0
- package/dist/label/label.svelte.d.ts +8 -0
- package/dist/listbox/index.d.ts +6 -0
- package/dist/listbox/index.js +10 -0
- package/dist/listbox/index.parts.d.ts +2 -0
- package/dist/listbox/index.parts.js +3 -0
- package/dist/listbox/item/listbox-item.svelte +186 -0
- package/dist/listbox/item/listbox-item.svelte.d.ts +34 -0
- package/dist/listbox/root/context.d.ts +73 -0
- package/dist/listbox/root/context.js +249 -0
- package/dist/listbox/root/listbox-numeric-id-test.svelte +18 -0
- package/dist/listbox/root/listbox-numeric-id-test.svelte.d.ts +3 -0
- package/dist/listbox/root/listbox-test.svelte +27 -0
- package/dist/listbox/root/listbox-test.svelte.d.ts +8 -0
- package/dist/listbox/root/listbox.svelte +146 -0
- package/dist/listbox/root/listbox.svelte.d.ts +54 -0
- package/dist/popover/content/popover-content-test.svelte +43 -0
- package/dist/popover/content/popover-content-test.svelte.d.ts +12 -0
- package/dist/popover/content/popover-content.svelte +167 -0
- package/dist/popover/content/popover-content.svelte.d.ts +38 -0
- package/dist/popover/index.d.ts +8 -0
- package/dist/popover/index.js +14 -0
- package/dist/popover/index.parts.d.ts +4 -0
- package/dist/popover/index.parts.js +5 -0
- package/dist/popover/root/context.d.ts +24 -0
- package/dist/popover/root/context.js +10 -0
- package/dist/popover/root/popover-root.svelte +87 -0
- package/dist/popover/root/popover-root.svelte.d.ts +20 -0
- package/dist/popover/root/popover-test.svelte +40 -0
- package/dist/popover/root/popover-test.svelte.d.ts +11 -0
- package/dist/popover/trigger/popover-trigger-button.svelte +42 -0
- package/dist/popover/trigger/popover-trigger-button.svelte.d.ts +12 -0
- package/dist/popover/trigger/popover-trigger-in-dialog-test.svelte +29 -0
- package/dist/popover/trigger/popover-trigger-in-dialog-test.svelte.d.ts +18 -0
- package/dist/popover/trigger/popover-trigger.svelte +71 -0
- package/dist/popover/trigger/popover-trigger.svelte.d.ts +12 -0
- package/dist/portal/index.d.ts +1 -0
- package/dist/portal/index.js +1 -0
- package/dist/portal/portal.svelte +44 -0
- package/dist/portal/portal.svelte.d.ts +10 -0
- package/dist/primitives/aria-hide-outside.d.ts +38 -0
- package/dist/primitives/aria-hide-outside.js +152 -0
- package/dist/primitives/click-outside.d.ts +26 -0
- package/dist/primitives/click-outside.js +66 -0
- package/dist/primitives/floating.d.ts +57 -0
- package/dist/primitives/floating.js +179 -0
- package/dist/primitives/focus-trap.d.ts +19 -0
- package/dist/primitives/focus-trap.js +102 -0
- package/dist/primitives/index.d.ts +6 -0
- package/dist/primitives/index.js +7 -0
- package/dist/primitives/keyboard-navigation.d.ts +88 -0
- package/dist/primitives/keyboard-navigation.js +274 -0
- package/dist/primitives/scroll-lock.d.ts +19 -0
- package/dist/primitives/scroll-lock.js +62 -0
- package/dist/test-mocks/app-environment.d.ts +7 -0
- package/dist/test-mocks/app-environment.js +7 -0
- package/dist/test-mocks/app-navigation.d.ts +11 -0
- package/dist/test-mocks/app-navigation.js +11 -0
- package/dist/test-mocks/app-stores.d.ts +16 -0
- package/dist/test-mocks/app-stores.js +18 -0
- package/dist/utils/cn.d.ts +2 -0
- package/dist/utils/cn.js +5 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- 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>
|