@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.
@@ -113,6 +113,7 @@
113
113
  aria-label={ariaLabel}
114
114
  aria-labelledby={ariaLabelledby}
115
115
  aria-describedby={ariaDescribedby}
116
+ autocomplete="off"
116
117
  value={ctx.displayValue}
117
118
  isDisabled={ctx.isDisabled}
118
119
  isReadOnly={ctx.isReadOnly}
@@ -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
  />
@@ -73,4 +73,5 @@
73
73
  value={ctx.selectedValue}
74
74
  onChange={handleSelectionChange}
75
75
  aria-label={ariaLabel}
76
+ disableFocusHandling={true}
76
77
  />
@@ -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 id={country.id} textValue={country.name}>
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}
@@ -4,6 +4,7 @@ type Props = {
4
4
  isPending?: boolean;
5
5
  isReadOnly?: boolean;
6
6
  trigger?: 'focus' | 'input' | 'press';
7
+ disabledIds?: string[];
7
8
  };
8
9
  declare const ComboboxTest: import("svelte").Component<Props, {}, "">;
9
10
  type ComboboxTest = ReturnType<typeof ComboboxTest>;
@@ -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
- // Update input directly without triggering deselection
222
- inputValueInternal = label;
223
- inputValue = label;
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
- inputValueInternal = selectedLabel;
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
- 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';
@@ -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
- listboxCtx.setFocusedId(id);
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={isFocusVisibleComputed || undefined}
321
- data-hovered={isHovered || undefined}
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 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.
@@ -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(event: FocusEvent) {
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
- focusVisible = false;
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
- focusVisible = true;
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="0"
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@human-kit/svelte-components",
3
- "version": "1.0.0-alpha.11",
3
+ "version": "1.0.0-alpha.13",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "svelte": "./dist/index.js",