@human-kit/svelte-components 1.0.0-alpha.12 → 1.0.0-alpha.14

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.
Files changed (29) hide show
  1. package/dist/checkbox/root/checkbox-root.svelte +22 -2
  2. package/dist/checkbox/root/checkbox-root.svelte.d.ts +4 -1
  3. package/dist/combobox/input/combobox-input.svelte +1 -0
  4. package/dist/combobox/list/combobox-listbox.svelte +1 -0
  5. package/dist/combobox/list/combobox-listbox.svelte.d.ts +1 -0
  6. package/dist/combobox/root/combobox-test.svelte +8 -2
  7. package/dist/combobox/root/combobox-test.svelte.d.ts +1 -0
  8. package/dist/combobox/root/combobox.svelte +16 -9
  9. package/dist/listbox/item/listbox-item.svelte +24 -2
  10. package/dist/listbox/root/listbox.svelte +14 -2
  11. package/dist/listbox/root/listbox.svelte.d.ts +2 -0
  12. package/dist/table/IMPLEMENTATION_NOTES.md +2 -1
  13. package/dist/table/PLAN.md +440 -17
  14. package/dist/table/TODO.md +39 -1
  15. package/dist/table/cell/table-cell.svelte +86 -79
  16. package/dist/table/checkbox/table-checkbox-test.svelte +7 -0
  17. package/dist/table/checkbox/table-checkbox-test.svelte.d.ts +3 -1
  18. package/dist/table/checkbox/table-checkbox.svelte +55 -30
  19. package/dist/table/index.d.ts +1 -1
  20. package/dist/table/root/context.d.ts +16 -1
  21. package/dist/table/root/context.js +199 -24
  22. package/dist/table/root/table-root.svelte +30 -0
  23. package/dist/table/root/table-root.svelte.d.ts +4 -1
  24. package/dist/table/root/table-test.svelte +29 -0
  25. package/dist/table/root/table-test.svelte.d.ts +5 -1
  26. package/dist/table/row/table-row.svelte +44 -67
  27. package/dist/table/utils/handle-body-keydown.d.ts +13 -0
  28. package/dist/table/utils/handle-body-keydown.js +67 -0
  29. package/package.json +1 -1
@@ -24,6 +24,7 @@
24
24
  | 'value'
25
25
  > & {
26
26
  id?: string;
27
+ element?: HTMLSpanElement | null;
27
28
  name?: string;
28
29
  value?: string;
29
30
  isChecked?: boolean;
@@ -39,8 +40,20 @@
39
40
  class?: string;
40
41
  'aria-label'?: string;
41
42
  'aria-labelledby'?: string;
43
+ onclick?: HTMLAttributes<HTMLSpanElement>['onclick'];
44
+ onkeydown?: HTMLAttributes<HTMLSpanElement>['onkeydown'];
42
45
  };
43
46
 
47
+ function composeEventHandlers<TEvent extends Event>(
48
+ internalHandler: ((event: TEvent) => void) | undefined,
49
+ externalHandler: ((event: TEvent) => void) | undefined
50
+ ): (event: TEvent) => void {
51
+ return (event: TEvent) => {
52
+ internalHandler?.(event);
53
+ externalHandler?.(event);
54
+ };
55
+ }
56
+
44
57
  function resolveState(isChecked: boolean, isIndeterminate: boolean): CheckboxState {
45
58
  if (isIndeterminate) return 'indeterminate';
46
59
  return isChecked ? 'checked' : 'unchecked';
@@ -56,6 +69,7 @@
56
69
 
57
70
  let {
58
71
  id,
72
+ element = $bindable(),
59
73
  name,
60
74
  value = 'on',
61
75
  isChecked = $bindable(),
@@ -71,6 +85,8 @@
71
85
  class: className = '',
72
86
  'aria-label': ariaLabel,
73
87
  'aria-labelledby': ariaLabelledby,
88
+ onclick: onClickExternal,
89
+ onkeydown: onKeyDownExternal,
74
90
  ...restProps
75
91
  }: CheckboxRootProps = $props();
76
92
 
@@ -91,6 +107,10 @@
91
107
  let rootRef: HTMLSpanElement | null = $state(null);
92
108
  let inputRef: HTMLInputElement | null = $state(null);
93
109
 
110
+ $effect(() => {
111
+ element = rootRef;
112
+ });
113
+
94
114
  if (untrack(() => isChecked) === undefined) {
95
115
  isChecked = initialState === 'checked';
96
116
  }
@@ -326,8 +346,8 @@
326
346
  data-required={required || undefined}
327
347
  data-focused={focused || undefined}
328
348
  data-focus-visible={focusVisible || undefined}
329
- onclick={handleClick}
330
- onkeydown={handleKeyDown}
349
+ onclick={composeEventHandlers(onClickExternal ?? undefined, handleClick)}
350
+ onkeydown={composeEventHandlers(handleKeyDown, onKeyDownExternal ?? undefined)}
331
351
  onkeyup={handleKeyUp}
332
352
  onpointerdown={handlePointerDown}
333
353
  onmousedown={handlePointerDown}
@@ -2,6 +2,7 @@ import { type Snippet } from 'svelte';
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
3
  type CheckboxRootProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children' | 'class' | 'id' | 'role' | 'tabindex' | 'aria-checked' | 'aria-disabled' | 'aria-readonly' | 'aria-required' | 'onclick' | 'onkeydown' | 'value'> & {
4
4
  id?: string;
5
+ element?: HTMLSpanElement | null;
5
6
  name?: string;
6
7
  value?: string;
7
8
  isChecked?: boolean;
@@ -17,7 +18,9 @@ type CheckboxRootProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children' | 'cla
17
18
  class?: string;
18
19
  'aria-label'?: string;
19
20
  'aria-labelledby'?: string;
21
+ onclick?: HTMLAttributes<HTMLSpanElement>['onclick'];
22
+ onkeydown?: HTMLAttributes<HTMLSpanElement>['onkeydown'];
20
23
  };
