@primitiv-ui/react 0.1.29 → 0.1.30

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 (213) 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/RadioGroup/README.md +2 -2
  120. package/src/RadioGroup/RadioGroup.tsx +1 -1
  121. package/src/SegmentedControl/README.md +2 -2
  122. package/src/SegmentedControl/SegmentedControl.tsx +1 -1
  123. package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +1 -0
  124. package/src/Select/README.md +2 -2
  125. package/src/Select/Select.tsx +8 -8
  126. package/src/Select/__tests__/Select.placeholder.test.tsx +3 -3
  127. package/src/Select/__tests__/Select.typeahead.test.tsx +3 -3
  128. package/src/Select/__tests__/Select.value-mirror.test.tsx +1 -1
  129. package/src/Select/types.ts +2 -2
  130. package/src/SkipNav/README.md +3 -3
  131. package/src/SkipNav/SkipNav.tsx +5 -5
  132. package/src/Slider/README.md +37 -7
  133. package/src/Slider/Slider.tsx +9 -3
  134. package/src/Slot/Slot.tsx +2 -2
  135. package/src/Slot/__tests__/Slot.test.tsx +2 -2
  136. package/src/Slot/types.ts +1 -1
  137. package/src/SplitButton/README.md +225 -0
  138. package/src/SplitButton/SplitButton.tsx +457 -0
  139. package/src/SplitButton/SplitButtonContext.ts +26 -0
  140. package/src/SplitButton/__tests__/SplitButton.accessibility.test.tsx +55 -0
  141. package/src/SplitButton/__tests__/SplitButton.asChild.test.tsx +51 -0
  142. package/src/SplitButton/__tests__/SplitButton.basic-rendering.test.tsx +57 -0
  143. package/src/SplitButton/__tests__/SplitButton.data-attributes.test.tsx +28 -0
  144. package/src/SplitButton/__tests__/SplitButton.disabled.test.tsx +79 -0
  145. package/src/SplitButton/__tests__/SplitButton.error-handling.test.tsx +29 -0
  146. package/src/SplitButton/__tests__/SplitButton.keyboard-interaction.test.tsx +56 -0
  147. package/src/SplitButton/__tests__/SplitButton.state-modes.test.tsx +68 -0
  148. package/src/SplitButton/index.ts +2 -0
  149. package/src/SplitButton/types.ts +186 -0
  150. package/src/Switch/README.md +1 -1
  151. package/src/Switch/Switch.tsx +2 -2
  152. package/src/Switch/types.ts +1 -1
  153. package/src/Table/README.md +110 -8
  154. package/src/Table/Table.tsx +206 -10
  155. package/src/Table/TableExpandableContext.ts +7 -0
  156. package/src/Table/__tests__/Table.DetailRow.test.tsx +83 -0
  157. package/src/Table/__tests__/Table.ExpandTrigger.test.tsx +102 -0
  158. package/src/Table/__tests__/Table.Expandable.test.tsx +121 -0
  159. package/src/Table/types.ts +125 -0
  160. package/src/Tabs/README.md +4 -4
  161. package/src/Tabs/Tabs.tsx +5 -4
  162. package/src/Tabs/__tests__/Tabs.activation-mode.test.tsx +58 -0
  163. package/src/Tabs/__tests__/Tabs.basic-rendering.test.tsx +15 -0
  164. package/src/Tabs/__tests__/Tabs.change-event-callbacks.test.tsx +24 -0
  165. package/src/Tabs/__tests__/Tabs.disabled-tabs.test.tsx +36 -0
  166. package/src/Tabs/__tests__/Tabs.lazy-mount.test.tsx +17 -0
  167. package/src/Tabs/hooks/useTabsContent.ts +5 -0
  168. package/src/Tabs/hooks/useTabsRoot.ts +29 -5
  169. package/src/Tabs/hooks/useTabsTrigger.ts +7 -2
  170. package/src/ToggleGroup/README.md +4 -4
  171. package/src/Tooltip/README.md +7 -7
  172. package/src/Tooltip/Tooltip.tsx +2 -2
  173. package/src/Tooltip/__tests__/Tooltip.basic-rendering.test.tsx +50 -0
  174. package/src/Tooltip/__tests__/Tooltip.error-handling.test.tsx +37 -0
  175. package/src/Tooltip/__tests__/Tooltip.escape-hatches.test.tsx +73 -0
  176. package/src/Tooltip/__tests__/Tooltip.keyboard-interaction.test.tsx +134 -0
  177. package/src/Tooltip/__tests__/Tooltip.skip-delay.test.tsx +216 -0
  178. package/src/Tooltip/__tests__/Tooltip.timer-coordination.test.tsx +223 -0
  179. package/src/Tooltip/hooks/useTooltipContent.ts +3 -0
  180. package/src/Tooltip/hooks/useTooltipProvider.ts +40 -14
  181. package/src/Tooltip/hooks/useTooltipRoot.ts +30 -6
  182. package/src/Tree/README.md +69 -5
  183. package/src/Tree/Tree.tsx +1 -1
  184. package/src/Tree/__tests__/Tree.styled-wrapper.test.tsx +159 -0
  185. package/src/Tree/hooks/useTreePath.ts +33 -1
  186. package/src/Tree/index.ts +6 -1
  187. package/src/Tree/utils.ts +46 -4
  188. package/src/VisuallyHidden/VisuallyHidden.tsx +1 -1
  189. package/src/hooks/index.ts +1 -0
  190. package/src/hooks/useTypeahead.ts +95 -0
  191. package/src/index.ts +6 -0
  192. package/src/useLocalStorage/__tests__/useLocalStorage.reading.test.tsx +31 -0
  193. package/src/useLocalStorage/__tests__/useLocalStorage.resilience.test.tsx +97 -0
  194. package/src/useLocalStorage/__tests__/useLocalStorage.ssr.test.tsx +20 -0
  195. package/src/useLocalStorage/__tests__/useLocalStorage.writing.test.tsx +72 -0
  196. package/src/useLocalStorage/index.ts +1 -0
  197. package/src/useLocalStorage/useLocalStorage.ts +140 -0
  198. package/src/useMediaQuery/README.md +66 -0
  199. package/src/useMediaQuery/__tests__/useMediaQuery.matching.test.tsx +42 -0
  200. package/src/useMediaQuery/__tests__/useMediaQuery.ssr.test.tsx +19 -0
  201. package/src/useMediaQuery/__tests__/useMediaQuery.subscription.test.tsx +89 -0
  202. package/src/useMediaQuery/index.ts +1 -0
  203. package/src/useMediaQuery/useMediaQuery.ts +49 -0
  204. package/src/usePagination/README.md +170 -0
  205. package/src/usePagination/__tests__/usePagination.navigation.test.tsx +109 -0
  206. package/src/usePagination/__tests__/usePagination.page-count.test.tsx +56 -0
  207. package/src/usePagination/__tests__/usePagination.page-state.test.tsx +87 -0
  208. package/src/usePagination/__tests__/usePagination.range.test.tsx +82 -0
  209. package/src/usePagination/__tests__/usePagination.slice.test.tsx +59 -0
  210. package/src/usePagination/__tests__/usePagination.windowing.test.tsx +68 -0
  211. package/src/usePagination/index.ts +1 -0
  212. package/src/usePagination/usePagination.ts +292 -0
  213. package/src/utils/getKeyToActionMap.ts +1 -1
