@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
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ButtonHTMLAttributes,
2
3
  HTMLAttributes,
3
4
  ReactNode,
4
5
  TableHTMLAttributes,
@@ -88,6 +89,130 @@ export type TableScrollAreaProps = {
88
89
  style?: HTMLAttributes<HTMLDivElement>["style"];
89
90
  } & Omit<HTMLAttributes<HTMLDivElement>, "style">;
90
91
 
92
+ /**
93
+ * The value shared by {@link TableExpandable | `Table.Expandable`} with its
94
+ * {@link TableExpandTrigger | `Table.ExpandTrigger`} and
95
+ * {@link TableDetailRow | `Table.DetailRow`} children.
96
+ *
97
+ * @internal
98
+ */
99
+ export type TableExpandableContextValue = {
100
+ /** Whether the detail row is currently revealed. */
101
+ expanded: boolean;
102
+ /** The id wired to the detail row's `<tr>` and the trigger's `aria-controls`. */
103
+ detailId: string;
104
+ /** Flips `expanded` and notifies `onExpandedChange`. */
105
+ toggle: () => void;
106
+ };
107
+
108
+ /**
109
+ * Props for {@link TableExpandable | `Table.Expandable`} — the pairing
110
+ * provider for one expandable row and its detail row.
111
+ */
112
+ export type TableExpandableProps = {
113
+ /** The parent {@link TableRow | `Table.Row`} and its
114
+ * {@link TableDetailRow | `Table.DetailRow`}, as siblings. */
115
+ children: ReactNode;
116
+ /**
117
+ * Whether the detail row is revealed. Pass this (with
118
+ * `onExpandedChange`) to drive expansion from outside — which is what an
119
+ * external table engine such as TanStack Table does.
120
+ */
121
+ expanded?: boolean;
122
+ /**
123
+ * Initial expanded state when running uncontrolled. Ignored when
124
+ * `expanded` is supplied.
125
+ * @default false
126
+ */
127
+ defaultExpanded?: boolean;
128
+ /** Called with the next expanded state whenever the trigger is activated. */
129
+ onExpandedChange?: (expanded: boolean) => void;
130
+ };
131
+
132
+ /**
133
+ * Props for {@link TableExpandTrigger | `Table.ExpandTrigger`} — all
134
+ * `ButtonHTMLAttributes` on the native `<button>`.
135
+ *
136
+ * `aria-expanded`, `aria-controls` and `type` are owned by the component and
137
+ * therefore `Omit`-ted: they are derived from the surrounding
138
+ * {@link TableExpandable | `Table.Expandable`} and must not be overridden, or
139
+ * the relationship the pair exists to express would break.
140
+ *
141
+ * @extends HTMLButtonElement
142
+ */
143
+ export type TableExpandTriggerProps = {
144
+ /**
145
+ * Render a consumer-supplied control instead of the native `<button>`, with
146
+ * this part's `aria-expanded`, `aria-controls`, `data-state` and click
147
+ * handling merged onto it via the {@link Slot} pattern.
148
+ *
149
+ * This is how a styled layer composes a real Button rather than re-creating
150
+ * one: the registry `data-table` renders a ghost Button through it, matching
151
+ * the Figma set, whose Expand Trigger is an Icon Button instance rather than a
152
+ * drawn glyph. The child must be a single element that accepts a `ref`, and it
153
+ * should be a `<button>` — the wiring assumes a control.
154
+ * @default false
155
+ */
156
+ asChild?: boolean;
157
+ } & Omit<
158
+ ButtonHTMLAttributes<HTMLButtonElement>,
159
+ "aria-expanded" | "aria-controls" | "type"
160
+ >;
161
+
162
+ /**
163
+ * Props for {@link TableDetailRow | `Table.DetailRow`} — a required `colSpan`
164
+ * plus all `HTMLAttributes` on the native `<tr>`.
165
+ *
166
+ * `id` is component-owned (it is the target of the trigger's `aria-controls`)
167
+ * and so is `Omit`-ted.
168
+ *
169
+ * @extends HTMLTableRowElement
170
+ */
171
+ export type TableDetailRowProps = {
172
+ /** The detail panel's content. */
173
+ children?: ReactNode;
174
+ /**
175
+ * How many columns the panel spans — **every** column the table renders,
176
+ * control columns included.
177
+ *
178
+ * Deliberately a required prop rather than a value counted from context:
179
+ * counting would mean the table registering its own columns, and an
180
+ * external table engine already knows the number (TanStack Table:
181
+ * `table.getVisibleLeafColumns().length`).
182
+ */
183
+ colSpan: number;
184
+ /**
185
+ * How many leading columns to leave empty before the panel starts — the
186
+ * control columns (select, expand) a row renders in front of its data.
187
+ *
188
+ * The panel then spans the columns that remain, so `colSpan` keeps its
189
+ * meaning: the table's full column count, gutter included.
190
+ *
191
+ * This is how a panel aligns with the first **data** column instead of the
192
+ * table's edge (a panel starting under the checkbox reads as a new section of
193
+ * the table rather than an opened row). Alignment by empty cell rather than by
194
+ * a CSS indent is deliberate: the browser's own table layout resolves the
195
+ * width, so it stays exact across every size, density and control-column set
196
+ * with nothing measured and no arithmetic to drift.
197
+ * @default 0
198
+ */
199
+ gutter?: number;
200
+ /**
201
+ * Keep the row mounted while collapsed so a CSS transition can run.
202
+ *
203
+ * Off by default, and that default matters more here than it does on a
204
+ * `<div>`-based disclosure: a mounted row is still a **row**, so assistive
205
+ * technology counts it, and a table of 24 rows with 24 force-mounted
206
+ * detail rows announces 48. Opt in only when you are animating, and note
207
+ * that the collapsed row remains in the accessibility tree while you do.
208
+ * @default false
209
+ */
210
+ forceMount?: boolean;
211
+ /** Passed through to the `<td>` that spans the row, for styling the cell
212
+ * itself rather than the row. */
213
+ cellProps?: TdHTMLAttributes<HTMLTableCellElement>;
214
+ } & Omit<HTMLAttributes<HTMLTableRowElement>, "id" | "children">;
215
+
91
216
  /**
92
217
  * Props for {@link TableCaption | `Table.Caption`} — a required `children`,
93
218
  * an optional `captionSide`, plus all `HTMLAttributes` on the native
@@ -11,8 +11,8 @@ import { Tabs } from "@primitiv-ui/react";
11
11
  <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
12
12
  <Tabs.Trigger value="settings">Settings</Tabs.Trigger>
13
13
  </Tabs.List>
14
- <Tabs.Content value="overview">Dashboard…</Tabs.Content>
15
- <Tabs.Content value="settings">Preferences…</Tabs.Content>
14
+ <Tabs.Content value="overview">Dashboard...</Tabs.Content>
15
+ <Tabs.Content value="settings">Preferences...</Tabs.Content>
16
16
  </Tabs.Root>;
17
17
  ```
18
18
 
@@ -63,7 +63,7 @@ trigger and panel is always present.
63
63
 
64
64
  ```tsx
65
65
  <Tabs.Root defaultValue="overview" lazyMount>
66
- …
66
+ ...
67
67
  </Tabs.Root>
68
68
  ```
69
69
 
@@ -76,7 +76,7 @@ position must be computed while the panel has real dimensions.
76
76
  ```tsx
77
77
  const ref = useRef<TabsImperativeApi>(null);
78
78
  <Tabs.Root ref={ref} defaultValue="a">
79
- …
79
+ ...
80
80
  </Tabs.Root>;
81
81
  ref.current?.setActiveTab("b");
82
82
  ```
package/src/Tabs/Tabs.tsx CHANGED
@@ -75,8 +75,8 @@ import type {
75
75
  * <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
76
76
  * <Tabs.Trigger value="settings">Settings</Tabs.Trigger>
77
77
  * </Tabs.List>
78
- * <Tabs.Content value="overview">Dashboard…</Tabs.Content>
79
- * <Tabs.Content value="settings">Preferences…</Tabs.Content>
78
+ * <Tabs.Content value="overview">Dashboard...</Tabs.Content>
79
+ * <Tabs.Content value="settings">Preferences...</Tabs.Content>
80
80
  * </Tabs.Root>
81
81
  * ```
82
82
  *
@@ -89,7 +89,7 @@ import type {
89
89
  * onValueChange={setTab}
90
90
  * onChange={({ name, index }) => track("tab_view", { name, index })}
91
91
  * >
92
- * …
92
+ * ...
93
93
  * </Tabs.Root>
94
94
  * ```
95
95
  *
@@ -98,7 +98,7 @@ import type {
98
98
  * const ref = useRef<TabsImperativeApi>(null);
99
99
  *
100
100
  * <Tabs.Root ref={ref} orientation="vertical" defaultValue="one">
101
- * …
101
+ * ...
102
102
  * </Tabs.Root>
103
103
  *
104
104
  * <button onClick={() => ref.current?.setActiveTab("two")}>Go to two</button>
@@ -148,6 +148,7 @@ export const TabsRoot: ForwardRefExoticComponent<
148
148
  );
149
149
  });
150
150
 
151
+ // Stryker disable next-line StringLiteral: overwritten by the compound alias — an equivalent mutant.
151
152
  TabsRoot.displayName = "TabsRoot";
152
153
 
153
154
  /**
@@ -28,6 +28,64 @@ describe("Activation mode tests", () => {
28
28
  expect(secondTabPanel).not.toHaveAttribute("hidden");
29
29
  });
30
30
 
31
+ it("does not activate a tab on arrow navigation in manual mode", async () => {
32
+ // Directly asserts the manual guard: arrow moves focus without activating,
33
+ // so Tab 1 stays selected and Tab 2 does not.
34
+ const user = userEvent.setup();
35
+ render(
36
+ <Tabs.Root defaultValue="tab1" activationMode="manual">
37
+ <Tabs.List label="Test tabs">
38
+ <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
39
+ <Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
40
+ </Tabs.List>
41
+ <Tabs.Content value="tab1">Content 1</Tabs.Content>
42
+ <Tabs.Content value="tab2">Content 2</Tabs.Content>
43
+ </Tabs.Root>,
44
+ );
45
+
46
+ await user.tab();
47
+ await user.keyboard("{ArrowRight}");
48
+
49
+ expect(screen.getByRole("tab", { name: "Tab 2" })).toHaveAttribute(
50
+ "aria-selected",
51
+ "false",
52
+ );
53
+ expect(screen.getByRole("tab", { name: "Tab 1" })).toHaveAttribute(
54
+ "aria-selected",
55
+ "true",
56
+ );
57
+ });
58
+
59
+ it("activates a non-native asChild tab via the roving Enter handler (manual)", async () => {
60
+ // A plain <div> fires no synthetic click on Enter, so activation can only
61
+ // come from the roving hook's includeActivate mapping — the guarantee a
62
+ // native <button> would mask.
63
+ const user = userEvent.setup();
64
+ render(
65
+ <Tabs.Root defaultValue="tab1" activationMode="manual">
66
+ <Tabs.List label="Test tabs">
67
+ <Tabs.Trigger asChild value="tab1">
68
+ <div>Tab 1</div>
69
+ </Tabs.Trigger>
70
+ <Tabs.Trigger asChild value="tab2">
71
+ <div>Tab 2</div>
72
+ </Tabs.Trigger>
73
+ </Tabs.List>
74
+ <Tabs.Content value="tab1">Content 1</Tabs.Content>
75
+ <Tabs.Content value="tab2">Content 2</Tabs.Content>
76
+ </Tabs.Root>,
77
+ );
78
+
79
+ await user.tab();
80
+ await user.keyboard("{ArrowRight}");
81
+ await user.keyboard("{Enter}");
82
+
83
+ expect(screen.getByRole("tab", { name: "Tab 2" })).toHaveAttribute(
84
+ "aria-selected",
85
+ "true",
86
+ );
87
+ });
88
+
31
89
  it("should not activate the second tab when focusing its tab in manual mode", async () => {
32
90
  // Arrange
33
91
  const user = userEvent.setup();
@@ -1,6 +1,21 @@
1
1
  import { render, screen } from "@testing-library/react";
2
2
 
3
3
  import { Tabs, TabsOrientation } from "../index.ts";
4
+ import { TabsContext } from "../TabsContext";
5
+
6
+ describe("Tabs displayNames", () => {
7
+ it("sets a displayName on the compound, each sub-component, and the context", () => {
8
+ // Assert — empty displayNames would render each as anonymous in DevTools.
9
+ // `Tabs`, `Tabs.Root`, and the underlying component are one object
10
+ // (Object.assign compound), so the compound's "Tabs" alias is the
11
+ // observable Root displayName; the sub-components are distinct objects.
12
+ expect(Tabs.displayName).toBe("Tabs");
13
+ expect(Tabs.List.displayName).toBe("TabsList");
14
+ expect(Tabs.Trigger.displayName).toBe("TabsTrigger");
15
+ expect(Tabs.Content.displayName).toBe("TabsContent");
16
+ expect(TabsContext.displayName).toBe("TabsContext");
17
+ });
18
+ });
4
19
 
5
20
  describe("Tabs basic rendering tests", () => {
6
21
  describe("Tabs.Root", () => {
@@ -110,6 +110,30 @@ describe('Tabs change event callbacks', () => {
110
110
  expect(handleChange).not.toHaveBeenCalled();
111
111
  });
112
112
 
113
+ it('does not re-fire onChange when re-clicking a tab activated after mount', async () => {
114
+ // No defaultValue: the guard reads an activeValue that changes after the
115
+ // first click, so a stale activateTab closure would re-fire on the second.
116
+ const user = userEvent.setup();
117
+ const handleChange = vi.fn();
118
+
119
+ render(
120
+ <Tabs.Root onChange={handleChange}>
121
+ <Tabs.List label="Test tabs">
122
+ <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
123
+ <Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
124
+ </Tabs.List>
125
+ <Tabs.Content value="tab1">Content 1</Tabs.Content>
126
+ <Tabs.Content value="tab2">Content 2</Tabs.Content>
127
+ </Tabs.Root>,
128
+ );
129
+
130
+ const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
131
+ await user.click(tab2);
132
+ await user.click(tab2);
133
+
134
+ expect(handleChange).toHaveBeenCalledTimes(1);
135
+ });
136
+
113
137
  it('should not call onValueChange if clicking already active tab in controlled mode', async () => {
114
138
  const user = userEvent.setup();
115
139
  const handleValueChange = vi.fn();
@@ -2,6 +2,42 @@ import { render, screen } from "@testing-library/react";
2
2
  import { Tabs } from "../Tabs";
3
3
  import userEvent from "@testing-library/user-event";
4
4
 
5
+ describe("Tabs disabled re-registration", () => {
6
+ it("re-registers a trigger's disabled flag when it changes after mount", async () => {
7
+ // Arrange — automatic mode: arrowing onto a tab activates it unless the
8
+ // trigger is registered as disabled. Tab 2 starts enabled, then disables.
9
+ const user = userEvent.setup();
10
+ function Fixture({ tab2Disabled }: { tab2Disabled: boolean }) {
11
+ return (
12
+ <Tabs.Root defaultValue="tab1">
13
+ <Tabs.List label="Test tabs">
14
+ <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
15
+ <Tabs.Trigger value="tab2" disabled={tab2Disabled}>
16
+ Tab 2
17
+ </Tabs.Trigger>
18
+ </Tabs.List>
19
+ <Tabs.Content value="tab1">Content 1</Tabs.Content>
20
+ <Tabs.Content value="tab2">Content 2</Tabs.Content>
21
+ </Tabs.Root>
22
+ );
23
+ }
24
+ const { rerender } = render(<Fixture tab2Disabled={false} />);
25
+
26
+ // Act — disable Tab 2 after mount, then arrow onto it.
27
+ rerender(<Fixture tab2Disabled />);
28
+ await user.tab();
29
+ await user.keyboard("{ArrowRight}");
30
+
31
+ // Assert — the registrar effect re-ran, so Tab 2 is focused but NOT
32
+ // activated. A stale registration would treat it as enabled and activate
33
+ // it on arrow (automatic mode).
34
+ expect(screen.getByRole("tab", { name: "Tab 2" })).toHaveAttribute(
35
+ "aria-selected",
36
+ "false",
37
+ );
38
+ });
39
+ });
40
+
5
41
  describe("Tabs disabled tabs tests", () => {
6
42
  it("should have the aria-disabled attribute set to false by default", () => {
7
43
  // Arrange
@@ -26,6 +26,23 @@ describe("Tabs lazyMount", () => {
26
26
  expect(screen.getByText("Content 3")).toBeInTheDocument();
27
27
  });
28
28
 
29
+ it("defaults lazyMount to off when the prop is omitted (eager render)", () => {
30
+ // Omitting the prop must fall back to eager mounting — a default of `true`
31
+ // would hide inactive panel children on mount.
32
+ render(
33
+ <Tabs.Root defaultValue="tab1">
34
+ <Tabs.List label="Test tabs">
35
+ <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
36
+ <Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
37
+ </Tabs.List>
38
+ <Tabs.Content value="tab1">Content 1</Tabs.Content>
39
+ <Tabs.Content value="tab2">Content 2</Tabs.Content>
40
+ </Tabs.Root>,
41
+ );
42
+
43
+ expect(screen.getByText("Content 2")).toBeInTheDocument();
44
+ });
45
+
29
46
  it("should not render inactive panel children on initial mount when lazyMount is true", () => {
30
47
  renderTabs(true);
31
48
 
@@ -14,6 +14,11 @@ export function useTabsContent({ value }: Pick<TabsContentProps, "value">) {
14
14
 
15
15
  // Track whether this panel has ever been active. Starts true for the
16
16
  // default active panel; flips once on first activation for all others.
17
+ // The mount effect below sets `activated` for the active panel within the
18
+ // same commit, so the initialiser's value is only ever observable as a
19
+ // pre-effect first-paint flash (not reproducible in jsdom); for inactive
20
+ // panels the initialiser's `false` and a mutated `undefined` are both falsy.
21
+ // Stryker disable next-line ArrowFunction: equivalent under the test env — see above.
17
22
  const [activated, setActivated] = useState(() => isActive);
18
23
  useEffect(() => {
19
24
  if (isActive) setActivated(true);
@@ -18,15 +18,23 @@ type TriggerMeta = {
18
18
 
19
19
  export function useTabsRoot(
20
20
  {
21
- orientation = "horizontal",
22
- dir = "ltr",
23
- activationMode = "automatic",
21
+ orientation,
22
+ dir,
23
+ activationMode,
24
24
  defaultValue,
25
25
  value,
26
26
  onValueChange,
27
27
  onChange,
28
- lazyMount = false,
29
- }: Omit<TabsRootProps, "className">,
28
+ lazyMount,
29
+ }: // `TabsRoot` always resolves these four before calling the hook, so they
30
+ // are required here (a default would be dead code the tests never reach).
31
+ Omit<TabsRootProps, "className"> &
32
+ Required<
33
+ Pick<
34
+ TabsRootProps,
35
+ "orientation" | "dir" | "activationMode" | "lazyMount"
36
+ >
37
+ >,
30
38
  ref: Ref<TabsImperativeApi>,
31
39
  ) {
32
40
  const tabsId = useId();
@@ -47,6 +55,10 @@ export function useTabsRoot(
47
55
  (
48
56
  triggerValue: string,
49
57
  element: HTMLButtonElement | null,
58
+ // The default is only reached by the cleanup call `registerTrigger(value,
59
+ // null)`, where `element` is null and `disabled` is discarded — so its
60
+ // value is never observable.
61
+ // Stryker disable next-line BooleanLiteral
50
62
  disabled = false,
51
63
  ) => {
52
64
  registerTriggerBase(
@@ -54,6 +66,9 @@ export function useTabsRoot(
54
66
  element ? { element, disabled } : null,
55
67
  );
56
68
  },
69
+ // `registerTriggerBase` is a stable useCollection callback, so emptying this
70
+ // array yields the identical memoised function.
71
+ // Stryker disable next-line ArrayDeclaration: equivalent — stable dependency.
57
72
  [registerTriggerBase],
58
73
  );
59
74
 
@@ -71,8 +86,14 @@ export function useTabsRoot(
71
86
 
72
87
  const focusTrigger = useCallback(
73
88
  (triggerValue: string) => {
89
+ // `triggerValue` is always sourced from the navigable set (registered
90
+ // triggers), so a value that reaches here has a live entry.
91
+ // Stryker disable next-line OptionalChaining: unreachable given that invariant.
74
92
  triggersRef.current.get(triggerValue)?.element.focus();
75
93
  },
94
+ // `triggersRef` is a stable RefObject, so emptying this array yields the
95
+ // identical memoised function.
96
+ // Stryker disable next-line ArrayDeclaration: equivalent — stable dependency.
76
97
  [triggersRef],
77
98
  );
78
99
 
@@ -100,6 +121,9 @@ export function useTabsRoot(
100
121
  }
101
122
 
102
123
  if (isControlled) {
124
+ // Controlled mode requires `onValueChange` (discriminated-union prop
125
+ // type), so the optional call can never no-op in valid usage.
126
+ // Stryker disable next-line OptionalChaining: unreachable — controlled requires onValueChange.
103
127
  onValueChange?.(newValue);
104
128
  } else {
105
129
  setActiveValue(newValue);
@@ -51,6 +51,9 @@ export function useTabsTrigger({
51
51
  (newValue: string, index: number) => {
52
52
  if (activeValue === newValue) return;
53
53
  if (isControlled) {
54
+ // Controlled mode requires `onValueChange` (discriminated-union prop
55
+ // type), so the optional call can never no-op in valid usage.
56
+ // Stryker disable next-line OptionalChaining: unreachable — controlled requires onValueChange.
54
57
  onValueChange?.(newValue);
55
58
  } else {
56
59
  setActiveValue(newValue);
@@ -87,10 +90,12 @@ export function useTabsTrigger({
87
90
  includeActivate: true,
88
91
  onNavigate: (targetValue, action) => {
89
92
  const isDisabled = disabledTriggerValues.has(targetValue);
93
+ // Automatic mode activates on any navigation; manual mode activates only
94
+ // on an explicit activation key (Enter/Space). Since the mode is always
95
+ // "automatic" or "manual", `automatic || isActivationKey` captures both.
90
96
  const shouldActivate =
91
97
  !isDisabled &&
92
- (activationMode === "automatic" ||
93
- (activationMode === "manual" && action === "activate"));
98
+ (activationMode === "automatic" || action === "activate");
94
99
  if (shouldActivate) {
95
100
  activateTab(targetValue, triggerValues.indexOf(targetValue));
96
101
  }
@@ -30,13 +30,13 @@ At most one item is pressed at a time. Pressing the active item again
30
30
  ```tsx
31
31
  // Uncontrolled
32
32
  <ToggleGroup.Root type="single" defaultValue="center" aria-label="Alignment">
33
- …
33
+ ...
34
34
  </ToggleGroup.Root>
35
35
 
36
36
  // Controlled — onValueChange receives string | undefined
37
37
  const [align, setAlign] = useState<string | undefined>("center");
38
38
  <ToggleGroup.Root type="single" value={align} onValueChange={setAlign} aria-label="Alignment">
39
- …
39
+ ...
40
40
  </ToggleGroup.Root>
41
41
  ```
42
42
 
@@ -48,13 +48,13 @@ independently.
48
48
  ```tsx
49
49
  // Uncontrolled
50
50
  <ToggleGroup.Root type="multiple" defaultValue={["bold"]} aria-label="Formatting">
51
- …
51
+ ...
52
52
  </ToggleGroup.Root>
53
53
 
54
54
  // Controlled — onValueChange receives string[]
55
55
  const [formats, setFormats] = useState<string[]>([]);
56
56
  <ToggleGroup.Root type="multiple" value={formats} onValueChange={setFormats} aria-label="Formatting">
57
- …
57
+ ...
58
58
  </ToggleGroup.Root>
59
59
  ```
60
60
 
@@ -60,12 +60,12 @@ The two shapes are statically discriminated at the type level.
60
60
 
61
61
  ```tsx
62
62
  // Uncontrolled
63
- <Tooltip.Root defaultOpen>…</Tooltip.Root>
63
+ <Tooltip.Root defaultOpen>...</Tooltip.Root>
64
64
 
65
65
  // Controlled
66
66
  const [open, setOpen] = useState(false);
67
67
  <Tooltip.Root open={open} onOpenChange={setOpen}>
68
- …
68
+ ...
69
69
  </Tooltip.Root>
70
70
  ```
71
71
 
@@ -82,7 +82,7 @@ before the normal delay resumes.
82
82
 
83
83
  ```tsx
84
84
  <Tooltip.Provider delayDuration={400} skipDelayDuration={150}>
85
- …
85
+ ...
86
86
  </Tooltip.Provider>
87
87
  ```
88
88
 
@@ -95,7 +95,7 @@ when the cursor leaves the trigger:
95
95
 
96
96
  ```tsx
97
97
  <Tooltip.Root disableHoverableContent>
98
- …
98
+ ...
99
99
  </Tooltip.Root>
100
100
  ```
101
101
 
@@ -114,7 +114,7 @@ tooltip open:
114
114
  event.preventDefault(); // keep open when clicking outside
115
115
  }}
116
116
  >
117
- …
117
+ ...
118
118
  </Tooltip.Content>
119
119
  ```
120
120
 
@@ -178,7 +178,7 @@ subtree stays in the DOM while closed so a CSS animation can play on
178
178
 
179
179
  ```tsx
180
180
  <Tooltip.Portal forceMount>
181
- <Tooltip.Content forceMount>…</Tooltip.Content>
181
+ <Tooltip.Content forceMount>...</Tooltip.Content>
182
182
  </Tooltip.Portal>
183
183
  ```
184
184
 
@@ -199,7 +199,7 @@ child element; the child's own handlers run first.
199
199
  </Tooltip.Trigger>
200
200
 
201
201
  <Tooltip.Arrow asChild>
202
- <svg viewBox="0 0 10 5">…</svg>
202
+ <svg viewBox="0 0 10 5">...</svg>
203
203
  </Tooltip.Arrow>
204
204
  ```
205
205
 
@@ -120,7 +120,7 @@ export function TooltipRoot({
120
120
  return <TooltipProvider value={contextValue}>{children}</TooltipProvider>;
121
121
  }
122
122
 
123
- /** @internal */
123
+ // Stryker disable next-line StringLiteral: overwritten by the compound alias — an equivalent mutant.
124
124
  TooltipRoot.displayName = "TooltipRoot";
