@human-kit/svelte-components 1.0.0-alpha.11 → 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(
@@ -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
  />
@@ -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>;
@@ -381,6 +381,9 @@
381
381
  // Use navigation hook methods for keyboard navigation
382
382
  function selectFocusedItem() {
383
383
  if (navigation.focusedId !== null) {
384
+ if (listboxCtxRef?.isDisabled(navigation.focusedId)) {
385
+ return;
386
+ }
384
387
  const label = navigation.itemLabels.get(navigation.focusedId) ?? String(navigation.focusedId);
385
388
  selectItem(navigation.focusedId, label);
386
389
  }
@@ -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,6 +140,7 @@
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)
@@ -146,6 +164,30 @@
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
 
@@ -156,25 +198,40 @@
156
198
  onItemSelect(id, label);
157
199
  } else {
158
200
  listboxCtx.select(id);
159
- listboxCtx.setFocusedId(id);
201
+ }
202
+
203
+ if (!disableFocusHandling) {
204
+ listboxCtx.keyboardNav.focusById(id);
160
205
  }
161
206
  }
162
207
 
163
208
  function handleFocus() {
164
209
  if (isDisabledComputed) return;
165
- isFocusVisible = shouldShowFocusVisible(elementRef);
210
+ isFocusVisible = suppressNextFocusVisible ? false : shouldShowFocusVisible(elementRef);
211
+ suppressNextFocusVisible = false;
212
+ if (isFocusVisible) {
213
+ isHovered = false;
214
+ }
166
215
  if (!disableFocusHandling) {
216
+ listboxCtx.setFocusVisible(isFocusVisible);
167
217
  listboxCtx.setFocusedId(id);
168
218
  }
169
219
  }
170
220
 
171
221
  function handleBlur() {
172
222
  isFocusVisible = false;
223
+ if (!disableFocusHandling && listboxCtx.isFocused(id)) {
224
+ listboxCtx.setFocusVisible(false);
225
+ listboxCtx.setFocusedId(null);
226
+ }
173
227
  }
174
228
 
175
229
  function handlePointerDown(event: PointerEvent) {
176
230
  trackInteractionModality(event, elementRef);
177
231
  isFocusVisible = false;
232
+ if (!disableFocusHandling) {
233
+ listboxCtx.setFocusVisible(false);
234
+ }
178
235
 
179
236
  if (isDisabledComputed) {
180
237
  event.preventDefault();
@@ -200,6 +257,12 @@
200
257
  function handlePointerEnter(event: PointerEvent) {
201
258
  if (isDisabledComputed) return;
202
259
 
260
+ trackInteractionModality(event, elementRef);
261
+ if (!disableFocusHandling) {
262
+ listboxCtx.setFocusVisible(false);
263
+ }
264
+ transferHoverFocus();
265
+
203
266
  if ((event.buttons & 1) === 1 && pressedKey === null) {
204
267
  isPressed = true;
205
268
  }
@@ -211,9 +274,15 @@
211
274
  }
212
275
  }
213
276
 
214
- function handleMouseEnter() {
277
+ function handleMouseEnter(event: MouseEvent) {
215
278
  if (!isDisabledComputed) {
279
+ trackInteractionModality(event, elementRef);
216
280
  isHovered = true;
281
+ isFocusVisible = false;
282
+ if (!disableFocusHandling) {
283
+ listboxCtx.setFocusVisible(false);
284
+ transferHoverFocus();
285
+ }
217
286
  }
218
287
  }
219
288
 
@@ -228,7 +297,11 @@
228
297
  function handleKeydown(event: KeyboardEvent) {
229
298
  trackInteractionModality(event, elementRef);
230
299
  if (isFocusedComputed) {
300
+ isHovered = false;
231
301
  isFocusVisible = true;
302
+ if (!disableFocusHandling) {
303
+ listboxCtx.setFocusVisible(true);
304
+ }
232
305
  }
233
306
 
234
307
  const key =
@@ -276,6 +349,9 @@
276
349
  function handleMouseDown(event: MouseEvent) {
277
350
  trackInteractionModality(event, elementRef);
278
351
  isFocusVisible = false;
352
+ if (!disableFocusHandling) {
353
+ listboxCtx.setFocusVisible(false);
354
+ }
279
355
 
280
356
  // Prevent focus stealing when used in ComboBox (disableFocusHandling=true)
281
357
  // This keeps the focus on the input while allowing click selection
@@ -317,8 +393,8 @@
317
393
  data-selected={isSelected || undefined}
318
394
  data-disabled={isDisabledComputed || undefined}
319
395
  data-focused={isFocusedComputed || undefined}
320
- data-focus-visible={isFocusVisibleComputed || undefined}
321
- data-hovered={isHovered || undefined}
396
+ data-focus-visible={showFocusVisible || undefined}
397
+ data-hovered={showHovered || undefined}
322
398
  data-pressed={isPressedComputed || undefined}
323
399
  onpointerdown={handlePointerDown}
324
400
  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.
@@ -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}
@@ -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.12",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "svelte": "./dist/index.js",