@@ -10,11 +10,11 @@ styles ship.
10
10
  ```tsx
11
11
  import { Slider } from "@primitiv-ui/react";
12
12
 
13
- <Slider.Root defaultValue={[40]} aria-label="Volume">
13
+ <Slider.Root defaultValue={[40]}>
14
14
  <Slider.Track>
15
15
  <Slider.Range />
16
16
  </Slider.Track>
17
- <Slider.Thumb />
17
+ <Slider.Thumb aria-label="Volume" />
18
18
  </Slider.Root>
19
19
  ```
20
20
 
@@ -56,12 +56,12 @@ Pass `defaultValue` (or omit it for a single thumb seeded at `min`). The
56
56
  component owns the value internally.
57
57
 
58
58
  ```tsx
59
- <Slider.Root defaultValue={[25, 75]} aria-label="Price range">
59
+ <Slider.Root defaultValue={[25, 75]}>
60
60
  <Slider.Track>
61
61
  <Slider.Range />
62
62
  </Slider.Track>
63
- <Slider.Thumb />
64
- <Slider.Thumb />
63
+ <Slider.Thumb aria-label="Minimum" />
64
+ <Slider.Thumb aria-label="Maximum" />
65
65
  </Slider.Root>
66
66
  ```
67
67
 
@@ -92,10 +92,40 @@ only the final value.
92
92
  defaultValue={[40]}
