@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,457 @@
1
+ import { useId, useMemo, type KeyboardEvent, type ReactElement } from "react";
2
+
3
+ import { Dropdown } from "../Dropdown/index.ts";
4
+ import { useDropdownContext } from "../Dropdown/hooks/index.ts";
5
+ import type { DropdownRootProps } from "../Dropdown/types";
6
+ import { Slot, composeEventHandlers } from "../Slot/index.ts";
7
+ import { deriveId } from "../utils/index.ts";
8
+ import {
9
+ SplitButtonProvider,
10
+ useSplitButtonContext,
11
+ } from "./SplitButtonContext";
12
+ import {
13
+ SplitButtonActionProps,
14
+ SplitButtonFrameProps,
15
+ SplitButtonItemProps,
16
+ SplitButtonMenuProps,
17
+ SplitButtonRootProps,
18
+ SplitButtonSeparatorProps,
19
+ SplitButtonTriggerProps,
20
+ } from "./types";
21
+
22
+ /**
23
+ * The root of a SplitButton — renders the `role="group"` element that binds
24
+ * the primary action and the menu trigger into one widget, and owns the
25
+ * menu's open state by rendering a {@link Dropdown | `Dropdown.Root`} around
26
+ * it.
27
+ *
28
+ * **Two controls, one widget.** A split button is *not* a `Select`: the left
29
+ * half performs an action immediately, the right half opens a menu of related
30
+ * alternatives. Both halves stay independently tabbable — that is correct for
31
+ * two genuinely separate commands, and deliberately *not* a roving tabstop.
32
+ *
33
+ * **Open state.** Identical to `Dropdown.Root`'s: uncontrolled via
34
+ * `defaultOpen`, or controlled via `open` + `onOpenChange`, statically
35
+ * discriminated at the type level. `dir` is forwarded too, so submenus
36
+ * composed from `Dropdown.Sub` invert their arrow keys in RTL.
37
+ *
38
+ * **Styling hooks.** `data-split-button=""`, `data-state="open" | "closed"`
39
+ * tracking the menu, and `data-disabled=""` when the group is disabled.
40
+ *
41
+ * **`asChild` composition.** Pass `asChild` to render a consumer-supplied
42
+ * element as the group instead of `<div>`.
43
+ *
44
+ * @extends HTMLDivElement
45
+ *
46
+ * @example Uncontrolled
47
+ * ```tsx
48
+ * <SplitButton>
49
+ * <SplitButton.Action onClick={squashAndMerge}>
50
+ * Squash and merge
51
+ * </SplitButton.Action>
52
+ * <SplitButton.Trigger>
53
+ * <ChevronDown aria-hidden="true" />
54
+ * <VisuallyHidden>More merge options</VisuallyHidden>
55
+ * </SplitButton.Trigger>
56
+ * <SplitButton.Menu>
57
+ * <SplitButton.Item onSelect={mergeCommit}>
58
+ * Create a merge commit
59
+ * </SplitButton.Item>
60
+ * <SplitButton.Item onSelect={rebase}>Rebase and merge</SplitButton.Item>
61
+ * </SplitButton.Menu>
62
+ * </SplitButton>
63
+ * ```
64
+ *
65
+ * @example Controlled menu, whole widget disabled
66
+ * ```tsx
67
+ * const [open, setOpen] = useState(false);
68
+ *
69
+ * <SplitButton open={open} onOpenChange={setOpen} disabled={saving}>
70
+ * ...
71
+ * </SplitButton>
72
+ * ```
73
+ */
74
+ export function SplitButtonRoot({
75
+ defaultOpen,
76
+ open,
77
+ onOpenChange,
78
+ dir,
79
+ disabled = false,
80
+ children,
81
+ ref,
82
+ ...rest
83
+ }: SplitButtonRootProps): ReactElement {
84
+ return (
85
+ <Dropdown.Root
86
+ {...({ defaultOpen, open, onOpenChange, dir } as DropdownRootProps)}
87
+ >
88
+ <SplitButtonFrame {...rest} ref={ref} disabled={disabled}>
89
+ {children}
90
+ </SplitButtonFrame>
91
+ </Dropdown.Root>
92
+ );
93
+ }
94
+
95
+ /** @internal */
96
+ // Stryker disable next-line StringLiteral: the compound is `Object.assign(SplitButtonRoot, ...)`,
97
+ // so `SplitButton.displayName = "SplitButton"` overwrites this value at load — it is never
98
+ // observable at runtime. The assignment itself must stay: it is what declares `displayName`
99
+ // on `typeof SplitButtonRoot`, which `TSplitButtonCompound` extends.
100
+ SplitButtonRoot.displayName = "SplitButtonRoot";
101
+
102
+ /**
103
+ * The group element itself, split out of {@link SplitButtonRoot} so it can
104
+ * read the open state of the `Dropdown.Root` that Root renders around it.
105
+ *
106
+ * @internal
107
+ */
108
+ function SplitButtonFrame({
109
+ asChild = false,
110
+ disabled,
111
+ children,
112
+ ref,
113
+ ...rest
114
+ }: SplitButtonFrameProps): ReactElement {
115
+ const { open } = useDropdownContext();
116
+ const rootId = useId();
117
+ const contextValue = useMemo(
118
+ () => ({
119
+ actionId: deriveId(rootId, "split-button", "action"),
120
+ triggerId: deriveId(rootId, "split-button", "trigger"),
121
+ disabled,
122
+ }),
123
+ [rootId, disabled],
124
+ );
125
+
126
+ const frameProps = {
127
+ ...rest,
128
+ ref,
129
+ role: "group" as const,
130
+ "data-split-button": "",
131
+ "data-state": open ? ("open" as const) : ("closed" as const),
132
+ "data-disabled": disabled ? "" : undefined,
133
+ };
134
+
135
+ return (
136
+ <SplitButtonProvider value={contextValue}>
137
+ {asChild ? (
138
+ <Slot {...frameProps}>{children}</Slot>
139
+ ) : (
140
+ <div {...frameProps}>{children}</div>
141
+ )}
142
+ </SplitButtonProvider>
143
+ );
144
+ }
145
+
146
+ /**
147
+ * The primary half — a `<button type="button">` that performs the default
148
+ * action immediately on click. It is **not** the menu trigger and carries no
149
+ * `aria-haspopup`.
150
+ *
151
+ * **Keyboard.** `ArrowDown` opens the menu and moves focus to its first item,
152
+ * the standard split-button affordance for reaching the alternatives without
153
+ * tabbing past the action. Every other key falls through, and a consumer's own
154
+ * `onKeyDown` runs first.
155
+ *
156
+ * **Accessible naming.** This element's `id` is component-owned (hence
157
+ * `Omit`-ted from the props) because
158
+ * {@link SplitButtonTrigger | `SplitButton.Trigger`} references it to build
159
+ * its own accessible name — give the action a visible text label and the
160
+ * chevron half is named for free.
161
+ *
162
+ * **Disabled.** Sets native `disabled` plus `data-disabled=""` when either
163
+ * this prop or the group's `disabled` is set; the two are OR-ed, so the group
164
+ * can never be overridden back to enabled.
165
+ *
166
+ * **`asChild` composition.** Renders the consumer's element instead of
167
+ * `<button>` — e.g. a router link. `type="button"` is **not** forwarded in
168
+ * this mode; the child owns its own type semantics.
169
+ *
170
+ * @extends HTMLButtonElement
171
+ *
172
+ * @example
173
+ * ```tsx
174
+ * <SplitButton.Action onClick={squashAndMerge}>
175
+ * Squash and merge
176
+ * </SplitButton.Action>
177
+ * ```
178
+ *
179
+ * @example asChild — the action navigates instead of firing a handler
180
+ * ```tsx
181
+ * <SplitButton.Action asChild>
182
+ * <a href="/merge">Squash and merge</a>
183
+ * </SplitButton.Action>
184
+ * ```
185
+ */
186
+ export function SplitButtonAction({
187
+ asChild = false,
188
+ children,
189
+ disabled,
190
+ onKeyDown,
191
+ ref,
192
+ ...rest
193
+ }: SplitButtonActionProps): ReactElement {
194
+ const { actionId, disabled: groupDisabled } = useSplitButtonContext();
195
+ const { setOpen } = useDropdownContext();
196
+ const isDisabled = groupDisabled || disabled === true;
197
+
198
+ const openMenu = (event: KeyboardEvent<HTMLButtonElement>) => {
199
+ if (event.key !== "ArrowDown") return;
200
+ event.preventDefault();
201
+ setOpen(true);
202
+ };
203
+
204
+ const actionProps = {
205
+ ...rest,
206
+ ref,
207
+ id: actionId,
208
+ disabled: isDisabled,
209
+ onKeyDown: composeEventHandlers(onKeyDown, openMenu),
210
+ "data-disabled": isDisabled ? "" : undefined,
211
+ "data-split-button-action": "",
212
+ };
213
+
214
+ if (asChild) {
215
+ return <Slot {...actionProps}>{children}</Slot>;
216
+ }
217
+
218
+ return (
219
+ <button type="button" {...actionProps}>
220
+ {children}
221
+ </button>
222
+ );
223
+ }
224
+
225
+ SplitButtonAction.displayName = "SplitButtonAction";
226
+
227
+ /**
228
+ * The menu half — a `<button type="button">` carrying `aria-haspopup="menu"`,
229
+ * `aria-expanded` and `aria-controls`, all inherited from
230
+ * {@link Dropdown | `Dropdown.Trigger`}.
231
+ *
232
+ * **Accessible name, derived.** A chevron-only control has no text of its own,
233
+ * so unless you pass `aria-label` or `aria-labelledby` the trigger defaults to
234
+ * `aria-labelledby="<its own id> <the action's id>"`. Self-reference is valid
235
+ * in `aria-labelledby` and resolves to the element's own contents, so a
236
+ * visually-hidden "More options" inside the trigger yields
237
+ * *"More options, Squash and merge"* rather than an unlabelled button or a
238
+ * second control with the same name as the action. Passing either ARIA
239
+ * attribute yourself opts out entirely.
240
+ *
241
+ * **Disabled.** OR-ed with the group's `disabled`, exactly like
242
+ * {@link SplitButtonAction | `SplitButton.Action`}.
243
+ *
244
+ * **Styling hooks.** `data-split-button-trigger=""`, plus `data-disabled=""`.
245
+ * Its `id` is component-owned (`Omit`-ted from the props) because the derived
246
+ * name references it.
247
+ *
248
+ * @extends HTMLButtonElement
249
+ *
250
+ * @example Derived name — chevron plus hidden text
251
+ * ```tsx
252
+ * <SplitButton.Trigger>
253
+ * <ChevronDown aria-hidden="true" />
254
+ * <VisuallyHidden>More merge options</VisuallyHidden>
255
+ * </SplitButton.Trigger>
256
+ * ```
257
+ *
258
+ * @example Explicit name — opts out of the derivation
259
+ * ```tsx
260
+ * <SplitButton.Trigger aria-label="More merge options">
261
+ * <ChevronDown aria-hidden="true" />
262
+ * </SplitButton.Trigger>
263
+ * ```
264
+ */
265
+ export function SplitButtonTrigger({
266
+ "aria-label": ariaLabel,
267
+ "aria-labelledby": ariaLabelledBy,
268
+ disabled,
269
+ ...rest
270
+ }: SplitButtonTriggerProps): ReactElement {
271
+ const {
272
+ actionId,
273
+ triggerId,
274
+ disabled: groupDisabled,
275
+ } = useSplitButtonContext();
276
+ const named = ariaLabel !== undefined || ariaLabelledBy !== undefined;
277
+ const isDisabled = groupDisabled || disabled === true;
278
+
279
+ return (
280
+ <Dropdown.Trigger
281
+ {...rest}
282
+ id={triggerId}
283
+ aria-label={ariaLabel}
284
+ aria-labelledby={named ? ariaLabelledBy : `${triggerId} ${actionId}`}
285
+ disabled={isDisabled}
286
+ data-disabled={isDisabled ? "" : undefined}
287
+ data-split-button-trigger=""
288
+ />
289
+ );
290
+ }
291
+
292
+ SplitButtonTrigger.displayName = "SplitButtonTrigger";
293
+
294
+ /**
295
+ * The menu panel of alternative actions — a `role="menu"` native
296
+ * `popover="auto"` element delegating wholly to
297
+ * {@link Dropdown | `Dropdown.Content`}: arrow-key navigation, Home/End,
298
+ * typeahead, Escape-to-close-and-restore-focus, and light dismiss all come
299
+ * from there.
300
+ *
301
+ * Anything valid inside a `Dropdown.Content` is valid here — `Dropdown.Group`,
302
+ * `Dropdown.Label`, `Dropdown.CheckboxItem`, `Dropdown.Sub` — because Root
303
+ * provides the same Dropdown context. `SplitButton` re-exports only the two
304
+ * parts a split button's menu almost always needs
305
+ * ({@link SplitButtonItem | `Item`} and
306
+ * {@link SplitButtonSeparator | `Separator`}); reach for `Dropdown.*` directly
307
+ * for the rest.
308
+ *
309
+ * **Styling hook.** `data-split-button-menu=""`, alongside Dropdown's own.
310
+ *
311
+ * @extends HTMLMenuElement
312
+ *
313
+ * @example
314
+ * ```tsx
315
+ * <SplitButton.Menu>
316
+ * <SplitButton.Item onSelect={mergeCommit}>Create a merge commit</SplitButton.Item>
317
+ * <SplitButton.Separator />
318
+ * <SplitButton.Item disabled>Rebase and merge</SplitButton.Item>
319
+ * </SplitButton.Menu>
320
+ * ```
321
+ */
322
+ export function SplitButtonMenu(props: SplitButtonMenuProps): ReactElement {
323
+ return <Dropdown.Content {...props} data-split-button-menu="" />;
324
+ }
325
+
326
+ SplitButtonMenu.displayName = "SplitButtonMenu";
327
+
328
+ /**
329
+ * One alternative action in the menu — a `role="menuitem"` row, delegating
330
+ * wholly to {@link Dropdown | `Dropdown.Item`}. Fires `onSelect` with a
331
+ * cancellable event and auto-closes the menu; call `event.preventDefault()`
332
+ * to keep it open. `disabled` rows get `aria-disabled` and are skipped by
333
+ * arrow navigation and typeahead.
334
+ *
335
+ * @extends HTMLDivElement
336
+ *
337
+ * @example
338
+ * ```tsx
339
+ * <SplitButton.Item onSelect={() => merge("rebase")}>
340
+ * Rebase and merge
341
+ * </SplitButton.Item>
342
+ * ```
343
+ */
344
+ export function SplitButtonItem(props: SplitButtonItemProps): ReactElement {
345
+ return <Dropdown.Item {...props} />;
346
+ }
347
+
348
+ SplitButtonItem.displayName = "SplitButtonItem";
349
+
350
+ /**
351
+ * A `role="separator"` divider between groups of alternatives, delegating
352
+ * wholly to {@link Dropdown | `Dropdown.Separator`}. Skipped by focus and
353
+ * typeahead.
354
+ *
355
+ * @extends HTMLDivElement
356
+ *
357
+ * @example
358
+ * ```tsx
359
+ * <SplitButton.Separator />
360
+ * ```
361
+ */
362
+ export function SplitButtonSeparator(
363
+ props: SplitButtonSeparatorProps,
364
+ ): ReactElement {
365
+ return <Dropdown.Separator {...props} />;
366
+ }
367
+
368
+ SplitButtonSeparator.displayName = "SplitButtonSeparator";
369
+
370
+ /** Type of the {@link SplitButton} compound: the root callable plus its
371
+ * attached sub-components. */
372
+ export type TSplitButtonCompound = typeof SplitButtonRoot & {
373
+ Root: typeof SplitButtonRoot;
374
+ Action: typeof SplitButtonAction;
375
+ Trigger: typeof SplitButtonTrigger;
376
+ Menu: typeof SplitButtonMenu;
377
+ Item: typeof SplitButtonItem;
378
+ Separator: typeof SplitButtonSeparator;
379
+ };
380
+
381
+ /**
382
+ * Headless, accessible **SplitButton** — one primary action paired with a menu
383
+ * of related alternatives, bound into a single `role="group"` widget. Zero
384
+ * styles ship.
385
+ *
386
+ * A split button is an **actions** control, not a value control: clicking the
387
+ * left half runs the default action immediately, and the right half opens a
388
+ * menu of alternatives (which may run their own action, or re-point what the
389
+ * primary half does). If what you need is "which option is currently
390
+ * selected?", reach for {@link Select} instead.
391
+ *
392
+ * `SplitButton` is a composition over {@link Dropdown} — Root renders a
393
+ * `Dropdown.Root` and every menu part delegates to Dropdown's, so all the menu
394
+ * keyboard/focus/light-dismiss behaviour is Dropdown's, tested once. What this
395
+ * component adds on top is the coordination between the two halves that
396
+ * Dropdown alone cannot provide:
397
+ *
398
+ * - the `role="group"` boundary that makes them read as one widget;
399
+ * - the trigger's accessible name, derived from the primary action;
400
+ * - `disabled` propagating from the group to both halves;
401
+ * - `ArrowDown` on the action opening the menu.
402
+ *
403
+ * `SplitButton` is both callable (an alias of `SplitButton.Root`) and carries
404
+ * its sub-components as static properties:
405
+ *
406
+ * - {@link SplitButtonRoot | `SplitButton.Root`} — the `role="group"` element
407
+ * and the menu's state owner.
408
+ * - {@link SplitButtonAction | `SplitButton.Action`} — the primary half.
409
+ * - {@link SplitButtonTrigger | `SplitButton.Trigger`} — the menu half.
410
+ * - {@link SplitButtonMenu | `SplitButton.Menu`} — the menu panel.
411
+ * - {@link SplitButtonItem | `SplitButton.Item`} — one alternative action.
412
+ * - {@link SplitButtonSeparator | `SplitButton.Separator`} — a divider.
413
+ *
414
+ * **Both halves are tabbable.** They are two separate commands, so each is its
415
+ * own tab stop — this is not a roving-tabindex widget, and should not become
416
+ * one.
417
+ *
418
+ * @example
419
+ * ```tsx
420
+ * import { SplitButton, VisuallyHidden } from "@primitiv-ui/react";
421
+ *
422
+ * <SplitButton>
423
+ * <SplitButton.Action onClick={squashAndMerge}>
424
+ * Squash and merge
425
+ * </SplitButton.Action>
426
+ * <SplitButton.Trigger>
427
+ * <ChevronDown aria-hidden="true" />
428
+ * <VisuallyHidden>More merge options</VisuallyHidden>
429
+ * </SplitButton.Trigger>
430
+ * <SplitButton.Menu>
431
+ * <SplitButton.Item onSelect={mergeCommit}>
432
+ * Create a merge commit
433
+ * </SplitButton.Item>
434
+ * <SplitButton.Item onSelect={rebase}>Rebase and merge</SplitButton.Item>
435
+ * </SplitButton.Menu>
436
+ * </SplitButton>
437
+ * ```
438
+ *
439
+ * @see {@link SplitButtonRoot} for open-state modes and the group anatomy.
440
+ * @see {@link SplitButtonTrigger} for how the derived accessible name works.
441
+ * @see {@link Dropdown} for everything the menu itself does.
442
+ */
443
+ const SplitButtonCompound: TSplitButtonCompound = Object.assign(
444
+ SplitButtonRoot,
445
+ {
446
+ Root: SplitButtonRoot,
447
+ Action: SplitButtonAction,
448
+ Trigger: SplitButtonTrigger,
449
+ Menu: SplitButtonMenu,
450
+ Item: SplitButtonItem,
451
+ Separator: SplitButtonSeparator,
452
+ },
453
+ );
454
+
455
+ SplitButtonCompound.displayName = "SplitButton";
456
+
457
+ export { SplitButtonCompound as SplitButton };
@@ -0,0 +1,26 @@
1
+ import type { Context, Provider } from "react";
2
+
3
+ import { createStrictContext } from "../utils/index.ts";
4
+
5
+ import type { SplitButtonContextValue } from "./types";
6
+
7
+ const splitButtonContextPair = createStrictContext<SplitButtonContextValue>(
8
+ "SplitButton sub-components must be rendered inside a <SplitButton.Root>.",
9
+ "SplitButtonContext",
10
+ );
11
+
12
+ /** Strict React context carrying the {@link SplitButtonContextValue} from
13
+ * `SplitButton.Root` to its Action and Trigger. `null` outside a Root. */
14
+ export const SplitButtonContext: Context<SplitButtonContextValue | null> =
15
+ splitButtonContextPair[0];
16
+
17
+ /** Reads the {@link SplitButtonContextValue}; throws outside a
18
+ * `SplitButton.Root`. */
19
+ export const useSplitButtonContext: () => SplitButtonContextValue =
20
+ splitButtonContextPair[1];
21
+
22
+ /** Provider for {@link SplitButtonContext}, used by `SplitButton.Root`. */
23
+ const SplitButtonProvider: Provider<SplitButtonContextValue | null> =
24
+ SplitButtonContext.Provider;
25
+
26
+ export { SplitButtonProvider };
@@ -0,0 +1,55 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { SplitButton } from "../SplitButton";
5
+
6
+ describe("SplitButton — accessibility", () => {
7
+ it("names the menu trigger with its own text followed by the action's label", () => {
8
+ render(
9
+ <SplitButton>
10
+ <SplitButton.Action>Squash and merge</SplitButton.Action>
11
+ <SplitButton.Trigger>
12
+ <span>More options</span>
13
+ </SplitButton.Trigger>
14
+ <SplitButton.Menu />
15
+ </SplitButton>,
16
+ );
17
+
18
+ expect(
19
+ screen.getByRole("button", { name: "More options Squash and merge" }),
20
+ ).toHaveAttribute("aria-haspopup", "menu");
21
+ });
22
+
23
+ it("lets an explicit aria-labelledby replace the derived name", () => {
24
+ render(
25
+ <>
26
+ <span id="external-label">Merge options</span>
27
+ <SplitButton>
28
+ <SplitButton.Action>Squash and merge</SplitButton.Action>
29
+ <SplitButton.Trigger aria-labelledby="external-label" />
30
+ <SplitButton.Menu />
31
+ </SplitButton>
32
+ </>,
33
+ );
34
+
35
+ expect(
36
+ screen.getByRole("button", { name: "Merge options" }),
37
+ ).toHaveAttribute("aria-labelledby", "external-label");
38
+ });
39
+
40
+ it("gives each half a stable, role-suffixed id derived from the root", () => {
41
+ render(
42
+ <SplitButton>
43
+ <SplitButton.Action>Squash and merge</SplitButton.Action>
44
+ <SplitButton.Trigger aria-label="More merge options" />
45
+ <SplitButton.Menu />
46
+ </SplitButton>,
47
+ );
48
+
49
+ const action = screen.getByRole("button", { name: "Squash and merge" });
50
+ const trigger = screen.getByRole("button", { name: "More merge options" });
51
+
52
+ expect(action.id).toMatch(/-split-button-action$/);
53
+ expect(trigger.id).toMatch(/-split-button-trigger$/);
54
+ });
55
+ });
@@ -0,0 +1,51 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { createRef } from "react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+
6
+ import { SplitButton } from "../SplitButton";
7
+
8
+ describe("SplitButton — asChild", () => {
9
+ it("renders the consumer's element as the group", () => {
10
+ render(
11
+ <SplitButton asChild>
12
+ <section aria-label="Merge">
13
+ <SplitButton.Action>Squash and merge</SplitButton.Action>
14
+ </section>
15
+ </SplitButton>,
16
+ );
17
+
18
+ const group = screen.getByRole("group", { name: "Merge" });
19
+
20
+ expect(group.tagName).toBe("SECTION");
21
+ expect(group).toHaveAttribute("data-split-button", "");
22
+ });
23
+
24
+ it("merges the action onto a consumer element, composing handlers and refs", async () => {
25
+ const user = userEvent.setup();
26
+ const onClick = vi.fn();
27
+ const ref = createRef<HTMLAnchorElement>();
28
+
29
+ render(
30
+ <SplitButton>
31
+ <SplitButton.Action asChild>
32
+ <a href="/merge" ref={ref} onClick={onClick}>
33
+ Squash and merge
34
+ </a>
35
+ </SplitButton.Action>
36
+ <SplitButton.Trigger aria-label="More merge options" />
37
+ <SplitButton.Menu />
38
+ </SplitButton>,
39
+ );
40
+
41
+ const action = screen.getByRole("link", { name: "Squash and merge" });
42
+
43
+ expect(ref.current).toBe(action);
44
+ expect(action).toHaveAttribute("data-split-button-action", "");
45
+ expect(action).not.toHaveAttribute("type");
46
+
47
+ await user.click(action);
48
+
49
+ expect(onClick).toHaveBeenCalledOnce();
50
+ });
51
+ });
@@ -0,0 +1,57 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { SplitButton } from "../SplitButton";
5
+ import { SplitButtonContext } from "../SplitButtonContext";
6
+
7
+ describe("SplitButton — basic rendering", () => {
8
+ it("renders a group wrapping the primary action", () => {
9
+ render(
10
+ <SplitButton>
11
+ <SplitButton.Action>Squash and merge</SplitButton.Action>
12
+ </SplitButton>,
13
+ );
14
+
15
+ const group = screen.getByRole("group");
16
+
17
+ expect(group).toContainElement(
18
+ screen.getByRole("button", { name: "Squash and merge" }),
19
+ );
20
+ });
21
+
22
+ it("renders the menu half as a menu button over a menu of items", () => {
23
+ render(
24
+ <SplitButton>
25
+ <SplitButton.Action>Squash and merge</SplitButton.Action>
26
+ <SplitButton.Trigger aria-label="More merge options" />
27
+ <SplitButton.Menu>
28
+ <SplitButton.Item>Create a merge commit</SplitButton.Item>
29
+ <SplitButton.Separator />
30
+ <SplitButton.Item>Rebase and merge</SplitButton.Item>
31
+ </SplitButton.Menu>
32
+ </SplitButton>,
33
+ );
34
+
35
+ const trigger = screen.getByRole("button", { name: "More merge options" });
36
+
37
+ expect(trigger).toHaveAttribute("aria-haspopup", "menu");
38
+ expect(trigger).toHaveAttribute(
39
+ "aria-controls",
40
+ screen.getByRole("menu", { hidden: true }).id,
41
+ );
42
+ expect(screen.getAllByRole("menuitem", { hidden: true })).toHaveLength(2);
43
+ expect(screen.getByRole("separator", { hidden: true })).toBeInTheDocument();
44
+ });
45
+
46
+ it("sets a displayName on the compound, every sub-component and the context", () => {
47
+ // Empty displayNames would render each as anonymous in DevTools. Root
48
+ // aliases the compound (Object.assign), so its name is "SplitButton".
49
+ expect(SplitButton.displayName).toBe("SplitButton");
50
+ expect(SplitButton.Action.displayName).toBe("SplitButtonAction");
51
+ expect(SplitButton.Trigger.displayName).toBe("SplitButtonTrigger");
52
+ expect(SplitButton.Menu.displayName).toBe("SplitButtonMenu");
53
+ expect(SplitButton.Item.displayName).toBe("SplitButtonItem");
54
+ expect(SplitButton.Separator.displayName).toBe("SplitButtonSeparator");
55
+ expect(SplitButtonContext.displayName).toBe("SplitButtonContext");
56
+ });
57
+ });
@@ -0,0 +1,28 @@
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 { SplitButton } from "../SplitButton";
6
+
7
+ describe("SplitButton — data attributes", () => {
8
+ it("reflects the menu's open state on the group", async () => {
9
+ const user = userEvent.setup();
10
+ render(
11
+ <SplitButton>
12
+ <SplitButton.Action>Squash and merge</SplitButton.Action>
13
+ <SplitButton.Trigger aria-label="More merge options" />
14
+ <SplitButton.Menu>
15
+ <SplitButton.Item>Rebase and merge</SplitButton.Item>
16
+ </SplitButton.Menu>
17
+ </SplitButton>,
18
+ );
19
+
20
+ expect(screen.getByRole("group")).toHaveAttribute("data-state", "closed");
21
+
22
+ await user.click(
23
+ screen.getByRole("button", { name: "More merge options" }),
24
+ );
25
+
26
+ expect(screen.getByRole("group")).toHaveAttribute("data-state", "open");
27
+ });
28
+ });