@human-kit/svelte-components 1.0.0-alpha.6 → 1.0.0-alpha.8

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 (39) hide show
  1. package/dist/button/README.md +48 -0
  2. package/dist/button/TODO.md +13 -0
  3. package/dist/button/index.d.ts +5 -0
  4. package/dist/button/index.js +4 -0
  5. package/dist/button/index.parts.d.ts +1 -0
  6. package/dist/button/index.parts.js +1 -0
  7. package/dist/button/root/README.md +43 -0
  8. package/dist/button/root/button-root.svelte +362 -0
  9. package/dist/button/root/button-root.svelte.d.ts +21 -0
  10. package/dist/button/root/button-test.svelte +76 -0
  11. package/dist/button/root/button-test.svelte.d.ts +11 -0
  12. package/dist/calendar/trigger-next/calendar-trigger-next.svelte +9 -4
  13. package/dist/calendar/trigger-next/calendar-trigger-next.svelte.d.ts +2 -1
  14. package/dist/calendar/trigger-previous/calendar-trigger-previous.svelte +9 -4
  15. package/dist/calendar/trigger-previous/calendar-trigger-previous.svelte.d.ts +2 -1
  16. package/dist/combobox/button/combobox-button.svelte +5 -4
  17. package/dist/combobox/list/combobox-listbox.svelte.d.ts +1 -1
  18. package/dist/combobox/popover/combobox-popover.svelte +34 -4
  19. package/dist/combobox/root/combobox.svelte +8 -0
  20. package/dist/combobox/tag-remove/combobox-tag-remove.svelte +3 -2
  21. package/dist/datepicker/trigger/date-picker-trigger.svelte +5 -5
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.js +2 -0
  24. package/dist/input/README.md +36 -0
  25. package/dist/input/TODO.md +12 -0
  26. package/dist/input/input-test.svelte +38 -0
  27. package/dist/input/input-test.svelte.d.ts +11 -0
  28. package/dist/input/input.svelte +142 -7
  29. package/dist/input/input.svelte.d.ts +7 -2
  30. package/dist/listbox/item/README.md +2 -1
  31. package/dist/listbox/item/listbox-item.svelte +129 -1
  32. package/dist/listbox/item/listbox-item.svelte.d.ts +2 -0
  33. package/dist/listbox/root/listbox-test.svelte +14 -2
  34. package/dist/listbox/root/listbox-test.svelte.d.ts +1 -0
  35. package/dist/listbox/root/listbox.svelte.d.ts +2 -2
  36. package/dist/popover/trigger/popover-trigger-button.svelte +4 -3
  37. package/dist/table/root/table-root.svelte.d.ts +1 -1
  38. package/dist/timepicker/trigger/time-picker-trigger.svelte +5 -5
  39. package/package.json +6 -1
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLButtonAttributes } from 'svelte/elements';
4
+ import { ButtonRoot } from '../../button/index.js';
4
5
  import { useComboBoxContext } from '../root/context';
5
6
 