93
93
  onValueCommit={(value) => save(value)}
94
94
  >
95
- …
95
+ ...
96
96
  </Slider.Root>
97
97
  ```
98
98
 
99
+ ## Labelling
100
+
101
+ **The accessible name goes on the `Thumb`, not the Root.** The Thumb is the
102
+ `role="slider"`; the Root renders a plain `<span>` with no role, so an
103
+ `aria-label` there is attached to nothing and announced nowhere. It is a silent
104
+ failure — the slider still looks and behaves correctly — and the examples in this
105
+ file used to model it.
106
+
107
+ ```tsx
108
+ <Slider.Thumb aria-label="Volume" />
109
+ ```
110
+
111
+ For a **visible** label, give it an id and point the thumb at it. A
112
+ `<label htmlFor>` cannot associate with a `<span role="slider">`, and `Slider`
113
+ does not read `FieldContext` (only `Input`, `Textarea` and `Select` do) — so a
114
+ `Field.Label` does not reach the thumb on its own:
115
+
116
+ ```tsx
117
+ <Field.Root>
118
+ <Field.Label id="volume-label">Volume</Field.Label>
119
+ <Slider.Root defaultValue={[40]}>
120
+ <Slider.Track><Slider.Range /></Slider.Track>
121
+ <Slider.Thumb aria-labelledby="volume-label" />
122
+ </Slider.Root>
123
+ </Field.Root>
124
+ ```
125
+
126
+ On a range, name each thumb separately — two thumbs sharing one label are
127
+ announced identically, so you cannot tell by ear which end you are on.
128
+
99
129
  ## Keyboard interaction
100
130
 
101
131
  Each thumb is independently focusable.
@@ -141,7 +171,7 @@ surrounding `<form>`. Multi-thumb sliders suffix the name with `[]`
141
171
  ```tsx
142
172
  <form>
143
173
  <Slider.Root defaultValue={[10, 90]} name="price">
144
- …
174
+ ...
145
175
  </Slider.Root>
146
176
  </form>
147
177
  ```
@@ -330,18 +330,24 @@ export type TSliderCompound = typeof SliderRoot & {
330
330
  * - {@link SliderRange | `Slider.Range`} — the filled portion of the track.
331
331
  * - {@link SliderThumb | `Slider.Thumb`} — a draggable handle; one per value.
332
332
  *
333
+ * The accessible name belongs on the **Thumb**, which is the `role="slider"`.
334
+ * The Root renders a plain `<span>` with no role, so an `aria-label` there is
335
+ * attached to nothing and announced nowhere — a silent failure, because the
336
+ * slider still looks and behaves correctly. This example used to put it on the
337
+ * Root.
338
+ *
333
339
  * @example
334
340
  * ```tsx
335
341
  * import { Slider } from "@primitiv-ui/react";
336
342
  *
337
- * <Slider.Root defaultValue={[40]} aria-label="Volume">
343
+ * <Slider.Root defaultValue={[40]}>
338
344
  * <Slider.Track><Slider.Range /></Slider.Track>
339
- * <Slider.Thumb />
345
+ * <Slider.Thumb aria-label="Volume" />
340
346
  * </Slider.Root>
341
347
  * ```
342
348
  *
343
349
  * @see {@link SliderRoot} for state modes, form submission, and validation.
344
- * @see {@link SliderThumb} for keyboard interaction.
350
+ * @see {@link SliderThumb} for keyboard interaction and labelling.
345
351
  */
346
352
  const SliderCompound: TSliderCompound = Object.assign(SliderRoot, {
347
353
  Root: SliderRoot,
package/src/Slot/Slot.tsx CHANGED
@@ -138,7 +138,7 @@ function mergeProps(slotProps: AnyProps, childProps: AnyProps): AnyProps {
138
138
  * data attributes, event handlers, and `ref` all still land on the
139
139
  * rendered DOM node, just not the node `Slot` itself would have created.
140
140
  * The pattern avoids an extra wrapper element in the DOM, which matters for
141
- * layout (no unwanted `<button><a>…</a></button>` nesting) and for CSS
141
+ * layout (no unwanted `<button><a>...</a></button>` nesting) and for CSS
142
142
  * selectors that expect a single element.
143
143
  *
144
144
  * **Usage shape** — every component using `Slot` follows the same
@@ -205,7 +205,7 @@ function mergeProps(slotProps: AnyProps, childProps: AnyProps): AnyProps {
205
205
  * Settings
206
206
  * </RouterLink>
207
207
  * </Button>
208
- * // Rendered DOM: a single <a class="…settings-link"> — trackClick and the
208
+ * // Rendered DOM: a single <a class="...settings-link"> — trackClick and the
209
209
  * // link's own onClick both fire; no wrapping <button>.
210
210
  * ```