125
125
 
126
126
  /**
@@ -195,7 +195,7 @@ TooltipTrigger.displayName = "TooltipTrigger";
195
195
  * @example
196
196
  * ```tsx
197
197
  * <Tooltip.Portal>
198
- * <Tooltip.Content>…</Tooltip.Content>
198
+ * <Tooltip.Content>...</Tooltip.Content>
199
199
  * </Tooltip.Portal>
200
200
  * ```
201
201
  */
@@ -1,6 +1,24 @@
1
1
  import { render, screen } from "@testing-library/react";
2
2
 
3
3
  import { Tooltip } from "../Tooltip";
4
+ import { TooltipContext, TooltipProviderContext } from "../TooltipContext";
5
+
6
+ describe("Tooltip displayNames", () => {
7
+ it("sets a displayName on the compound, each sub-component, and both contexts", () => {
8
+ // Assert — empty displayNames would render each as anonymous in DevTools.
9
+ // Tooltip.Root is the same object as the compound (Object.assign), so its
10
+ // own "TooltipRoot" assignment is overwritten and has no observable
11
+ // Root displayName; the sub-components are distinct objects.
12
+ expect(Tooltip.displayName).toBe("Tooltip");
13
+ expect(Tooltip.Provider.displayName).toBe("TooltipProvider");
14
+ expect(Tooltip.Trigger.displayName).toBe("TooltipTrigger");
15
+ expect(Tooltip.Portal.displayName).toBe("TooltipPortal");
16
+ expect(Tooltip.Content.displayName).toBe("TooltipContent");
17
+ expect(Tooltip.Arrow.displayName).toBe("TooltipArrow");
18
+ expect(TooltipProviderContext.displayName).toBe("TooltipProviderContext");
19
+ expect(TooltipContext.displayName).toBe("TooltipContext");
20
+ });
21
+ });
4
22
 