6
7
  type ComboBoxButtonProps = HTMLButtonAttributes & {
@@ -26,14 +27,14 @@
26
27
 
27
28
  <!-- * CHANGE: CHANGE NAME FROM BUTTON TO TRIGGER -->
28
29
 
29
- <button
30
+ <ButtonRoot
30
31
  type="button"
31
32
  {tabindex}
32
33
  aria-label={ctx.isOpen ? 'Close menu' : 'Open menu'}
33
34
  aria-expanded={ctx.isOpen}
34
35
  aria-controls={`combobox-listbox-${ctx.instanceId}`}
35
- disabled={ctx.isDisabled}
36
- data-pressed={ctx.isOpen}
36
+ isDisabled={ctx.isDisabled}
37
+ pressed={ctx.isOpen}
37
38
  onmousedown={handleMouseDown}
38
39
  class={className}
39
40
  {...restProps}
@@ -56,4 +57,4 @@
56
57
  <path d="m6 9 6 6 6-6" />
57
58
  </svg>
58
59
  {/if}
59
- </button>
60
+ </ButtonRoot>
@@ -17,7 +17,7 @@ declare function $$render<T extends object = object>(): {
17
17
  } & {
18
18
  context?: ListBoxContext;
19
19
  element?: HTMLElement;
20
- }, "children" | "id" | "value" | "selectionMode" | "selectionBehavior" | "defaultValue" | "onChange" | "items" | "context" | "element"> & {
20
+ }, "children" | "value" | "id" | "element" | "selectionMode" | "selectionBehavior" | "defaultValue" | "onChange" | "items" | "context"> & {
21
21
  /** Optional items for dynamic rendering - overrides items from ComboBox context */
22
22
  items?: Iterable<T>;
23
23
  /** Content of the listbox. Receives item in dynamic mode. */
@@ -26,11 +26,41 @@
26
26
  }
27
27
 
28
28
  function handleOpenChange(open: boolean, details?: PopoverOpenChangeDetails) {
29
- if (!open && details?.reason === 'outside-press') {
30
- const target = resolveFocusTarget(details);
31
- if (target) {
32
- focusWithModality(target, 'pointer' satisfies InputModality);
29
+ if (!open) {
30
+ // Cancel Popover.Root's close to prevent scheduleTriggerCloseFocus from
31
+ // setting stale data-focused on the trigger. The combobox passes triggerRef
32
+ // via prop (not Popover.Trigger), so Popover.Root never registers a
33
+ // blur-cleanup listener and any data-focused it sets persists forever.
34
+ //
35
+ // IMPORTANT: the actual state change is deferred to a microtask so that
36
+ // when Popover.Root's closePopover re-reads `isOpen` after the callback,
37
+ // the derived value is still `true` and the guard succeeds. A synchronous
38
+ // ctx.onOpenChange(false) would update the upstream signal immediately,
39
+ // making the derived `false` and bypassing the guard despite the cancel.
40
+ details?.cancel();
41
+
42
+ if (details?.reason === 'outside-press') {
43
+ const target = resolveFocusTarget(details);
44
+ queueMicrotask(() => {
45
+ ctx.onOpenChange(false);
46
+
47
+ if (target) {
48
+ focusWithModality(target, 'pointer' satisfies InputModality);
49
+ }
50
+
51
+ // If focus is still on the input (non-focusable target), blur it
52
+ // so focusWithin becomes false.
53
+ if (document.activeElement === ctx.inputRef) {
54
+ ctx.inputRef?.blur();
55
+ }
56
+ });
57
+ return;
33
58
  }
59
+
60
+ queueMicrotask(() => {
61
+ ctx.onOpenChange(false);
62
+ });
63
+ return;
34
64
  }
35
65
 
36
66
  ctx.onOpenChange(open);
@@ -346,6 +346,13 @@
346
346
  !!wrapperRef && !!document.activeElement && wrapperRef.contains(document.activeElement);
347
347
  if (!focusWithin) {
348
348
  focusVisible = false;
349
+ // Clean up stale data-focused/data-focus-visible that Popover.Root's
350
+ // focus-state module may have set imperatively on the wrapper (the
351
+ // wrapper is briefly used as triggerRef before the input overrides it).
352
+ if (wrapperRef) {
353
+ delete wrapperRef.dataset.focused;
354
+ delete wrapperRef.dataset.focusVisible;
355
+ }
349
356
  }
350
357
  }
351
358
 
@@ -717,6 +724,7 @@
717
724
  aria-labelledby={ariaLabelledby}
718
725
  class={className}
719
726
  data-combobox
727
+ data-focused={focusWithin || undefined}
720
728
  data-disabled={isDisabled || undefined}
721
729
  data-readonly={isReadOnly || undefined}
722
730
  data-focus-within={focusWithin || undefined}
@@ -2,6 +2,7 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLButtonAttributes } from 'svelte/elements';
4
4
  import { getContext } from 'svelte';
5
+ import { ButtonRoot } from '../../button/index.js';
5
6
  import { cn } from '../../utils/cn';
6
7
  import { TAG_CONTEXT_KEY, type TagContext } from '../tag/combobox-tag.svelte';
7
8
 
@@ -27,7 +28,7 @@
27
28
  </script>
28
29
 
29
30
  {#if !tagCtx.disabled}
30
- <button
31
+ <ButtonRoot
31
32
  type="button"
32
33
  onclick={handleClick}
33
34
  aria-label={`Remove ${tagCtx.label}`}
@@ -49,5 +50,5 @@
49
50
  />
50
51
  </svg>
51
52
  {/if}
52
- </button>
53
+ </ButtonRoot>
53
54
  {/if}
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLButtonAttributes } from 'svelte/elements';
4
+ import { ButtonRoot } from '../../button/index.js';
4
5
  import { useDatePickerContext } from '../root/context';
5
6
  import {
6
7
  shouldShowFocusVisible,
@@ -86,14 +87,13 @@
86
87
  </script>
87
88
 
88
89
  {#if !datePicker.isReadOnly}
89
- <button
90
- bind:this={buttonRef}
90
+ <ButtonRoot
91
+ bind:element={buttonRef}
91
92
  type="button"
92
- disabled={datePicker.isDisabled}
93
+ isDisabled={datePicker.isDisabled}
93
94
  class={className}
94
95
  aria-haspopup="dialog"
95
96
  aria-expanded={datePicker.open}
96
- data-disabled={datePicker.isDisabled || undefined}
97
97
  data-focused={isFocused || undefined}
98
98
  data-focus-visible={isFocused && datePicker.focusVisible ? 'true' : undefined}
99
99
  onmousedown={handleMouseDown}
@@ -106,5 +106,5 @@
106
106
  {#if children}
107
107
  {@render children()}
108
108
  {/if}
109
- </button>
109
+ </ButtonRoot>
110
110
  {/if}
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { Button } from './button/index.js';
1
2
  export { Checkbox } from './checkbox/index.js';
2
3
  export { ComboBox } from './combobox/index.js';
3
4
  export { Calendar } from './calendar/index.js';
@@ -13,6 +14,7 @@ export { default as Label } from './label/index.js';
13
14
  export { default as LocaleProvider } from './locale-provider/index.js';
14
15
  export { Portal } from './portal/index.js';
15
16
  export * from './locale-provider/index.js';
17
+ export * from './button/index.js';
16
18
  export * from './checkbox/index.js';
17
19
  export * from './combobox/index.js';
18
20
  export * from './calendar/index.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Main library entry point
2
2
  // Components (namespace exports)
3
+ export { Button } from './button/index.js';
3
4
  export { Checkbox } from './checkbox/index.js';
4
5
  export { ComboBox } from './combobox/index.js';
5
6
  export { Calendar } from './calendar/index.js';
@@ -17,6 +18,7 @@ export { default as LocaleProvider } from './locale-provider/index.js';
17
18
  export { Portal } from './portal/index.js';
18
19
  export * from './locale-provider/index.js';
19
20
  // Re-export named exports from components
21
+ export * from './button/index.js';
20
22
  export * from './checkbox/index.js';
21
23
  export * from './combobox/index.js';
22
24
  export * from './calendar/index.js';
@@ -0,0 +1,36 @@
1
+ # Input
2
+
3
+ ## Description
4
+
5
+ `Input` is a headless native text input with modality-aware focus state, RAC-style disabled and read-only booleans, and data attributes for validation and form styling.
6
+
7
+ ## Anatomy
8
+
9
+ - `Input`
10
+
11
+ ```svelte
12
+ <Input aria-label="Email" placeholder="name@example.com" isInvalid={hasError} isRequired />
13
+ ```
14
+
15
+ ## Usage guidelines
16
+
17
+ - Use native input props like `type`, `name`, `value`, `defaultValue`, `placeholder`, and `autocomplete` directly on `Input`.
18
+ - Prefer `isDisabled`, `isReadOnly`, `isInvalid`, and `isRequired` when you want RAC-style naming while keeping native behavior.
19
+ - Style state with `data-focused`, `data-focus-visible`, `data-hovered`, `data-disabled`, `data-readonly`, `data-invalid`, and `data-required`.
20
+
21
+ ## API reference
22
+
23
+ `Input` supports:
24
+
25
+ - `isDisabled?: boolean`
26
+ - `isReadOnly?: boolean`
27
+ - `isInvalid?: boolean`
28
+ - `isRequired?: boolean`
29
+ - `element?: HTMLInputElement | null`
30
+ - `...restProps: HTMLInputAttributes`
31
+
32
+ ## Accessibility
33
+
34
+ - `Input` renders a native `<input>` with `type="text"` by default.
35
+ - `data-focus-visible` follows the shared modality contract and only appears for keyboard or virtual focus.
36
+ - `isInvalid` maps to `aria-invalid`, `isReadOnly` maps to `readonly` and `aria-readonly`, and `isRequired` maps to `required` and `aria-required`.
@@ -0,0 +1,12 @@
1
+ # Input TODO
2
+
3
+ ## Goal
4
+
5
+ Track Input work with the repository TODO format.
6
+
7
+ ## Backlog
8
+
9
+ - [x] [S][P0][Area: Interaction][Owner: Unassigned][Target: Done] Expose modality-aware focus and hover state on the native input.
10
+ - [x] [S][P0][Area: Accessibility][Owner: Unassigned][Target: Done] Support RAC-style `isDisabled`, `isReadOnly`, `isInvalid`, and `isRequired` props.
11
+ - [x] [S][P0][Area: Testing][Owner: Unassigned][Target: Done] Add baseline tests for focus-visible, hover, disabled, read-only, and validation attributes.
12
+ - [ ] [S][P1][Area: API][Owner: Unassigned][Target: TBD] Evaluate whether a composed `TextField` wrapper should own label, description, and error text semantics.
@@ -0,0 +1,38 @@
1
+ <script lang="ts">
2
+ import Input from './index';
3
+
4
+ type Props = {
5
+ isDisabled?: boolean;
6
+ isReadOnly?: boolean;
7
+ isInvalid?: boolean;
8
+ isRequired?: boolean;
9
+ onMouseEnter?: (event: MouseEvent) => void;
10
+ onFocus?: (event: FocusEvent) => void;
11
+ };
12
+
13
+ let {
14
+ isDisabled = false,
15
+ isReadOnly = false,
16
+ isInvalid = false,
17
+ isRequired = false,
18
+ onMouseEnter,
19
+ onFocus
20
+ }: Props = $props();
21
+ </script>
22
+
23
+ <form>
24
+ <button type="button">Before</button>
25
+
26
+ <Input
27
+ aria-label="Email"
28
+ placeholder="name@example.com"
29
+ {isDisabled}
30
+ {isReadOnly}
31
+ {isInvalid}
32
+ {isRequired}
33
+ onmouseenter={onMouseEnter}
34
+ onfocus={onFocus}
35
+ />
36
+
37
+ <button type="button">After</button>
38
+ </form>
@@ -0,0 +1,11 @@
1
+ type Props = {
2
+ isDisabled?: boolean;
3
+ isReadOnly?: boolean;
4
+ isInvalid?: boolean;
5
+ isRequired?: boolean;
6
+ onMouseEnter?: (event: MouseEvent) => void;
7
+ onFocus?: (event: FocusEvent) => void;
8
+ };
9
+ declare const InputTest: import("svelte").Component<Props, {}, "">;
10
+ type InputTest = ReturnType<typeof InputTest>;
11
+ export default InputTest;
@@ -1,19 +1,154 @@
1
1
  <script lang="ts">
2
- import type { HTMLInputAttributes } from 'svelte/elements';
3
2
  import type { ClassValue } from 'class-variance-authority/types';
3
+ import { untrack } from 'svelte';
4
+ import type { HTMLInputAttributes } from 'svelte/elements';
5
+ import { shouldShowFocusVisible, trackInteractionModality } from '../primitives/input-modality';
4
6
  import { cn } from '../utils/cn';
5
7
 
8
+ type AriaInvalidValue = HTMLInputAttributes['aria-invalid'];
9
+
6
10
  type InputProps = HTMLInputAttributes & {
7
11
  class?: ClassValue;
12
+ isDisabled?: boolean;
13
+ isReadOnly?: boolean;
14
+ isInvalid?: boolean;
15
+ isRequired?: boolean;
16
+ element?: HTMLInputElement | null;
8
17
  };
9
18
 
10
- let { class: className, ...props }: InputProps = $props();
19
+ function composeEventHandlers<TEvent extends Event>(
20
+ internalHandler: ((event: TEvent) => void) | undefined,
21
+ externalHandler: ((event: TEvent) => void) | undefined
22
+ ): (event: TEvent) => void {
23
+ return (event: TEvent) => {
24
+ internalHandler?.(event);
25
+ externalHandler?.(event);
26
+ };
27
+ }
28
+
29
+ function isAriaInvalidValue(value: AriaInvalidValue | undefined): boolean {
30
+ return value === true || value === 'true' || value === 'grammar' || value === 'spelling';
31
+ }
32
+
33
+ const generatedId = $props.id();
34
+
35
+ let {
36
+ id,
37
+ type = 'text',
38
+ class: className,
39
+ disabled: disabledProp = false,
40
+ readonly: readOnlyProp = false,
41
+ required: requiredProp = false,
42
+ 'aria-invalid': ariaInvalidProp,
43
+ isDisabled = false,
44
+ isReadOnly = false,
45
+ isInvalid = false,
46
+ isRequired = false,
47
+ element = $bindable<HTMLInputElement | null>(null),
48
+ onfocus: onFocusExternal,
49
+ onblur: onBlurExternal,
50
+ onkeydown: onKeyDownExternal,
51
+ onmousedown: onMouseDownExternal,
52
+ onpointerdown: onPointerDownExternal,
53
+ onmouseenter: onMouseEnterExternal,
54
+ onmouseleave: onMouseLeaveExternal,
55
+ ...restProps
56
+ }: InputProps = $props();
57
+
58
+ const resolvedId = untrack(() => id) ?? generatedId;
59
+
60
+ let inputRef: HTMLInputElement | null = $state(null);
61
+ let hovered = $state(false);
62
+ let focused = $state(false);
63
+ let focusVisible = $state(false);
64
+
65
+ const resolvedDisabled = $derived(Boolean(isDisabled || disabledProp));
66
+ const resolvedReadOnly = $derived(Boolean(isReadOnly || readOnlyProp));
67
+ const resolvedRequired = $derived(Boolean(isRequired || requiredProp));
68
+ const resolvedInvalid = $derived(Boolean(isInvalid || isAriaInvalidValue(ariaInvalidProp)));
69
+ const renderedAriaInvalid = $derived.by<AriaInvalidValue | undefined>(() => {
70
+ if (!resolvedInvalid) return undefined;
71
+ return ariaInvalidProp === 'grammar' || ariaInvalidProp === 'spelling'
72
+ ? ariaInvalidProp
73
+ : 'true';
74
+ });
75
+
76
+ $effect(() => {
77
+ element = inputRef;
78
+ });
79
+
80
+ $effect(() => {
81
+ if (!resolvedDisabled) return;
82
+ hovered = false;
83
+ focused = false;
84
+ focusVisible = false;
85
+ });
86
+
87
+ function handleFocus() {
88
+ if (resolvedDisabled) return;
89
+ focused = true;
90
+ focusVisible = shouldShowFocusVisible(inputRef);
91
+ }
92
+
93
+ function handleBlur() {
94
+ focused = false;
95
+ focusVisible = false;
96
+ }
97
+
98
+ function handleKeyDown(event: KeyboardEvent) {
99
+ trackInteractionModality(event, inputRef);
100
+ focusVisible = focused ? true : shouldShowFocusVisible(inputRef);
101
+ }
102
+
103
+ function handleMouseDown(event: MouseEvent) {
104
+ trackInteractionModality(event, inputRef);
105
+ focusVisible = false;
106
+ }
107
+
108
+ function handlePointerDown(event: PointerEvent) {
109
+ trackInteractionModality(event, inputRef);
110
+ focusVisible = false;
111
+ }
112
+
113
+ function handleMouseEnter() {
114
+ if (resolvedDisabled) {
115
+ hovered = false;
116
+ return;
117
+ }
118
+
119
+ hovered = true;
120
+ }
121
+
122
+ function handleMouseLeave() {
123
+ hovered = false;
124
+ }
11
125
  </script>
12
126
 
13
127
  <input
14
- {...props}
15
- class={cn(
16
- `bg-depth-2 sunken placeholder:text-muted-foreground hover:bg-depth-1 focus:ring-border h-8 w-full rounded-xs border px-2 text-sm shadow-xs transition-all ease-out outline-none focus:ring focus:ring-offset-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus:data-[invalid=true]:ring-red-500`,
17
- className
18
- )}
128
+ {...restProps}
129
+ bind:this={inputRef}
130
+ id={resolvedId}
131
+ {type}
132
+ disabled={resolvedDisabled}
133
+ readonly={resolvedReadOnly}
134
+ required={resolvedRequired}
135
+ aria-invalid={renderedAriaInvalid}
136
+ aria-readonly={resolvedReadOnly || undefined}
137
+ aria-required={resolvedRequired || undefined}
138
+ data-input-root="true"
139
+ data-disabled={resolvedDisabled || undefined}
140
+ data-readonly={resolvedReadOnly || undefined}
141
+ data-invalid={resolvedInvalid || undefined}
142
+ data-required={resolvedRequired || undefined}
143
+ data-hovered={hovered || undefined}
144
+ data-focused={focused || undefined}
145
+ data-focus-visible={focusVisible || undefined}
146
+ onfocus={composeEventHandlers(handleFocus, onFocusExternal ?? undefined)}
147
+ onblur={composeEventHandlers(handleBlur, onBlurExternal ?? undefined)}
148
+ onkeydown={composeEventHandlers(handleKeyDown, onKeyDownExternal ?? undefined)}
149
+ onmousedown={composeEventHandlers(handleMouseDown, onMouseDownExternal ?? undefined)}
150
+ onpointerdown={composeEventHandlers(handlePointerDown, onPointerDownExternal ?? undefined)}
151
+ onmouseenter={composeEventHandlers(handleMouseEnter, onMouseEnterExternal ?? undefined)}
152
+ onmouseleave={composeEventHandlers(handleMouseLeave, onMouseLeaveExternal ?? undefined)}
153
+ class={cn('outline-none', className)}
19
154
  />
@@ -1,8 +1,13 @@
1
- import type { HTMLInputAttributes } from 'svelte/elements';
2
1
  import type { ClassValue } from 'class-variance-authority/types';
2
+ import type { HTMLInputAttributes } from 'svelte/elements';
3
3
  type InputProps = HTMLInputAttributes & {
4
4
  class?: ClassValue;
5
+ isDisabled?: boolean;
6
+ isReadOnly?: boolean;
7
+ isInvalid?: boolean;
8
+ isRequired?: boolean;
9
+ element?: HTMLInputElement | null;
5
10
  };
6
- declare const Input: import("svelte").Component<InputProps, {}, "">;
11
+ declare const Input: import("svelte").Component<InputProps, {}, "element">;
7
12
  type Input = ReturnType<typeof Input>;
8
13
  export default Input;
@@ -5,7 +5,7 @@
5
5
  ### ListBox.Item
6
6
 
7
7
  Name: `ListBox.Item`
8
- Description: Selectable option element with built-in selected, focused, hovered, and disabled states.
8
+ Description: Selectable option element with built-in selected, focused, hovered, pressed, and disabled states.
9
9
 
10
10
  | Prop | Type | Default | Description |
11
11
  | ---------------------- | -------------------------------- | -------------------- | -------------------------------------------------------- |
@@ -21,4 +21,5 @@ Description: Selectable option element with built-in selected, focused, hovered,
21
21
  | `onResolvedTextValue` | `(label: string) => void` | `undefined` | Called when item text value is resolved. |
22
22
  | `scrollOnFocus` | `boolean` | `false` | Scrolls item into view when focused. |
23
23
  | `isParentDisabled` | `boolean` | `false` | Additional disabled state inherited from parent wrapper. |
24
+ | `pressed` | `boolean` | `undefined` | Forces the visual pressed state from parent composition. |
24
25
  | `...restProps` | `HTMLAttributes<HTMLDivElement>` | `-` | Additional option attributes. |
@@ -34,6 +34,8 @@
34
34
  scrollOnFocus?: boolean;
35
35
  /** Additional disabled state from parent. */
36
36
  isParentDisabled?: boolean;
37
+ /** Override the visual pressed state. When provided, this value is used instead of internal press tracking. */
38
+ pressed?: boolean;
37
39
  };
38
40
 
39
41
  let {
@@ -50,6 +52,7 @@
50
52
  onResolvedTextValue,
51
53
  scrollOnFocus = false,
52
54
  isParentDisabled = false,
55
+ pressed: pressedOverride,
53
56
  ...restProps
54
57
  }: ListBoxItemProps = $props();
55
58
 
@@ -59,6 +62,8 @@
59
62
  let isSelected = $state(false);
60
63
  let isFocused = $state(false);
61
64
  let isHovered = $state(false);
65
+ let isPressed = $state(false);
66
+ let pressedKey: 'Enter' | 'Space' | null = $state(null);
62
67
 
63
68
  // Focus: use override if provided, otherwise use internal state
64
69
  const isFocusedComputed = $derived(
@@ -67,6 +72,11 @@
67
72
  const isDisabledComputed = $derived(
68
73
  disabled || listboxCtx.disabledIds.has(id) || isParentDisabled
69
74
  );
75
+ const isPressedComputed = $derived(
76
+ pressedOverride !== undefined
77
+ ? Boolean(pressedOverride) && !isDisabledComputed
78
+ : isPressed && !isDisabledComputed
79
+ );
70
80
 
71
81
  // ID: use custom if provided, otherwise generate
72
82
  const uniqueId = $derived(customId ?? `listbox-item-${id}`);
@@ -113,6 +123,17 @@
113
123
  }
114
124
  });
115
125
 
126
+ $effect(() => {
127
+ if (!isDisabledComputed) return;
128
+ clearPressedState();
129
+ isHovered = false;
130
+ });
131
+
132
+ function clearPressedState() {
133
+ isPressed = false;
134
+ pressedKey = null;
135
+ }
136
+
116
137
  function handleClick() {
117
138
  if (isDisabledComputed) return;
118
139
 
@@ -135,6 +156,42 @@
135
156
 
136
157
  function handleBlur() {}
137
158
 
159
+ function handlePointerDown(event: PointerEvent) {
160
+ if (isDisabledComputed) {
161
+ event.preventDefault();
162
+ clearPressedState();
163
+ return;
164
+ }
165
+
166
+ if (event.button !== 0) return;
167
+ isPressed = true;
168
+ pressedKey = null;
169
+ }
170
+
171
+ function handlePointerUp(event: PointerEvent) {
172
+ if (event.button !== 0) return;
173
+ isPressed = false;
174
+ pressedKey = null;
175
+ }
176
+
177
+ function handlePointerCancel() {
178
+ clearPressedState();
179
+ }
180
+
181
+ function handlePointerEnter(event: PointerEvent) {
182
+ if (isDisabledComputed) return;
183
+
184
+ if ((event.buttons & 1) === 1 && pressedKey === null) {
185
+ isPressed = true;
186
+ }
187
+ }
188
+
189
+ function handlePointerLeave() {
190
+ if (pressedKey === null) {
191
+ isPressed = false;
192
+ }
193
+ }
194
+
138
195
  function handleMouseEnter() {
139
196
  if (!isDisabledComputed) {
140
197
  isHovered = true;
@@ -143,17 +200,80 @@
143
200
 
144
201
  function handleMouseLeave() {
145
202
  isHovered = false;
203
+ if (pressedKey === null) {
204
+ isPressed = false;
205
+ }
146
206
  }
147
207
 
148
208
  // Keyboard is handled by parent container
149
- function handleKeydown() {}
209
+ function handleKeydown(event: KeyboardEvent) {
210
+ const key =
211
+ event.key === 'Enter'
212
+ ? 'Enter'
213
+ : event.key === ' ' || event.key === 'Spacebar'
214
+ ? 'Space'
215
+ : null;
216
+
217
+ if (!key) return;
218
+
219
+ if (isDisabledComputed) {
220
+ event.preventDefault();
221
+ clearPressedState();
222
+ return;
223
+ }
224
+
225
+ if (event.repeat && isPressed && pressedKey === key) return;
226
+
227
+ isPressed = true;
228
+ pressedKey = key;
229
+ }
230
+
231
+ function handleKeyup(event: KeyboardEvent) {
232
+ const key =
233
+ event.key === 'Enter'
234
+ ? 'Enter'
235
+ : event.key === ' ' || event.key === 'Spacebar'
236
+ ? 'Space'
237
+ : null;
238
+
239
+ if (!key) return;
240
+
241
+ if (isDisabledComputed) {
242
+ event.preventDefault();
243
+ clearPressedState();
244
+ return;
245
+ }
246
+
247
+ if (pressedKey === key) {
248
+ clearPressedState();
249
+ }
250
+ }
251
+
150
252
  function handleMouseDown(event: MouseEvent) {
151
253
  // Prevent focus stealing when used in ComboBox (disableFocusHandling=true)
152
254
  // This keeps the focus on the input while allowing click selection
255
+ if (isDisabledComputed) {
256
+ event.preventDefault();
257
+ clearPressedState();
258
+ return;
259
+ }
260
+
261
+ if (event.button === 0) {
262
+ isPressed = true;
263
+ pressedKey = null;
264
+ }
265
+
153
266
  if (disableFocusHandling) {
154
267
  event.preventDefault();
155
268
  }
156
269
  }
270
+
271
+ function handleMouseUp(event: MouseEvent) {
272
+ if (event.button !== 0) return;
273
+ if (pressedKey === null) {
274
+ clearPressedState();
275
+ }
276
+ }
157
277
  </script>
158
278
 
159
279
  <div
@@ -171,9 +291,17 @@
171
291
  data-disabled={isDisabledComputed || undefined}
172
292
  data-focused={isFocusedComputed || undefined}
173
293
  data-hovered={isHovered || undefined}
294
+ data-pressed={isPressedComputed || undefined}
295
+ onpointerdown={handlePointerDown}
296
+ onpointerup={handlePointerUp}
297
+ onpointercancel={handlePointerCancel}
298
+ onpointerenter={handlePointerEnter}
299
+ onpointerleave={handlePointerLeave}
174
300
  onmousedown={handleMouseDown}
301
+ onmouseup={handleMouseUp}
175
302
  onclick={handleClick}
176
303
  onkeydown={handleKeydown}
304
+ onkeyup={handleKeyup}
177
305
  onfocus={handleFocus}
178
306
  onblur={handleBlur}
179
307
  onmouseenter={handleMouseEnter}