211
211
  */
@@ -132,8 +132,8 @@ describe("Slot tests", () => {
132
132
  const baseElement = React.createElement("div", null, "text");
133
133
  const react18Element = {
134
134
  ...baseElement,
135
- ref: childRef, // React ≤18: ref lives here …
136
- props: { children: "text" }, // … not in props
135
+ ref: childRef, // React ≤18: ref lives here ...
136
+ props: { children: "text" }, // ... not in props
137
137
  } as unknown as React.ReactElement;
138
138
  const SlotWithRef = React.forwardRef<HTMLElement>((_props, ref) => (
139
139
  <Slot ref={ref}>{react18Element}</Slot>
package/src/Slot/types.ts CHANGED
@@ -2,7 +2,7 @@ import { HTMLAttributes, ReactNode } from "react";
2
2
 
3
3
  /**
4
4
  * Props for {@link Slot} — all native `HTMLAttributes` (event handlers,
5
- * `style`, `className`, `aria-*`, `data-*`, …), merged onto the single
5
+ * `style`, `className`, `aria-*`, `data-*`, ...), merged onto the single
6
6
  * child element rather than rendered onto an element `Slot` creates
7
7
  * itself. See {@link Slot} for the exact prop-merging rules (event
8
8
  * handlers compose child-first, `style` shallow-merges child-wins,
@@ -0,0 +1,225 @@
1
+ # SplitButton
2
+
3
+ One primary action paired with a menu of related alternatives, bound into
4
+ a single `role="group"` widget. Zero styles ship.
5
+
6
+ ```tsx
7
+ import { SplitButton, VisuallyHidden } from "@primitiv-ui/react";
8
+
9
+ <SplitButton>
10
+ <SplitButton.Action onClick={squashAndMerge}>
11
+ Squash and merge
12
+ </SplitButton.Action>
13
+ <SplitButton.Trigger>
14
+ <ChevronDown aria-hidden="true" />
15
+ <VisuallyHidden>More merge options</VisuallyHidden>
16
+ </SplitButton.Trigger>
17
+ <SplitButton.Menu>
18
+ <SplitButton.Item onSelect={mergeCommit}>
19
+ Create a merge commit
20
+ </SplitButton.Item>
21
+ <SplitButton.Item onSelect={rebase}>Rebase and merge</SplitButton.Item>
22
+ </SplitButton.Menu>
23
+ </SplitButton>;
24
+ ```
25
+
26
+ ## Split button, not select
27
+
28
+ A `Select` answers *"which option is currently selected?"* — picking an
29
+ item changes a **value**. A split button is an **actions** control: the
30
+ left half runs the default action immediately, and the right half opens a
31
+ menu of alternatives. Those alternatives may run their own action, or
32
+ re-point what the primary half does next; either way nothing about the
33
+ widget models a selected value.
34
+
35
+ If you want a value picker, use [`Select`](../Select/README.md). If you
36
+ want a plain menu button with no default action, use
37
+ [`Dropdown`](../Dropdown/README.md) on its own.
38
+
39
+ ## Sub-components
40
+
41
+ | Export | Element | Role | Notes |
42
+ | ------------------------ | -------------------------- | ------------ | ----------------------------------------------------------------------- |
43
+ | `SplitButton` / `.Root` | `<div>` | `group` | Owns the menu's open state; uncontrolled (`defaultOpen`) or controlled |
44
+ | `SplitButton.Action` | `<button type="button">` | `button` | The primary action; `ArrowDown` opens the menu |
45
+ | `SplitButton.Trigger` | `<button type="button">` | `button` | `aria-haspopup="menu"`; name derived from the action |
46
+ | `SplitButton.Menu` | `<menu popover="auto">` | `menu` | The panel of alternatives |
47
+ | `SplitButton.Item` | `<div>` | `menuitem` | One alternative action, with `onSelect` |
48
+ | `SplitButton.Separator` | `<div>` | `separator` | Divider between groups of alternatives |
49
+
50
+ `Root`, `Action` and the group accept `asChild`; `Trigger`, `Menu`,
51
+ `Item` and `Separator` inherit `asChild` from the Dropdown parts they
52
+ delegate to.
53
+
54
+ ## Anatomy and styling hooks
55
+
56
+ | Part | Hook |
57
+ | ------------- | ------------------------------------------------------ |
58
+ | Root | `data-split-button=""`, `data-state="open" \| "closed"` |
59
+ | Action | `data-split-button-action=""` |
60
+ | Trigger | `data-split-button-trigger=""` |
61
+ | Menu | `data-split-button-menu=""` |
62
+ | Any disabled | `data-disabled=""` |
63
+
64
+ `data-state` on the **root** tracks the menu, which is what makes the
65
+ seam between the two halves styleable while the menu is open (a pressed
66
+ chevron, a joined border) without reaching for `:has()`.
67
+
68
+ ## What this component adds over `Dropdown`
69
+
70
+ The menu half *is* a `Dropdown` — Root renders a `Dropdown.Root`, and
71
+ `Menu` / `Item` / `Separator` delegate to `Dropdown.Content` / `.Item` /
72
+ `.Separator`. All the arrow-key navigation, typeahead, Escape handling,
73
+ light dismiss and focus restoration are Dropdown's, tested once over
74
+ there.
75
+
76
+ What `SplitButton` owns is the coordination **between the two halves**,
77
+ which is the whole reason it exists as a primitive rather than a styled
78
+ recipe:
79
+
80
+ 1. **The `role="group"` boundary** — assistive technology reads two
81
+ buttons as one widget rather than two unrelated commands.
82
+ 2. **The trigger's accessible name, derived from the action** — see
83
+ below. This is the thing hand-rolled split buttons get wrong most
84
+ often.
85
+ 3. **`disabled` propagation** — one prop on the group disables both
86
+ halves; it is OR-ed with each half's own `disabled`, so a part can
87
+ never override the group back to enabled.
88
+ 4. **`ArrowDown` on the action opens the menu** — the action is *not*
89
+ the menu trigger, so nothing in `Dropdown` wires this. Note this
90
+ affordance is specific to `SplitButton`; `Dropdown.Trigger` itself
91
+ opens on click/Enter/Space only.
92
+
93
+ ## The derived accessible name
94
+
95
+ A chevron-only control has no text of its own. Unless you pass
96
+ `aria-label` or `aria-labelledby`, `SplitButton.Trigger` defaults to:
97
+
98
+ ```
99
+ aria-labelledby="<the trigger's own id> <the action's id>"
100
+ ```
101
+
102
+ Self-reference is valid in `aria-labelledby` and resolves to the
103
+ element's own contents, so putting visually-hidden text inside the
104
+ trigger produces *"More merge options, Squash and merge"*:
105
+
106
+ ```tsx
107
+ <SplitButton.Trigger>
108
+ <ChevronDown aria-hidden="true" />
109
+ <VisuallyHidden>More merge options</VisuallyHidden>
110
+ </SplitButton.Trigger>
111
+ ```
112
+
113
+ With no text inside the trigger at all, the name falls back to just the
114
+ action's label — ambiguous, but still better than an unlabelled button.
115
+ Passing either ARIA attribute yourself opts out of the derivation
116
+ completely:
117
+
118
+ ```tsx
119
+ <SplitButton.Trigger aria-label="More merge options">
120
+ <ChevronDown aria-hidden="true" />
121
+ </SplitButton.Trigger>
122
+ ```
123
+
124
+ Because the derivation depends on both ids, **`id` is component-owned on
125
+ `Action` and `Trigger`** and is `Omit`-ted from their prop types. Each
126
+ id is derived from the root's `useId()` with a stable `-split-button-action`
127
+ / `-split-button-trigger` suffix.
128
+
129
+ ## Keyboard interaction
130
+
131
+ | Key | On | Behaviour |
132
+ | ----------------------- | ----------------- | ------------------------------------------------ |
133
+ | `Enter` / `Space` | Action | Runs the primary action (native button) |
134
+ | `ArrowDown` | Action | Opens the menu, focusing its first item |
135
+ | `Enter` / `Space` | Trigger | Toggles the menu |
136
+ | `ArrowDown` / `ArrowUp` | Menu | Moves between items (wraps) |
137
+ | `Home` / `End` | Menu | First / last enabled item |
138
+ | printable characters | Menu | Typeahead |
139
+ | `Escape` | Menu | Closes and returns focus to the trigger |
140
+
141
+ **Both halves are separate tab stops.** They are two genuinely distinct
142
+ commands, so each is independently tabbable — this is deliberately *not*
143
+ a roving-tabindex widget, and shouldn't become one. (Contrast
144
+ `NavigationMenu`, which uses the roving-tabindex hook for its keymap only,
145
+ for the same underlying reason.)
146
+
147
+ ## State modes
148
+
149
+ Identical to `Dropdown.Root`'s, and statically discriminated at the type
150
+ level:
151
+
152
+ - **Uncontrolled** — pass `defaultOpen` (or omit it to start closed).
153
+ Optional `onOpenChange` observes user-driven transitions.
154
+ - **Controlled** — pass `open` and `onOpenChange` together.
155
+
156
+ ```tsx
157
+ const [open, setOpen] = useState(false);
158
+
159
+ <SplitButton open={open} onOpenChange={setOpen}>
160
+ ...
161
+ </SplitButton>;
162
+ ```
163
+
164
+ ## Disabled
165
+
166
+ ```tsx
167
+ {/* Both halves disabled */}
168
+ <SplitButton disabled>...</SplitButton>
169
+
170
+ {/* Only the action — alternatives stay reachable */}
171
+ <SplitButton.Action disabled>Squash and merge</SplitButton.Action>
172
+
173
+ {/* Only the menu — the default action still works */}
174
+ <SplitButton.Trigger disabled aria-label="More merge options" />
175
+ ```
176
+
177
+ ## Richer menus
178
+
179
+ `SplitButton` re-exports only the two menu parts a split button almost
180
+ always needs. Root provides the same Dropdown context, so every other
181
+ `Dropdown` part composes inside `SplitButton.Menu` directly:
182
+
183
+ ```tsx
184
+ import { Dropdown, SplitButton } from "@primitiv-ui/react";
185
+
186
+ <SplitButton.Menu>
187
+ <Dropdown.Group>
188
+ <Dropdown.Label>Merge strategy</Dropdown.Label>
189
+ <SplitButton.Item onSelect={mergeCommit}>
190
+ Create a merge commit
191
+ </SplitButton.Item>
192
+ </Dropdown.Group>
193
+ <SplitButton.Separator />
194
+ <Dropdown.Sub>
195
+ <Dropdown.SubTrigger>Advanced</Dropdown.SubTrigger>
196
+ <Dropdown.SubContent>
197
+ <SplitButton.Item onSelect={rebase}>Rebase and merge</SplitButton.Item>
198
+ </Dropdown.SubContent>
199
+ </Dropdown.Sub>
200
+ </SplitButton.Menu>;
201
+ ```
202
+
203
+ `dir` on `SplitButton.Root` is forwarded to the Dropdown, so submenus
204
+ invert their open/close arrow keys in RTL.
205
+
206
+ ## `asChild`
207
+
208
+ ```tsx
209
+ {/* The group is a <section> */}
210
+ <SplitButton asChild>
211
+ <section aria-label="Merge">...</section>
212
+ </SplitButton>
213
+
214
+ {/* The primary action navigates instead of firing a handler.
215
+ type="button" is not forwarded in asChild mode. */}
216
+ <SplitButton.Action asChild>
217
+ <a href="/merge">Squash and merge</a>
218
+ </SplitButton.Action>
219
+ ```
220
+
221
+ ## Positioning
222
+
223
+ Like `Dropdown`, the menu is a native `popover="auto"` element in the top
224
+ layer — placement is the consumer's CSS concern (CSS anchor positioning,
225
+ as the registry `dropdown` component does).