@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
@@ -0,0 +1,196 @@
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 { Combobox } from "../Combobox";
6
+
7
+ function renderCombobox() {
8
+ return render(
9
+ <Combobox.Root defaultOpen>
10
+ <Combobox.Input aria-label="Framework" />
11
+ <Combobox.Content aria-label="Frameworks">
12
+ <Combobox.Item value="react">React</Combobox.Item>
13
+ <Combobox.Item value="preact">Preact</Combobox.Item>
14
+ </Combobox.Content>
15
+ </Combobox.Root>,
16
+ );
17
+ }
18
+
19
+ /*
20
+ * Cursor scrolling. The registry panel caps at 18rem and scrolls past it, so a
21
+ * cursor that moves without bringing itself into view walks straight off the
22
+ * bottom of a filtered list — APG requires the referenced option to be made
23
+ * visible. `block: "nearest"` specifically: it is the option that does nothing
24
+ * when the item is already on screen, so ordinary arrowing does not jerk the list
25
+ * about. Same contract, and the same spy technique, as Listbox.
26
+ */
27
+ describe("Combobox cursor scrolling", () => {
28
+ it("scrolls the seeded item into view when ArrowDown opens the cursor", async () => {
29
+ const user = userEvent.setup();
30
+ renderCombobox();
31
+
32
+ const react = screen.getByRole("option", { hidden: true, name: "React" });
33
+ const scrollIntoView = vi.spyOn(react, "scrollIntoView");
34
+
35
+ await user.click(screen.getByRole("combobox", { name: "Framework" }));
36
+ await user.keyboard("{ArrowDown}");
37
+
38
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" });
39
+ });
40
+
41
+ it("scrolls each item into view as the cursor moves onto it", async () => {
42
+ const user = userEvent.setup();
43
+ renderCombobox();
44
+
45
+ const preact = screen.getByRole("option", { hidden: true, name: "Preact" });
46
+ const scrollIntoView = vi.spyOn(preact, "scrollIntoView");
47
+
48
+ await user.click(screen.getByRole("combobox", { name: "Framework" }));
49
+ await user.keyboard("{ArrowDown}{ArrowDown}");
50
+
51
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" });
52
+ });
53
+
54
+ it("scrolls the last item into view on End", async () => {
55
+ const user = userEvent.setup();
56
+ renderCombobox();
57
+
58
+ const preact = screen.getByRole("option", { hidden: true, name: "Preact" });
59
+ const scrollIntoView = vi.spyOn(preact, "scrollIntoView");
60
+
61
+ await user.click(screen.getByRole("combobox", { name: "Framework" }));
62
+ // Seed the cursor first: Home/End move an existing cursor rather than
63
+ // creating one.
64
+ await user.keyboard("{ArrowDown}{End}");
65
+
66
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" });
67
+ });
68
+
69
+ it("scrolls the seeded item into view when ArrowUp opens the cursor at the end", async () => {
70
+ const user = userEvent.setup();
71
+ renderCombobox();
72
+
73
+ const preact = screen.getByRole("option", { hidden: true, name: "Preact" });
74
+ const scrollIntoView = vi.spyOn(preact, "scrollIntoView");
75
+
76
+ await user.click(screen.getByRole("combobox", { name: "Framework" }));
77
+ await user.keyboard("{ArrowUp}");
78
+
79
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" });
80
+ });
81
+ });
82
+
83
+ describe("Combobox keyboard interaction", () => {
84
+ it("closes the popup on Escape while leaving focus in the input", async () => {
85
+ const user = userEvent.setup();
86
+ renderCombobox();
87
+
88
+ const input = screen.getByRole("combobox", { name: "Framework" });
89
+ await user.click(input);
90
+ expect(screen.getByRole("listbox", { hidden: true })).toBeInTheDocument();
91
+
92
+ await user.keyboard("{Escape}");
93
+
94
+ expect(screen.queryByRole("listbox", { hidden: true })).not.toBeInTheDocument();
95
+ expect(input).toHaveFocus();
96
+ });
97
+
98
+ it("moves the cursor with the arrow keys and publishes it as aria-activedescendant", async () => {
99
+ const user = userEvent.setup();
100
+ renderCombobox();
101
+
102
+ const input = screen.getByRole("combobox", { name: "Framework" });
103
+ await user.click(input);
104
+
105
+ // no cursor until the user asks for one
106
+ expect(input).not.toHaveAttribute("aria-activedescendant");
107
+
108
+ await user.keyboard("{ArrowDown}");
109
+ const react = screen.getByRole("option", { hidden: true, name: "React" });
110
+ expect(input).toHaveAttribute("aria-activedescendant", react.id);
111
+ expect(react).toHaveAttribute("data-highlighted", "");
112
+
113
+ await user.keyboard("{ArrowDown}");
114
+ const preact = screen.getByRole("option", { hidden: true, name: "Preact" });
115
+ expect(input).toHaveAttribute("aria-activedescendant", preact.id);
116
+ expect(react).not.toHaveAttribute("data-highlighted");
117
+
118
+ await user.keyboard("{ArrowUp}");
119
+ expect(input).toHaveAttribute("aria-activedescendant", react.id);
120
+
121
+ // virtual focus: the cursor moves without an option ever taking DOM focus
122
+ expect(input).toHaveFocus();
123
+ expect(react).not.toHaveFocus();
124
+ });
125
+
126
+ it("seeds the cursor on the last item when ArrowUp opens the cursor", async () => {
127
+ const user = userEvent.setup();
128
+ renderCombobox();
129
+
130
+ const input = screen.getByRole("combobox", { name: "Framework" });
131
+ await user.click(input);
132
+ await user.keyboard("{ArrowUp}");
133
+
134
+ expect(input).toHaveAttribute(
135
+ "aria-activedescendant",
136
+ screen.getByRole("option", { hidden: true, name: "Preact" }).id,
137
+ );
138
+ });
139
+
140
+ it("commits the cursor item on Enter", async () => {
141
+ const user = userEvent.setup();
142
+ const onValueChange = vi.fn();
143
+
144
+ render(
145
+ <Combobox.Root defaultOpen onValueChange={onValueChange}>
146
+ <Combobox.Input aria-label="Framework" />
147
+ <Combobox.Content aria-label="Frameworks">
148
+ <Combobox.Item value="react">React</Combobox.Item>
149
+ <Combobox.Item value="preact">Preact</Combobox.Item>
150
+ </Combobox.Content>
151
+ </Combobox.Root>,
152
+ );
153
+
154
+ const input = screen.getByRole("combobox", { name: "Framework" });
155
+ await user.click(input);
156
+ await user.keyboard("{ArrowDown}{ArrowDown}");
157
+ await user.keyboard("{Enter}");
158
+
159
+ expect(onValueChange).toHaveBeenCalledWith("preact");
160
+ expect(screen.queryByRole("listbox", { hidden: true })).not.toBeInTheDocument();
161
+ expect(input).toHaveValue("Preact");
162
+ });
163
+
164
+ it("ignores Enter while there is no cursor", async () => {
165
+ const user = userEvent.setup();
166
+ const onValueChange = vi.fn();
167
+
168
+ render(
169
+ <Combobox.Root defaultOpen onValueChange={onValueChange}>
170
+ <Combobox.Input aria-label="Framework" />
171
+ <Combobox.Content aria-label="Frameworks">
172
+ <Combobox.Item value="react">React</Combobox.Item>
173
+ </Combobox.Content>
174
+ </Combobox.Root>,
175
+ );
176
+
177
+ await user.click(screen.getByRole("combobox", { name: "Framework" }));
178
+ await user.keyboard("{Enter}");
179
+
180
+ expect(onValueChange).not.toHaveBeenCalled();
181
+ expect(screen.getByRole("listbox", { hidden: true })).toBeInTheDocument();
182
+ });
183
+
184
+ it("keeps the committed value when Escape closes the popup", async () => {
185
+ const user = userEvent.setup();
186
+ renderCombobox();
187
+
188
+ const input = screen.getByRole("combobox", { name: "Framework" });
189
+ await user.click(screen.getByRole("option", { hidden: true, name: "React" }));
190
+ await user.type(input, "zzz");
191
+ await user.keyboard("{Escape}");
192
+
193
+ // Escape abandons the query, so the text returns to the committed value
194
+ expect(input).toHaveValue("React");
195
+ });
196
+ });
@@ -0,0 +1,101 @@
1
+ import { fireEvent, 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 { Combobox } from "../Combobox";
6
+
7
+ /**
8
+ * Light dismiss — clicking outside an open combobox closes it.
9
+ *
10
+ * The popup is a `popover="auto"` element, so the browser does the outside-click
11
+ * detection itself and reports it back as a `toggle` event; there is deliberately
12
+ * no hand-rolled pointerdown-outside listener. That is the same pattern
13
+ * `useSelectContent` uses, and these tests drive it the same way Select's do: by
14
+ * dispatching the `toggle` the browser would have sent, since jsdom implements
15
+ * neither the top layer nor real light-dismiss.
16
+ */
17
+ function renderCombobox(onOpenChange?: (open: boolean) => void) {
18
+ return render(
19
+ <Combobox.Root defaultOpen onOpenChange={onOpenChange}>
20
+ <Combobox.Input aria-label="Framework" />
21
+ <Combobox.Content aria-label="Frameworks">
22
+ <Combobox.Item value="react">React</Combobox.Item>
23
+ <Combobox.Item value="preact">Preact</Combobox.Item>
24
+ </Combobox.Content>
25
+ </Combobox.Root>,
26
+ );
27
+ }
28
+
29
+ const listbox = () => screen.getByRole("listbox", { hidden: true });
30
+
31
+ const dismiss = (element: HTMLElement) => {
32
+ const event = new Event("toggle");
33
+ Object.defineProperty(event, "newState", { value: "closed" });
34
+ fireEvent(element, event);
35
+ };
36
+
37
+ describe("Combobox light dismiss", () => {
38
+ it("closes when the popup reports a browser light-dismiss", () => {
39
+ renderCombobox();
40
+ const input = screen.getByRole("combobox", { name: "Framework" });
41
+ expect(input).toHaveAttribute("aria-expanded", "true");
42
+
43
+ dismiss(listbox());
44
+
45
+ expect(input).toHaveAttribute("aria-expanded", "false");
46
+ expect(screen.queryByRole("listbox", { hidden: true })).not.toBeInTheDocument();
47
+ });
48
+
49
+ it("ignores a toggle to open, so an opening popup does not thrash state", () => {
50
+ const onOpenChange = vi.fn();
51
+ renderCombobox(onOpenChange);
52
+ const input = screen.getByRole("combobox", { name: "Framework" });
53
+
54
+ const event = new Event("toggle");
55
+ Object.defineProperty(event, "newState", { value: "open" });
56
+ fireEvent(listbox(), event);
57
+
58
+ expect(input).toHaveAttribute("aria-expanded", "true");
59
+ expect(onOpenChange).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it("still dismisses when the consumer attaches its own ref to the popup", () => {
63
+ // The popup needs an internal ref to call showPopover() on. A consumer ref
64
+ // must compose with it rather than replace it, or the whole popover
65
+ // machinery — top layer and light dismiss alike — silently stops working.
66
+ const consumerRef = { current: null as HTMLDivElement | null };
67
+ render(
68
+ <Combobox.Root defaultOpen>
69
+ <Combobox.Input aria-label="Framework" />
70
+ <Combobox.Content aria-label="Frameworks" ref={consumerRef}>
71
+ <Combobox.Item value="react">React</Combobox.Item>
72
+ </Combobox.Content>
73
+ </Combobox.Root>,
74
+ );
75
+
76
+ expect(consumerRef.current).not.toBeNull();
77
+
78
+ dismiss(listbox());
79
+
80
+ expect(screen.getByRole("combobox", { name: "Framework" })).toHaveAttribute(
81
+ "aria-expanded",
82
+ "false",
83
+ );
84
+ });
85
+
86
+ it("restores the committed label, so a dismissed query never lingers", async () => {
87
+ const user = userEvent.setup();
88
+ renderCombobox();
89
+ const input = screen.getByRole("combobox", { name: "Framework" });
90
+
91
+ await user.click(screen.getByRole("option", { hidden: true, name: "React" }));
92
+ await user.type(input, "zzz");
93
+ expect(input).toHaveValue("Reactzzz");
94
+
95
+ dismiss(listbox());
96
+
97
+ // Same contract as Escape: dismissing abandons the query rather than leaving
98
+ // the field showing text that is not the value.
99
+ expect(input).toHaveValue("React");
100
+ });
101
+ });
@@ -0,0 +1,105 @@
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 { Combobox } from "../Combobox";
6
+
7
+ describe("Combobox query", () => {
8
+ it("opens the popup and shows the typed text as the user types", async () => {
9
+ const user = userEvent.setup();
10
+
11
+ render(
12
+ <Combobox.Root>
13
+ <Combobox.Input aria-label="Framework" />
14
+ <Combobox.Content aria-label="Frameworks" />
15
+ </Combobox.Root>,
16
+ );
17
+
18
+ const input = screen.getByRole("combobox", { name: "Framework" });
19
+ expect(screen.queryByRole("listbox", { hidden: true })).not.toBeInTheDocument();
20
+
21
+ await user.type(input, "Rea");
22
+
23
+ expect(input).toHaveValue("Rea");
24
+ expect(input).toHaveAttribute("aria-expanded", "true");
25
+ expect(screen.getByRole("listbox", { hidden: true })).toBeInTheDocument();
26
+ });
27
+
28
+ it("reports the query to onQueryChange on every keystroke", async () => {
29
+ const user = userEvent.setup();
30
+ const onQueryChange = vi.fn();
31
+
32
+ render(
33
+ <Combobox.Root onQueryChange={onQueryChange}>
34
+ <Combobox.Input aria-label="Framework" />
35
+ <Combobox.Content aria-label="Frameworks" />
36
+ </Combobox.Root>,
37
+ );
38
+
39
+ await user.type(screen.getByRole("combobox", { name: "Framework" }), "Re");
40
+
41
+ expect(onQueryChange.mock.calls.map(([q]) => q)).toEqual(["R", "Re"]);
42
+ });
43
+
44
+ /*
45
+ * Emptying the field is a deselect.
46
+ *
47
+ * The exploration (§D1) settled that closing resets the text FROM the value, so
48
+ * the field can never sit showing something that is not the value. Taken alone
49
+ * that made a cleared field un-clearable: Escape put the old label straight
50
+ * back. Clearing the value too keeps §D1's invariant intact — empty text, empty
51
+ * value — while letting the gesture mean what the user intended.
52
+ */
53
+ it("clears the committed value when the query is emptied", async () => {
54
+ const user = userEvent.setup();
55
+ const onValueChange = vi.fn();
56
+
57
+ render(
58
+ <Combobox.Root defaultOpen onValueChange={onValueChange}>
59
+ <Combobox.Input aria-label="Framework" />
60
+ <Combobox.Content aria-label="Frameworks">
61
+ <Combobox.Item value="react">React</Combobox.Item>
62
+ </Combobox.Content>
63
+ </Combobox.Root>,
64
+ );
65
+
66
+ const input = screen.getByRole("combobox", { name: "Framework" });
67
+ await user.click(screen.getByRole("option", { hidden: true, name: "React" }));
68
+ expect(input).toHaveValue("React");
69
+ onValueChange.mockClear();
70
+
71
+ await user.clear(input);
72
+
73
+ expect(onValueChange).toHaveBeenCalledWith("");
74
+ expect(screen.getByRole("option", { hidden: true, name: "React" })).toHaveAttribute(
75
+ "aria-selected",
76
+ "false",
77
+ );
78
+
79
+ // The point of the change: Escape must not resurrect the label just deleted.
80
+ await user.keyboard("{Escape}");
81
+ expect(input).toHaveValue("");
82
+ });
83
+
84
+ it("does not report a value change when an already-empty field is cleared", async () => {
85
+ const user = userEvent.setup();
86
+ const onValueChange = vi.fn();
87
+
88
+ render(
89
+ <Combobox.Root onValueChange={onValueChange}>
90
+ <Combobox.Input aria-label="Framework" />
91
+ <Combobox.Content aria-label="Frameworks">
92
+ <Combobox.Item value="react">React</Combobox.Item>
93
+ </Combobox.Content>
94
+ </Combobox.Root>,
95
+ );
96
+
97
+ const input = screen.getByRole("combobox", { name: "Framework" });
98
+ // Typing and deleting back to empty never committed anything, so there is no
99
+ // selection to clear and no reason to tell the consumer otherwise.
100
+ await user.type(input, "Re");
101
+ await user.clear(input);
102
+
103
+ expect(onValueChange).not.toHaveBeenCalled();
104
+ });
105
+ });
@@ -0,0 +1 @@
1
+ export * from "./useComboboxRoot";
@@ -0,0 +1,2 @@
1
+ /* Combobox root state hook — scaffold only (RED). */
2
+ export {};
@@ -0,0 +1,3 @@
1
+ export * from "./Combobox";
2
+ export * from "./ComboboxContext";
3
+ export * from "./types";
@@ -0,0 +1,133 @@
1
+ import type { ComponentProps, KeyboardEvent } from "react";
2
+
3
+ /** What a mounted `Combobox.Item` registers with the root. */
4
+ export interface ComboboxItemMeta {
5
+ /**
6
+ * The rendered element, used to scroll the cursor into view. Non-nullable:
7
+ * registration happens in the item's own effect, so the ref has always been
8
+ * attached by then — an item is never in the registry without its element.
9
+ */
10
+ element: HTMLElement;
11
+ /** The label to show in the input once this item is committed. */
12
+ label: string;
13
+ }
14
+
15
+ /** The value carried from `Combobox.Root` to its parts. */
16
+ export interface ComboboxContextValue {
17
+ /** Whether the popup listbox is open. */
18
+ open: boolean;
19
+ /** DOM id of the popup listbox, wired to the input's `aria-controls`. */
20
+ listboxId: string;
21
+ /** The text currently in the input — the live query while open. */
22
+ query: string;
23
+ /** Sets the query, opening the popup, as the user types. */
24
+ setQuery: (query: string) => void;
25
+ /** The selected value, or `""` when nothing is selected. */
26
+ value: string;
27
+ /** Commits a selection: sets the value, closes, and resets the input text. */
28
+ select: (value: string, label: string) => void;
29
+ /** The input's whole keydown behaviour: Escape, cursor seeding, arrow keys. */
30
+ handleInputKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
31
+ /**
32
+ * Closes the popup, clears the cursor and resets the input text from the
33
+ * committed value. Shared by Escape and by the browser's own light-dismiss, so
34
+ * clicking away and pressing Escape leave the field in the same state.
35
+ */
36
+ dismiss: () => void;
37
+ /** The value the virtual-focus cursor sits on, or `null` for no cursor. */
38
+ activeValue: string | null;
39
+ /** Builds the DOM id for an item, so the input can point at it. */
40
+ getItemId: (value: string) => string;
41
+ /**
42
+ * Registers a mounted item so the arrow keys know the order and Enter can
43
+ * resolve the same label a click would.
44
+ */
45
+ registerItem: (value: string, meta: ComboboxItemMeta | null) => void;
46
+ }
47
+
48
+ /** Props shared by every asChild-capable part. */
49
+ export interface ComboboxSlottableProps {
50
+ /**
51
+ * Render the consumer's own element instead of the default one, merging
52
+ * behaviour onto it.
53
+ *
54
+ * @default false
55
+ */
56
+ asChild?: boolean;
57
+ }
58
+
59
+ /** Props for `Combobox.Root` — the wrapper that owns the combobox's state. */
60
+ export interface ComboboxRootProps extends ComponentProps<"div">, ComboboxSlottableProps {
61
+ /**
62
+ * Whether the popup is open on first render, for the uncontrolled case.
63
+ *
64
+ * @default false
65
+ */
66
+ defaultOpen?: boolean;
67
+ /**
68
+ * Whether the popup is open, for the controlled case. Pair with
69
+ * {@link ComboboxRootProps.onOpenChange | `onOpenChange`} or it can never
70
+ * change.
71
+ *
72
+ * @default undefined
73
+ */
74
+ open?: boolean;
75
+ /**
76
+ * Called when the combobox wants the popup opened or closed. Under a
77
+ * controlled {@link ComboboxRootProps.open | `open`} the combobox only asks —
78
+ * the parent decides.
79
+ *
80
+ * @default undefined
81
+ */
82
+ onOpenChange?: (open: boolean) => void;
83
+ /**
84
+ * The selected value on first render, for the uncontrolled case.
85
+ *
86
+ * @default ""
87
+ */
88
+ defaultValue?: string;
89
+ /**
90
+ * The selected value, for the controlled case. Pair with
91
+ * {@link ComboboxRootProps.onValueChange | `onValueChange`}.
92
+ *
93
+ * @default undefined
94
+ */
95
+ value?: string;
96
+ /**
97
+ * Called with the input's text on every keystroke. Filtering is
98
+ * **consumer-owned** — narrow the options you render in response. The
99
+ * component deliberately ships no `filter` prop.
100
+ *
101
+ * @default undefined
102
+ */
103
+ onQueryChange?: (query: string) => void;
104
+ /**
105
+ * Called with the newly selected value when the user commits a choice,
106
+ * whether by click or by Enter.
107
+ *
108
+ * @default undefined
109
+ */
110
+ onValueChange?: (value: string) => void;
111
+ }
112
+
113
+ /** Props for `Combobox.Input` — the editable text field. */
114
+ export interface ComboboxInputProps extends ComponentProps<"input">, ComboboxSlottableProps {}
115
+
116
+ /** Props for `Combobox.Content` — the popup listbox. */
117
+ export interface ComboboxContentProps extends ComponentProps<"div">, ComboboxSlottableProps {}
118
+
119
+ /** Props for `Combobox.Item` — one selectable option in the popup. */
120
+ export interface ComboboxItemProps
121
+ extends Omit<ComponentProps<"div">, "value">,
122
+ ComboboxSlottableProps {
123
+ /**
124
+ * The value committed when this item is chosen. This narrows the native
125
+ * `value` attribute, so `value` is `Omit`-ted from the base props above —
126
+ * without that the two resolve to an intersection artifact that leaks into
127
+ * consumer types and the generated prop tables.
128
+ */
129
+ value: string;
130
+ }
131
+
132
+ /** Props for `Combobox.Empty` — the no-results message inside the popup. */
133
+ export interface ComboboxEmptyProps extends ComponentProps<"div">, ComboboxSlottableProps {}
@@ -88,7 +88,7 @@ import { MENUITEM_SELECTOR, TYPEAHEAD_RESET_MS } from "./constants";
88
88
  * const [open, setOpen] = useState(false);
89
89
  *
90
90
  * <ContextMenu.Root open={open} onOpenChange={setOpen}>
91
- * …
91
+ * ...
92
92
  * </ContextMenu.Root>
93
93
  * ```
94
94
  */
@@ -182,7 +182,7 @@ function useCloseSiblingSub() {
182
182
  * @example asChild — attach the gesture to any element
183
183
  * ```tsx
184
184
  * <ContextMenu.Trigger asChild>
185
- * <ImageCard src="…" alt="…" />
185
+ * <ImageCard src="..." alt="..." />
186
186
  * </ContextMenu.Trigger>
187
187
  * ```
188
188
  */
@@ -1050,7 +1050,7 @@ ContextMenuSub.displayName = "ContextMenuSub";
1050
1050
  * ```tsx
1051
1051
  * <ContextMenu.Sub>
1052
1052
  * <ContextMenu.SubTrigger>More tools</ContextMenu.SubTrigger>
1053
- * <ContextMenu.SubContent>…</ContextMenu.SubContent>
1053
+ * <ContextMenu.SubContent>...</ContextMenu.SubContent>
1054
1054
  * </ContextMenu.Sub>
1055
1055
  * ```
1056
1056
  */
@@ -60,7 +60,7 @@ canvas, a list row, an image, etc.).
60
60
 
61
61
  ```tsx
62
62
  <ContextMenu.Trigger asChild>
63
- <ImageCard src="…" alt="…" />
63
+ <ImageCard src="..." alt="..." />
64
64
  </ContextMenu.Trigger>
65
65
  ```
66
66
 
@@ -21,9 +21,9 @@ import { DirectionProviderProps } from "./types";
21
21
  * ```tsx
22
22
  * <DirectionProvider dir="rtl">
23
23
  * {/* inherits rtl *\/}
24
- * <Tabs.Root defaultValue="a">…</Tabs.Root>
24
+ * <Tabs.Root defaultValue="a">...</Tabs.Root>
25
25
  * {/* explicit ltr overrides the provider *\/}
26
- * <Tabs.Root dir="ltr" defaultValue="a">…</Tabs.Root>
26
+ * <Tabs.Root dir="ltr" defaultValue="a">...</Tabs.Root>
27
27
  * </DirectionProvider>
28
28
  * ```
29
29
  *
@@ -39,7 +39,7 @@ import { DirectionProviderProps } from "./types";
39
39
  * import { DirectionProvider } from "@primitiv-ui/react";
40
40
  *
41
41
  * <DirectionProvider dir="rtl">
42
- * <Tabs.Root defaultValue="overview">…</Tabs.Root>
42
+ * <Tabs.Root defaultValue="overview">...</Tabs.Root>
43
43
  * <Slider.Root defaultValue={[50]} aria-label="Volume" />
44
44
  * </DirectionProvider>
45
45
  * ```
@@ -47,9 +47,9 @@ import { DirectionProviderProps } from "./types";
47
47
  * @example Nested providers — inner overrides outer for its subtree
48
48
  * ```tsx
49
49
  * <DirectionProvider dir="rtl">
50
- * <Tabs.Root defaultValue="a">…</Tabs.Root>
50
+ * <Tabs.Root defaultValue="a">...</Tabs.Root>
51
51
  * <DirectionProvider dir="ltr">
52
- * <Tabs.Root defaultValue="b">…</Tabs.Root>
52
+ * <Tabs.Root defaultValue="b">...</Tabs.Root>
53
53
  * </DirectionProvider>
54
54
  * </DirectionProvider>
55
55
  * ```
@@ -12,7 +12,7 @@ Renders **no DOM** — only a React context provider.
12
12
  import { DirectionProvider } from "@primitiv-ui/react";
13
13
 
14
14
  <DirectionProvider dir="rtl">
15
- <Tabs.Root defaultValue="overview">…</Tabs.Root>
15
+ <Tabs.Root defaultValue="overview">...</Tabs.Root>
16
16
  <Slider.Root defaultValue={[50]} aria-label="Volume" />
17
17
  </DirectionProvider>;
18
18
  ```
@@ -23,9 +23,9 @@ components fall back to the provider only when `dir` is omitted:
23
23
  ```tsx
24
24
  <DirectionProvider dir="rtl">
25
25
  {/* inherits rtl */}
26
- <Tabs.Root defaultValue="a">…</Tabs.Root>
26
+ <Tabs.Root defaultValue="a">...</Tabs.Root>
27
27
  {/* explicit ltr overrides the provider */}
28
- <Tabs.Root dir="ltr" defaultValue="a">…</Tabs.Root>
28
+ <Tabs.Root dir="ltr" defaultValue="a">...</Tabs.Root>
29
29
  </DirectionProvider>;
30
30
  ```
31
31
 
@@ -48,7 +48,7 @@ import { useDirection } from "@primitiv-ui/react";
48
48
 
49
49
  function MyControl({ dir }: { dir?: "ltr" | "rtl" }) {
50
50
  const resolvedDir = dir ?? useDirection();
51
- // …
51
+ // ...
52
52
  }
53
53
  ```
54
54
 
@@ -12,7 +12,7 @@ import { Divider } from "../Divider";
12
12
  * and asserted against the rendered headless component* so it can never drift
13
13
  * from what the component actually emits. Divider has **no** data-* surface — it
14
14
  * styles off `aria-orientation` — so the guard also pins that hook: the registry
15
- * stylesheet's `[aria-orientation="…"]` selectors depend on it.
15
+ * stylesheet's `[aria-orientation="..."]` selectors depend on it.
16
16
  */
17
17
  const contractPath = resolve(
18
18
  dirname(fileURLToPath(import.meta.url)),