5
23
  describe("Tooltip — basic rendering", () => {
6
24
  it("renders Tooltip.Trigger as a button with type='button'", () => {
@@ -135,6 +153,38 @@ describe("Tooltip — basic rendering", () => {
135
153
  expect(screen.queryByRole("tooltip")).toBeNull();
136
154
  });
137
155
 
156
+ it("Tooltip.Portal does not render arbitrary children when closed, independent of Content's own guard", () => {
157
+ // Content has its own open guard, so wrapping it can't tell Portal's
158
+ // guard apart from Content's. Wrap a plain element instead.
159
+ render(
160
+ <Tooltip.Provider>
161
+ <Tooltip.Root>
162
+ <Tooltip.Trigger>Hover me</Tooltip.Trigger>
163
+ <Tooltip.Portal>
164
+ <div data-testid="portal-child">child</div>
165
+ </Tooltip.Portal>
166
+ </Tooltip.Root>
167
+ </Tooltip.Provider>,
168
+ );
169
+
170
+ expect(screen.queryByTestId("portal-child")).toBeNull();
171
+ });
172
+
173
+ it("Tooltip.Portal renders arbitrary children via forceMount even when closed", () => {
174
+ render(
175
+ <Tooltip.Provider>
176
+ <Tooltip.Root>
177
+ <Tooltip.Trigger>Hover me</Tooltip.Trigger>
178
+ <Tooltip.Portal forceMount>
179
+ <div data-testid="portal-child">child</div>
180
+ </Tooltip.Portal>
181
+ </Tooltip.Root>
182
+ </Tooltip.Provider>,
183
+ );
184
+
185
+ expect(screen.getByTestId("portal-child")).toBeInTheDocument();
186
+ });
187
+
138
188
  it("Tooltip.Arrow renders a span inside the content", () => {
139
189
  render(
140
190
  <Tooltip.Provider>