@human-kit/svelte-components 1.0.0-alpha.10 → 1.0.0-alpha.12
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/item/combobox-listboxitem.svelte +13 -2
- package/dist/combobox/popover/README.md +18 -4
- package/dist/combobox/popover/combobox-popover-props-test.svelte +38 -0
- package/dist/combobox/popover/combobox-popover-props-test.svelte.d.ts +11 -0
- package/dist/combobox/popover/combobox-popover.svelte +33 -6
- package/dist/combobox/popover/combobox-popover.svelte.d.ts +3 -3
- 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.svelte +29 -6
- package/dist/combobox/root/context.d.ts +10 -0
- package/dist/hooks/use-virtual-focus.svelte.js +3 -1
- package/dist/listbox/item/listbox-item.svelte +87 -5
- package/dist/listbox/item/listbox-item.svelte.d.ts +4 -0
- package/dist/listbox/root/context.d.ts +6 -0
- package/dist/listbox/root/context.js +23 -13
- package/dist/listbox/root/listbox.svelte +4 -13
- package/dist/popover/content/popover-content.svelte +1 -1
- 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(
|
|
@@ -66,6 +67,7 @@
|
|
|
66
67
|
|
|
67
68
|
// Virtual focus from ComboBox context
|
|
68
69
|
const isFocused = $derived(ctx.focusedItemId === id);
|
|
70
|
+
const isFocusVisible = $derived(isFocused && ctx.isFocusVisible);
|
|
69
71
|
|
|
70
72
|
// Generate unique ID using instanceId
|
|
71
73
|
const uniqueId = $derived(`combobox-item-${ctx.instanceId}-${id}`);
|
|
@@ -76,14 +78,15 @@
|
|
|
76
78
|
// Reactive registration: register when visible, unregister when hidden
|
|
77
79
|
$effect(() => {
|
|
78
80
|
const visible = isVisible;
|
|
81
|
+
const disabled = isDisabled;
|
|
79
82
|
const label = effectiveTextValue || String(id);
|
|
80
83
|
const itemId = id;
|
|
81
84
|
|
|
82
85
|
untrack(() => {
|
|
83
|
-
if (visible && !isRegistered) {
|
|
86
|
+
if (visible && !disabled && !isRegistered) {
|
|
84
87
|
ctx.registerItem(itemId, label);
|
|
85
88
|
isRegistered = true;
|
|
86
|
-
} else if (!visible && isRegistered) {
|
|
89
|
+
} else if ((!visible || disabled) && isRegistered) {
|
|
87
90
|
ctx.unregisterItem(itemId);
|
|
88
91
|
isRegistered = false;
|
|
89
92
|
}
|
|
@@ -108,8 +111,14 @@
|
|
|
108
111
|
|
|
109
112
|
// Custom select handler that uses ComboBox context
|
|
110
113
|
function handleSelect(itemId: string | number, label: string) {
|
|
114
|
+
ctx.setFocusedItemId(itemId);
|
|
111
115
|
ctx.select(itemId, label);
|
|
112
116
|
}
|
|
117
|
+
|
|
118
|
+
function handleHoverStart(itemId: string | number) {
|
|
119
|
+
ctx.setFocusVisible(false);
|
|
120
|
+
ctx.setFocusedItemId(itemId);
|
|
121
|
+
}
|
|
113
122
|
</script>
|
|
114
123
|
|
|
115
124
|
{#if isVisible}
|
|
@@ -120,8 +129,10 @@
|
|
|
120
129
|
customId={uniqueId}
|
|
121
130
|
disableFocusHandling={true}
|
|
122
131
|
isFocusedOverride={isFocused}
|
|
132
|
+
isFocusVisibleOverride={isFocusVisible}
|
|
123
133
|
onItemSelect={handleSelect}
|
|
124
134
|
onResolvedTextValue={handleResolvedTextValue}
|
|
135
|
+
onItemHoverStart={handleHoverStart}
|
|
125
136
|
scrollOnFocus={true}
|
|
126
137
|
isParentDisabled={ctx.isDisabled}
|
|
127
138
|
/>
|
|
@@ -7,7 +7,21 @@
|
|
|
7
7
|
Name: `ComboBox.Popover`
|
|
8
8
|
Description: Floating container for combobox options. Internally composes `Popover.Root` and `Popover.Content` in non-modal mode.
|
|
9
9
|
|
|
10
|
-
| Prop
|
|
11
|
-
|
|
|
12
|
-
| `
|
|
13
|
-
| `
|
|
10
|
+
| Prop | Type | Default | Description |
|
|
11
|
+
| ------------------------------ | ---------------------------------- | ---------------- | -------------------------------------------------------------------------- |
|
|
12
|
+
| `offset` | `number` | `8` | Main-axis offset from the combobox trigger. |
|
|
13
|
+
| `placement` | `ExtendedPlacement` | `'bottom-start'` | Preferred floating placement. |
|
|
14
|
+
| `shouldFlip` | `boolean` | `true` | Enables automatic fallback placement when space is limited. |
|
|
15
|
+
| `boundaryElement` | `Element \| null` | `null` | Optional boundary element for positioning constraints. |
|
|
16
|
+
| `class` | `string` | `''` | CSS class names for the floating panel. |
|
|
17
|
+
| `children` | `Snippet` | `undefined` | Popover content, typically `ComboBox.List`. |
|
|
18
|
+
| `isNonModal` | `boolean` | `true` | Controls whether the popover behaves as a non-modal overlay. |
|
|
19
|
+
| `shouldCloseOnInteractOutside` | `boolean` | `true` | Closes when interacting outside the panel. |
|
|
20
|
+
| `shouldCloseOnEscape` | `boolean` | `true` | Closes on Escape key press. |
|
|
21
|
+
| `shouldCloseOnBlur` | `boolean` | `true` | Closes on focus leaving trigger/content in the combobox interaction model. |
|
|
22
|
+
| `initialFocus` | `FocusTrapOptions['initialFocus']` | `undefined` | Initial focus target when modal focus trapping is enabled. |
|
|
23
|
+
|
|
24
|
+
## Notes
|
|
25
|
+
|
|
26
|
+
- `ComboBox.Popover` forwards all `Popover.Content` configuration props except the controlled open-state wiring (`open`, `triggerRef`, and `onOpenChange`).
|
|
27
|
+
- The default placement is `bottom-start` to match the combobox input.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { ComponentProps } from 'svelte';
|
|
3
|
+
import ComboBox from '../index';
|
|
4
|
+
import type { PopoverContent } from '../../popover';
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
offset?: number;
|
|
8
|
+
placement?: ComponentProps<typeof PopoverContent>['placement'];
|
|
9
|
+
shouldFlip?: boolean;
|
|
10
|
+
shouldCloseOnEscape?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
let {
|
|
14
|
+
offset = 8,
|
|
15
|
+
placement = 'bottom-start',
|
|
16
|
+
shouldFlip = true,
|
|
17
|
+
shouldCloseOnEscape = true
|
|
18
|
+
}: Props = $props();
|
|
19
|
+
|
|
20
|
+
const countries = [
|
|
21
|
+
{ id: 'ar', name: 'Argentina' },
|
|
22
|
+
{ id: 'br', name: 'Brazil' },
|
|
23
|
+
{ id: 'ca', name: 'Canada' }
|
|
24
|
+
];
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<ComboBox.Root trigger="press">
|
|
28
|
+
<ComboBox.Input placeholder="Search countries..." />
|
|
29
|
+
<ComboBox.Trigger />
|
|
30
|
+
|
|
31
|
+
<ComboBox.Popover {offset} {placement} {shouldFlip} {shouldCloseOnEscape}>
|
|
32
|
+
<ComboBox.List emptyPlaceholder="No countries found">
|
|
33
|
+
{#each countries as country (country.id)}
|
|
34
|
+
<ComboBox.Item id={country.id} textValue={country.name}>{country.name}</ComboBox.Item>
|
|
35
|
+
{/each}
|
|
36
|
+
</ComboBox.List>
|
|
37
|
+
</ComboBox.Popover>
|
|
38
|
+
</ComboBox.Root>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ComponentProps } from 'svelte';
|
|
2
|
+
import type { PopoverContent } from '../../popover';
|
|
3
|
+
type Props = {
|
|
4
|
+
offset?: number;
|
|
5
|
+
placement?: ComponentProps<typeof PopoverContent>['placement'];
|
|
6
|
+
shouldFlip?: boolean;
|
|
7
|
+
shouldCloseOnEscape?: boolean;
|
|
8
|
+
};
|
|
9
|
+
declare const ComboboxPopoverPropsTest: import("svelte").Component<Props, {}, "">;
|
|
10
|
+
type ComboboxPopoverPropsTest = ReturnType<typeof ComboboxPopoverPropsTest>;
|
|
11
|
+
export default ComboboxPopoverPropsTest;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { ComponentProps, Snippet } from 'svelte';
|
|
3
3
|
import { useComboBoxContext } from '../root/context';
|
|
4
4
|
import { Popover } from '../../popover';
|
|
5
5
|
import { focusWithModality, type InputModality } from '../../primitives/input-modality';
|
|
@@ -9,12 +9,22 @@
|
|
|
9
9
|
* ComboBox.Popover - Just the floating container wrapper.
|
|
10
10
|
* Should contain ComboBox.ListBox as a child.
|
|
11
11
|
*/
|
|
12
|
-
type ComboBoxPopoverProps =
|
|
13
|
-
|
|
12
|
+
type ComboBoxPopoverProps = Omit<
|
|
13
|
+
ComponentProps<typeof Popover.Content>,
|
|
14
|
+
'open' | 'triggerRef' | 'onOpenChange' | 'children'
|
|
15
|
+
> & {
|
|
14
16
|
children?: Snippet;
|
|
15
17
|
};
|
|
16
18
|
|
|
17
|
-
let {
|
|
19
|
+
let {
|
|
20
|
+
class: className = '',
|
|
21
|
+
children,
|
|
22
|
+
placement = 'bottom-start',
|
|
23
|
+
isNonModal = true,
|
|
24
|
+
shouldCloseOnEscape = true,
|
|
25
|
+
shouldCloseOnBlur = true,
|
|
26
|
+
...contentProps
|
|
27
|
+
}: ComboBoxPopoverProps = $props();
|
|
18
28
|
|
|
19
29
|
const ctx = useComboBoxContext();
|
|
20
30
|
let restoreListboxMaxHeight: (() => void) | undefined;
|
|
@@ -168,15 +178,32 @@
|
|
|
168
178
|
ctx.inputRef?.focus();
|
|
169
179
|
}
|
|
170
180
|
});
|
|
181
|
+
|
|
182
|
+
$effect(() => {
|
|
183
|
+
ctx.setShouldCloseOnEscape(shouldCloseOnEscape);
|
|
184
|
+
return () => {
|
|
185
|
+
ctx.setShouldCloseOnEscape(true);
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
$effect(() => {
|
|
190
|
+
ctx.setShouldCloseOnBlur(shouldCloseOnBlur);
|
|
191
|
+
return () => {
|
|
192
|
+
ctx.setShouldCloseOnBlur(true);
|
|
193
|
+
};
|
|
194
|
+
});
|
|
171
195
|
</script>
|
|
172
196
|
|
|
173
197
|
<Popover.Root open={ctx.isOpen} triggerRef={ctx.triggerRef} onOpenChange={handleOpenChange}>
|
|
174
198
|
<Popover.Content
|
|
175
|
-
isNonModal
|
|
176
|
-
placement
|
|
199
|
+
{isNonModal}
|
|
200
|
+
{placement}
|
|
201
|
+
{shouldCloseOnEscape}
|
|
202
|
+
{shouldCloseOnBlur}
|
|
177
203
|
class={className}
|
|
178
204
|
onmousedown={handleMouseDown}
|
|
179
205
|
onwheel={handleWheel}
|
|
206
|
+
{...contentProps}
|
|
180
207
|
>
|
|
181
208
|
{#if children}
|
|
182
209
|
{@render children()}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { Snippet } from 'svelte';
|
|
1
|
+
import type { ComponentProps, Snippet } from 'svelte';
|
|
2
|
+
import { Popover } from '../../popover';
|
|
2
3
|
/**
|
|
3
4
|
* ComboBox.Popover - Just the floating container wrapper.
|
|
4
5
|
* Should contain ComboBox.ListBox as a child.
|
|
5
6
|
*/
|
|
6
|
-
type ComboBoxPopoverProps = {
|
|
7
|
-
class?: string;
|
|
7
|
+
type ComboBoxPopoverProps = Omit<ComponentProps<typeof Popover.Content>, 'open' | 'triggerRef' | 'onOpenChange' | 'children'> & {
|
|
8
8
|
children?: Snippet;
|
|
9
9
|
};
|
|
10
10
|
declare const ComboboxPopover: import("svelte").Component<ComboBoxPopoverProps, {}, "">;
|
|
@@ -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>;
|
|
@@ -107,6 +107,8 @@
|
|
|
107
107
|
let focusWithin = $state(false);
|
|
108
108
|
let focusVisible = $state(false);
|
|
109
109
|
let popoverPointerDownPending = $state(false);
|
|
110
|
+
let shouldCloseOnEscapeState = $state(true);
|
|
111
|
+
let shouldCloseOnBlurState = $state(true);
|
|
110
112
|
|
|
111
113
|
// Flag to control whether inputValue should be used for filtering
|
|
112
114
|
// When false, all items are shown regardless of inputValue
|
|
@@ -379,6 +381,9 @@
|
|
|
379
381
|
// Use navigation hook methods for keyboard navigation
|
|
380
382
|
function selectFocusedItem() {
|
|
381
383
|
if (navigation.focusedId !== null) {
|
|
384
|
+
if (listboxCtxRef?.isDisabled(navigation.focusedId)) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
382
387
|
const label = navigation.itemLabels.get(navigation.focusedId) ?? String(navigation.focusedId);
|
|
383
388
|
selectItem(navigation.focusedId, label);
|
|
384
389
|
}
|
|
@@ -407,6 +412,9 @@
|
|
|
407
412
|
return;
|
|
408
413
|
}
|
|
409
414
|
|
|
415
|
+
if (!shouldCloseOnBlurState) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
410
418
|
// Close popover first to prevent flash of options when clearing input
|
|
411
419
|
closePopover();
|
|
412
420
|
|
|
@@ -611,16 +619,16 @@
|
|
|
611
619
|
}
|
|
612
620
|
break;
|
|
613
621
|
case 'Escape':
|
|
614
|
-
if (currentIsOpen) {
|
|
622
|
+
if (currentIsOpen && shouldCloseOnEscapeState) {
|
|
615
623
|
closePopover(true); // Keep focus on input after Escape
|
|
616
624
|
// Stop propagation so parent dialogs don't also close
|
|
617
625
|
event.stopPropagation();
|
|
618
626
|
event.stopImmediatePropagation();
|
|
627
|
+
handleInputBlur();
|
|
628
|
+
// Escape is a keyboard-only path, so focus-visible remains enabled for the input.
|
|
629
|
+
focusVisible = true;
|
|
630
|
+
event.preventDefault();
|
|
619
631
|
}
|
|
620
|
-
handleInputBlur();
|
|
621
|
-
// Escape is a keyboard-only path, so focus-visible remains enabled for the input.
|
|
622
|
-
focusVisible = true;
|
|
623
|
-
event.preventDefault();
|
|
624
632
|
break;
|
|
625
633
|
case 'Backspace':
|
|
626
634
|
// In multiple mode, remove last tag when input is empty
|
|
@@ -668,6 +676,15 @@
|
|
|
668
676
|
get isPending() {
|
|
669
677
|
return isPending;
|
|
670
678
|
},
|
|
679
|
+
get isFocusVisible() {
|
|
680
|
+
return focusVisible;
|
|
681
|
+
},
|
|
682
|
+
get shouldCloseOnEscape() {
|
|
683
|
+
return shouldCloseOnEscapeState;
|
|
684
|
+
},
|
|
685
|
+
get shouldCloseOnBlur() {
|
|
686
|
+
return shouldCloseOnBlurState;
|
|
687
|
+
},
|
|
671
688
|
get isReadOnly() {
|
|
672
689
|
return isReadOnly;
|
|
673
690
|
},
|
|
@@ -742,7 +759,13 @@
|
|
|
742
759
|
markPopoverPointerDown: () => {
|
|
743
760
|
popoverPointerDownPending = true;
|
|
744
761
|
},
|
|
745
|
-
consumePopoverPointerDown
|
|
762
|
+
consumePopoverPointerDown,
|
|
763
|
+
setShouldCloseOnEscape: (value: boolean) => {
|
|
764
|
+
shouldCloseOnEscapeState = value;
|
|
765
|
+
},
|
|
766
|
+
setShouldCloseOnBlur: (value: boolean) => {
|
|
767
|
+
shouldCloseOnBlurState = value;
|
|
768
|
+
}
|
|
746
769
|
};
|
|
747
770
|
|
|
748
771
|
setComboBoxContext(ctx);
|
|
@@ -22,6 +22,8 @@ export type ComboBoxContext<T extends object = object> = {
|
|
|
22
22
|
isDisabled: boolean;
|
|
23
23
|
/** Whether the combobox is pending async work */
|
|
24
24
|
isPending: boolean;
|
|
25
|
+
/** Whether focus should currently be presented as keyboard-visible */
|
|
26
|
+
isFocusVisible: boolean;
|
|
25
27
|
/** Whether the combobox is read-only */
|
|
26
28
|
isReadOnly: boolean;
|
|
27
29
|
/** Selection mode */
|
|
@@ -92,6 +94,14 @@ export type ComboBoxContext<T extends object = object> = {
|
|
|
92
94
|
markPopoverPointerDown: () => void;
|
|
93
95
|
/** Consume the pending popover-pointer marker. */
|
|
94
96
|
consumePopoverPointerDown: () => boolean;
|
|
97
|
+
/** Whether Escape should close the popover. */
|
|
98
|
+
shouldCloseOnEscape: boolean;
|
|
99
|
+
/** Whether blur should close the popover. */
|
|
100
|
+
shouldCloseOnBlur: boolean;
|
|
101
|
+
/** Update whether Escape should close the popover. */
|
|
102
|
+
setShouldCloseOnEscape: (value: boolean) => void;
|
|
103
|
+
/** Update whether blur should close the popover. */
|
|
104
|
+
setShouldCloseOnBlur: (value: boolean) => void;
|
|
95
105
|
};
|
|
96
106
|
export declare function setComboBoxContext<T extends object = object>(ctx: ComboBoxContext<T>): void;
|
|
97
107
|
export declare function getComboBoxContext<T extends object = object>(): ComboBoxContext<T> | undefined;
|
|
@@ -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';
|
|
@@ -30,10 +31,14 @@
|
|
|
30
31
|
disableFocusHandling?: boolean;
|
|
31
32
|
/** Override the focused state. When provided, this value is used instead of internal focus tracking. */
|
|
32
33
|
isFocusedOverride?: boolean;
|
|
34
|
+
/** Override the focus-visible presentation state. */
|
|
35
|
+
isFocusVisibleOverride?: boolean;
|
|
33
36
|
/** Override the select behavior. When provided, called instead of default listbox selection. */
|
|
34
37
|
onItemSelect?: (id: string | number, label: string) => void;
|
|
35
38
|
/** Callback with resolved text value when mounted (from prop or rendered content). */
|
|
36
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;
|
|
37
42
|
/** Whether to scroll this item into view when focused. Useful for virtual focus patterns. */
|
|
38
43
|
scrollOnFocus?: boolean;
|
|
39
44
|
/** Additional disabled state from parent. */
|
|
@@ -52,8 +57,10 @@
|
|
|
52
57
|
customId,
|
|
53
58
|
disableFocusHandling = false,
|
|
54
59
|
isFocusedOverride,
|
|
60
|
+
isFocusVisibleOverride,
|
|
55
61
|
onItemSelect,
|
|
56
62
|
onResolvedTextValue,
|
|
63
|
+
onItemHoverStart,
|
|
57
64
|
scrollOnFocus = false,
|
|
58
65
|
isParentDisabled = false,
|
|
59
66
|
pressed: pressedOverride,
|
|
@@ -66,9 +73,11 @@
|
|
|
66
73
|
let isSelected = $state(false);
|
|
67
74
|
let isFocused = $state(false);
|
|
68
75
|
let isFocusVisible = $state(false);
|
|
76
|
+
let listFocusVisible = $state(false);
|
|
69
77
|
let isHovered = $state(false);
|
|
70
78
|
let isPressed = $state(false);
|
|
71
79
|
let pressedKey: 'Enter' | 'Space' | null = $state(null);
|
|
80
|
+
let suppressNextFocusVisible = $state(false);
|
|
72
81
|
|
|
73
82
|
// Focus: use override if provided, otherwise use internal state
|
|
74
83
|
const isFocusedComputed = $derived(
|
|
@@ -82,12 +91,23 @@
|
|
|
82
91
|
? Boolean(pressedOverride) && !isDisabledComputed
|
|
83
92
|
: isPressed && !isDisabledComputed
|
|
84
93
|
);
|
|
94
|
+
const isFocusVisibleComputed = $derived(
|
|
95
|
+
isFocusVisibleOverride !== undefined ? isFocusVisibleOverride : isFocusVisible
|
|
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);
|
|
85
104
|
|
|
86
105
|
// ID: use custom if provided, otherwise generate
|
|
87
106
|
const uniqueId = $derived(customId ?? `listbox-item-${id}`);
|
|
88
107
|
|
|
89
108
|
let unsubscribeSelection: (() => void) | null = null;
|
|
90
109
|
let unsubscribeFocus: (() => void) | null = null;
|
|
110
|
+
let unsubscribeFocusVisible: (() => void) | null = null;
|
|
91
111
|
|
|
92
112
|
function getResolvedTextValue() {
|
|
93
113
|
return textValue || elementRef?.textContent?.trim() || String(id);
|
|
@@ -109,6 +129,9 @@
|
|
|
109
129
|
unsubscribeFocus = listboxCtx.subscribeToFocus(id, (focused) => {
|
|
110
130
|
isFocused = focused;
|
|
111
131
|
});
|
|
132
|
+
unsubscribeFocusVisible = listboxCtx.subscribeToFocusVisible((visible) => {
|
|
133
|
+
listFocusVisible = visible;
|
|
134
|
+
});
|
|
112
135
|
listboxCtx.keyboardNav.updateItems();
|
|
113
136
|
}
|
|
114
137
|
});
|
|
@@ -117,6 +140,7 @@
|
|
|
117
140
|
listboxCtx.unregisterItem(id);
|
|
118
141
|
unsubscribeSelection?.();
|
|
119
142
|
unsubscribeFocus?.();
|
|
143
|
+
unsubscribeFocusVisible?.();
|
|
120
144
|
});
|
|
121
145
|
|
|
122
146
|
// Scroll into view when focused (if enabled)
|
|
@@ -140,6 +164,30 @@
|
|
|
140
164
|
pressedKey = null;
|
|
141
165
|
}
|
|
142
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
|
+
|
|
143
191
|
function handleClick() {
|
|
144
192
|
if (isDisabledComputed) return;
|
|
145
193
|
|
|
@@ -150,25 +198,40 @@
|
|
|
150
198
|
onItemSelect(id, label);
|
|
151
199
|
} else {
|
|
152
200
|
listboxCtx.select(id);
|
|
153
|
-
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!disableFocusHandling) {
|
|
204
|
+
listboxCtx.keyboardNav.focusById(id);
|
|
154
205
|
}
|
|
155
206
|
}
|
|
156
207
|
|
|
157
208
|
function handleFocus() {
|
|
158
209
|
if (isDisabledComputed) return;
|
|
159
|
-
isFocusVisible = shouldShowFocusVisible(elementRef);
|
|
210
|
+
isFocusVisible = suppressNextFocusVisible ? false : shouldShowFocusVisible(elementRef);
|
|
211
|
+
suppressNextFocusVisible = false;
|
|
212
|
+
if (isFocusVisible) {
|
|
213
|
+
isHovered = false;
|
|
214
|
+
}
|
|
160
215
|
if (!disableFocusHandling) {
|
|
216
|
+
listboxCtx.setFocusVisible(isFocusVisible);
|
|
161
217
|
listboxCtx.setFocusedId(id);
|
|
162
218
|
}
|
|
163
219
|
}
|
|
164
220
|
|
|
165
221
|
function handleBlur() {
|
|
166
222
|
isFocusVisible = false;
|
|
223
|
+
if (!disableFocusHandling && listboxCtx.isFocused(id)) {
|
|
224
|
+
listboxCtx.setFocusVisible(false);
|
|
225
|
+
listboxCtx.setFocusedId(null);
|
|
226
|
+
}
|
|
167
227
|
}
|
|
168
228
|
|
|
169
229
|
function handlePointerDown(event: PointerEvent) {
|
|
170
230
|
trackInteractionModality(event, elementRef);
|
|
171
231
|
isFocusVisible = false;
|
|
232
|
+
if (!disableFocusHandling) {
|
|
233
|
+
listboxCtx.setFocusVisible(false);
|
|
234
|
+
}
|
|
172
235
|
|
|
173
236
|
if (isDisabledComputed) {
|
|
174
237
|
event.preventDefault();
|
|
@@ -194,6 +257,12 @@
|
|
|
194
257
|
function handlePointerEnter(event: PointerEvent) {
|
|
195
258
|
if (isDisabledComputed) return;
|
|
196
259
|
|
|
260
|
+
trackInteractionModality(event, elementRef);
|
|
261
|
+
if (!disableFocusHandling) {
|
|
262
|
+
listboxCtx.setFocusVisible(false);
|
|
263
|
+
}
|
|
264
|
+
transferHoverFocus();
|
|
265
|
+
|
|
197
266
|
if ((event.buttons & 1) === 1 && pressedKey === null) {
|
|
198
267
|
isPressed = true;
|
|
199
268
|
}
|
|
@@ -205,9 +274,15 @@
|
|
|
205
274
|
}
|
|
206
275
|
}
|
|
207
276
|
|
|
208
|
-
function handleMouseEnter() {
|
|
277
|
+
function handleMouseEnter(event: MouseEvent) {
|
|
209
278
|
if (!isDisabledComputed) {
|
|
279
|
+
trackInteractionModality(event, elementRef);
|
|
210
280
|
isHovered = true;
|
|
281
|
+
isFocusVisible = false;
|
|
282
|
+
if (!disableFocusHandling) {
|
|
283
|
+
listboxCtx.setFocusVisible(false);
|
|
284
|
+
transferHoverFocus();
|
|
285
|
+
}
|
|
211
286
|
}
|
|
212
287
|
}
|
|
213
288
|
|
|
@@ -222,7 +297,11 @@
|
|
|
222
297
|
function handleKeydown(event: KeyboardEvent) {
|
|
223
298
|
trackInteractionModality(event, elementRef);
|
|
224
299
|
if (isFocusedComputed) {
|
|
300
|
+
isHovered = false;
|
|
225
301
|
isFocusVisible = true;
|
|
302
|
+
if (!disableFocusHandling) {
|
|
303
|
+
listboxCtx.setFocusVisible(true);
|
|
304
|
+
}
|
|
226
305
|
}
|
|
227
306
|
|
|
228
307
|
const key =
|
|
@@ -270,6 +349,9 @@
|
|
|
270
349
|
function handleMouseDown(event: MouseEvent) {
|
|
271
350
|
trackInteractionModality(event, elementRef);
|
|
272
351
|
isFocusVisible = false;
|
|
352
|
+
if (!disableFocusHandling) {
|
|
353
|
+
listboxCtx.setFocusVisible(false);
|
|
354
|
+
}
|
|
273
355
|
|
|
274
356
|
// Prevent focus stealing when used in ComboBox (disableFocusHandling=true)
|
|
275
357
|
// This keeps the focus on the input while allowing click selection
|
|
@@ -311,8 +393,8 @@
|
|
|
311
393
|
data-selected={isSelected || undefined}
|
|
312
394
|
data-disabled={isDisabledComputed || undefined}
|
|
313
395
|
data-focused={isFocusedComputed || undefined}
|
|
314
|
-
data-focus-visible={
|
|
315
|
-
data-hovered={
|
|
396
|
+
data-focus-visible={showFocusVisible || undefined}
|
|
397
|
+
data-hovered={showHovered || undefined}
|
|
316
398
|
data-pressed={isPressedComputed || undefined}
|
|
317
399
|
onpointerdown={handlePointerDown}
|
|
318
400
|
onpointerup={handlePointerUp}
|
|
@@ -20,10 +20,14 @@ type ListBoxItemProps = Omit<HTMLAttributes<HTMLDivElement>, 'id' | 'children'>
|
|
|
20
20
|
disableFocusHandling?: boolean;
|
|
21
21
|
/** Override the focused state. When provided, this value is used instead of internal focus tracking. */
|
|
22
22
|
isFocusedOverride?: boolean;
|
|
23
|
+
/** Override the focus-visible presentation state. */
|
|
24
|
+
isFocusVisibleOverride?: boolean;
|
|
23
25
|
/** Override the select behavior. When provided, called instead of default listbox selection. */
|
|
24
26
|
onItemSelect?: (id: string | number, label: string) => void;
|
|
25
27
|
/** Callback with resolved text value when mounted (from prop or rendered content). */
|
|
26
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;
|
|
27
31
|
/** Whether to scroll this item into view when focused. Useful for virtual focus patterns. */
|
|
28
32
|
scrollOnFocus?: boolean;
|
|
29
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.
|
|
@@ -120,21 +117,16 @@
|
|
|
120
117
|
const hasItems = $derived(itemsArray.length > 0 || itemCount > 0);
|
|
121
118
|
|
|
122
119
|
let focusWithin = $state(false);
|
|
123
|
-
let focusVisible = $state(false);
|
|
124
120
|
|
|
125
121
|
function syncFocusWithin() {
|
|
126
122
|
focusWithin =
|
|
127
123
|
!!listboxElement &&
|
|
128
124
|
!!document.activeElement &&
|
|
129
125
|
listboxElement.contains(document.activeElement);
|
|
130
|
-
if (!focusWithin) {
|
|
131
|
-
focusVisible = false;
|
|
132
|
-
}
|
|
133
126
|
}
|
|
134
127
|
|
|
135
|
-
function handleFocusIn(
|
|
128
|
+
function handleFocusIn() {
|
|
136
129
|
focusWithin = true;
|
|
137
|
-
focusVisible = shouldShowFocusVisible(event.target as HTMLElement | null);
|
|
138
130
|
}
|
|
139
131
|
|
|
140
132
|
function handleFocusOut() {
|
|
@@ -143,13 +135,13 @@
|
|
|
143
135
|
|
|
144
136
|
function handleMouseDown(event: MouseEvent) {
|
|
145
137
|
trackInteractionModality(event, event.target as HTMLElement | null);
|
|
146
|
-
|
|
138
|
+
ctx.setFocusVisible(false);
|
|
147
139
|
}
|
|
148
140
|
|
|
149
141
|
function handleKeyDown(event: KeyboardEvent) {
|
|
150
142
|
trackInteractionModality(event, event.target as HTMLElement | null);
|
|
151
143
|
if (focusWithin) {
|
|
152
|
-
|
|
144
|
+
ctx.setFocusVisible(true);
|
|
153
145
|
}
|
|
154
146
|
}
|
|
155
147
|
</script>
|
|
@@ -163,7 +155,6 @@
|
|
|
163
155
|
class={className}
|
|
164
156
|
tabindex="0"
|
|
165
157
|
data-focus-within={focusWithin || undefined}
|
|
166
|
-
data-focus-visible={focusVisible || undefined}
|
|
167
158
|
use:keyboardAction
|
|
168
159
|
onfocusin={handleFocusIn}
|
|
169
160
|
onfocusout={handleFocusOut}
|
|
@@ -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
|
}
|