@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
@@ -26,7 +26,7 @@ import type {
26
26
  * @example Uncontrolled
27
27
  * ```tsx
28
28
  * <Drawer.Root defaultOpen>
29
- * <Drawer.Content>…</Drawer.Content>
29
+ * <Drawer.Content>...</Drawer.Content>
30
30
  * </Drawer.Root>
31
31
  * ```
32
32
  *
@@ -38,7 +38,7 @@ import type {
38
38
  * <Drawer.Trigger>Open</Drawer.Trigger>
39
39
  * <Drawer.Portal>
40
40
  * <Drawer.Overlay />
41
- * <Drawer.Content side="right">…</Drawer.Content>
41
+ * <Drawer.Content side="right">...</Drawer.Content>
42
42
  * </Drawer.Portal>
43
43
  * </Drawer.Root>
44
44
  * ```
@@ -92,7 +92,7 @@ DrawerTrigger.displayName = "DrawerTrigger";
92
92
  * ```tsx
93
93
  * <Drawer.Portal container={document.getElementById("drawer-root")!}>
94
94
  * <Drawer.Overlay />
95
- * <Drawer.Content>…</Drawer.Content>
95
+ * <Drawer.Content>...</Drawer.Content>
96
96
  * </Drawer.Portal>
97
97
  * ```
98
98
  */
@@ -159,12 +159,12 @@ DrawerOverlay.displayName = "DrawerOverlay";
159
159
  *
160
160
  * @example Right-hand drawer (default)
161
161
  * ```tsx
162
- * <Drawer.Content>…</Drawer.Content>
162
+ * <Drawer.Content>...</Drawer.Content>
163
163
  * ```
164
164
  *
165
165
  * @example Bottom sheet
166
166
  * ```tsx
167
- * <Drawer.Content side="bottom">…</Drawer.Content>
167
+ * <Drawer.Content side="bottom">...</Drawer.Content>
168
168
  * ```
169
169
  */
170
170
  function DrawerContent({
@@ -61,8 +61,8 @@ and changes no behaviour, focus, or ARIA — it exists purely so the styling lay
61
61
  can position and animate the panel against the right edge:
62
62
 
63
63
  ```tsx
64
- <Drawer.Content side="bottom">…</Drawer.Content>
65
- // → <dialog data-side="bottom" data-state="open" …>
64
+ <Drawer.Content side="bottom">...</Drawer.Content>
65
+ // → <dialog data-side="bottom" data-state="open" ...>
66
66
  ```
67
67
 
68
68
  ```css
@@ -81,7 +81,7 @@ import { MENUITEM_SELECTOR } from "./constants";
81
81
  * const [open, setOpen] = useState(false);
82
82
  *
83
83
  * <Dropdown.Root open={open} onOpenChange={setOpen}>
84
- * …
84
+ * ...
85
85
  * </Dropdown.Root>
86
86
  * ```
87
87
  */
@@ -960,7 +960,7 @@ export type TDropdownCompound = typeof DropdownRoot & {
960
960
  * styles ship.
961
961
  *
962
962
  * The default export is the `Root`; sub-components are attached as static
963
- * properties (`Dropdown.Trigger`, `Dropdown.Content`, …).
963
+ * properties (`Dropdown.Trigger`, `Dropdown.Content`, ...).
964
964
  */
965
965
  const DropdownCompound: TDropdownCompound = Object.assign(DropdownRoot, {
966
966
  Root: DropdownRoot,
@@ -213,7 +213,7 @@ submenu, which is unfocusable. While focus lives inside an open
213
213
  <Dropdown.Group>
214
214
  <Dropdown.Label>File</Dropdown.Label>
215
215
  <Dropdown.Item>New</Dropdown.Item>
216
- <Dropdown.Item>Open…</Dropdown.Item>
216
+ <Dropdown.Item>Open...</Dropdown.Item>
217
217
  </Dropdown.Group>
218
218
  <Dropdown.Separator />
219
219
  <Dropdown.Group>
@@ -249,7 +249,7 @@ the component's):
249
249
 
250
250
  ```css
251
251
  /* Open state on the menu panel */
252
- [role="menu"][data-popover-open] {
252
+ [role="menu"]:popover-open {
253
253
  animation: fade-in var(--primitiv-motion-duration-control) var(--primitiv-motion-easing-enter);
254
254
  }
255
255
 
@@ -274,9 +274,11 @@ the component's):
274
274
  }
275
275
  ```
276
276
 
277
- The native popover API adds `data-popover-open` on the element while the
278
- popover is showing; combine it with the standard ARIA attributes for
279
- state-driven styling.
277
+ The popover API exposes the open state as the **`:popover-open` pseudo-class**,
278
+ not as an attribute — combine it with the standard ARIA attributes for
279
+ state-driven styling. (`data-popover-open` appears in this package's tests
280
+ because the jsdom popover polyfill mirrors the state to an attribute; no browser
281
+ sets it, so do not style against it.)
280
282
 
281
283
  `data-highlighted` is present on `Item`, `CheckboxItem`, `RadioItem`, and
282
284
  `SubTrigger` while the item has pointer focus (mouseenter). On `SubTrigger`
@@ -69,7 +69,7 @@ For an empty state that is part of the initial, static page — and so has
69
69
  nothing to announce — opt out of the live region:
70
70
 
71
71
  ```tsx
72
- <EmptyState.Root role={undefined}>…</EmptyState.Root>
72
+ <EmptyState.Root role={undefined}>...</EmptyState.Root>
73
73
  ```
74
74
 
75
75
  ## Media
@@ -106,7 +106,7 @@ instead of the default, merging in the sub-component's props (and, for
106
106
 
107
107
  ```tsx
108
108
  <EmptyState.Root asChild>
109
- <section>…</section>
109
+ <section>...</section>
110
110
  </EmptyState.Root>
111
111
  ```
112
112
 
@@ -21,7 +21,7 @@ import { Field, Input } from "@primitiv-ui/react";
21
21
  | --------------------------- | ------------------------ | ------------------------------------- |
22
22
  | `Field` / `Field.Root` | `<div>` | `data-field=""` |
23
23
  | `Field.Label` | `<label>` (`htmlFor=id`) | n/a |
24
- | `Field.Description` | `<div id=…-description>` | n/a |
24
+ | `Field.Description` | `<div id=...-description>` | n/a |
25
25
  | `Field.ErrorText` | `<div role="alert">` | n/a (only renders when invalid) |
26
26
 
27
27
  Plus three state-driven hooks on the root: `data-field-invalid`,
@@ -19,7 +19,7 @@ type FieldAwareProps = {
19
19
  /**
20
20
  * Merges consumer-supplied props with any {@link FieldContext} available
21
21
  * higher in the tree. Use it inside form controls (Input, Textarea,
22
- * Select, …) so they automatically inherit the field's id, aria
22
+ * Select, ...) so they automatically inherit the field's id, aria
23
23
  * wiring, and `disabled` / `required` cascade when nested inside
24
24
  * `<Field.Root>`.
25
25
  *
@@ -28,7 +28,7 @@ import { FieldsetLegendProps, FieldsetProps } from "./types";
28
28
  *
29
29
  * ```tsx
30
30
  * const ref = useRef<HTMLFieldSetElement>(null);
31
- * <Fieldset.Root ref={ref}>…</Fieldset.Root>
31
+ * <Fieldset.Root ref={ref}>...</Fieldset.Root>
32
32
  * ```
33
33
  *
34
34
  * @extends HTMLFieldSetElement
@@ -46,7 +46,7 @@ import { FieldsetLegendProps, FieldsetProps } from "./types";
46
46
  * ```tsx
47
47
  * <Fieldset.Root disabled>
48
48
  * <Fieldset.Legend>Billing</Fieldset.Legend>
49
- * …every nested control is disabled…
49
+ * ...every nested control is disabled...
50
50
  * </Fieldset.Root>
51
51
  * ```
52
52
  */
@@ -138,7 +138,7 @@ export type FieldsetCompound = typeof FieldsetRoot & {
138
138
  * ```tsx
139
139
  * <Fieldset.Root disabled>
140
140
  * <Fieldset.Legend>Billing</Fieldset.Legend>
141
- * …every nested control is disabled…
141
+ * ...every nested control is disabled...
142
142
  * </Fieldset.Root>
143
143
  * ```
144
144
  *
@@ -48,7 +48,7 @@ within the group.
48
48
  ```tsx
49
49
  <Fieldset.Root>
50
50
  <Fieldset.Legend>Shipping address</Fieldset.Legend>
51
- …
51
+ ...
52
52
  </Fieldset.Root>
53
53
  ```
54
54
 
@@ -63,7 +63,7 @@ directly:
63
63
  ```tsx
64
64
  <Fieldset.Root disabled>
65
65
  <Fieldset.Legend>Billing</Fieldset.Legend>
66
- …every nested control is disabled…
66
+ ...every nested control is disabled...
67
67
  </Fieldset.Root>
68
68
  ```
69
69
 
@@ -11,7 +11,7 @@ import { InputProps } from "./types";
11
11
  *
12
12
  * **Default type.** `type="text"` is set by default. Override with the
13
13
  * `type` prop for any native variant (`"email"`, `"password"`,
14
- * `"number"`, `"search"`, `"tel"`, `"url"`, `"date"`, …). Future
14
+ * `"number"`, `"search"`, `"tel"`, `"url"`, `"date"`, ...). Future
15
15
  * composite primitives (`PasswordInput`, `NumberInput`, `DatePicker`)
16
16
  * will layer richer interaction on top of those types.
17
17
  *
@@ -14,11 +14,11 @@ import { Input } from "@primitiv-ui/react";
14
14
 
15
15
  | Prop | Type | Default | Notes |
16
16
  | ---------- | -------------------------- | -------- | -------------------------------------------------------------- |
17
- | `type` | `string` | `"text"` | Any native `<input>` type (`"email"`, `"password"`, …) |
17
+ | `type` | `string` | `"text"` | Any native `<input>` type (`"email"`, `"password"`, ...) |
18
18
  | `disabled` | `boolean` | — | Native `disabled` + `data-disabled=""` styling hook |
19
19
  | `asChild` | `boolean` | `false` | Delegate rendering to the child element via Slot |
20
20
  | `ref` | `Ref<HTMLInputElement>` | — | Forwarded to the underlying input element |
21
- | `...rest` | `ComponentProps<"input">` | — | All other `<input>` props (`value`, `placeholder`, `aria-*`, …) |
21
+ | `...rest` | `ComponentProps<"input">` | — | All other `<input>` props (`value`, `placeholder`, `aria-*`, ...) |
22
22
 
23
23
  ## Default type
24
24
 
@@ -0,0 +1,475 @@
1
+ import {
2
+ useEffect,
3
+ useId,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type ReactElement,
8
+ } from "react";
9
+
10
+ import { useDirection } from "../DirectionProvider/index.ts";
11
+ import { Slot, composeEventHandlers, composeRefs } from "../Slot/index.ts";
12
+
13
+ import {
14
+ ListboxGroupProvider,
15
+ ListboxProvider,
16
+ useListboxContext,
17
+ useListboxGroupContext,
18
+ } from "./ListboxContext";
19
+ import { useListboxRoot } from "./hooks/index.ts";
20
+ import type {
21
+ ListboxGroupLabelProps,
22
+ ListboxGroupProps,
23
+ ListboxOptionProps,
24
+ ListboxRootProps,
25
+ } from "./types";
26
+
27
+ /**
28
+ * The root of a Listbox — a `<div role="listbox">` that owns the selection,
29
+ * holds the single tab stop, and provides {@link ListboxContext} to its
30
+ * descendant {@link ListboxOption | `Listbox.Option`}s.
31
+ *
32
+ * **Virtual focus, not roving tabindex.** This is the distinguishing choice.
33
+ * Real DOM focus stays on the root; the visually-focused option is published
34
+ * as `aria-activedescendant` and marked `data-highlighted`. Options are never
35
+ * given a `tabIndex` and are never `focus()`ed. That is what lets a text input
36
+ * elsewhere keep DOM focus while driving this list — the combobox / command
37
+ * palette case — which a roving tabstop cannot do. Per APG, the cursor is
38
+ * seeded when the root takes focus (onto the first selected option, or the
39
+ * first enabled one) and the active option is scrolled into view as it moves.
40
+ *
41
+ * Two type modes, statically discriminated at the type level:
42
+ *
43
+ * - **`type="single"`** — at most one option selected. Re-selecting the current
44
+ * option is a no-op, not a deselect. Controlled via `value: string` +
45
+ * `onValueChange: (value: string) => void`.
46
+ * - **`type="multiple"`** — options toggle independently, and the root carries
47
+ * `aria-multiselectable`. Controlled via `value: string[]` +
48
+ * `onValueChange: (value: string[]) => void`.
49
+ *
50
+ * Each mode supports **uncontrolled** (`defaultValue`, or omit for an empty
51
+ * selection) and **controlled** (`value` + `onValueChange`) shapes.
52
+ *
53
+ * **Keyboard.** Arrow keys move the cursor along the
54
+ * {@link ListboxRootBaseProps.orientation | orientation} axis, wrapping at
55
+ * both ends; Home/End jump to the first/last option; Enter and Space select
56
+ * (single) or toggle (multiple) the cursor option; printable characters run a
57
+ * prefix typeahead. Selection is manual by default — set
58
+ * {@link ListboxRootBaseProps.selectionFollowsFocus | `selectionFollowsFocus`}
59
+ * for the APG example's select-as-you-arrow behaviour.
60
+ *
61
+ * In `type="multiple"`, APG's optional modifier shortcuts are implemented too:
62
+ * `Shift+Arrow` moves the cursor and selects what it lands on (without
63
+ * wrapping), `Ctrl`/`Cmd`+`Shift`+`Home`/`End` sweeps the selection to that
64
+ * edge, and `Ctrl`/`Cmd`+`A` selects every enabled option or clears the
65
+ * selection when all are already selected. Every **other** chorded shortcut is
66
+ * deliberately left alone — `Alt+Arrow` and unclaimed `Ctrl`/`Cmd` combinations
67
+ * reach your own `onKeyDown` and the browser untouched, so a toolbar can bind
68
+ * them.
69
+ *
70
+ * **Reordering.** Navigation order is read from the live DOM on each
71
+ * interaction rather than from mount order, so moving an option in place is
72
+ * picked up immediately.
73
+ *
74
+ * **ARIA.** `role="listbox"` on the root, plus `aria-activedescendant`,
75
+ * `aria-multiselectable` (multiple mode only) and `aria-orientation`
76
+ * (horizontal only — vertical is the ARIA default). The root needs an
77
+ * accessible name: pass `aria-label` or `aria-labelledby`.
78
+ *
79
+ * **Styling hooks.** `data-orientation="horizontal" | "vertical"` on the root.
80
+ *
81
+ * @extends HTMLDivElement
82
+ *
83
+ * @example Single selection
84
+ * ```tsx
85
+ * <Listbox.Root type="single" defaultValue="apple" aria-label="Fruit">
86
+ * <Listbox.Option value="apple">Apple</Listbox.Option>
87
+ * <Listbox.Option value="banana">Banana</Listbox.Option>
88
+ * </Listbox.Root>
89
+ * ```
90
+ *
91
+ * @example Multiple selection, controlled
92
+ * ```tsx
93
+ * const [value, setValue] = useState<string[]>([]);
94
+ *
95
+ * <Listbox.Root type="multiple" value={value} onValueChange={setValue} aria-label="Toppings">
96
+ * <Listbox.Option value="cheese">Cheese</Listbox.Option>
97
+ * <Listbox.Option value="olives">Olives</Listbox.Option>
98
+ * </Listbox.Root>
99
+ * ```
100
+ *
101
+ * @example As a semantic list
102
+ * ```tsx
103
+ * <Listbox.Root type="single" asChild aria-label="Fruit">
104
+ * <ul>
105
+ * <Listbox.Option value="apple" asChild>
106
+ * <li>Apple</li>
107
+ * </Listbox.Option>
108
+ * </ul>
109
+ * </Listbox.Root>
110
+ * ```
111
+ */
112
+ export function ListboxRoot({
113
+ type,
114
+ defaultValue,
115
+ value: controlledValue,
116
+ onValueChange,
117
+ selectionFollowsFocus = false,
118
+ orientation = "vertical",
119
+ dir,
120
+ asChild = false,
121
+ onFocus,
122
+ onBlur,
123
+ onKeyDown,
124
+ children,
125
+ ref,
126
+ ...rest
127
+ }: ListboxRootProps): ReactElement {
128
+ const resolvedDir = dir ?? useDirection();
129
+ const {
130
+ selectedValues,
131
+ select,
132
+ activeValue,
133
+ registerOption,
134
+ getOptionId,
135
+ seedActiveValue,
136
+ clearActiveValue,
137
+ handleKeyDown,
138
+ moveCursor,
139
+ } = useListboxRoot({
140
+ type,
141
+ defaultValue,
142
+ value: controlledValue,
143
+ onValueChange,
144
+ selectionFollowsFocus,
145
+ orientation,
146
+ dir: resolvedDir,
147
+ });
148
+
149
+ const contextValue = useMemo(
150
+ () => ({
151
+ selectedValues,
152
+ select,
153
+ activeValue,
154
+ registerOption,
155
+ getOptionId,
156
+ moveCursor,
157
+ }),
158
+ [
159
+ selectedValues,
160
+ select,
161
+ activeValue,
162
+ registerOption,
163
+ getOptionId,
164
+ moveCursor,
165
+ ],
166
+ );
167
+
168
+ const rootProps = {
169
+ ...rest,
170
+ ref,
171
+ role: "listbox" as const,
172
+ tabIndex: 0,
173
+ "aria-multiselectable": type === "multiple" ? true : undefined,
174
+ "aria-orientation":
175
+ orientation === "horizontal" ? ("horizontal" as const) : undefined,
176
+ "data-orientation": orientation,
177
+ "aria-activedescendant":
178
+ activeValue === undefined ? undefined : getOptionId(activeValue),
179
+ onFocus: composeEventHandlers(onFocus, seedActiveValue),
180
+ onBlur: composeEventHandlers(onBlur, clearActiveValue),
181
+ onKeyDown: composeEventHandlers(onKeyDown, handleKeyDown),
182
+ };
183
+
184
+ return (
185
+ <ListboxProvider value={contextValue}>
186
+ {asChild ? (
187
+ <Slot {...rootProps}>{children}</Slot>
188
+ ) : (
189
+ <div {...rootProps}>{children}</div>
190
+ )}
191
+ </ListboxProvider>
192
+ );
193
+ }
194
+
195
+ /** @internal */
196
+ // Stryker disable next-line StringLiteral: overwritten by the compound alias — an equivalent mutant.
197
+ ListboxRoot.displayName = "ListboxRoot";
198
+
199
+ /**
200
+ * A single choice inside a Listbox — a `<div role="option">` reporting its own
201
+ * selected and highlighted state.
202
+ *
203
+ * **No tab stop, no DOM focus.** The option is never focusable; the root owns
204
+ * focus and points at this element with `aria-activedescendant` when the
205
+ * cursor reaches it. Style the cursor from `data-highlighted`, not `:focus`.
206
+ *
207
+ * **Selection.** Clicking an option selects it (single) or toggles it
208
+ * (multiple), as does Enter / Space while the cursor is on it. A click also
209
+ * moves the cursor onto the clicked option, so a following arrow key resumes
210
+ * from there — APG treats the clicked option as the focused one.
211
+ *
212
+ * **Hover does nothing.** Unlike `Select`'s popup listbox, pointing at an
213
+ * option does not move the cursor: a mouse resting over the list would
214
+ * otherwise steal it mid-keystroke. Give hover its own weaker treatment in
215
+ * CSS, distinct from `data-highlighted`.
216
+ *
217
+ * **Disabled.** `disabled` sets `aria-disabled` and removes the option from
218
+ * cursor navigation, focus seeding, and typeahead — it stays in the
219
+ * accessibility tree and in the DOM. This is a house extension: APG's listbox
220
+ * pattern does not itself specify disabled options.
221
+ *
222
+ * **Typeahead matching** reads the option's rendered text, so keep the label
223
+ * in `children`.
224
+ *
225
+ * **Styling hooks.** `data-highlighted=""` when the cursor is on it,
226
+ * `data-disabled=""` when disabled. Selection is read from `aria-selected`.
227
+ *
228
+ * @throws if rendered outside a `Listbox.Root`.
229
+ *
230
+ * @extends HTMLDivElement
231
+ *
232
+ * @example
233
+ * ```tsx
234
+ * <Listbox.Option value="apple">Apple</Listbox.Option>
235
+ * <Listbox.Option value="durian" disabled>Durian (out of stock)</Listbox.Option>
236
+ * ```
237
+ */
238
+ export function ListboxOption({
239
+ value,
240
+ disabled = false,
241
+ asChild = false,
242
+ onClick,
243
+ children,
244
+ ref,
245
+ ...rest
246
+ }: ListboxOptionProps): ReactElement {
247
+ const {
248
+ selectedValues,
249
+ select,
250
+ activeValue,
251
+ registerOption,
252
+ getOptionId,
253
+ moveCursor,
254
+ } = useListboxContext();
255
+ const selected = selectedValues.includes(value);
256
+
257
+ const localRef = useRef<HTMLDivElement | null>(null);
258
+ // Stryker disable next-line ArrayDeclaration: equivalent — localRef is a stable RefObject, so the composed ref behaves identically however the deps are spelled.
259
+ const setRef = useMemo(() => composeRefs(localRef, ref), [ref]);
260
+
261
+ useEffect(() => {
262
+ registerOption(value, localRef.current, disabled);
263
+ return () => registerOption(value, null);
264
+ }, [value, disabled, registerOption]);
265
+
266
+ const optionProps = {
267
+ ...rest,
268
+ ref: setRef,
269
+ id: getOptionId(value),
270
+ role: "option" as const,
271
+ "aria-selected": selected,
272
+ "aria-disabled": disabled || undefined,
273
+ "data-disabled": disabled ? "" : undefined,
274
+ "data-highlighted": activeValue === value ? "" : undefined,
275
+ onClick: composeEventHandlers(onClick, () => {
276
+ if (disabled) return;
277
+ select(value);
278
+ // A click is a deliberate act of choosing, so it also becomes the
279
+ // cursor's position — APG treats the clicked option as the focused one.
280
+ // Without this the two ways of choosing disagree about where you are in
281
+ // the list: focus (which fires first) seeds the cursor onto the
282
+ // previously-selected option, and the next arrow key would resume from
283
+ // there rather than from the row just clicked. Deliberately not
284
+ // `navigateTo`, which would re-apply selectionFollowsFocus and, in
285
+ // multiple mode, toggle the option straight back off.
286
+ moveCursor(value);
287
+ }),
288
+ };
289
+
290
+ return asChild ? (
291
+ <Slot {...optionProps}>{children}</Slot>
292
+ ) : (
293
+ <div {...optionProps}>{children}</div>
294
+ );
295
+ }
296
+
297
+ /** @internal */
298
+ // Stryker disable next-line StringLiteral: equivalent — a DevTools label, asserted by no behaviour.
299
+ ListboxOption.displayName = "ListboxOption";
300
+
301
+ /**
302
+ * A titled cluster of options — a `<div role="group">` carrying the accessible
303
+ * name APG requires on every option group.
304
+ *
305
+ * Grouping is presentational only: the cursor still walks every option in DOM
306
+ * order, crossing group boundaries without stopping, and typeahead searches
307
+ * the whole list.
308
+ *
309
+ * @extends HTMLDivElement
310
+ *
311
+ * @example
312
+ * ```tsx
313
+ * <Listbox.Root type="single" aria-label="Fruit">
314
+ * <Listbox.Group label="Citrus">
315
+ * <Listbox.Option value="lemon">Lemon</Listbox.Option>
316
+ * </Listbox.Group>
317
+ * <Listbox.Group label="Berries">
318
+ * <Listbox.Option value="fig">Fig</Listbox.Option>
319
+ * </Listbox.Group>
320
+ * </Listbox.Root>
321
+ * ```
322
+ */
323
+ export function ListboxGroup({
324
+ label,
325
+ asChild = false,
326
+ children,
327
+ ref,
328
+ ...rest
329
+ }: ListboxGroupProps): ReactElement {
330
+ const labelId = useId();
331
+ const [hasLabel, setHasLabel] = useState(false);
332
+
333
+ const contextValue = useMemo(
334
+ () => ({ labelId, registerLabel: setHasLabel }),
335
+ // Stryker disable next-line ArrayDeclaration: equivalent — labelId is a useId() constant and setHasLabel is a stable setState, so an empty dep array memoises identically.
336
+ [labelId],
337
+ );
338
+
339
+ const groupProps = {
340
+ ...rest,
341
+ ref,
342
+ role: "group" as const,
343
+ // A rendered GroupLabel wins: aria-labelledby outranks aria-label in the
344
+ // accessible-name algorithm, so emitting both would be misleading.
345
+ "aria-labelledby": hasLabel ? labelId : undefined,
346
+ "aria-label": hasLabel ? undefined : label,
347
+ };
348
+
349
+ return (
350
+ <ListboxGroupProvider value={contextValue}>
351
+ {asChild ? (
352
+ <Slot {...groupProps}>{children}</Slot>
353
+ ) : (
354
+ <div {...groupProps}>{children}</div>
355
+ )}
356
+ </ListboxGroupProvider>
357
+ );
358
+ }
359
+
360
+ /** @internal */
361
+ // Stryker disable next-line StringLiteral: equivalent — a DevTools label, asserted by no behaviour.
362
+ ListboxGroup.displayName = "ListboxGroup";
363
+
364
+ /**
365
+ * The visible heading that names its enclosing
366
+ * {@link ListboxGroup | `Listbox.Group`} — APG's grouped-options example
367
+ * labels each group this way rather than with an invisible `aria-label`.
368
+ *
369
+ * Renders `role="presentation"` so the heading is not exposed as an option
370
+ * (only `role="option"` elements may sit inside a listbox), while still being
371
+ * referenceable: the Group points `aria-labelledby` at this element's id,
372
+ * which is derived and wired automatically.
373
+ *
374
+ * @throws if rendered outside a `Listbox.Group`.
375
+ *
376
+ * @extends HTMLDivElement
377
+ *
378
+ * @example
379
+ * ```tsx
380
+ * <Listbox.Group>
381
+ * <Listbox.GroupLabel>Land</Listbox.GroupLabel>
382
+ * <Listbox.Option value="cat">Cat</Listbox.Option>
383
+ * </Listbox.Group>
384
+ * ```
385
+ */
386
+ export function ListboxGroupLabel({
387
+ asChild = false,
388
+ children,
389
+ ref,
390
+ ...rest
391
+ }: ListboxGroupLabelProps): ReactElement {
392
+ const { labelId, registerLabel } = useListboxGroupContext();
393
+
394
+ useEffect(
395
+ () => {
396
+ registerLabel(true);
397
+ return () => registerLabel(false);
398
+ },
399
+ // Stryker disable next-line ArrayDeclaration: equivalent — registerLabel is a stable setState from the Group above.
400
+ [registerLabel],
401
+ );
402
+
403
+ const labelProps = {
404
+ ...rest,
405
+ ref,
406
+ id: labelId,
407
+ role: "presentation" as const,
408
+ };
409
+
410
+ return asChild ? (
411
+ <Slot {...labelProps}>{children}</Slot>
412
+ ) : (
413
+ <div {...labelProps}>{children}</div>
414
+ );
415
+ }
416
+
417
+ /** @internal */
418
+ // Stryker disable next-line StringLiteral: equivalent — a DevTools label, asserted by no behaviour.
419
+ ListboxGroupLabel.displayName = "ListboxGroupLabel";
420
+
421
+ /**
422
+ * The shape of the exported `Listbox` value — callable as `Listbox.Root` and
423
+ * carrying `Root`, `Option` and `Group` as static properties.
424
+ */
425
+ export type TListboxCompound = typeof ListboxRoot & {
426
+ Root: typeof ListboxRoot;
427
+ Option: typeof ListboxOption;
428
+ Group: typeof ListboxGroup;
429
+ GroupLabel: typeof ListboxGroupLabel;
430
+ };
431
+
432
+ /**
433
+ * Headless, accessible **Listbox** — a persistently-visible list of options
434
+ * implementing the WAI-ARIA APG listbox pattern with a virtual-focus cursor.
435
+ *
436
+ * Unlike `Select`, nothing here opens or closes: there is no trigger and no
437
+ * popup layer. And unlike every roving-tabindex collection in this library,
438
+ * DOM focus never moves to an option — the root keeps it and publishes
439
+ * `aria-activedescendant`, so an external control (a search input, a command
440
+ * palette) can hold focus and drive the list.
441
+ *
442
+ * `Listbox` is both callable (an alias of {@link ListboxRoot | `Listbox.Root`})
443
+ * and carries its sub-components as static properties.
444
+ *
445
+ * - {@link ListboxRoot | `Listbox.Root`} — state owner, context provider, tab
446
+ * stop, `<div role="listbox">` wrapper.
447
+ * - {@link ListboxOption | `Listbox.Option`} — one selectable choice.
448
+ * - {@link ListboxGroup | `Listbox.Group`} — a named cluster of options.
449
+ * - {@link ListboxGroupLabel | `Listbox.GroupLabel`} — a group's visible heading.
450
+ *
451
+ * @example
452
+ * ```tsx
453
+ * import { Listbox } from "@primitiv-ui/react";
454
+ *
455
+ * <Listbox.Root type="single" defaultValue="apple" aria-label="Fruit">
456
+ * <Listbox.Option value="apple">Apple</Listbox.Option>
457
+ * <Listbox.Option value="banana">Banana</Listbox.Option>
458
+ * </Listbox.Root>;
459
+ * ```
460
+ *
461
+ * @see {@link ListboxRoot} for selection modes, keyboard, and ARIA.
462
+ * @see {@link ListboxOption} for per-option state and disabling.
463
+ * @see {@link ListboxGroup} for grouping.
464
+ */
465
+ const ListboxCompound: TListboxCompound = Object.assign(ListboxRoot, {
466
+ Root: ListboxRoot,
467
+ Option: ListboxOption,
468
+ Group: ListboxGroup,
469
+ GroupLabel: ListboxGroupLabel,
470
+ });
471
+
472
+ // Stryker disable next-line StringLiteral: equivalent — a DevTools label, asserted by no behaviour.
473
+ ListboxCompound.displayName = "Listbox";
474
+
475
+ export { ListboxCompound as Listbox };