@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.
@@ -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 | Type | Default | Description |
11
- | ---------- | --------- | ----------- | ------------------------------------------- |
12
- | `class` | `string` | `''` | CSS class names for the floating panel. |
13
- | `children` | `Snippet` | `undefined` | Popover content, typically `ComboBox.List`. |
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
- class?: string;
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 { class: className = '', children }: ComboBoxPopoverProps = $props();
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={true}
176
- placement="bottom-start"
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
- orderedIds.push(registeredId ?? rawId);
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
- listboxCtx.setFocusedId(id);
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={isFocusVisible || undefined}
315
- data-hovered={isHovered || undefined}
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 notifyFocus(id, focused) {
63
- const callbacks = focusCallbacks.get(id);
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
- if (previousId !== null) {
74
- notifyFocus(previousId, false);
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(event: FocusEvent) {
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
- focusVisible = false;
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
- focusVisible = true;
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}
@@ -57,7 +57,7 @@
57
57
  } & Omit<HTMLAttributes<HTMLDivElement>, 'class' | 'children'>;
58
58
 
59
59
  let {
60
- offset = 8,
60
+ offset = 4,
61
61
  placement = 'bottom',
62
62
  shouldFlip = true,
63
63
  boundaryElement = null,
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@human-kit/svelte-components",
3
- "version": "1.0.0-alpha.10",
3
+ "version": "1.0.0-alpha.12",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "svelte": "./dist/index.js",