@human-kit/svelte-components 1.0.0-alpha.11 → 1.0.0-alpha.13
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/input/combobox-input.svelte +1 -0
- package/dist/combobox/item/combobox-listboxitem.svelte +11 -2
- package/dist/combobox/list/combobox-listbox.svelte +1 -0
- package/dist/combobox/list/combobox-listbox.svelte.d.ts +1 -0
- package/dist/combobox/root/combobox-multiselect-test.svelte +4 -2
- package/dist/combobox/root/combobox-multiselect-test.svelte.d.ts +1 -0
- package/dist/combobox/root/combobox-test.svelte +8 -2
- package/dist/combobox/root/combobox-test.svelte.d.ts +1 -0
- package/dist/combobox/root/combobox.svelte +18 -9
- package/dist/hooks/use-virtual-focus.svelte.js +3 -1
- package/dist/listbox/item/listbox-item.svelte +91 -6
- package/dist/listbox/item/listbox-item.svelte.d.ts +2 -0
- package/dist/listbox/root/context.d.ts +6 -0
- package/dist/listbox/root/context.js +23 -13
- package/dist/listbox/root/listbox.svelte +11 -14
- package/dist/listbox/root/listbox.svelte.d.ts +2 -0
- package/dist/primitives/keyboard-navigation.d.ts +1 -0
- package/dist/primitives/keyboard-navigation.js +17 -0
- package/package.json +1 -1
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
|
|
57
57
|
// Normalized input for filtering comparison
|
|
58
58
|
const normalizedInput = $derived(ctx.inputValue.trim().toLowerCase());
|
|
59
|
+
const isDisabled = $derived(Boolean(props.disabled) || ctx.isDisabled);
|
|
59
60
|
|
|
60
61
|
// Automatic filtering: if text is not resolved yet, keep item visible until mount resolves it.
|
|
61
62
|
const isVisible = $derived(
|
|
@@ -77,14 +78,15 @@
|
|
|
77
78
|
// Reactive registration: register when visible, unregister when hidden
|
|
78
79
|
$effect(() => {
|
|
79
80
|
const visible = isVisible;
|
|
81
|
+
const disabled = isDisabled;
|
|
80
82
|
const label = effectiveTextValue || String(id);
|
|
81
83
|
const itemId = id;
|
|
82
84
|
|
|
83
85
|
untrack(() => {
|
|
84
|
-
if (visible && !isRegistered) {
|
|
86
|
+
if (visible && !disabled && !isRegistered) {
|
|
85
87
|
ctx.registerItem(itemId, label);
|
|
86
88
|
isRegistered = true;
|
|
87
|
-
} else if (!visible && isRegistered) {
|
|
89
|
+
} else if ((!visible || disabled) && isRegistered) {
|
|
88
90
|
ctx.unregisterItem(itemId);
|
|
89
91
|
isRegistered = false;
|
|
90
92
|
}
|
|
@@ -109,8 +111,14 @@
|
|
|
109
111
|
|
|
110
112
|
// Custom select handler that uses ComboBox context
|
|
111
113
|
function handleSelect(itemId: string | number, label: string) {
|
|
114
|
+
ctx.setFocusedItemId(itemId);
|
|
112
115
|
ctx.select(itemId, label);
|
|
113
116
|
}
|
|
117
|
+
|
|
118
|
+
function handleHoverStart(itemId: string | number) {
|
|
119
|
+
ctx.setFocusVisible(false);
|
|
120
|
+
ctx.setFocusedItemId(itemId);
|
|
121
|
+
}
|
|
114
122
|
</script>
|
|
115
123
|
|
|
116
124
|
{#if isVisible}
|
|
@@ -124,6 +132,7 @@
|
|
|
124
132
|
isFocusVisibleOverride={isFocusVisible}
|
|
125
133
|
onItemSelect={handleSelect}
|
|
126
134
|
onResolvedTextValue={handleResolvedTextValue}
|
|
135
|
+
onItemHoverStart={handleHoverStart}
|
|
127
136
|
scrollOnFocus={true}
|
|
128
137
|
isParentDisabled={ctx.isDisabled}
|
|
129
138
|
/>
|
|
@@ -14,6 +14,7 @@ declare function $$render<T extends object = object>(): {
|
|
|
14
14
|
id?: string;
|
|
15
15
|
'aria-label'?: string;
|
|
16
16
|
onChange?: ((value: Set<string | number>) => void) | undefined;
|
|
17
|
+
disableFocusHandling?: boolean;
|
|
17
18
|
} & {
|
|
18
19
|
context?: ListBoxContext;
|
|
19
20
|
element?: HTMLElement;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
onValueChange?: (value: (string | number)[]) => void;
|
|
8
8
|
trigger?: 'focus' | 'input' | 'press';
|
|
9
9
|
closeOnSelect?: boolean;
|
|
10
|
+
disabledIds?: string[];
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
let {
|
|
@@ -20,7 +21,8 @@
|
|
|
20
21
|
value = $bindable([]),
|
|
21
22
|
onValueChange,
|
|
22
23
|
trigger = 'press',
|
|
23
|
-
closeOnSelect = false
|
|
24
|
+
closeOnSelect = false,
|
|
25
|
+
disabledIds = []
|
|
24
26
|
}: Props = $props();
|
|
25
27
|
|
|
26
28
|
function handleChange(newValue: string | number | (string | number)[] | undefined) {
|
|
@@ -53,7 +55,7 @@
|
|
|
53
55
|
<ComboBox.Popover>
|
|
54
56
|
<ComboBox.List>
|
|
55
57
|
{#each items as item (item.id)}
|
|
56
|
-
<ComboBox.Item id={item.id} textValue={item.name}>
|
|
58
|
+
<ComboBox.Item id={item.id} textValue={item.name} disabled={disabledIds.includes(item.id)}>
|
|
57
59
|
{item.name}
|
|
58
60
|
<ComboBox.ItemIndicator />
|
|
59
61
|
</ComboBox.Item>
|
|
@@ -7,6 +7,7 @@ interface Props {
|
|
|
7
7
|
onValueChange?: (value: (string | number)[]) => void;
|
|
8
8
|
trigger?: 'focus' | 'input' | 'press';
|
|
9
9
|
closeOnSelect?: boolean;
|
|
10
|
+
disabledIds?: string[];
|
|
10
11
|
}
|
|
11
12
|
declare const ComboboxMultiselectTest: import("svelte").Component<Props, {}, "value">;
|
|
12
13
|
type ComboboxMultiselectTest = ReturnType<typeof ComboboxMultiselectTest>;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
isPending?: boolean;
|
|
8
8
|
isReadOnly?: boolean;
|
|
9
9
|
trigger?: 'focus' | 'input' | 'press';
|
|
10
|
+
disabledIds?: string[];
|
|
10
11
|
};
|
|
11
12
|
|
|
12
13
|
let {
|
|
@@ -14,7 +15,8 @@
|
|
|
14
15
|
isDisabled = false,
|
|
15
16
|
isPending = false,
|
|
16
17
|
isReadOnly = false,
|
|
17
|
-
trigger = 'press'
|
|
18
|
+
trigger = 'press',
|
|
19
|
+
disabledIds = []
|
|
18
20
|
}: Props = $props();
|
|
19
21
|
|
|
20
22
|
let selectedValue = $state<string | number | undefined>();
|
|
@@ -40,7 +42,11 @@
|
|
|
40
42
|
<ComboBox.Popover>
|
|
41
43
|
<ComboBox.List emptyPlaceholder="No countries found">
|
|
42
44
|
{#each countries as country (country.id)}
|
|
43
|
-
<ComboBox.Item
|
|
45
|
+
<ComboBox.Item
|
|
46
|
+
id={country.id}
|
|
47
|
+
textValue={country.name}
|
|
48
|
+
disabled={disabledIds.includes(country.id)}
|
|
49
|
+
>
|
|
44
50
|
{country.name}
|
|
45
51
|
</ComboBox.Item>
|
|
46
52
|
{/each}
|
|
@@ -211,6 +211,15 @@
|
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
function syncInputValue(val: string, options?: { notifyInputChange?: boolean }) {
|
|
215
|
+
inputValueInternal = val;
|
|
216
|
+
inputValue = val;
|
|
217
|
+
|
|
218
|
+
if (options?.notifyInputChange ?? true) {
|
|
219
|
+
onInputChange?.(val);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
214
223
|
function selectItem(id: string | number, label: string) {
|
|
215
224
|
let newSelection: Set<string | number>;
|
|
216
225
|
|
|
@@ -218,10 +227,9 @@
|
|
|
218
227
|
newSelection = new Set([id]);
|
|
219
228
|
// Save the label persistently for restore on blur/escape
|
|
220
229
|
selectedLabel = label;
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
onInputChange?.(label);
|
|
230
|
+
// Keep the selected label visible in the input without re-triggering
|
|
231
|
+
// external filtering during the popover close animation.
|
|
232
|
+
syncInputValue(label, { notifyInputChange: false });
|
|
225
233
|
if (effectiveCloseOnSelect) {
|
|
226
234
|
closePopover(true); // Close and keep focus on input
|
|
227
235
|
}
|
|
@@ -318,6 +326,8 @@
|
|
|
318
326
|
onInputChange?.('');
|
|
319
327
|
}
|
|
320
328
|
// Otherwise user typed, keep their filter
|
|
329
|
+
} else {
|
|
330
|
+
shouldFilter = true;
|
|
321
331
|
}
|
|
322
332
|
setIsOpen(true);
|
|
323
333
|
// Auto-focus the selected item when opening with a selection
|
|
@@ -333,8 +343,6 @@
|
|
|
333
343
|
setIsOpen(false);
|
|
334
344
|
// Reset navigation state
|
|
335
345
|
navigation.reset();
|
|
336
|
-
// Re-enable filtering for next open
|
|
337
|
-
shouldFilter = true;
|
|
338
346
|
// Only refocus input when explicitly requested (e.g., after selection)
|
|
339
347
|
// Never refocus in focus mode to prevent re-opening
|
|
340
348
|
if (refocusInput && trigger !== 'focus') {
|
|
@@ -381,6 +389,9 @@
|
|
|
381
389
|
// Use navigation hook methods for keyboard navigation
|
|
382
390
|
function selectFocusedItem() {
|
|
383
391
|
if (navigation.focusedId !== null) {
|
|
392
|
+
if (listboxCtxRef?.isDisabled(navigation.focusedId)) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
384
395
|
const label = navigation.itemLabels.get(navigation.focusedId) ?? String(navigation.focusedId);
|
|
385
396
|
selectItem(navigation.focusedId, label);
|
|
386
397
|
}
|
|
@@ -439,9 +450,7 @@
|
|
|
439
450
|
if (currentSelection.size > 0 && selectedLabel) {
|
|
440
451
|
if (selectedLabel !== currentInputValue) {
|
|
441
452
|
// Restore the selected label
|
|
442
|
-
|
|
443
|
-
inputValue = selectedLabel;
|
|
444
|
-
onInputChange?.(selectedLabel);
|
|
453
|
+
syncInputValue(selectedLabel, { notifyInputChange: false });
|
|
445
454
|
}
|
|
446
455
|
}
|
|
447
456
|
}
|
|
@@ -62,7 +62,9 @@ export function useVirtualFocus(options) {
|
|
|
62
62
|
if (fullId && fullId.startsWith(prefix)) {
|
|
63
63
|
const rawId = fullId.substring(prefix.length);
|
|
64
64
|
const registeredId = itemIds.find((id) => String(id) === rawId);
|
|
65
|
-
|
|
65
|
+
if (registeredId !== undefined) {
|
|
66
|
+
orderedIds.push(registeredId);
|
|
67
|
+
}
|
|
66
68
|
}
|
|
67
69
|
});
|
|
68
70
|
cachedItemOrder = orderedIds;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { useListBoxContext } from '../root/context';
|
|
5
5
|
import { onMount, onDestroy } from 'svelte';
|
|
6
6
|
import {
|
|
7
|
+
focusWithModality,
|
|
7
8
|
shouldShowFocusVisible,
|
|
8
9
|
trackInteractionModality
|
|
9
10
|
} from '../../primitives/input-modality';
|
|
@@ -36,6 +37,8 @@
|
|
|
36
37
|
onItemSelect?: (id: string | number, label: string) => void;
|
|
37
38
|
/** Callback with resolved text value when mounted (from prop or rendered content). */
|
|
38
39
|
onResolvedTextValue?: (label: string) => void;
|
|
40
|
+
/** Callback when pointer hover should move logical focus to this item. */
|
|
41
|
+
onItemHoverStart?: (id: string | number, label: string) => void;
|
|
39
42
|
/** Whether to scroll this item into view when focused. Useful for virtual focus patterns. */
|
|
40
43
|
scrollOnFocus?: boolean;
|
|
41
44
|
/** Additional disabled state from parent. */
|
|
@@ -57,6 +60,7 @@
|
|
|
57
60
|
isFocusVisibleOverride,
|
|
58
61
|
onItemSelect,
|
|
59
62
|
onResolvedTextValue,
|
|
63
|
+
onItemHoverStart,
|
|
60
64
|
scrollOnFocus = false,
|
|
61
65
|
isParentDisabled = false,
|
|
62
66
|
pressed: pressedOverride,
|
|
@@ -69,9 +73,11 @@
|
|
|
69
73
|
let isSelected = $state(false);
|
|
70
74
|
let isFocused = $state(false);
|
|
71
75
|
let isFocusVisible = $state(false);
|
|
76
|
+
let listFocusVisible = $state(false);
|
|
72
77
|
let isHovered = $state(false);
|
|
73
78
|
let isPressed = $state(false);
|
|
74
79
|
let pressedKey: 'Enter' | 'Space' | null = $state(null);
|
|
80
|
+
let suppressNextFocusVisible = $state(false);
|
|
75
81
|
|
|
76
82
|
// Focus: use override if provided, otherwise use internal state
|
|
77
83
|
const isFocusedComputed = $derived(
|
|
@@ -88,12 +94,20 @@
|
|
|
88
94
|
const isFocusVisibleComputed = $derived(
|
|
89
95
|
isFocusVisibleOverride !== undefined ? isFocusVisibleOverride : isFocusVisible
|
|
90
96
|
);
|
|
97
|
+
const isActiveFocusVisible = $derived(
|
|
98
|
+
isFocusVisibleOverride !== undefined
|
|
99
|
+
? isFocusVisibleComputed
|
|
100
|
+
: isFocusedComputed && listFocusVisible
|
|
101
|
+
);
|
|
102
|
+
const showFocusVisible = $derived(isActiveFocusVisible && !isHovered);
|
|
103
|
+
const showHovered = $derived(isHovered && !isActiveFocusVisible);
|
|
91
104
|
|
|
92
105
|
// ID: use custom if provided, otherwise generate
|
|
93
106
|
const uniqueId = $derived(customId ?? `listbox-item-${id}`);
|
|
94
107
|
|
|
95
108
|
let unsubscribeSelection: (() => void) | null = null;
|
|
96
109
|
let unsubscribeFocus: (() => void) | null = null;
|
|
110
|
+
let unsubscribeFocusVisible: (() => void) | null = null;
|
|
97
111
|
|
|
98
112
|
function getResolvedTextValue() {
|
|
99
113
|
return textValue || elementRef?.textContent?.trim() || String(id);
|
|
@@ -115,6 +129,9 @@
|
|
|
115
129
|
unsubscribeFocus = listboxCtx.subscribeToFocus(id, (focused) => {
|
|
116
130
|
isFocused = focused;
|
|
117
131
|
});
|
|
132
|
+
unsubscribeFocusVisible = listboxCtx.subscribeToFocusVisible((visible) => {
|
|
133
|
+
listFocusVisible = visible;
|
|
134
|
+
});
|
|
118
135
|
listboxCtx.keyboardNav.updateItems();
|
|
119
136
|
}
|
|
120
137
|
});
|
|
@@ -123,11 +140,12 @@
|
|
|
123
140
|
listboxCtx.unregisterItem(id);
|
|
124
141
|
unsubscribeSelection?.();
|
|
125
142
|
unsubscribeFocus?.();
|
|
143
|
+
unsubscribeFocusVisible?.();
|
|
126
144
|
});
|
|
127
145
|
|
|
128
146
|
// Scroll into view when focused (if enabled)
|
|
129
147
|
$effect(() => {
|
|
130
|
-
if (scrollOnFocus && isFocusedComputed && elementRef) {
|
|
148
|
+
if (scrollOnFocus && isFocusedComputed && isFocusVisibleComputed && elementRef) {
|
|
131
149
|
requestAnimationFrame(() => {
|
|
132
150
|
elementRef?.scrollIntoView({ block: 'nearest' });
|
|
133
151
|
});
|
|
@@ -146,35 +164,83 @@
|
|
|
146
164
|
pressedKey = null;
|
|
147
165
|
}
|
|
148
166
|
|
|
167
|
+
function applyPointerFocusState() {
|
|
168
|
+
suppressNextFocusVisible = true;
|
|
169
|
+
listboxCtx.setFocusVisible(false);
|
|
170
|
+
listboxCtx.setFocusedId(id);
|
|
171
|
+
listboxCtx.keyboardNav.setCurrentId(id);
|
|
172
|
+
if (elementRef) {
|
|
173
|
+
focusWithModality(elementRef, 'pointer');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function transferHoverFocus() {
|
|
178
|
+
const label = getResolvedTextValue();
|
|
179
|
+
if (onItemHoverStart) {
|
|
180
|
+
onItemHoverStart(id, label);
|
|
181
|
+
} else if (!disableFocusHandling) {
|
|
182
|
+
applyPointerFocusState();
|
|
183
|
+
requestAnimationFrame(() => {
|
|
184
|
+
if (isHovered && !isDisabledComputed) {
|
|
185
|
+
applyPointerFocusState();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
149
191
|
function handleClick() {
|
|
150
192
|
if (isDisabledComputed) return;
|
|
151
193
|
|
|
152
194
|
const label = getResolvedTextValue();
|
|
153
195
|
|
|
196
|
+
if (!disableFocusHandling && elementRef) {
|
|
197
|
+
suppressNextFocusVisible = true;
|
|
198
|
+
isFocusVisible = false;
|
|
199
|
+
listboxCtx.setFocusVisible(false);
|
|
200
|
+
listboxCtx.setFocusedId(id);
|
|
201
|
+
listboxCtx.keyboardNav.setCurrentId(id);
|
|
202
|
+
focusWithModality(elementRef, 'pointer');
|
|
203
|
+
}
|
|
204
|
+
|
|
154
205
|
// Use custom select handler if provided, otherwise use listbox default
|
|
155
206
|
if (onItemSelect) {
|
|
156
207
|
onItemSelect(id, label);
|
|
157
208
|
} else {
|
|
158
209
|
listboxCtx.select(id);
|
|
159
|
-
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!disableFocusHandling) {
|
|
213
|
+
listboxCtx.keyboardNav.setCurrentId(id);
|
|
160
214
|
}
|
|
161
215
|
}
|
|
162
216
|
|
|
163
217
|
function handleFocus() {
|
|
164
218
|
if (isDisabledComputed) return;
|
|
165
|
-
isFocusVisible = shouldShowFocusVisible(elementRef);
|
|
219
|
+
isFocusVisible = suppressNextFocusVisible ? false : shouldShowFocusVisible(elementRef);
|
|
220
|
+
suppressNextFocusVisible = false;
|
|
221
|
+
if (isFocusVisible) {
|
|
222
|
+
isHovered = false;
|
|
223
|
+
}
|
|
166
224
|
if (!disableFocusHandling) {
|
|
225
|
+
listboxCtx.setFocusVisible(isFocusVisible);
|
|
167
226
|
listboxCtx.setFocusedId(id);
|
|
168
227
|
}
|
|
169
228
|
}
|
|
170
229
|
|
|
171
230
|
function handleBlur() {
|
|
172
231
|
isFocusVisible = false;
|
|
232
|
+
if (!disableFocusHandling && listboxCtx.isFocused(id)) {
|
|
233
|
+
listboxCtx.setFocusVisible(false);
|
|
234
|
+
listboxCtx.setFocusedId(null);
|
|
235
|
+
}
|
|
173
236
|
}
|
|
174
237
|
|
|
175
238
|
function handlePointerDown(event: PointerEvent) {
|
|
176
239
|
trackInteractionModality(event, elementRef);
|
|
177
240
|
isFocusVisible = false;
|
|
241
|
+
if (!disableFocusHandling) {
|
|
242
|
+
listboxCtx.setFocusVisible(false);
|
|
243
|
+
}
|
|
178
244
|
|
|
179
245
|
if (isDisabledComputed) {
|
|
180
246
|
event.preventDefault();
|
|
@@ -200,6 +266,12 @@
|
|
|
200
266
|
function handlePointerEnter(event: PointerEvent) {
|
|
201
267
|
if (isDisabledComputed) return;
|
|
202
268
|
|
|
269
|
+
trackInteractionModality(event, elementRef);
|
|
270
|
+
if (!disableFocusHandling) {
|
|
271
|
+
listboxCtx.setFocusVisible(false);
|
|
272
|
+
}
|
|
273
|
+
transferHoverFocus();
|
|
274
|
+
|
|
203
275
|
if ((event.buttons & 1) === 1 && pressedKey === null) {
|
|
204
276
|
isPressed = true;
|
|
205
277
|
}
|
|
@@ -211,9 +283,15 @@
|
|
|
211
283
|
}
|
|
212
284
|
}
|
|
213
285
|
|
|
214
|
-
function handleMouseEnter() {
|
|
286
|
+
function handleMouseEnter(event: MouseEvent) {
|
|
215
287
|
if (!isDisabledComputed) {
|
|
288
|
+
trackInteractionModality(event, elementRef);
|
|
216
289
|
isHovered = true;
|
|
290
|
+
isFocusVisible = false;
|
|
291
|
+
if (!disableFocusHandling) {
|
|
292
|
+
listboxCtx.setFocusVisible(false);
|
|
293
|
+
transferHoverFocus();
|
|
294
|
+
}
|
|
217
295
|
}
|
|
218
296
|
}
|
|
219
297
|
|
|
@@ -228,7 +306,11 @@
|
|
|
228
306
|
function handleKeydown(event: KeyboardEvent) {
|
|
229
307
|
trackInteractionModality(event, elementRef);
|
|
230
308
|
if (isFocusedComputed) {
|
|
309
|
+
isHovered = false;
|
|
231
310
|
isFocusVisible = true;
|
|
311
|
+
if (!disableFocusHandling) {
|
|
312
|
+
listboxCtx.setFocusVisible(true);
|
|
313
|
+
}
|
|
232
314
|
}
|
|
233
315
|
|
|
234
316
|
const key =
|
|
@@ -276,6 +358,9 @@
|
|
|
276
358
|
function handleMouseDown(event: MouseEvent) {
|
|
277
359
|
trackInteractionModality(event, elementRef);
|
|
278
360
|
isFocusVisible = false;
|
|
361
|
+
if (!disableFocusHandling) {
|
|
362
|
+
listboxCtx.setFocusVisible(false);
|
|
363
|
+
}
|
|
279
364
|
|
|
280
365
|
// Prevent focus stealing when used in ComboBox (disableFocusHandling=true)
|
|
281
366
|
// This keeps the focus on the input while allowing click selection
|
|
@@ -317,8 +402,8 @@
|
|
|
317
402
|
data-selected={isSelected || undefined}
|
|
318
403
|
data-disabled={isDisabledComputed || undefined}
|
|
319
404
|
data-focused={isFocusedComputed || undefined}
|
|
320
|
-
data-focus-visible={
|
|
321
|
-
data-hovered={
|
|
405
|
+
data-focus-visible={showFocusVisible || undefined}
|
|
406
|
+
data-hovered={showHovered || undefined}
|
|
322
407
|
data-pressed={isPressedComputed || undefined}
|
|
323
408
|
onpointerdown={handlePointerDown}
|
|
324
409
|
onpointerup={handlePointerUp}
|
|
@@ -26,6 +26,8 @@ type ListBoxItemProps = Omit<HTMLAttributes<HTMLDivElement>, 'id' | 'children'>
|
|
|
26
26
|
onItemSelect?: (id: string | number, label: string) => void;
|
|
27
27
|
/** Callback with resolved text value when mounted (from prop or rendered content). */
|
|
28
28
|
onResolvedTextValue?: (label: string) => void;
|
|
29
|
+
/** Callback when pointer hover should move logical focus to this item. */
|
|
30
|
+
onItemHoverStart?: (id: string | number, label: string) => void;
|
|
29
31
|
/** Whether to scroll this item into view when focused. Useful for virtual focus patterns. */
|
|
30
32
|
scrollOnFocus?: boolean;
|
|
31
33
|
/** Additional disabled state from parent. */
|
|
@@ -20,6 +20,8 @@ export type ListBoxContext = {
|
|
|
20
20
|
isDisabled: (id: string | number) => boolean;
|
|
21
21
|
/** Checks if an item is focused. */
|
|
22
22
|
isFocused: (id: string | number) => boolean;
|
|
23
|
+
/** Whether keyboard focus-visible should be shown for the currently focused item. */
|
|
24
|
+
getFocusVisible: () => boolean;
|
|
23
25
|
/** Keyboard navigation controller from the shared primitive. */
|
|
24
26
|
keyboardNav: KeyboardNavigationReturn;
|
|
25
27
|
/** Map of registered items with their metadata. */
|
|
@@ -45,10 +47,14 @@ export type ListBoxContext = {
|
|
|
45
47
|
setSelection: (selection: Set<string | number>) => void;
|
|
46
48
|
/** Sets the focused item ID. */
|
|
47
49
|
setFocusedId: (id: string | number | null) => void;
|
|
50
|
+
/** Sets whether the focused item should render keyboard focus-visible. */
|
|
51
|
+
setFocusVisible: (visible: boolean) => void;
|
|
48
52
|
/** Subscribes to selection changes for a specific item. Returns unsubscribe function. */
|
|
49
53
|
subscribeToItem: (id: string | number, callback: (selected: boolean) => void) => () => void;
|
|
50
54
|
/** Subscribes to focus changes for a specific item. Returns unsubscribe function. */
|
|
51
55
|
subscribeToFocus: (id: string | number, callback: (focused: boolean) => void) => () => void;
|
|
56
|
+
/** Subscribes to focus-visible state changes. Returns unsubscribe function. */
|
|
57
|
+
subscribeToFocusVisible: (callback: (visible: boolean) => void) => () => void;
|
|
52
58
|
/** Returns the next item ID respecting loop setting, or null if at end. */
|
|
53
59
|
getNextItemId: (currentId: string | number | null) => string | number | null;
|
|
54
60
|
/** Returns the previous item ID respecting loop setting, or null if at start. */
|
|
@@ -53,30 +53,30 @@ export function createListBoxContext(options = {}) {
|
|
|
53
53
|
}
|
|
54
54
|
let focusedId = null;
|
|
55
55
|
const focusCallbacks = new Map();
|
|
56
|
+
let focusVisible = false;
|
|
57
|
+
const focusVisibleCallbacks = new Set();
|
|
56
58
|
function getFocusedId() {
|
|
57
59
|
return focusedId;
|
|
58
60
|
}
|
|
59
61
|
function isFocused(id) {
|
|
60
62
|
return focusedId === id || String(focusedId) === String(id);
|
|
61
63
|
}
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
if (callbacks) {
|
|
65
|
-
callbacks.forEach((cb) => cb(focused));
|
|
66
|
-
}
|
|
64
|
+
function getFocusVisible() {
|
|
65
|
+
return focusVisible;
|
|
67
66
|
}
|
|
68
67
|
function setFocusedId(newId) {
|
|
69
|
-
if (focusedId === newId)
|
|
70
|
-
return;
|
|
71
|
-
const previousId = focusedId;
|
|
72
68
|
focusedId = newId;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (newId !== null) {
|
|
77
|
-
notifyFocus(newId, true);
|
|
69
|
+
for (const [id, callbacks] of focusCallbacks) {
|
|
70
|
+
const focused = newId !== null && (id === newId || String(id) === String(newId));
|
|
71
|
+
callbacks.forEach((callback) => callback(focused));
|
|
78
72
|
}
|
|
79
73
|
}
|
|
74
|
+
function setFocusVisible(visible) {
|
|
75
|
+
if (focusVisible === visible)
|
|
76
|
+
return;
|
|
77
|
+
focusVisible = visible;
|
|
78
|
+
focusVisibleCallbacks.forEach((callback) => callback(visible));
|
|
79
|
+
}
|
|
80
80
|
function subscribeToFocus(id, callback) {
|
|
81
81
|
if (!focusCallbacks.has(id)) {
|
|
82
82
|
focusCallbacks.set(id, new Set());
|
|
@@ -93,6 +93,13 @@ export function createListBoxContext(options = {}) {
|
|
|
93
93
|
}
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
|
+
function subscribeToFocusVisible(callback) {
|
|
97
|
+
focusVisibleCallbacks.add(callback);
|
|
98
|
+
callback(focusVisible);
|
|
99
|
+
return () => {
|
|
100
|
+
focusVisibleCallbacks.delete(callback);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
96
103
|
function subscribeToItem(id, callback) {
|
|
97
104
|
if (!itemCallbacks.has(id)) {
|
|
98
105
|
itemCallbacks.set(id, new Set());
|
|
@@ -221,6 +228,7 @@ export function createListBoxContext(options = {}) {
|
|
|
221
228
|
isSelected,
|
|
222
229
|
isDisabled,
|
|
223
230
|
isFocused,
|
|
231
|
+
getFocusVisible,
|
|
224
232
|
keyboardNav,
|
|
225
233
|
items,
|
|
226
234
|
registerItem,
|
|
@@ -232,8 +240,10 @@ export function createListBoxContext(options = {}) {
|
|
|
232
240
|
selectAll,
|
|
233
241
|
setSelection,
|
|
234
242
|
setFocusedId,
|
|
243
|
+
setFocusVisible,
|
|
235
244
|
subscribeToItem,
|
|
236
245
|
subscribeToFocus,
|
|
246
|
+
subscribeToFocusVisible,
|
|
237
247
|
getNextItemId,
|
|
238
248
|
getPreviousItemId
|
|
239
249
|
};
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
<script lang="ts" generics="T extends object = object">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import { createListBoxContext, type ListBoxContext } from './context';
|
|
4
|
-
import {
|
|
5
|
-
shouldShowFocusVisible,
|
|
6
|
-
trackInteractionModality
|
|
7
|
-
} from '../../primitives/input-modality';
|
|
4
|
+
import { trackInteractionModality } from '../../primitives/input-modality';
|
|
8
5
|
|
|
9
6
|
/**
|
|
10
7
|
* Props for the ListBox component.
|
|
@@ -35,6 +32,8 @@
|
|
|
35
32
|
'aria-label'?: string;
|
|
36
33
|
/** Callback fired when the selection changes. */
|
|
37
34
|
onChange?: (value: Set<string | number>) => void;
|
|
35
|
+
/** Disable DOM focus handling on the root container for virtual-focus compositions. */
|
|
36
|
+
disableFocusHandling?: boolean;
|
|
38
37
|
};
|
|
39
38
|
|
|
40
39
|
let {
|
|
@@ -50,6 +49,7 @@
|
|
|
50
49
|
id,
|
|
51
50
|
'aria-label': ariaLabel,
|
|
52
51
|
onChange,
|
|
52
|
+
disableFocusHandling = false,
|
|
53
53
|
context = $bindable(),
|
|
54
54
|
element = $bindable()
|
|
55
55
|
}: ListBoxProps & { context?: ListBoxContext; element?: HTMLElement } = $props();
|
|
@@ -120,21 +120,16 @@
|
|
|
120
120
|
const hasItems = $derived(itemsArray.length > 0 || itemCount > 0);
|
|
121
121
|
|
|
122
122
|
let focusWithin = $state(false);
|
|
123
|
-
let focusVisible = $state(false);
|
|
124
123
|
|
|
125
124
|
function syncFocusWithin() {
|
|
126
125
|
focusWithin =
|
|
127
126
|
!!listboxElement &&
|
|
128
127
|
!!document.activeElement &&
|
|
129
128
|
listboxElement.contains(document.activeElement);
|
|
130
|
-
if (!focusWithin) {
|
|
131
|
-
focusVisible = false;
|
|
132
|
-
}
|
|
133
129
|
}
|
|
134
130
|
|
|
135
|
-
function handleFocusIn(
|
|
131
|
+
function handleFocusIn() {
|
|
136
132
|
focusWithin = true;
|
|
137
|
-
focusVisible = shouldShowFocusVisible(event.target as HTMLElement | null);
|
|
138
133
|
}
|
|
139
134
|
|
|
140
135
|
function handleFocusOut() {
|
|
@@ -143,13 +138,16 @@
|
|
|
143
138
|
|
|
144
139
|
function handleMouseDown(event: MouseEvent) {
|
|
145
140
|
trackInteractionModality(event, event.target as HTMLElement | null);
|
|
146
|
-
|
|
141
|
+
ctx.setFocusVisible(false);
|
|
142
|
+
if (disableFocusHandling) {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
}
|
|
147
145
|
}
|
|
148
146
|
|
|
149
147
|
function handleKeyDown(event: KeyboardEvent) {
|
|
150
148
|
trackInteractionModality(event, event.target as HTMLElement | null);
|
|
151
149
|
if (focusWithin) {
|
|
152
|
-
|
|
150
|
+
ctx.setFocusVisible(true);
|
|
153
151
|
}
|
|
154
152
|
}
|
|
155
153
|
</script>
|
|
@@ -161,9 +159,8 @@
|
|
|
161
159
|
aria-multiselectable={selectionMode === 'multiple'}
|
|
162
160
|
aria-label={ariaLabel}
|
|
163
161
|
class={className}
|
|
164
|
-
tabindex=
|
|
162
|
+
tabindex={disableFocusHandling ? undefined : 0}
|
|
165
163
|
data-focus-within={focusWithin || undefined}
|
|
166
|
-
data-focus-visible={focusVisible || undefined}
|
|
167
164
|
use:keyboardAction
|
|
168
165
|
onfocusin={handleFocusIn}
|
|
169
166
|
onfocusout={handleFocusOut}
|
|
@@ -26,6 +26,8 @@ declare function $$render<T extends object = object>(): {
|
|
|
26
26
|
'aria-label'?: string;
|
|
27
27
|
/** Callback fired when the selection changes. */
|
|
28
28
|
onChange?: (value: Set<string | number>) => void;
|
|
29
|
+
/** Disable DOM focus handling on the root container for virtual-focus compositions. */
|
|
30
|
+
disableFocusHandling?: boolean;
|
|
29
31
|
} & {
|
|
30
32
|
context?: ListBoxContext;
|
|
31
33
|
element?: HTMLElement;
|
|
@@ -53,6 +53,7 @@ export type KeyboardNavigationReturn = {
|
|
|
53
53
|
focusFirst: () => void;
|
|
54
54
|
focusLast: () => void;
|
|
55
55
|
focusById: (id: string | number) => void;
|
|
56
|
+
setCurrentId: (id: string | number | null) => void;
|
|
56
57
|
/** Update items (call after DOM changes) */
|
|
57
58
|
updateItems: () => void;
|
|
58
59
|
};
|
|
@@ -130,6 +130,22 @@ export function createKeyboardNavigation(options = {}) {
|
|
|
130
130
|
focusItem(element);
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
|
+
function setCurrentId(id) {
|
|
134
|
+
if (id === null) {
|
|
135
|
+
focusedId.set(null);
|
|
136
|
+
focusedElement.set(null);
|
|
137
|
+
onFocusChange?.(null, null);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
items = getItems();
|
|
141
|
+
const element = items.find((el) => {
|
|
142
|
+
const itemId = getItemId(el);
|
|
143
|
+
return itemId === id || String(itemId) === String(id);
|
|
144
|
+
});
|
|
145
|
+
focusedId.set(id);
|
|
146
|
+
focusedElement.set(element ?? null);
|
|
147
|
+
onFocusChange?.(id, element ?? null);
|
|
148
|
+
}
|
|
133
149
|
function handleTypeahead(char) {
|
|
134
150
|
if (!typeahead)
|
|
135
151
|
return;
|
|
@@ -254,6 +270,7 @@ export function createKeyboardNavigation(options = {}) {
|
|
|
254
270
|
focusFirst,
|
|
255
271
|
focusLast,
|
|
256
272
|
focusById,
|
|
273
|
+
setCurrentId,
|
|
257
274
|
updateItems
|
|
258
275
|
};
|
|
259
276
|
}
|