@primitiv-ui/react 0.1.29 → 0.1.31

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 (216) hide show
  1. package/README.md +15 -2
  2. package/package.json +1 -1
  3. package/src/AccessibleIcon/AccessibleIcon.tsx +1 -1
  4. package/src/Accordion/Accordion.tsx +7 -7
  5. package/src/Accordion/README.md +2 -2
  6. package/src/Avatar/__tests__/Avatar.fallback.test.tsx +3 -3
  7. package/src/Breadcrumb/Breadcrumb.tsx +7 -7
  8. package/src/Breadcrumb/README.md +2 -2
  9. package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +1 -1
  10. package/src/Breadcrumb/types.ts +1 -1
  11. package/src/Button/__tests__/Button.contract.test.tsx +1 -1
  12. package/src/Carousel/Carousel.tsx +6 -6
  13. package/src/Carousel/README.md +22 -22
  14. package/src/Carousel/__tests__/Carousel.infinite.test.tsx +5 -5
  15. package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +2 -2
  16. package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +1 -1
  17. package/src/Carousel/loopEngine.ts +1 -1
  18. package/src/Carousel/types.ts +1 -1
  19. package/src/Checkbox/Checkbox.tsx +2 -2
  20. package/src/CheckboxCard/CheckboxCard.tsx +1 -1
  21. package/src/CheckboxCard/README.md +1 -1
  22. package/src/Collapsible/README.md +2 -2
  23. package/src/Combobox/Combobox.tsx +508 -0
  24. package/src/Combobox/ComboboxContext.ts +23 -0
  25. package/src/Combobox/README.md +170 -0
  26. package/src/Combobox/__tests__/Combobox.asChild.test.tsx +62 -0
  27. package/src/Combobox/__tests__/Combobox.basic-rendering.test.tsx +16 -0
  28. package/src/Combobox/__tests__/Combobox.content.test.tsx +36 -0
  29. package/src/Combobox/__tests__/Combobox.controlled-state.test.tsx +88 -0
  30. package/src/Combobox/__tests__/Combobox.empty.test.tsx +42 -0
  31. package/src/Combobox/__tests__/Combobox.ids.test.tsx +66 -0
  32. package/src/Combobox/__tests__/Combobox.item.test.tsx +76 -0
  33. package/src/Combobox/__tests__/Combobox.keyboard-interaction.test.tsx +196 -0
  34. package/src/Combobox/__tests__/Combobox.light-dismiss.test.tsx +101 -0
  35. package/src/Combobox/__tests__/Combobox.query.test.tsx +105 -0
  36. package/src/Combobox/hooks/index.ts +1 -0
  37. package/src/Combobox/hooks/useComboboxRoot.ts +2 -0
  38. package/src/Combobox/index.ts +3 -0
  39. package/src/Combobox/types.ts +133 -0
  40. package/src/ContextMenu/ContextMenu.tsx +3 -3
  41. package/src/ContextMenu/README.md +1 -1
  42. package/src/DirectionProvider/DirectionProvider.tsx +5 -5
  43. package/src/DirectionProvider/README.md +4 -4
  44. package/src/Divider/__tests__/Divider.contract.test.tsx +1 -1
  45. package/src/Drawer/Drawer.tsx +5 -5
  46. package/src/Drawer/README.md +2 -2
  47. package/src/Dropdown/Dropdown.tsx +2 -2
  48. package/src/Dropdown/README.md +7 -5
  49. package/src/EmptyState/README.md +2 -2
  50. package/src/Field/README.md +1 -1
  51. package/src/Field/hooks/useFieldProps.ts +1 -1
  52. package/src/Fieldset/Fieldset.tsx +3 -3
  53. package/src/Fieldset/README.md +2 -2
  54. package/src/Input/Input.tsx +1 -1
  55. package/src/Input/README.md +2 -2
  56. package/src/Listbox/Listbox.tsx +475 -0
  57. package/src/Listbox/ListboxContext.ts +48 -0
  58. package/src/Listbox/README.md +248 -0
  59. package/src/Listbox/__tests__/Listbox.active-descendant.test.tsx +128 -0
  60. package/src/Listbox/__tests__/Listbox.asChild.test.tsx +91 -0
  61. package/src/Listbox/__tests__/Listbox.basic-rendering.test.tsx +16 -0
  62. package/src/Listbox/__tests__/Listbox.controlled-state.test.tsx +114 -0
  63. package/src/Listbox/__tests__/Listbox.disabled.test.tsx +90 -0
  64. package/src/Listbox/__tests__/Listbox.fixtures.ts +27 -0
  65. package/src/Listbox/__tests__/Listbox.group-label.test.tsx +97 -0
  66. package/src/Listbox/__tests__/Listbox.group.test.tsx +45 -0
  67. package/src/Listbox/__tests__/Listbox.keyboard-edge-cases.test.tsx +144 -0
  68. package/src/Listbox/__tests__/Listbox.keyboard-interaction.test.tsx +112 -0
  69. package/src/Listbox/__tests__/Listbox.modifier-keys.test.tsx +86 -0
  70. package/src/Listbox/__tests__/Listbox.multi-select-keyboard.test.tsx +368 -0
  71. package/src/Listbox/__tests__/Listbox.multiple-mode.test.tsx +91 -0
  72. package/src/Listbox/__tests__/Listbox.mutation-hardening.test.tsx +480 -0
  73. package/src/Listbox/__tests__/Listbox.orientation.test.tsx +59 -0
  74. package/src/Listbox/__tests__/Listbox.reading-direction.test.tsx +65 -0
  75. package/src/Listbox/__tests__/Listbox.reorder.test.tsx +78 -0
  76. package/src/Listbox/__tests__/Listbox.selection-follows-focus.test.tsx +64 -0
  77. package/src/Listbox/__tests__/Listbox.strict-context.test.tsx +21 -0
  78. package/src/Listbox/__tests__/Listbox.typeahead.test.tsx +123 -0
  79. package/src/Listbox/__tests__/Listbox.uncontrolled-state.test.tsx +48 -0
  80. package/src/Listbox/constants.ts +2 -0
  81. package/src/Listbox/hooks/index.ts +1 -0
  82. package/src/Listbox/hooks/useListboxRoot.ts +407 -0
  83. package/src/Listbox/index.ts +3 -0
  84. package/src/Listbox/types.ts +238 -0
  85. package/src/MillerColumns/MillerColumns.tsx +104 -30
  86. package/src/MillerColumns/README.md +90 -20
  87. package/src/MillerColumns/__tests__/MillerColumns.aria.test.tsx +45 -0
  88. package/src/MillerColumns/__tests__/MillerColumns.auto-scroll.test.tsx +4 -2
  89. package/src/MillerColumns/__tests__/MillerColumns.basic-rendering.test.tsx +18 -3
  90. package/src/MillerColumns/__tests__/MillerColumns.data-attributes.test.tsx +48 -0
  91. package/src/MillerColumns/__tests__/MillerColumns.keyboard-interaction.test.tsx +35 -2
  92. package/src/MillerColumns/__tests__/MillerColumns.keyboard-selection-state.test.tsx +96 -0
  93. package/src/MillerColumns/__tests__/MillerColumns.preview-panel.test.tsx +29 -4
  94. package/src/MillerColumns/__tests__/MillerColumns.preview-visibility.test.tsx +79 -0
  95. package/src/MillerColumns/__tests__/MillerColumns.resize.test.tsx +189 -0
  96. package/src/MillerColumns/__tests__/MillerColumns.styled-parts.test.tsx +143 -0
  97. package/src/MillerColumns/__tests__/MillerColumns.typeahead.test.tsx +104 -0
  98. package/src/MillerColumns/constants.ts +2 -0
  99. package/src/MillerColumns/hooks/useMillerColumnsColumn.ts +5 -2
  100. package/src/MillerColumns/hooks/useMillerColumnsItem.ts +29 -7
  101. package/src/MillerColumns/hooks/useMillerColumnsResizeHandle.ts +80 -10
  102. package/src/MillerColumns/hooks/useMillerColumnsRoot.ts +0 -0
  103. package/src/MillerColumns/index.ts +4 -0
  104. package/src/MillerColumns/types.ts +56 -6
  105. package/src/MillerColumns/utils.ts +69 -3
  106. package/src/Modal/Modal.tsx +4 -4
  107. package/src/Modal/README.md +5 -5
  108. package/src/Modal/types.ts +1 -1
  109. package/src/NavigationMenu/NavigationMenu.tsx +6 -6
  110. package/src/NavigationMenu/README.md +3 -3
  111. package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +1 -1
  112. package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +1 -1
  113. package/src/Popover/Popover.tsx +2 -2
  114. package/src/Popover/README.md +1 -1
  115. package/src/Portal/Portal.tsx +2 -2
  116. package/src/Portal/README.md +2 -2
  117. package/src/Progress/README.md +1 -1
  118. package/src/Radio/Radio.tsx +1 -1
  119. package/src/RadioCard/README.md +2 -1
  120. package/src/RadioCard/RadioCard.tsx +5 -2
  121. package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +23 -0
  122. package/src/RadioGroup/README.md +2 -2
  123. package/src/RadioGroup/RadioGroup.tsx +1 -1
  124. package/src/SegmentedControl/README.md +2 -2
  125. package/src/SegmentedControl/SegmentedControl.tsx +1 -1
  126. package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +1 -0
  127. package/src/Select/README.md +2 -2
  128. package/src/Select/Select.tsx +8 -8
  129. package/src/Select/__tests__/Select.placeholder.test.tsx +3 -3
  130. package/src/Select/__tests__/Select.typeahead.test.tsx +3 -3
  131. package/src/Select/__tests__/Select.value-mirror.test.tsx +1 -1
  132. package/src/Select/types.ts +2 -2
  133. package/src/SkipNav/README.md +3 -3
  134. package/src/SkipNav/SkipNav.tsx +5 -5
  135. package/src/Slider/README.md +37 -7
  136. package/src/Slider/Slider.tsx +9 -3
  137. package/src/Slot/Slot.tsx +2 -2
  138. package/src/Slot/__tests__/Slot.test.tsx +2 -2
  139. package/src/Slot/types.ts +1 -1
  140. package/src/SplitButton/README.md +225 -0
  141. package/src/SplitButton/SplitButton.tsx +457 -0
  142. package/src/SplitButton/SplitButtonContext.ts +26 -0
  143. package/src/SplitButton/__tests__/SplitButton.accessibility.test.tsx +55 -0
  144. package/src/SplitButton/__tests__/SplitButton.asChild.test.tsx +51 -0
  145. package/src/SplitButton/__tests__/SplitButton.basic-rendering.test.tsx +57 -0
  146. package/src/SplitButton/__tests__/SplitButton.data-attributes.test.tsx +28 -0
  147. package/src/SplitButton/__tests__/SplitButton.disabled.test.tsx +79 -0
  148. package/src/SplitButton/__tests__/SplitButton.error-handling.test.tsx +29 -0
  149. package/src/SplitButton/__tests__/SplitButton.keyboard-interaction.test.tsx +56 -0
  150. package/src/SplitButton/__tests__/SplitButton.state-modes.test.tsx +68 -0
  151. package/src/SplitButton/index.ts +2 -0
  152. package/src/SplitButton/types.ts +186 -0
  153. package/src/Switch/README.md +1 -1
  154. package/src/Switch/Switch.tsx +2 -2
  155. package/src/Switch/types.ts +1 -1
  156. package/src/Table/README.md +110 -8
  157. package/src/Table/Table.tsx +206 -10
  158. package/src/Table/TableExpandableContext.ts +7 -0
  159. package/src/Table/__tests__/Table.DetailRow.test.tsx +83 -0
  160. package/src/Table/__tests__/Table.ExpandTrigger.test.tsx +102 -0
  161. package/src/Table/__tests__/Table.Expandable.test.tsx +121 -0
  162. package/src/Table/types.ts +125 -0
  163. package/src/Tabs/README.md +4 -4
  164. package/src/Tabs/Tabs.tsx +5 -4
  165. package/src/Tabs/__tests__/Tabs.activation-mode.test.tsx +58 -0
  166. package/src/Tabs/__tests__/Tabs.basic-rendering.test.tsx +15 -0
  167. package/src/Tabs/__tests__/Tabs.change-event-callbacks.test.tsx +24 -0
  168. package/src/Tabs/__tests__/Tabs.disabled-tabs.test.tsx +36 -0
  169. package/src/Tabs/__tests__/Tabs.lazy-mount.test.tsx +17 -0
  170. package/src/Tabs/hooks/useTabsContent.ts +5 -0
  171. package/src/Tabs/hooks/useTabsRoot.ts +29 -5
  172. package/src/Tabs/hooks/useTabsTrigger.ts +7 -2
  173. package/src/ToggleGroup/README.md +4 -4
  174. package/src/Tooltip/README.md +7 -7
  175. package/src/Tooltip/Tooltip.tsx +2 -2
  176. package/src/Tooltip/__tests__/Tooltip.basic-rendering.test.tsx +50 -0
  177. package/src/Tooltip/__tests__/Tooltip.error-handling.test.tsx +37 -0
  178. package/src/Tooltip/__tests__/Tooltip.escape-hatches.test.tsx +73 -0
  179. package/src/Tooltip/__tests__/Tooltip.keyboard-interaction.test.tsx +134 -0
  180. package/src/Tooltip/__tests__/Tooltip.skip-delay.test.tsx +216 -0
  181. package/src/Tooltip/__tests__/Tooltip.timer-coordination.test.tsx +223 -0
  182. package/src/Tooltip/hooks/useTooltipContent.ts +3 -0
  183. package/src/Tooltip/hooks/useTooltipProvider.ts +40 -14
  184. package/src/Tooltip/hooks/useTooltipRoot.ts +30 -6
  185. package/src/Tree/README.md +69 -5
  186. package/src/Tree/Tree.tsx +1 -1
  187. package/src/Tree/__tests__/Tree.styled-wrapper.test.tsx +159 -0
  188. package/src/Tree/hooks/useTreePath.ts +33 -1
  189. package/src/Tree/index.ts +6 -1
  190. package/src/Tree/utils.ts +46 -4
  191. package/src/VisuallyHidden/VisuallyHidden.tsx +1 -1
  192. package/src/hooks/index.ts +1 -0
  193. package/src/hooks/useTypeahead.ts +95 -0
  194. package/src/index.ts +6 -0
  195. package/src/useLocalStorage/__tests__/useLocalStorage.reading.test.tsx +31 -0
  196. package/src/useLocalStorage/__tests__/useLocalStorage.resilience.test.tsx +97 -0
  197. package/src/useLocalStorage/__tests__/useLocalStorage.ssr.test.tsx +20 -0
  198. package/src/useLocalStorage/__tests__/useLocalStorage.writing.test.tsx +72 -0
  199. package/src/useLocalStorage/index.ts +1 -0
  200. package/src/useLocalStorage/useLocalStorage.ts +140 -0
  201. package/src/useMediaQuery/README.md +66 -0
  202. package/src/useMediaQuery/__tests__/useMediaQuery.matching.test.tsx +42 -0
  203. package/src/useMediaQuery/__tests__/useMediaQuery.ssr.test.tsx +19 -0
  204. package/src/useMediaQuery/__tests__/useMediaQuery.subscription.test.tsx +89 -0
  205. package/src/useMediaQuery/index.ts +1 -0
  206. package/src/useMediaQuery/useMediaQuery.ts +49 -0
  207. package/src/usePagination/README.md +170 -0
  208. package/src/usePagination/__tests__/usePagination.navigation.test.tsx +109 -0
  209. package/src/usePagination/__tests__/usePagination.page-count.test.tsx +56 -0
  210. package/src/usePagination/__tests__/usePagination.page-state.test.tsx +87 -0
  211. package/src/usePagination/__tests__/usePagination.range.test.tsx +82 -0
  212. package/src/usePagination/__tests__/usePagination.slice.test.tsx +59 -0
  213. package/src/usePagination/__tests__/usePagination.windowing.test.tsx +68 -0
  214. package/src/usePagination/index.ts +1 -0
  215. package/src/usePagination/usePagination.ts +292 -0
  216. package/src/utils/getKeyToActionMap.ts +1 -1
