@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,79 @@
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 — disabled", () => {
7
+ it("disables both halves when the group is disabled", () => {
8
+ render(
9
+ <SplitButton disabled>
10
+ <SplitButton.Action>Squash and merge</SplitButton.Action>
11
+ <SplitButton.Trigger aria-label="More merge options" />
12
+ <SplitButton.Menu />
13
+ </SplitButton>,
14
+ );
15
+
16
+ expect(screen.getByRole("group")).toHaveAttribute("data-disabled", "");
17
+ expect(
18
+ screen.getByRole("button", { name: "Squash and merge" }),
19
+ ).toBeDisabled();
20
+ expect(
21
+ screen.getByRole("button", { name: "More merge options" }),
22
+ ).toBeDisabled();
23
+ });
24
+
25
+ it("disables one half independently, leaving the other interactive", () => {
26
+ render(
27
+ <SplitButton>
28
+ <SplitButton.Action disabled>Squash and merge</SplitButton.Action>
29
+ <SplitButton.Trigger aria-label="More merge options" />
30
+ <SplitButton.Menu />
31
+ </SplitButton>,
32
+ );
33
+
34
+ expect(screen.getByRole("group")).not.toHaveAttribute("data-disabled");
35
+ expect(
36
+ screen.getByRole("button", { name: "Squash and merge" }),
37
+ ).toHaveAttribute("data-disabled", "");
38
+ expect(
39
+ screen.getByRole("button", { name: "More merge options" }),
40
+ ).toBeEnabled();
41
+ });
42
+
43
+ it("disables only the menu half when the trigger is disabled", () => {
44
+ render(
45
+ <SplitButton>
46
+ <SplitButton.Action>Squash and merge</SplitButton.Action>
47
+ <SplitButton.Trigger disabled aria-label="More merge options" />
48
+ <SplitButton.Menu />
49
+ </SplitButton>,
50
+ );
51
+
52
+ expect(
53
+ screen.getByRole("button", { name: "Squash and merge" }),
54
+ ).toBeEnabled();
55
+ expect(
56
+ screen.getByRole("button", { name: "More merge options" }),
57
+ ).toHaveAttribute("data-disabled", "");
58
+ });
59
+
60
+ it("re-propagates when the group's disabled flips after mount", () => {
61
+ const markup = (disabled: boolean) => (
62
+ <SplitButton disabled={disabled}>
63
+ <SplitButton.Action>Squash and merge</SplitButton.Action>
64
+ <SplitButton.Trigger aria-label="More merge options" />
65
+ <SplitButton.Menu />
66
+ </SplitButton>
67
+ );
68
+ const { rerender } = render(markup(false));
69
+
70
+ rerender(markup(true));
71
+
72
+ expect(
73
+ screen.getByRole("button", { name: "Squash and merge" }),
74
+ ).toBeDisabled();
75
+ expect(
76
+ screen.getByRole("button", { name: "More merge options" }),
77
+ ).toBeDisabled();
78
+ });
79
+ });
@@ -0,0 +1,29 @@
1
+ import { render } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { SplitButton } from "../SplitButton";
5
+
6
+ const outsideRoot =
7
+ "SplitButton sub-components must be rendered inside a <SplitButton.Root>.";
8
+
9
+ describe("SplitButton — error handling", () => {
10
+ beforeEach(() => {
11
+ vi.spyOn(console, "error").mockImplementation(() => {});
12
+ });
13
+
14
+ afterEach(() => {
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ it("throws when SplitButton.Action is used outside SplitButton.Root", () => {
19
+ expect(() =>
20
+ render(<SplitButton.Action>Orphan</SplitButton.Action>),
21
+ ).toThrowError(outsideRoot);
22
+ });
23
+
24
+ it("throws when SplitButton.Trigger is used outside SplitButton.Root", () => {
25
+ expect(() => render(<SplitButton.Trigger aria-label="Orphan" />)).toThrowError(
26
+ outsideRoot,
27
+ );
28
+ });
29
+ });
@@ -0,0 +1,56 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import { SplitButton } from "../SplitButton";
6
+
7
+ describe("SplitButton — keyboard interaction", () => {
8
+ it("opens the menu from the action with ArrowDown and focuses the first item", 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>Create a merge commit</SplitButton.Item>
16
+ <SplitButton.Item>Rebase and merge</SplitButton.Item>
17
+ </SplitButton.Menu>
18
+ </SplitButton>,
19
+ );
20
+
21
+ await user.tab();
22
+ await user.keyboard("{ArrowDown}");
23
+
24
+ expect(
25
+ screen.getByRole("button", { name: "More merge options" }),
26
+ ).toHaveAttribute("aria-expanded", "true");
27
+ expect(
28
+ screen.getByRole("menuitem", {
29
+ name: "Create a merge commit",
30
+ hidden: true,
31
+ }),
32
+ ).toHaveFocus();
33
+ });
34
+
35
+ it("leaves the menu closed for other keys, and still runs the action's own handler", async () => {
36
+ const user = userEvent.setup();
37
+ const onKeyDown = vi.fn();
38
+ render(
39
+ <SplitButton>
40
+ <SplitButton.Action onKeyDown={onKeyDown}>
41
+ Squash and merge
42
+ </SplitButton.Action>
43
+ <SplitButton.Trigger aria-label="More merge options" />
44
+ <SplitButton.Menu>
45
+ <SplitButton.Item>Create a merge commit</SplitButton.Item>
46
+ </SplitButton.Menu>
47
+ </SplitButton>,
48
+ );
49
+
50
+ await user.tab();
51
+ await user.keyboard("{ArrowUp}");
52
+
53
+ expect(onKeyDown).toHaveBeenCalledOnce();
54
+ expect(screen.getByRole("group")).toHaveAttribute("data-state", "closed");
55
+ });
56
+ });
@@ -0,0 +1,68 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import { Dropdown } from "../../Dropdown/index.ts";
6
+ import { SplitButton } from "../SplitButton";
7
+
8
+ describe("SplitButton — state modes", () => {
9
+ it("starts open in uncontrolled mode when defaultOpen is set", () => {
10
+ render(
11
+ <SplitButton defaultOpen>
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", "open");
21
+ });
22
+
23
+ it("defers to the parent in controlled mode", async () => {
24
+ const user = userEvent.setup();
25
+ const onOpenChange = vi.fn();
26
+ render(
27
+ <SplitButton open={false} onOpenChange={onOpenChange}>
28
+ <SplitButton.Action>Squash and merge</SplitButton.Action>
29
+ <SplitButton.Trigger aria-label="More merge options" />
30
+ <SplitButton.Menu>
31
+ <SplitButton.Item>Rebase and merge</SplitButton.Item>
32
+ </SplitButton.Menu>
33
+ </SplitButton>,
34
+ );
35
+
36
+ await user.click(screen.getByRole("button", { name: "More merge options" }));
37
+
38
+ expect(onOpenChange).toHaveBeenCalledWith(true);
39
+ expect(screen.getByRole("group")).toHaveAttribute("data-state", "closed");
40
+ });
41
+
42
+ it("passes the reading direction down to menus composed from Dropdown", async () => {
43
+ const user = userEvent.setup();
44
+ render(
45
+ <SplitButton dir="rtl" defaultOpen>
46
+ <SplitButton.Action>Squash and merge</SplitButton.Action>
47
+ <SplitButton.Trigger aria-label="More merge options" />
48
+ <SplitButton.Menu>
49
+ <Dropdown.Sub>
50
+ <Dropdown.SubTrigger>Merge strategies</Dropdown.SubTrigger>
51
+ <Dropdown.SubContent>
52
+ <SplitButton.Item>Rebase and merge</SplitButton.Item>
53
+ </Dropdown.SubContent>
54
+ </Dropdown.Sub>
55
+ </SplitButton.Menu>
56
+ </SplitButton>,
57
+ );
58
+
59
+ await user.keyboard("{ArrowLeft}");
60
+
61
+ expect(
62
+ screen.getByRole("menuitem", {
63
+ name: "Merge strategies",
64
+ hidden: true,
65
+ }),
66
+ ).toHaveAttribute("aria-expanded", "true");
67
+ });
68
+ });
@@ -0,0 +1,2 @@
1
+ export { SplitButton } from "./SplitButton";
2
+ export type * from "./types";
@@ -0,0 +1,186 @@
1
+ import { ComponentProps, ReactNode, Ref } from "react";
2
+
3
+ import type { Direction } from "../DirectionProvider/index.ts";
4
+ import type {
5
+ DropdownContentProps,
6
+ DropdownItemProps,
7
+ DropdownSeparatorProps,
8
+ DropdownTriggerProps,
9
+ } from "../Dropdown/types";
10
+
11
+ /**
12
+ * Shared base for both {@link SplitButtonRootProps} variants — the native
13
+ * `<div>` attributes (minus the component-owned `role` and the narrowed
14
+ * `dir`) plus the `asChild` escape hatch, reading direction, and the
15
+ * group-level `disabled`.
16
+ */
17
+ export type SplitButtonRootBaseProps = Omit<
18
+ ComponentProps<"div">,
19
+ "role" | "dir"
20
+ > & {
21
+ /**
22
+ * The widget's parts — typically one
23
+ * {@link SplitButtonActionProps | `SplitButton.Action`}, one
24
+ * {@link SplitButtonTriggerProps | `SplitButton.Trigger`}, and one
25
+ * {@link SplitButtonMenuProps | `SplitButton.Menu`}.
26
+ */
27
+ children?: ReactNode;
28
+ /** Forwarded to the underlying `HTMLDivElement` (or to the `asChild`
29
+ * element). */
30
+ ref?: Ref<HTMLDivElement>;
31
+ /**
32
+ * When `true`, Root delegates rendering to a single consumer-supplied
33
+ * element via the {@link Slot} pattern instead of the default `<div>`;
34
+ * `role="group"`, `data-state`, `data-disabled` and the remaining props are
35
+ * merged onto it.
36
+ * @default false
37
+ */
38
+ asChild?: boolean;
39
+ /**
40
+ * Reading direction for the menu. Only affects which arrow key opens or
41
+ * closes a submenu composed from `Dropdown.Sub` — `ArrowRight` opens in
42
+ * `"ltr"`, `ArrowLeft` in `"rtl"`. When omitted it is inherited from the
43
+ * nearest {@link DirectionProvider}, falling back to `"ltr"`.
44
+ */
45
+ dir?: Direction;
46
+ /**
47
+ * Disables the whole widget — both the primary action and the menu trigger
48
+ * become non-interactive, and `data-disabled=""` is set on the group. OR-ed
49
+ * with each half's own `disabled`, so a disabled group can never be
50
+ * overridden back to enabled by a part.
51
+ * @default false
52
+ */
53
+ disabled?: boolean;
54
+ };
55
+
56
+ /**
57
+ * Uncontrolled variant of {@link SplitButtonRootProps}: the component owns the
58
+ * menu's open state. Pass `defaultOpen` (or omit it); `onOpenChange` is
59
+ * optional and `open` is forbidden.
60
+ */
61
+ export type SplitButtonRootUncontrolledProps = SplitButtonRootBaseProps & {
62
+ /**
63
+ * Whether the menu is open on first render. Omit to start closed. The
64
+ * component owns the state thereafter.
65
+ * @default false
66
+ */
67
+ defaultOpen?: boolean;
68
+ /** Forbidden in uncontrolled mode — use `defaultOpen` instead. */
69
+ open?: never;
70
+ /**
71
+ * Called whenever a user-driven transition opens or closes the menu
72
+ * (trigger click, `ArrowDown` on the action, Escape, outside click,
73
+ * selection). Optional in uncontrolled mode.
74
+ */
75
+ onOpenChange?: (open: boolean) => void;
76
+ };
77
+
78
+ /**
79
+ * Controlled variant of {@link SplitButtonRootProps}: the parent owns the
80
+ * menu's open state. Pass `open` and `onOpenChange` together; `defaultOpen` is
81
+ * forbidden.
82
+ */
83
+ export type SplitButtonRootControlledProps = SplitButtonRootBaseProps & {
84
+ /** Forbidden in controlled mode — use `open` instead. */
85
+ defaultOpen?: never;
86
+ /**
87
+ * Whether the menu is currently open. Must be kept in sync by the parent via
88
+ * `onOpenChange`; the component never mutates it internally.
89
+ */
90
+ open: boolean;
91
+ /**
92
+ * Called whenever a user-driven transition would open or close the menu. The
93
+ * parent is responsible for reflecting the new value back into `open`.
94
+ * Required in controlled mode.
95
+ */
96
+ onOpenChange: (open: boolean) => void;
97
+ };
98
+
99
+ /**
100
+ * Props for {@link SplitButtonRoot | `SplitButton.Root`} — the
101
+ * controlled/uncontrolled discriminated union pairing
102
+ * {@link SplitButtonRootUncontrolledProps} with
103
+ * {@link SplitButtonRootControlledProps}, plus the shared group props.
104
+ */
105
+ export type SplitButtonRootProps =
106
+ | SplitButtonRootUncontrolledProps
107
+ | SplitButtonRootControlledProps;
108
+
109
+ /**
110
+ * Props for the internal frame component that renders the group element. Root
111
+ * splits it out so the frame sits *inside* the `Dropdown.Root` it renders and
112
+ * can therefore read the menu's open state.
113
+ *
114
+ * @internal
115
+ */
116
+ export type SplitButtonFrameProps = Omit<
117
+ SplitButtonRootBaseProps,
118
+ "dir" | "disabled"
119
+ > & {
120
+ /** Already defaulted by Root, so the frame receives a definite boolean. */
121
+ disabled: boolean;
122
+ };
123
+
124
+ /**
125
+ * Value published by `SplitButtonContext` — the derived ids that wire the
126
+ * trigger's accessible name to the action, plus the group's disabled flag.
127
+ *
128
+ * @internal
129
+ */
130
+ export type SplitButtonContextValue = {
131
+ /** DOM id applied to {@link SplitButtonActionProps | `SplitButton.Action`};
132
+ * referenced by the trigger's derived `aria-labelledby`. */
133
+ actionId: string;
134
+ /** DOM id applied to {@link SplitButtonTriggerProps | `SplitButton.Trigger`};
135
+ * the self-reference half of its derived `aria-labelledby`. */
136
+ triggerId: string;
137
+ /** Whether the whole widget is disabled (Root's `disabled` prop). */
138
+ disabled: boolean;
139
+ };
140
+
141
+ /**
142
+ * Props for {@link SplitButtonAction | `SplitButton.Action`} — the native
143
+ * `<button>` attributes minus `id`, which the component owns because the menu
144
+ * trigger's accessible name references it.
145
+ */
146
+ export type SplitButtonActionProps = Omit<ComponentProps<"button">, "id"> & {
147
+ /** The action's visible label. Keep it real text — the menu trigger borrows
148
+ * it for its own accessible name. */
149
+ children?: ReactNode;
150
+ /** Forwarded to the underlying `HTMLButtonElement` (or to the `asChild`
151
+ * element). */
152
+ ref?: Ref<HTMLButtonElement>;
153
+ /**
154
+ * When `true`, renders the consumer's element instead of `<button>` via the
155
+ * {@link Slot} pattern. `type="button"` is **not** forwarded in this mode —
156
+ * the child owns its own type semantics.
157
+ * @default false
158
+ */
159
+ asChild?: boolean;
160
+ };
161
+
162
+ /**
163
+ * Props for {@link SplitButtonTrigger | `SplitButton.Trigger`} —
164
+ * {@link DropdownTriggerProps} minus `id`, which the component owns because
165
+ * its derived `aria-labelledby` self-references it. Supply `aria-label` or
166
+ * `aria-labelledby` to opt out of the derived name.
167
+ */
168
+ export type SplitButtonTriggerProps = Omit<DropdownTriggerProps, "id">;
169
+
170
+ /**
171
+ * Props for {@link SplitButtonMenu | `SplitButton.Menu`} — identical to
172
+ * {@link DropdownContentProps}; the menu *is* a `Dropdown.Content`.
173
+ */
174
+ export type SplitButtonMenuProps = DropdownContentProps;
175
+
176
+ /**
177
+ * Props for {@link SplitButtonItem | `SplitButton.Item`} — identical to
178
+ * {@link DropdownItemProps}.
179
+ */
180
+ export type SplitButtonItemProps = DropdownItemProps;
181
+
182
+ /**
183
+ * Props for {@link SplitButtonSeparator | `SplitButton.Separator`} — identical
184
+ * to {@link DropdownSeparatorProps}.
185
+ */
186
+ export type SplitButtonSeparatorProps = DropdownSeparatorProps;
@@ -45,7 +45,7 @@ Pass `checked` and `onCheckedChange` together. The parent owns the value.
45
45
  ```tsx
46
46
  const [enabled, setEnabled] = useState(false);
47
47
 
48
- <Switch.Root checked={enabled} onCheckedChange={setEnabled} aria-label="…">
48
+ <Switch.Root checked={enabled} onCheckedChange={setEnabled} aria-label="...">
49
49
  <Switch.Thumb />
50
50
  </Switch.Root>
51
51
  ```
@@ -25,7 +25,7 @@ function dataStateOf(checked: boolean) {
25
25
  *
26
26
  * Props routing: `className` / `style` style the **track** (the `<label>` you
27
27
  * see — the input is hidden); every other prop (`name`, `value`, `id`,
28
- * `aria-*`, `required`, `disabled`, `ref`, …) spreads onto the input, because
28
+ * `aria-*`, `required`, `disabled`, `ref`, ...) spreads onto the input, because
29
29
  * semantically the Root *is* the switch.
30
30
  *
31
31
  * Supports two state modes, statically discriminated at the type level:
@@ -59,7 +59,7 @@ function dataStateOf(checked: boolean) {
59
59
  * ```tsx
60
60
  * const [enabled, setEnabled] = useState(false);
61
61
  *
62
- * <Switch.Root checked={enabled} onCheckedChange={setEnabled} aria-label="…">
62
+ * <Switch.Root checked={enabled} onCheckedChange={setEnabled} aria-label="...">
63
63
  * <Switch.Thumb />
64
64
  * </Switch.Root>
65
65
  * ```
@@ -8,7 +8,7 @@ import { ChangeEventHandler, ComponentProps, ReactNode, Ref } from "react";
8
8
  *
9
9
  * Prop routing: `className` / `style` style the **track** (the visible
10
10
  * `<label>`, since the input is visually hidden); every other prop — `name`,
11
- * `value`, `id`, `aria-*`, `required`, `disabled`, `ref`, … — spreads onto the
11
+ * `value`, `id`, `aria-*`, `required`, `disabled`, `ref`, ... — spreads onto the
12
12
  * hidden `<input>`, because semantically the Root *is* the switch.
13
13
  */
14
14
  export type SwitchRootBaseProps = Omit<
@@ -51,6 +51,9 @@ import { Table } from "@primitiv-ui/react";
51
51
  | `Table.Cell` | `<td>` | Data cell. Accepts `colSpan` and `rowSpan`. |
52
52
  | `Table.ScrollArea` | `<div>` | Horizontal-scroll wrapper — see [Responsive scrolling](#responsive-scrolling). |
53
53
  | `Table.Caption` | `<caption>` | Visible table label — see [Caption](#caption). |
54
+ | `Table.Expandable` | *nothing* | Pairs a row with its detail row — see [Expandable rows](#expandable-rows). |
55
+ | `Table.ExpandTrigger` | `<button>` | The row's disclosure control; owns `aria-expanded` / `aria-controls`. |
56
+ | `Table.DetailRow` | `<tr>` | The revealed panel, as a `<td colSpan>`. `gutter` offsets it to the first data column. |
54
57
 
55
58
  ## Accessible headers
56
59
 
@@ -96,14 +99,14 @@ the table via the CSS `caption-side` property. Defaults to `"bottom"`.
96
99
  /* Caption below table (default) */
97
100
  }
98
101
  <Table.Root>
99
- <Table.Caption>Q1 sales by region</Table.Caption>…
102
+ <Table.Caption>Q1 sales by region</Table.Caption>...
100
103
  </Table.Root>;
101
104
 
102
105
  {
103
106
  /* Caption above table */
104
107
  }
105
108
  <Table.Root>
106
- <Table.Caption captionSide="top">Q1 sales by region</Table.Caption>…
109
+ <Table.Caption captionSide="top">Q1 sales by region</Table.Caption>...
107
110
  </Table.Root>;
108
111
  ```
109
112
 
@@ -111,7 +114,7 @@ When a visible caption is not desirable, use `aria-label` directly on
111
114
  `Table.Root` instead:
112
115
 
113
116
  ```tsx
114
- <Table.Root aria-label="Q1 sales by region">…</Table.Root>
117
+ <Table.Root aria-label="Q1 sales by region">...</Table.Root>
115
118
  ```
116
119
 
117
120
  ## Responsive scrolling
@@ -124,7 +127,7 @@ narrow viewports instead of overflowing or compressing columns.
124
127
 
125
128
  ```tsx
126
129
  <Table.ScrollArea>
127
- <Table.Root>…</Table.Root>
130
+ <Table.Root>...</Table.Root>
128
131
  </Table.ScrollArea>
129
132
  ```
130
133
 
@@ -133,7 +136,7 @@ base scroll styles, so you can layer additional styles freely:
133
136
 
134
137
  ```tsx
135
138
  <Table.ScrollArea style={{ borderRadius: "8px" }}>
136
- <Table.Root>…</Table.Root>
139
+ <Table.Root>...</Table.Root>
137
140
  </Table.ScrollArea>
138
141
  ```
139
142
 
@@ -144,14 +147,14 @@ separating logical row groups within a single table:
144
147
 
145
148
  ```tsx
146
149
  <Table.Root>
147
- <Table.Head>…</Table.Head>
150
+ <Table.Head>...</Table.Head>
148
151
  <Table.Body>
149
152
  {/* Group 1 */}
150
- <Table.Row>…</Table.Row>
153
+ <Table.Row>...</Table.Row>
151
154
  </Table.Body>
152
155
  <Table.Body>
153
156
  {/* Group 2 */}
154
- <Table.Row>…</Table.Row>
157
+ <Table.Row>...</Table.Row>
155
158
  </Table.Body>
156
159
  </Table.Root>
157
160
  ```
@@ -203,3 +206,102 @@ caption {
203
206
  ---
204
207
 
205
208
  [Back to @primitiv-ui/react](../../README.md)
209
+
210
+ ## Expandable rows
211
+
212
+ A row can open onto a detail panel. This is a **disclosure**, not a
213
+ [treegrid](https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/): expanding into
214
+ hierarchical child *rows* is a different pattern with a full cell-navigation
215
+ keyboard model, and is deliberately out of scope here.
216
+
217
+ `Table.Expandable` renders no DOM of its own — it is a context provider, so both
218
+ `<tr>`s stay direct children of the `<tbody>` and the table's structure is
219
+ untouched.
220
+
221
+ ```tsx
222
+ <Table.Body>
223
+ <Table.Expandable expanded={open} onExpandedChange={setOpen}>
224
+ <Table.Row>
225
+ <Table.Cell>
226
+ <Table.ExpandTrigger>
227
+ <ChevronIcon aria-hidden="true" />
228
+ <span className="sr-only">Show details for Ada Lovelace</span>
229
+ </Table.ExpandTrigger>
230
+ </Table.Cell>
231
+ <Table.Cell>Ada Lovelace</Table.Cell>
232
+ </Table.Row>
233
+
234
+ <Table.DetailRow colSpan={2}>
235
+ <ContributorDetail />
236
+ </Table.DetailRow>
237
+ </Table.Expandable>
238
+ </Table.Body>
239
+ ```
240
+
241
+ Controlled via `expanded` + `onExpandedChange` (what an external table engine
242
+ drives), or uncontrolled with `defaultExpanded`.
243
+
244
+ ### Why the state is on the button
245
+
246
+ `role="row"` does list `aria-expanded` among its supported properties, but a
247
+ **button's** expanded state is announced universally while a row's is only
248
+ reliably announced inside a `role="treegrid"`. So the button owns
249
+ `aria-expanded` and `aria-controls`, and the detail row publishes the matching
250
+ `id`.
251
+
252
+ Give the trigger a name that identifies **its row**. A column of buttons all
253
+ called "Expand" is the same defect as a column of checkboxes all called
254
+ "Select": obvious on screen, useless in a screen reader's element list.
255
+
256
+ ### `colSpan` is required
257
+
258
+ It must cover every column the table renders, control columns included. It is a
259
+ prop rather than a count taken from context on purpose: counting would mean the
260
+ table registering its own columns, and an external engine already knows the
261
+ number (TanStack Table: `table.getVisibleLeafColumns().length`).
262
+
263
+ ### `gutter` aligns the panel with the first data column
264
+
265
+ A panel that starts at the table's edge, under the checkbox, reads as a new
266
+ section of the table rather than as an opened row. `gutter` is how many leading
267
+ columns to leave empty — the control columns in front of the data:
268
+
269
+ ```tsx
270
+ {/* select + expand columns, then eight data columns */}
271
+ <Table.DetailRow colSpan={10} gutter={2}>
272
+ ```
273
+
274
+ The panel spans what remains, so `colSpan` keeps its meaning: the table's full
275
+ column count, gutter included.
276
+
277
+ It renders **one** empty `<td colSpan={gutter}>`, not `gutter` of them — a
278
+ spanning cell takes the full width of the columns it covers, so the alignment is
279
+ identical and the row gains a single blank cell instead of several.
280
+
281
+ Aligning by empty cell rather than by a CSS indent is the point: the browser's
282
+ own table layout resolves the width, so it stays exact across every size,
283
+ density and control-column set. Indenting instead means summing control widths
284
+ and paddings by hand, which is right at one size and wrong at the other four —
285
+ measured drift of 7-21px across xs...xl before this existed.
286
+
287
+ ### `forceMount` and the row count
288
+
289
+ By default a collapsed detail row is `hidden`, so it is absent from the
290
+ accessibility tree — which matters more here than on a `<div>`-based disclosure,
291
+ because a mounted `<tr>` is still a **row**: 24 data rows each with a
292
+ force-mounted detail row would announce 48.
293
+
294
+ Pass `forceMount` to keep it mounted so CSS can transition it. While collapsed it
295
+ then carries `aria-hidden="true"`, so the announced row count stays truthful
296
+ either way.
297
+
298
+ ### Styling hooks
299
+
300
+ `data-state="open" | "closed"` on both the trigger and the detail row.
301
+
302
+ Note that the `display: grid` 0fr↔1fr open/close technique used by Accordion and
303
+ Collapsible **cannot** go on the `<tr>` or the `<td>` — `display: grid` on either
304
+ takes it out of the table layout algorithm and column alignment collapses with
305
+ it. Put the animated wrapper inside the cell, and zero the cell's block padding
306
+ so a closed row leaves no padded band. The registry `data-table` component does
307
+ this for you.