21
- declare const CheckboxRoot: import("svelte").Component<CheckboxRootProps, {}, "isChecked" | "isIndeterminate">;
24
+ declare const CheckboxRoot: import("svelte").Component<CheckboxRootProps, {}, "element" | "isChecked" | "isIndeterminate">;
22
25
  type CheckboxRoot = ReturnType<typeof CheckboxRoot>;
23
26
  export default CheckboxRoot;
@@ -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}
@@ -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
  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,17 +211,26 @@
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
 
217
226
  if (selectionMode === 'single') {
218
227
  newSelection = new Set([id]);
228
+ shouldFilter = false;
219
229
  // Save the label persistently for restore on blur/escape
220
230
  selectedLabel = label;
221
- // Update input directly without triggering deselection
222
- inputValueInternal = label;
223
- inputValue = label;
224
- onInputChange?.(label);
231
+ // Keep the selected label visible in the input without re-triggering
232
+ // external filtering during the popover close animation.
233
+ syncInputValue(label, { notifyInputChange: false });
225
234
  if (effectiveCloseOnSelect) {
226
235
  closePopover(true); // Close and keep focus on input
227
236
  }
@@ -318,6 +327,8 @@
318
327
  onInputChange?.('');
319
328
  }
320
329
  // Otherwise user typed, keep their filter
330
+ } else {
331
+ shouldFilter = true;
321
332
  }
322
333
  setIsOpen(true);
323
334
  // Auto-focus the selected item when opening with a selection
@@ -333,8 +344,6 @@
333
344
  setIsOpen(false);
334
345
  // Reset navigation state
335
346
  navigation.reset();
336
- // Re-enable filtering for next open
337
- shouldFilter = true;
338
347
  // Only refocus input when explicitly requested (e.g., after selection)
339
348
  // Never refocus in focus mode to prevent re-opening
340
349
  if (refocusInput && trigger !== 'focus') {
@@ -442,9 +451,7 @@
442
451
  if (currentSelection.size > 0 && selectedLabel) {
443
452
  if (selectedLabel !== currentInputValue) {
444
453
  // Restore the selected label
445
- inputValueInternal = selectedLabel;
446
- inputValue = selectedLabel;
447
- onInputChange?.(selectedLabel);
454
+ syncInputValue(selectedLabel, { notifyInputChange: false });
448
455
  }
449
456
  }
450
457
  }
@@ -145,7 +145,7 @@
145
145
 
146
146
  // Scroll into view when focused (if enabled)