@@ -0,0 +1,48 @@
1
+ import type { Context, Provider } from "react";
2
+
3
+ import { createStrictContext } from "../utils/index.ts";
4
+
5
+ import type { ListboxContextValue, ListboxGroupContextValue } from "./types";
6
+
7
+ const listboxContextPair = createStrictContext<ListboxContextValue>(
8
+ "Listbox.Option must be rendered as a child of Listbox.Root",
9
+ // Stryker disable next-line StringLiteral: equivalent — a DevTools label, asserted by no behaviour.
10
+ "ListboxContext",
11
+ );
12
+
13
+ /** Strict React context carrying the {@link ListboxContextValue} from
14
+ * `Listbox.Root` to its options. `null` outside a Root. */
15
+ export const ListboxContext: Context<ListboxContextValue | null> =
16
+ listboxContextPair[0];
17
+
18
+ /** Reads the {@link ListboxContextValue}; throws outside a `Listbox.Root`. */
19
+ export const useListboxContext: () => ListboxContextValue =
20
+ listboxContextPair[1];
21
+
22
+ /** Provider for {@link ListboxContext}, used by `Listbox.Root`. */
23
+ const ListboxProvider: Provider<ListboxContextValue | null> =
24
+ ListboxContext.Provider;
25
+
26
+ export { ListboxProvider };
27
+
28
+ const listboxGroupContextPair = createStrictContext<ListboxGroupContextValue>(
29
+ "Listbox.GroupLabel must be rendered as a child of Listbox.Group",
30
+ // Stryker disable next-line StringLiteral: equivalent — a DevTools label, asserted by no behaviour.
31
+ "ListboxGroupContext",
32
+ );
33
+
34
+ /** Strict React context carrying the {@link ListboxGroupContextValue} from
35
+ * `Listbox.Group` to its label. `null` outside a Group. */
36
+ export const ListboxGroupContext: Context<ListboxGroupContextValue | null> =
37
+ listboxGroupContextPair[0];
38
+
39
+ /** Reads the {@link ListboxGroupContextValue}; throws outside a
40
+ * `Listbox.Group`. */
41
+ export const useListboxGroupContext: () => ListboxGroupContextValue =
42
+ listboxGroupContextPair[1];
43
+
44
+ /** Provider for {@link ListboxGroupContext}, used by `Listbox.Group`. */
45
+ const ListboxGroupProvider: Provider<ListboxGroupContextValue | null> =
46
+ ListboxGroupContext.Provider;
47
+
48
+ export { ListboxGroupProvider };
@@ -0,0 +1,248 @@
1
+ # Listbox
2
+
3
+ A headless, accessible compound component implementing the
4
+ [WAI-ARIA APG listbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/)
5
+ — a persistently-visible list of options with a **virtual-focus** cursor.
6
+
7
+ ```tsx
8
+ import { Listbox } from "@primitiv-ui/react";
9
+
10
+ <Listbox.Root type="single" defaultValue="apple" aria-label="Fruit">
11
+ <Listbox.Option value="apple">Apple</Listbox.Option>
12
+ <Listbox.Option value="banana">Banana</Listbox.Option>
13
+ <Listbox.Option value="cherry">Cherry</Listbox.Option>
14
+ </Listbox.Root>;
15
+ ```
16
+
17
+ ## Sub-components
18
+
19
+ | Export | Element | Notes |
20
+ | ----------------- | ------- | ------------------------------------------------------------------------- |
21
+ | `Listbox.Root` | `<div>` | `role="listbox"`, the tab stop, `aria-activedescendant`, context provider, `asChild` |
22
+ | `Listbox.Option` | `<div>` | `role="option"`, `aria-selected`, `data-highlighted`, `disabled`, `asChild` |
23
+ | `Listbox.Group` | `<div>` | `role="group"`, named by `label` or a nested `GroupLabel`, `asChild` |
24
+ | `Listbox.GroupLabel` | `<div>` | `role="presentation"` visible heading; wires the group's `aria-labelledby` |
25
+
26
+ ## Why this is not `Select`
27
+
28
+ `Select` (in its default rich mode) is a listbox **inside a popup**, opened
29
+ from a trigger, and its headless layer moves **real DOM focus** between
30
+ options. `Listbox` is the list on its own: always visible, no trigger, no
31
+ popup — and crucially it never moves DOM focus.
32
+
33
+ Focus stays on the root. The visually-focused option is published as
34
+ `aria-activedescendant` on the root and marked `data-highlighted` on the
35
+ option itself. That is the whole point of the component: it is what lets a
36
+ **separate** control — a search input, a command palette's text field — hold
37
+ DOM focus and drive the list at the same time. A roving tabstop cannot do
38
+ that, which is why `Select`'s internals could not simply be reused.
39
+
40
+ Practical consequence when styling: **the cursor is `[data-highlighted]`, not
41
+ `:focus`.** An option never matches `:focus`.
42
+
43
+ ## Selection modes
44
+
45
+ ### `type="single"`
46
+
47
+ At most one option is selected. Re-selecting the current option is a no-op —
48
+ unlike `ToggleGroup`, a listbox is not a toggle, so the value never returns to
49
+ empty by clicking the same row twice.
50
+
51
+ ```tsx
52
+ // Uncontrolled
53
+ <Listbox.Root type="single" defaultValue="apple" aria-label="Fruit">...</Listbox.Root>
54
+
55
+ // Controlled — onValueChange receives string
56
+ const [fruit, setFruit] = useState("apple");
57
+ <Listbox.Root type="single" value={fruit} onValueChange={setFruit} aria-label="Fruit">
58
+ ...
59
+ </Listbox.Root>
60
+ ```
61
+
62
+ ### `type="multiple"`
63
+
64
+ Options toggle independently and the root carries `aria-multiselectable`.
65
+
66
+ ```tsx
67
+ // Controlled — onValueChange receives string[]
68
+ const [toppings, setToppings] = useState<string[]>([]);
69
+ <Listbox.Root type="multiple" value={toppings} onValueChange={setToppings} aria-label="Toppings">
70
+ ...
71
+ </Listbox.Root>
72
+ ```
73
+
74
+ ## Keyboard
75
+
76
+ | Key | Behaviour |
77
+ | ---------------------- | ---------------------------------------------------------------- |
78
+ | Arrow Down / Up | Move the cursor (vertical orientation), wrapping at both ends |
79
+ | Arrow Right / Left | Move the cursor (horizontal orientation), mirrored under `dir="rtl"` |
80
+ | Home / End | Move the cursor to the first / last enabled option |
81
+ | Enter, Space | Select (single) or toggle (multiple) the cursor option |
82
+ | Printable characters | Prefix typeahead — moves the cursor, resets after 500 ms idle |
83
+
84
+ Multiple-selection mode adds APG's optional modifier shortcuts:
85
+
86
+ | Key | Behaviour |
87
+ | ---------------------- | ---------------------------------------------------------------- |
88
+ | Shift+Arrow | Moves the cursor and selects the option it lands on. Does **not** wrap |
89
+ | Ctrl/Cmd+Shift+Home | Selects from the cursor to the first option, cursor parks there |
90
+ | Ctrl/Cmd+Shift+End | Selects from the cursor to the last option, cursor parks there |
91
+ | Ctrl/Cmd+A | Selects every enabled option, or clears the selection if all are already selected |
92
+
93
+ Every other chorded shortcut is left alone: `Alt+Arrow` and any unclaimed
94
+ `Ctrl`/`Cmd` combination pass straight through to your own `onKeyDown` and to
95
+ the browser. That is what lets you bind a toolbar to `Alt+Arrow`, as APG's
96
+ rearrangeable example does.
97
+
98
+ Moving the cursor **does not** select, by default. APG leaves
99
+ selection-follows-focus optional for single-select listboxes and warns it can
100
+ degrade accessibility; the manual model is also what a command palette needs,
101
+ since arrowing through results must not commit a choice. Opt into the APG
102
+ example's behaviour with `selectionFollowsFocus`:
103
+
104
+ ```tsx
105
+ <Listbox.Root type="single" selectionFollowsFocus aria-label="Fruit">...</Listbox.Root>
106
+ ```
107
+
108
+ Typeahead follows APG's repeat rule: pressing the same letter repeatedly
109
+ cycles through the options starting with it, rather than searching for a
110
+ literal run of that character. Matching is a case-insensitive prefix test
111
+ against the option's rendered text, so keep the label in `children`.
112
+
113
+ ## Pointer
114
+
115
+ Clicking an option selects it (single) or toggles it (multiple), **and moves
116
+ the cursor onto it**, so a following arrow key resumes from the row you
117
+ clicked rather than from wherever focus seeding left the cursor. APG treats
118
+ the clicked option as the focused option. Clicking a disabled option does
119
+ neither.
120
+
121
+ **Hovering does not move the cursor**, deliberately — there are no pointer
122
+ handlers on the option at all. `Select`'s popup listbox and `Dropdown` both
123
+ move their highlight on pointer movement, but those are transient surfaces
124
+ you are about to dismiss. This list is persistent and an external input may
125
+ be driving it, so a mouse resting anywhere over the list would steal the
126
+ cursor mid-keystroke.
127
+
128
+ The styling consequence: **hover needs its own treatment, weaker than the
129
+ cursor's and visually distinct from it.** If hover reused the cursor
130
+ treatment, two rows would look current at once and the pointer would appear
131
+ to move a cursor it cannot move.
132
+
133
+ ## Grouping
134
+
135
+ Groups are presentational. The cursor walks every option in DOM order,
136
+ crossing group boundaries without stopping, and typeahead searches the whole
137
+ list.
138
+
139
+ APG requires every option group to carry an accessible name. There are two
140
+ ways to give it one, and you must use one of them:
141
+
142
+ ```tsx
143
+ {/* Visible heading — what APG's grouped-options example does. */}
144
+ <Listbox.Group>
145
+ <Listbox.GroupLabel>Citrus</Listbox.GroupLabel>
146
+ <Listbox.Option value="lemon">Lemon</Listbox.Option>
147
+ </Listbox.Group>
148
+
149
+ {/* Invisible name, when the grouping is implied visually some other way. */}
150
+ <Listbox.Group label="Citrus">
151
+ <Listbox.Option value="lemon">Lemon</Listbox.Option>
152
+ </Listbox.Group>
153
+ ```
154
+
155
+ `GroupLabel` renders `role="presentation"` — only `role="option"` elements may
156
+ sit inside a listbox — and the group points `aria-labelledby` at it, with the
157
+ id derived for you. If both are supplied the rendered `GroupLabel` wins, since
158
+ `aria-labelledby` outranks `aria-label` in the accessible-name algorithm and
159
+ emitting both would be misleading.
160
+
161
+ ## Disabled options
162
+
163
+ `disabled` drops an option out of cursor navigation, focus seeding and
164
+ typeahead, and makes it unselectable — while keeping it in the DOM and in the
165
+ accessibility tree as `aria-disabled`.
166
+
167
+ ```tsx
168
+ <Listbox.Option value="durian" disabled>Durian (out of stock)</Listbox.Option>
169
+ ```
170
+
171
+ This is a **house extension**, matching `Select` and `Dropdown` in this
172
+ library. APG's listbox pattern does not itself specify disabled options.
173
+
174
+ ## Accessibility notes
175
+
176
+ - **Name the listbox.** Pass `aria-label` or `aria-labelledby` on the Root.
177
+ APG requires it for any listbox that is not part of another widget.
178
+ - `aria-orientation` is emitted **only** for `orientation="horizontal"`;
179
+ vertical is the ARIA default and is left implicit.
180
+ - The active option is scrolled into view (`block: "nearest"`) as the cursor
181
+ moves, per APG.
182
+ - On focus, the cursor is seeded onto the first selected option, or the first
183
+ enabled option when nothing is selected.
184
+
185
+ ## Styling hooks
186
+
187
+ Zero styles ship with this library. Style from:
188
+
189
+ | Attribute | Where | Meaning |
190
+ | -------------------------------------- | ------- | ----------------------------------- |
191
+ | `data-orientation="vertical\|horizontal"` | Root | Layout axis |
192
+ | `aria-invalid="true"` | Root | Invalid — **you** set this; see below |
193
+ | `data-highlighted=""` | Option | The cursor is on this option |
194
+ | `data-disabled=""` | Option | The option is disabled |
195
+ | `aria-selected="true\|false"` | Option | Selection state |
196
+
197
+ There is deliberately **no `invalid` prop**. `Listbox.Root` spreads unknown
198
+ props onto its `<div>`, so `aria-invalid` already reaches the DOM — style the
199
+ invalid frame from `[aria-invalid="true"]`, the same way `InputGroup` styles
200
+ itself with `:has(input[aria-invalid="true"])`. A bespoke prop would only
201
+ restate what the ARIA attribute already says.
202
+
203
+ There is also no root-level `disabled`. Only individual options can be
204
+ disabled; a whole inert listbox is out of scope.
205
+
206
+ ## `asChild`
207
+
208
+ Every part composes onto a consumer element via the `Slot` pattern, so the
209
+ listbox can be a semantic list:
210
+
211
+ ```tsx
212
+ <Listbox.Root type="single" asChild aria-label="Fruit">
213
+ <ul>
214
+ <Listbox.Option value="apple" asChild>
215
+ <li>Apple</li>
216
+ </Listbox.Option>
217
+ </ul>
218
+ </Listbox.Root>
219
+ ```
220
+
221
+ ## Reordering options
222
+
223
+ Navigation order is read from the **live DOM** on every interaction, not from
224
+ the order options happened to mount in. Moving an option in place — as APG's
225
+ rearrangeable example does — keeps the same element mounted with unchanged
226
+ props, so nothing re-registers and any cached order would silently go stale.
227
+ Arrow keys, `Home`/`End`, focus seeding, typeahead and the range shortcuts all
228
+ follow the new visual order immediately.
229
+
230
+ ## APG example coverage
231
+
232
+ | Example | Status |
233
+ | ------- | ------ |
234
+ | [Scrollable Listbox](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-scrollable/) | Covered. Set `selectionFollowsFocus`, which is what that example does |
235
+ | [Listbox with Grouped Options](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-grouped/) | Covered via `Listbox.GroupLabel` |
236
+ | [Rearrangeable Options](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-rearrangeable/) | Keyboard model covered — both selection modes, the modifier shortcuts, chord pass-through for the toolbar, and in-place reordering. The toolbar buttons and the move/transfer logic are the consumer's, since the component owns no data model |
237
+
238
+ ## Not in scope
239
+
240
+ - **`aria-setsize` / `aria-posinset`** for virtualised or lazily-loaded lists.
241
+ APG calls for these when the full option set is not in the DOM; add them
242
+ yourself on each Option (they pass straight through) if you virtualise.
243
+ - **Shift+Space** to select a contiguous run from an anchor. APG marks it
244
+ optional, and the shortcuts that ship here cover the same ground without
245
+ needing anchor state.
246
+ - **Combobox integration.** The virtual-focus model is what makes it possible,
247
+ but the input wiring itself belongs to `Combobox`
248
+ (see [`docs/select-future-work.md`](../../../../docs/select-future-work.md)).
@@ -0,0 +1,128 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { Listbox } from "../Listbox";
6
+
7
+ describe("Listbox active descendant", () => {
8
+ it("puts the listbox itself in the tab order and keeps DOM focus on it", async () => {
9
+ const user = userEvent.setup();
10
+ render(
11
+ <Listbox.Root type="single" aria-label="Fruits">
12
+ <Listbox.Option value="apple">Apple</Listbox.Option>
13
+ <Listbox.Option value="banana">Banana</Listbox.Option>
14
+ </Listbox.Root>,
15
+ );
16
+
17
+ await user.tab();
18
+
19
+ const listbox = screen.getByRole("listbox");
20
+ expect(listbox).toHaveFocus();
21
+ expect(screen.getByRole("option", { name: "Apple" })).not.toHaveAttribute(
22
+ "tabindex",
23
+ );
24
+ });
25
+
26
+ it("seeds the active option on focus and points aria-activedescendant at it", async () => {
27
+ const user = userEvent.setup();
28
+ render(
29
+ <Listbox.Root type="single" aria-label="Fruits">
30
+ <Listbox.Option value="apple">Apple</Listbox.Option>
31
+ <Listbox.Option value="banana">Banana</Listbox.Option>
32
+ </Listbox.Root>,
33
+ );
34
+
35
+ await user.tab();
36
+
37
+ const apple = screen.getByRole("option", { name: "Apple" });
38
+ expect(screen.getByRole("listbox")).toHaveAttribute(
39
+ "aria-activedescendant",
40
+ apple.id,
41
+ );
42
+ expect(apple).toHaveAttribute("data-highlighted", "");
43
+ expect(screen.getByRole("option", { name: "Banana" })).not.toHaveAttribute(
44
+ "data-highlighted",
45
+ );
46
+ });
47
+
48
+ it("seeds the active option from the current selection", async () => {
49
+ const user = userEvent.setup();
50
+ render(
51
+ <Listbox.Root type="single" defaultValue="banana" aria-label="Fruits">
52
+ <Listbox.Option value="apple">Apple</Listbox.Option>
53
+ <Listbox.Option value="banana">Banana</Listbox.Option>
54
+ </Listbox.Root>,
55
+ );
56
+
57
+ await user.tab();
58
+
59
+ expect(screen.getByRole("listbox")).toHaveAttribute(
60
+ "aria-activedescendant",
61
+ screen.getByRole("option", { name: "Banana" }).id,
62
+ );
63
+ });
64
+
65
+ it("moves the cursor to the option that was clicked", async () => {
66
+ const user = userEvent.setup();
67
+ render(
68
+ <Listbox.Root type="single" defaultValue="apple" aria-label="Fruits">
69
+ <Listbox.Option value="apple">Apple</Listbox.Option>
70
+ <Listbox.Option value="banana">Banana</Listbox.Option>
71
+ <Listbox.Option value="cherry">Cherry</Listbox.Option>
72
+ </Listbox.Root>,
73
+ );
74
+
75
+ await user.click(screen.getByRole("option", { name: "Cherry" }));
76
+
77
+ const cherry = screen.getByRole("option", { name: "Cherry" });
78
+ expect(cherry).toHaveAttribute("data-highlighted", "");
79
+ expect(screen.getByRole("listbox")).toHaveAttribute(
80
+ "aria-activedescendant",
81
+ cherry.id,
82
+ );
83
+ expect(screen.getByRole("option", { name: "Apple" })).not.toHaveAttribute(
84
+ "data-highlighted",
85
+ );
86
+ });
87
+
88
+ it("continues arrow navigation from the clicked option", async () => {
89
+ const user = userEvent.setup();
90
+ render(
91
+ <Listbox.Root type="single" defaultValue="apple" aria-label="Fruits">
92
+ <Listbox.Option value="apple">Apple</Listbox.Option>
93
+ <Listbox.Option value="banana">Banana</Listbox.Option>
94
+ <Listbox.Option value="cherry">Cherry</Listbox.Option>
95
+ </Listbox.Root>,
96
+ );
97
+
98
+ await user.click(screen.getByRole("option", { name: "Banana" }));
99
+ await user.keyboard("{ArrowDown}");
100
+
101
+ expect(screen.getByRole("option", { name: "Cherry" })).toHaveAttribute(
102
+ "data-highlighted",
103
+ "",
104
+ );
105
+ });
106
+
107
+ it("drops the highlight when focus leaves the listbox", async () => {
108
+ const user = userEvent.setup();
109
+ render(
110
+ <>
111
+ <Listbox.Root type="single" aria-label="Fruits">
112
+ <Listbox.Option value="apple">Apple</Listbox.Option>
113
+ </Listbox.Root>
114
+ <button type="button">After</button>
115
+ </>,
116
+ );
117
+
118
+ await user.tab();
119
+ await user.tab();
120
+
121
+ expect(screen.getByRole("listbox")).not.toHaveAttribute(
122
+ "aria-activedescendant",
123
+ );
124
+ expect(screen.getByRole("option", { name: "Apple" })).not.toHaveAttribute(
125
+ "data-highlighted",
126
+ );
127
+ });
128
+ });
@@ -0,0 +1,91 @@
1
+ import { createRef } from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { describe, expect, it, vi } from "vitest";
5
+
6
+ import { Listbox } from "../Listbox";
7
+
8
+ describe("Listbox asChild", () => {
9
+ it("renders the Root as a consumer-supplied element", () => {
10
+ render(
11
+ <Listbox.Root type="single" asChild aria-label="Fruits">
12
+ <ul>
13
+ <Listbox.Option value="apple">Apple</Listbox.Option>
14
+ </ul>
15
+ </Listbox.Root>,
16
+ );
17
+
18
+ const listbox = screen.getByRole("listbox");
19
+ expect(listbox.tagName).toBe("UL");
20
+ expect(listbox).toHaveAttribute("tabindex", "0");
21
+ });
22
+
23
+ it("renders an Option as a consumer-supplied element and composes its handler", async () => {
24
+ const user = userEvent.setup();
25
+ const onClick = vi.fn();
26
+ render(
27
+ <Listbox.Root type="single" aria-label="Fruits">
28
+ <Listbox.Option value="apple" asChild>
29
+ <li onClick={onClick}>Apple</li>
30
+ </Listbox.Option>
31
+ </Listbox.Root>,
32
+ );
33
+
34
+ const apple = screen.getByRole("option", { name: "Apple" });
35
+ expect(apple.tagName).toBe("LI");
36
+
37
+ await user.click(apple);
38
+
39
+ expect(onClick).toHaveBeenCalled();
40
+ expect(apple).toHaveAttribute("aria-selected", "true");
41
+ });
42
+
43
+ it("renders a Group as a consumer-supplied element", () => {
44
+ render(
45
+ <Listbox.Root type="single" aria-label="Fruits">
46
+ <Listbox.Group label="Citrus" asChild>
47
+ <section>
48
+ <Listbox.Option value="lemon">Lemon</Listbox.Option>
49
+ </section>
50
+ </Listbox.Group>
51
+ </Listbox.Root>,
52
+ );
53
+
54
+ expect(screen.getByRole("group", { name: "Citrus" }).tagName).toBe(
55
+ "SECTION",
56
+ );
57
+ });
58
+
59
+ it("renders a GroupLabel as a consumer-supplied element", () => {
60
+ render(
61
+ <Listbox.Root type="single" aria-label="Fruits">
62
+ <Listbox.Group>
63
+ <Listbox.GroupLabel asChild>
64
+ <h3>Citrus</h3>
65
+ </Listbox.GroupLabel>
66
+ <Listbox.Option value="lemon">Lemon</Listbox.Option>
67
+ </Listbox.Group>
68
+ </Listbox.Root>,
69
+ );
70
+
71
+ const heading = screen.getByText("Citrus");
72
+ expect(heading.tagName).toBe("H3");
73
+ expect(screen.getByRole("group", { name: "Citrus" })).toHaveAttribute(
74
+ "aria-labelledby",
75
+ heading.id,
76
+ );
77
+ });
78
+
79
+ it("forwards a ref through an asChild Option", () => {
80
+ const ref = createRef<HTMLDivElement>();
81
+ render(
82
+ <Listbox.Root type="single" aria-label="Fruits">
83
+ <Listbox.Option value="apple" asChild ref={ref}>
84
+ <li>Apple</li>
85
+ </Listbox.Option>
86
+ </Listbox.Root>,
87
+ );
88
+
89
+ expect(ref.current).toBe(screen.getByRole("option", { name: "Apple" }));
90
+ });
91
+ });
@@ -0,0 +1,16 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { Listbox } from "../Listbox";
5
+
6
+ describe("Listbox basic rendering", () => {
7
+ it("renders a listbox role on the root", () => {
8
+ render(
9
+ <Listbox.Root type="single" aria-label="Fruits">
10
+ <Listbox.Option value="apple">Apple</Listbox.Option>
11
+ </Listbox.Root>,
12
+ );
13
+
14
+ expect(screen.getByRole("listbox", { name: "Fruits" })).toBeInTheDocument();
15
+ });
16
+ });
@@ -0,0 +1,114 @@
1
+ import { useState } from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { describe, expect, it, vi } from "vitest";
5
+
6
+ import { Listbox } from "../Listbox";
7
+
8
+ import { fruits } from "./Listbox.fixtures";
9
+
10
+ describe("Listbox controlled state", () => {
11
+ it("renders the selection the consumer supplies", () => {
12
+ render(
13
+ <Listbox.Root type="single" value="cherry" aria-label="Fruits">
14
+ {fruits.map((fruit) => (
15
+ <Listbox.Option key={fruit.value} value={fruit.value}>
16
+ {fruit.label}
17
+ </Listbox.Option>
18
+ ))}
19
+ </Listbox.Root>,
20
+ );
21
+
22
+ expect(screen.getByRole("option", { name: "Cherry" })).toHaveAttribute(
23
+ "aria-selected",
24
+ "true",
25
+ );
26
+ });
27
+
28
+ it("does not move the selection on its own", async () => {
29
+ const user = userEvent.setup();
30
+ const onValueChange = vi.fn();
31
+ render(
32
+ <Listbox.Root
33
+ type="single"
34
+ value="cherry"
35
+ onValueChange={onValueChange}
36
+ aria-label="Fruits"
37
+ >
38
+ {fruits.map((fruit) => (
39
+ <Listbox.Option key={fruit.value} value={fruit.value}>
40
+ {fruit.label}
41
+ </Listbox.Option>
42
+ ))}
43
+ </Listbox.Root>,
44
+ );
45
+
46
+ await user.click(screen.getByRole("option", { name: "Apple" }));
47
+
48
+ expect(onValueChange).toHaveBeenCalledWith("apple");
49
+ expect(screen.getByRole("option", { name: "Apple" })).toHaveAttribute(
50
+ "aria-selected",
51
+ "false",
52
+ );
53
+ expect(screen.getByRole("option", { name: "Cherry" })).toHaveAttribute(
54
+ "aria-selected",
55
+ "true",
56
+ );
57
+ });
58
+
59
+ it("seeds the cursor from the controlled selection", async () => {
60
+ const user = userEvent.setup();
61
+ render(
62
+ <Listbox.Root type="single" value="cherry" aria-label="Fruits">
63
+ {fruits.map((fruit) => (
64
+ <Listbox.Option key={fruit.value} value={fruit.value}>
65
+ {fruit.label}
66
+ </Listbox.Option>
67
+ ))}
68
+ </Listbox.Root>,
69
+ );
70
+
71
+ await user.tab();
72
+
73
+ expect(screen.getByRole("listbox")).toHaveAttribute(
74
+ "aria-activedescendant",
75
+ screen.getByRole("option", { name: "Cherry" }).id,
76
+ );
77
+ });
78
+
79
+ it("follows the consumer's state through a full round trip", async () => {
80
+ const user = userEvent.setup();
81
+
82
+ function Controlled() {
83
+ const [value, setValue] = useState<string[]>([]);
84
+ return (
85
+ <Listbox.Root
86
+ type="multiple"
87
+ value={value}
88
+ onValueChange={setValue}
89
+ aria-label="Fruits"
90
+ >
91
+ {fruits.map((fruit) => (
92
+ <Listbox.Option key={fruit.value} value={fruit.value}>
93
+ {fruit.label}
94
+ </Listbox.Option>
95
+ ))}
96
+ </Listbox.Root>
97
+ );
98
+ }
99
+
100
+ render(<Controlled />);
101
+
102
+ await user.click(screen.getByRole("option", { name: "Apple" }));
103
+ await user.click(screen.getByRole("option", { name: "Cherry" }));
104
+
105
+ expect(screen.getByRole("option", { name: "Apple" })).toHaveAttribute(
106
+ "aria-selected",
107
+ "true",
108
+ );
109
+ expect(screen.getByRole("option", { name: "Cherry" })).toHaveAttribute(
110
+ "aria-selected",
111
+ "true",
112
+ );
113
+ });
114
+ });