@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,9 +1,19 @@
1
- import type { ReactElement } from "react";
1
+ import { useId, useMemo, type MouseEvent, type ReactElement } from "react";
2
2
 
3
+ import { useControllableState } from "../hooks/index.ts";
4
+ import { Slot } from "../Slot/index.ts";
5
+ import { deriveId } from "../utils/index.ts";
6
+ import {
7
+ TableExpandableContext,
8
+ useTableExpandableContext,
9
+ } from "./TableExpandableContext";
3
10
  import {
4
11
  TableBodyProps,
5
12
  TableCaptionProps,
6
13
  TableCellProps,
14
+ TableDetailRowProps,
15
+ TableExpandableProps,
16
+ TableExpandTriggerProps,
7
17
  TableFooterProps,
8
18
  TableHeaderProps,
9
19
  TableHeadProps,
@@ -16,10 +26,16 @@ import {
16
26
  * The root of a Table widget — renders a plain `<table>` element and passes
17
27
  * all `TableHTMLAttributes` through to the DOM.
18
28
  *
19
- * Stateless: `Table` is a purely structural compound with no context, no
20
- * internal state, and no `data-*` attributes of its own (verified by
21
- * `Table.contract.test.tsx` against the registry contract) — every part
22
- * renders exactly what its native HTML element provides.
29
+ * Structural: every part below renders exactly what its native HTML element
30
+ * provides, with no context and no `data-*` attributes of its own (verified by
31
+ * `Table.contract.test.tsx` against the registry contract).
32
+ *
33
+ * The one exception is the expandable-row trio —
34
+ * {@link TableExpandable | `Table.Expandable`},
35
+ * {@link TableExpandTrigger | `Table.ExpandTrigger`} and
36
+ * {@link TableDetailRow | `Table.DetailRow`} — which share a context so the
37
+ * trigger's `aria-controls` can reach the detail row. They are opt-in: a table
38
+ * that does not use them is still entirely static.
23
39
  *
24
40
  * The `<table>` element carries an implicit `role="table"` in the accessibility
25
41
  * tree. Assistive technology will announce it as a table and report the number
@@ -56,6 +72,10 @@ export function TableRoot({ children, ...rest }: TableRootProps): ReactElement {
56
72
  }
57
73
 
58
74
  /** @internal */
75
+ // `TableRoot` and the exported `Table` compound are the same object
76
+ // (`Object.assign`), and the compound re-assigns this displayName at the bottom
77
+ // of the file — so this assignment is unobservable and any value survives.
78
+ // Stryker disable next-line StringLiteral: equivalent — overwritten by the compound.
59
79
  TableRoot.displayName = "Table";
60
80
 
61
81
  /**
@@ -254,7 +274,7 @@ TableCell.displayName = "TableCell";
254
274
  *
255
275
  * ```tsx
256
276
  * <Table.ScrollArea style={{ borderRadius: "8px" }}>
257
- * …
277
+ * ...
258
278
  * </Table.ScrollArea>
259
279
  * ```
260
280
  *
@@ -264,7 +284,7 @@ TableCell.displayName = "TableCell";
264
284
  * ```tsx
265
285
  * <Table.ScrollArea>
266
286
  * <Table.Root>
267
- * …
287
+ * ...
268
288
  * </Table.Root>
269
289
  * </Table.ScrollArea>
270
290
  * ```
@@ -315,7 +335,7 @@ TableScrollArea.displayName = "TableScrollArea";
315
335
  * ```tsx
316
336
  * <Table.Root>
317
337
  * <Table.Caption>Q1 sales figures by region</Table.Caption>
318
- * …
338
+ * ...
319
339
  * </Table.Root>
320
340
  * ```
321
341
  *
@@ -323,7 +343,7 @@ TableScrollArea.displayName = "TableScrollArea";
323
343
  * ```tsx
324
344
  * <Table.Root>
325
345
  * <Table.Caption captionSide="top">Q1 sales figures by region</Table.Caption>
326
- * …
346
+ * ...
327
347
  * </Table.Root>
328
348
  * ```
329
349
  */
@@ -342,11 +362,184 @@ export function TableCaption({
342
362
  /** @internal */
343
363
  TableCaption.displayName = "TableCaption";
344
364
 
365
+ /**
366
+ * Pairs one expandable {@link TableRow | `Table.Row`} with its
367
+ * {@link TableDetailRow | `Table.DetailRow`}, sharing the id that wires the
368
+ * trigger's `aria-controls` to the detail row.
369
+ *
370
+ * Renders **no DOM of its own** — it is a context provider, so the two `<tr>`
371
+ * elements stay direct children of the `<tbody>` and the table's structure is
372
+ * untouched.
373
+ */
374
+ export function TableExpandable({
375
+ children,
376
+ expanded: expandedProp,
377
+ defaultExpanded = false,
378
+ onExpandedChange,
379
+ }: TableExpandableProps): ReactElement {
380
+ const reactId = useId();
381
+ const [expanded, setExpanded] = useControllableState(
382
+ expandedProp,
383
+ defaultExpanded,
384
+ onExpandedChange,
385
+ );
386
+
387
+ const value = useMemo(
388
+ () => ({
389
+ expanded,
390
+ // The id is opaque: the only contracts are that the trigger's
391
+ // aria-controls matches the detail row's id, and that two Expandables on
392
+ // one page differ — both of which `useId` alone guarantees. The suffixes
393
+ // are for legibility when inspecting the DOM, so emptying them yields an
394
+ // equally valid id.
395
+ // Stryker disable next-line StringLiteral: equivalent — see above.
396
+ detailId: deriveId(reactId, "detail", "row"),
397
+ toggle: () => setExpanded(!expanded),
398
+ }),
399
+ [expanded, reactId, setExpanded],
400
+ );
401
+
402
+ return (
403
+ <TableExpandableContext.Provider value={value}>
404
+ {children}
405
+ </TableExpandableContext.Provider>
406
+ );
407
+ }
408
+
409
+ /** @internal */
410
+ TableExpandable.displayName = "TableExpandable";
411
+
412
+ /**
413
+ * The disclosure control for an expandable row — renders a `<button>` inside
414
+ * one of the row's cells.
415
+ *
416
+ * The **button** carries the state, not the row. `role="row"` does list
417
+ * `aria-expanded` among its supported properties, but a button's expanded state
418
+ * is announced universally while a row's is only reliably announced inside a
419
+ * `role="treegrid"` — so the button owns `aria-expanded` and `aria-controls`.
420
+ *
421
+ * Give it a name that identifies its row ("Show details for Ada Lovelace", or
422
+ * visually-hidden text beside the glyph). A column of buttons all called
423
+ * "Expand" is the same defect as a column of checkboxes all called "Select":
424
+ * obvious on screen, useless in a screen reader's element list.
425
+ *
426
+ * @extends HTMLButtonElement
427
+ */
428
+ export function TableExpandTrigger({
429
+ children,
430
+ asChild = false,
431
+ onClick,
432
+ ...rest
433
+ }: TableExpandTriggerProps): ReactElement {
434
+ const { expanded, detailId, toggle } = useTableExpandableContext();
435
+
436
+ const wiring = {
437
+ "aria-expanded": expanded,
438
+ "aria-controls": detailId,
439
+ "data-state": expanded ? "open" : "closed",
440
+ onClick: (event: MouseEvent<HTMLButtonElement>) => {
441
+ onClick?.(event);
442
+ toggle();
443
+ },
444
+ ...rest,
445
+ };
446
+
447
+ // `type` is set only on the native button: a Slot child may not be a
448
+ // <button> at all, and `type` on a non-button element is invalid HTML.
449
+ return asChild ? (
450
+ <Slot {...wiring}>{children}</Slot>
451
+ ) : (
452
+ <button type="button" {...wiring}>
453
+ {children}
454
+ </button>
455
+ );
456
+ }
457
+
458
+ /** @internal */
459
+ TableExpandTrigger.displayName = "TableExpandTrigger";
460
+
461
+ /**
462
+ * The revealed panel for an expandable row — a `<tr>` holding a `<td colSpan>`,
463
+ * and the target of its {@link TableExpandTrigger | `Table.ExpandTrigger`}'s
464
+ * `aria-controls`. Must be rendered inside a
465
+ * {@link TableExpandable | `Table.Expandable`}, as the sibling of the data row.
466
+ *
467
+ * This is a **disclosure**, not a treegrid: the panel holds detail *about* the
468
+ * row (a summary, a form, a chart), not child rows. Expanding into hierarchical
469
+ * child rows is a different pattern with a different role and keyboard model.
470
+ *
471
+ * Two things it leaves to you, both because only the caller can know them:
472
+ * {@link TableDetailRowProps.colSpan | `colSpan`}, since counting columns would
473
+ * mean the table registering its own, and
474
+ * {@link TableDetailRowProps.gutter | `gutter`}, which aligns the panel with the
475
+ * first data column by spanning the control columns in front of it.
476
+ *
477
+ * Collapsed, the row is `hidden` — so it cannot be reached, and cannot inflate
478
+ * the table's announced row count. Pass
479
+ * {@link TableDetailRowProps.forceMount | `forceMount`} when you are animating
480
+ * it: `display: none` cannot transition, so the row stays in the layout and
481
+ * takes `aria-hidden` instead, which keeps the row count honest either way.
482
+ *
483
+ * @extends HTMLTableRowElement
484
+ *
485
+ * @example
486
+ * ```tsx
487
+ * <Table.Expandable>
488
+ * <Table.Row>
489
+ * <Table.Cell>
490
+ * <Table.ExpandTrigger aria-label="Show details for harmoni-engine">
491
+ * ▸
492
+ * </Table.ExpandTrigger>
493
+ * </Table.Cell>
494
+ * <Table.Cell>harmoni-engine</Table.Cell>
495
+ * </Table.Row>
496
+ * <Table.DetailRow colSpan={2} gutter={1}>
497
+ * <DeploymentSummary />
498
+ * </Table.DetailRow>
499
+ * </Table.Expandable>
500
+ * ```
501
+ */
502
+ export function TableDetailRow({
503
+ children,
504
+ colSpan,
505
+ gutter = 0,
506
+ forceMount = false,
507
+ cellProps,
508
+ ...rest
509
+ }: TableDetailRowProps): ReactElement | null {
510
+ const { expanded, detailId } = useTableExpandableContext();
511
+ const collapsed = !expanded;
512
+
513
+ return (
514
+ <tr
515
+ id={detailId}
516
+ data-state={collapsed ? "closed" : "open"}
517
+ hidden={collapsed && !forceMount}
518
+ aria-hidden={collapsed && forceMount ? true : undefined}
519
+ {...rest}
520
+ >
521
+ {/* ONE spanning cell rather than `gutter` empty ones: a spanning cell
522
+ takes the full width of the columns it covers, so the alignment is
523
+ identical and the row gains a single blank cell instead of several. */}
524
+ {gutter > 0 ? <td colSpan={gutter} /> : null}
525
+ <td colSpan={colSpan - gutter} {...cellProps}>
526
+ {children}
527
+ </td>
528
+ </tr>
529
+ );
530
+ }
531
+
532
+ /** @internal */
533
+ TableDetailRow.displayName = "TableDetailRow";
534
+
345
535
  /**
346
536
  * The shape of the exported `Table` value — callable as `Table.Root` and
347
537
  * carrying every sub-component as a static property.
348
538
  */
349
539
  export type TableCompound = typeof TableRoot & {
540
+ Expandable: typeof TableExpandable;
541
+ ExpandTrigger: typeof TableExpandTrigger;
542
+ DetailRow: typeof TableDetailRow;
350
543
  Root: typeof TableRoot;
351
544
  Head: typeof TableHead;
352
545
  Body: typeof TableBody;
@@ -404,7 +597,7 @@ export type TableCompound = typeof TableRoot & {
404
597
  * @example Responsive — horizontal scroll on narrow viewports
405
598
  * ```tsx
406
599
  * <Table.ScrollArea>
407
- * <Table.Root>…</Table.Root>
600
+ * <Table.Root>...</Table.Root>
408
601
  * </Table.ScrollArea>
409
602
  * ```
410
603
  *
@@ -423,6 +616,9 @@ export type TableCompound = typeof TableRoot & {
423
616
  * @see {@link TableScrollArea} for the horizontal-scroll style caveat.
424
617
  */
425
618
  const TableCompound: TableCompound = Object.assign(TableRoot, {
619
+ Expandable: TableExpandable,
620
+ ExpandTrigger: TableExpandTrigger,
621
+ DetailRow: TableDetailRow,
426
622
  Root: TableRoot,
427
623
  Head: TableHead,
428
624
  Body: TableBody,
@@ -0,0 +1,7 @@
1
+ import { createStrictContext } from "../utils/index.ts";
2
+ import { TableExpandableContextValue } from "./types";
3
+
4
+ export const [TableExpandableContext, useTableExpandableContext] =
5
+ createStrictContext<TableExpandableContextValue>(
6
+ "Table.ExpandTrigger and Table.DetailRow must be rendered inside a <Table.Expandable>.",
7
+ );
@@ -0,0 +1,83 @@
1
+ import { Table } from "../index.ts";
2
+ import { render, screen } from "@testing-library/react";
3
+
4
+ describe("Table.DetailRow rendering", () => {
5
+ it("should render a row whose single cell spans every column", () => {
6
+ // Arrange
7
+ render(
8
+ <table>
9
+ <tbody>
10
+ <Table.Expandable expanded>
11
+ <Table.DetailRow colSpan={4}>Detail panel</Table.DetailRow>
12
+ </Table.Expandable>
13
+ </tbody>
14
+ </table>,
15
+ );
16
+ const cell = screen.getByRole("cell");
17
+
18
+ // Assert
19
+ expect(cell).toHaveAttribute("colspan", "4");
20
+ expect(cell).toHaveTextContent("Detail panel");
21
+ });
22
+
23
+ it("should offset the panel behind an empty gutter cell, still spanning every column between them", () => {
24
+ // Arrange
25
+ render(
26
+ <table>
27
+ <tbody>
28
+ <Table.Expandable expanded>
29
+ <Table.DetailRow colSpan={5} gutter={2}>
30
+ Detail panel
31
+ </Table.DetailRow>
32
+ </Table.Expandable>
33
+ </tbody>
34
+ </table>,
35
+ );
36
+ const [gutter, panel] = screen.getAllByRole("cell");
37
+
38
+ // Assert — the gutter is what aligns the panel with the first DATA column
39
+ // at every size and density: it spans the control columns, so the browser's
40
+ // own table layout does the arithmetic and nothing has to be measured.
41
+ expect(gutter).toBeEmptyDOMElement();
42
+ expect(gutter).toHaveAttribute("colspan", "2");
43
+ expect(panel).toHaveAttribute("colspan", "3");
44
+ expect(panel).toHaveTextContent("Detail panel");
45
+ });
46
+
47
+ it("should hide the row while collapsed so it leaves the row count truthful", () => {
48
+ // Arrange
49
+ render(
50
+ <table>
51
+ <tbody>
52
+ <Table.Expandable>
53
+ <Table.DetailRow colSpan={2}>Detail panel</Table.DetailRow>
54
+ </Table.Expandable>
55
+ </tbody>
56
+ </table>,
57
+ );
58
+
59
+ // Assert
60
+ expect(screen.queryByRole("row")).not.toBeInTheDocument();
61
+ });
62
+
63
+ it("should stay mounted but aria-hidden while collapsed when forceMount is set", () => {
64
+ // Arrange
65
+ render(
66
+ <table>
67
+ <tbody>
68
+ <Table.Expandable>
69
+ <Table.DetailRow colSpan={2} forceMount>
70
+ Detail panel
71
+ </Table.DetailRow>
72
+ </Table.Expandable>
73
+ </tbody>
74
+ </table>,
75
+ );
76
+ const row = screen.getByRole("row", { hidden: true });
77
+
78
+ // Assert — no `hidden`, so CSS can transition it; `aria-hidden` instead, so
79
+ // a collapsed panel still does not inflate the table's announced row count.
80
+ expect(row).not.toHaveAttribute("hidden");
81
+ expect(row).toHaveAttribute("aria-hidden", "true");
82
+ });
83
+ });
@@ -0,0 +1,102 @@
1
+ import { Table } from "../index.ts";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+
5
+ function renderPair(props?: { expanded?: boolean }) {
6
+ return render(
7
+ <table>
8
+ <tbody>
9
+ <Table.Expandable expanded={props?.expanded}>
10
+ <Table.Row>
11
+ <Table.Cell>
12
+ <Table.ExpandTrigger>Show details for Ada</Table.ExpandTrigger>
13
+ </Table.Cell>
14
+ </Table.Row>
15
+ <Table.DetailRow colSpan={2}>Detail panel</Table.DetailRow>
16
+ </Table.Expandable>
17
+ </tbody>
18
+ </table>,
19
+ );
20
+ }
21
+
22
+ describe("Table.ExpandTrigger accessibility", () => {
23
+ it("should own the expanded state on the button and point aria-controls at the detail row", () => {
24
+ // Arrange
25
+ renderPair({ expanded: true });
26
+ const trigger = screen.getByRole("button", {
27
+ name: /show details for ada/i,
28
+ });
29
+ const detailRow = screen.getAllByRole("row")[1];
30
+
31
+ // Assert — the button carries the state, because a button's expanded state
32
+ // is announced universally while a row's is only reliable in a treegrid.
33
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
34
+ expect(detailRow).toHaveAttribute("id");
35
+ expect(trigger).toHaveAttribute(
36
+ "aria-controls",
37
+ detailRow.getAttribute("id"),
38
+ );
39
+ });
40
+ });
41
+
42
+ describe("Table.ExpandTrigger composition", () => {
43
+ it("should hand its aria wiring to a consumer's own control when asChild is set", () => {
44
+ // Arrange — the styled layer composes a real ghost Button here rather than
45
+ // hand-rolling a bare <button>, which is what the Figma set models.
46
+ render(
47
+ <table>
48
+ <tbody>
49
+ <Table.Expandable>
50
+ <Table.Row>
51
+ <Table.Cell>
52
+ <Table.ExpandTrigger asChild>
53
+ <button type="button" className="my-button">
54
+ Details
55
+ </button>
56
+ </Table.ExpandTrigger>
57
+ </Table.Cell>
58
+ </Table.Row>
59
+ <Table.DetailRow colSpan={1}>Panel</Table.DetailRow>
60
+ </Table.Expandable>
61
+ </tbody>
62
+ </table>,
63
+ );
64
+ const trigger = screen.getByRole("button", { name: "Details" });
65
+
66
+ // Assert — one button, the consumer's, carrying the wiring
67
+ expect(trigger).toHaveClass("my-button");
68
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
69
+ expect(trigger).toHaveAttribute("aria-controls");
70
+ expect(trigger).toHaveAttribute("data-state", "closed");
71
+ expect(screen.getAllByRole("button")).toHaveLength(1);
72
+ });
73
+ });
74
+
75
+ describe("Table.ExpandTrigger activation", () => {
76
+ it("should report the next expanded state and reveal the detail row when uncontrolled", async () => {
77
+ // Arrange
78
+ const onExpandedChange = vi.fn();
79
+ const user = userEvent.setup();
80
+ render(
81
+ <table>
82
+ <tbody>
83
+ <Table.Expandable onExpandedChange={onExpandedChange}>
84
+ <Table.Row>
85
+ <Table.Cell>
86
+ <Table.ExpandTrigger>Show details for Ada</Table.ExpandTrigger>
87
+ </Table.Cell>
88
+ </Table.Row>
89
+ <Table.DetailRow colSpan={2}>Detail panel</Table.DetailRow>
90
+ </Table.Expandable>
91
+ </tbody>
92
+ </table>,
93
+ );
94
+
95
+ // Act
96
+ await user.click(screen.getByRole("button"));
97
+
98
+ // Assert
99
+ expect(onExpandedChange).toHaveBeenCalledWith(true);
100
+ expect(screen.getByText("Detail panel")).toBeVisible();
101
+ });
102
+ });
@@ -0,0 +1,121 @@
1
+ import { Table } from "../index.ts";
2
+ import { render, screen } from "@testing-library/react";
3
+
4
+ describe("Table displayNames", () => {
5
+ it("sets a displayName on the compound and each sub-component", () => {
6
+ // Assert — empty displayNames would render each as anonymous in DevTools.
7
+ // `Table`, `Table.Root` and the underlying function are one object
8
+ // (Object.assign compound), so the compound's "Table" alias is the
9
+ // observable Root displayName; the sub-components are distinct objects.
10
+ expect(Table.displayName).toBe("Table");
11
+ expect(Table.Head.displayName).toBe("TableHead");
12
+ expect(Table.Body.displayName).toBe("TableBody");
13
+ expect(Table.Footer.displayName).toBe("TableFooter");
14
+ expect(Table.Row.displayName).toBe("TableRow");
15
+ expect(Table.Header.displayName).toBe("TableHeader");
16
+ expect(Table.Cell.displayName).toBe("TableCell");
17
+ expect(Table.ScrollArea.displayName).toBe("TableScrollArea");
18
+ expect(Table.Caption.displayName).toBe("TableCaption");
19
+ expect(Table.Expandable.displayName).toBe("TableExpandable");
20
+ expect(Table.ExpandTrigger.displayName).toBe("TableExpandTrigger");
21
+ expect(Table.DetailRow.displayName).toBe("TableDetailRow");
22
+ });
23
+ });
24
+
25
+ describe("Table.Expandable contract", () => {
26
+ it("should require its parts to be rendered inside a provider", () => {
27
+ // Assert — the strict context is what stops a trigger being wired to
28
+ // nothing. React logs the throw, so silence the expected console noise.
29
+ const consoleError = vi
30
+ .spyOn(console, "error")
31
+ .mockImplementation(() => undefined);
32
+
33
+ expect(() =>
34
+ render(
35
+ <table>
36
+ <tbody>
37
+ <Table.DetailRow colSpan={2}>Orphan</Table.DetailRow>
38
+ </tbody>
39
+ </table>,
40
+ ),
41
+ ).toThrow(
42
+ "Table.ExpandTrigger and Table.DetailRow must be rendered inside a <Table.Expandable>.",
43
+ );
44
+
45
+ consoleError.mockRestore();
46
+ });
47
+
48
+ it("should apply the hidden attribute — not merely leave the row unannounced — while collapsed", () => {
49
+ // Arrange
50
+ render(
51
+ <table>
52
+ <tbody>
53
+ <Table.Expandable>
54
+ <Table.DetailRow colSpan={2}>Detail panel</Table.DetailRow>
55
+ </Table.Expandable>
56
+ </tbody>
57
+ </table>,
58
+ );
59
+
60
+ // Assert — pins the `forceMount` default. Without `hidden` the row is only
61
+ // missing from the accessibility tree, which an aria-hidden row would be too.
62
+ expect(screen.getByText("Detail panel").closest("tr")).toHaveAttribute(
63
+ "hidden",
64
+ );
65
+ });
66
+
67
+ it.each([
68
+ { expanded: true, state: "open" },
69
+ { expanded: false, state: "closed" },
70
+ ])(
71
+ "should publish data-state=$state on both parts for the styling layer",
72
+ ({ expanded, state }) => {
73
+ // Arrange
74
+ render(
75
+ <table>
76
+ <tbody>
77
+ <Table.Expandable expanded={expanded}>
78
+ <Table.Row>
79
+ <Table.Cell>
80
+ <Table.ExpandTrigger>Toggle</Table.ExpandTrigger>
81
+ </Table.Cell>
82
+ </Table.Row>
83
+ <Table.DetailRow colSpan={2} forceMount>
84
+ Detail panel
85
+ </Table.DetailRow>
86
+ </Table.Expandable>
87
+ </tbody>
88
+ </table>,
89
+ );
90
+
91
+ // Assert — the registry stylesheet animates the panel off this attribute,
92
+ // because a CSS transition needs a selector, not an unmount.
93
+ expect(screen.getByRole("button", { name: "Toggle" })).toHaveAttribute(
94
+ "data-state",
95
+ state,
96
+ );
97
+ expect(
98
+ screen.getByText("Detail panel").closest("tr"),
99
+ ).toHaveAttribute("data-state", state);
100
+ },
101
+ );
102
+
103
+ it("should not aria-hide a force-mounted row once it is expanded", () => {
104
+ // Arrange
105
+ render(
106
+ <table>
107
+ <tbody>
108
+ <Table.Expandable expanded>
109
+ <Table.DetailRow colSpan={2} forceMount>
110
+ Detail panel
111
+ </Table.DetailRow>
112
+ </Table.Expandable>
113
+ </tbody>
114
+ </table>,
115
+ );
116
+
117
+ // Assert — aria-hidden belongs to "force-mounted AND collapsed" only; an
118
+ // expanded panel that is not announced would be invisible to a screen reader.
119
+ expect(screen.getByRole("row")).not.toHaveAttribute("aria-hidden");
120
+ });
121
+ });