147
147
  $effect(() => {
148
- if (scrollOnFocus && isFocusedComputed && elementRef) {
148
+ if (scrollOnFocus && isFocusedComputed && isFocusVisibleComputed && elementRef) {
149
149
  requestAnimationFrame(() => {
150
150
  elementRef?.scrollIntoView({ block: 'nearest' });
151
151
  });
@@ -159,6 +159,19 @@
159
159
  isFocusVisible = false;
160
160
  });
161
161
 
162
+ $effect(() => {
163
+ if (!isFocusedComputed) {
164
+ if (pressedKey !== null) {
165
+ clearPressedState();
166
+ }
167
+ return;
168
+ }
169
+
170
+ if (listFocusVisible || isFocusVisibleComputed) {
171
+ isHovered = false;
172
+ }
173
+ });
174
+
162
175
  function clearPressedState() {
163
176
  isPressed = false;
164
177
  pressedKey = null;
@@ -193,6 +206,15 @@
193
206
 
194
207
  const label = getResolvedTextValue();
195
208
 
209
+ if (!disableFocusHandling && elementRef) {
210
+ suppressNextFocusVisible = true;
211
+ isFocusVisible = false;
212
+ listboxCtx.setFocusVisible(false);
213
+ listboxCtx.setFocusedId(id);
214
+ listboxCtx.keyboardNav.setCurrentId(id);
215
+ focusWithModality(elementRef, 'pointer');
216
+ }
217
+
196
218
  // Use custom select handler if provided, otherwise use listbox default
197
219
  if (onItemSelect) {
198
220
  onItemSelect(id, label);
@@ -201,7 +223,7 @@
201
223
  }
202
224
 
203
225
  if (!disableFocusHandling) {
204
- listboxCtx.keyboardNav.focusById(id);
226
+ listboxCtx.keyboardNav.setCurrentId(id);
205
227
  }
206
228
  }
207
229
 
@@ -1,5 +1,6 @@
1
1
  <script lang="ts" generics="T extends object = object">
2
2
  import type { Snippet } from 'svelte';
3
+ import { onMount } from 'svelte';
3
4
  import { createListBoxContext, type ListBoxContext } from './context';
4
5
  import { trackInteractionModality } from '../../primitives/input-modality';
5
6
 
@@ -32,6 +33,8 @@
32
33
  'aria-label'?: string;
33
34
  /** Callback fired when the selection changes. */
34
35
  onChange?: (value: Set<string | number>) => void;
36
+ /** Disable DOM focus handling on the root container for virtual-focus compositions. */
37
+ disableFocusHandling?: boolean;
35
38
  };
36
39
 
37
40
  let {
@@ -47,6 +50,7 @@
47
50
  id,
48
51
  'aria-label': ariaLabel,
49
52
  onChange,
53
+ disableFocusHandling = false,
50
54
  context = $bindable(),
51
55
  element = $bindable()
52
56
  }: ListBoxProps & { context?: ListBoxContext; element?: HTMLElement } = $props();
@@ -115,9 +119,14 @@
115
119
 
116
120
  const itemsArray = $derived(items ? Array.from(items) : []);
117
121
  const hasItems = $derived(itemsArray.length > 0 || itemCount > 0);
122
+ let hasMounted = $state(false);
118
123
 
119
124
  let focusWithin = $state(false);
120
125
 
126
+ onMount(() => {
127
+ hasMounted = true;
128
+ });
129
+
121
130
  function syncFocusWithin() {
122
131
  focusWithin =
123
132
  !!listboxElement &&
@@ -136,6 +145,9 @@
136
145
  function handleMouseDown(event: MouseEvent) {
137
146
  trackInteractionModality(event, event.target as HTMLElement | null);
138
147
  ctx.setFocusVisible(false);
148
+ if (disableFocusHandling) {
149
+ event.preventDefault();
150
+ }
139
151
  }
140
152
 
141
153
  function handleKeyDown(event: KeyboardEvent) {
@@ -153,7 +165,7 @@
153
165
  aria-multiselectable={selectionMode === 'multiple'}
154
166
  aria-label={ariaLabel}
155
167
  class={className}
156
- tabindex="0"
168
+ tabindex={disableFocusHandling ? undefined : 0}
157
169
  data-focus-within={focusWithin || undefined}
158
170
  use:keyboardAction
159
171
  onfocusin={handleFocusIn}
@@ -169,7 +181,7 @@
169
181
  {@render (children as Snippet)()}
170
182
  {/if}
171
183
 
172
- {#if !hasItems && itemCount === 0}
184
+ {#if hasMounted && !hasItems && itemCount === 0}
173
185
  {#if typeof emptyPlaceholder === 'string'}
174
186
  <div role="option" aria-selected="false" aria-disabled="true" data-empty-placeholder>
175
187
  {emptyPlaceholder}
@@ -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;
@@ -2,7 +2,8 @@
2
2
 
3
3
  ## Open questions
4
4
 
5
- - Disabled body rows are currently rendered and keyboard-focusable, but they cannot be selected. We should validate whether this matches the desired UX.
5
+ - Disabled body rows are currently rendered and treated with an all-or-nothing disabled model. The planned `disabledBehavior` API (`'selection' | 'all'`) will require splitting focus/action disabling from selection disabling.
6
6
  - `Table.Column` is implemented as a logical wrapper and currently assumes the intended child is a single `Table.ColumnHeaderCell`.
7
7
  - `Table.Footer` renders semantic table cells but is intentionally excluded from the roving-focus model in v1.
8
8
  - Interactive controls nested inside `Table.Cell` are still intentionally out of scope for v1.
9
+ - `pressRow()` is currently selection-oriented. The planned `onRowAction` feature will require a clearer interaction pipeline so pointer click, double click, `Enter`, and `Space` can route to action and selection independently.