@primitiv-ui/react 0.1.29 → 0.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -2
- package/package.json +1 -1
- package/src/AccessibleIcon/AccessibleIcon.tsx +1 -1
- package/src/Accordion/Accordion.tsx +7 -7
- package/src/Accordion/README.md +2 -2
- package/src/Avatar/__tests__/Avatar.fallback.test.tsx +3 -3
- package/src/Breadcrumb/Breadcrumb.tsx +7 -7
- package/src/Breadcrumb/README.md +2 -2
- package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +1 -1
- package/src/Breadcrumb/types.ts +1 -1
- package/src/Button/__tests__/Button.contract.test.tsx +1 -1
- package/src/Carousel/Carousel.tsx +6 -6
- package/src/Carousel/README.md +22 -22
- package/src/Carousel/__tests__/Carousel.infinite.test.tsx +5 -5
- package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +2 -2
- package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +1 -1
- package/src/Carousel/loopEngine.ts +1 -1
- package/src/Carousel/types.ts +1 -1
- package/src/Checkbox/Checkbox.tsx +2 -2
- package/src/CheckboxCard/CheckboxCard.tsx +1 -1
- package/src/CheckboxCard/README.md +1 -1
- package/src/Collapsible/README.md +2 -2
- package/src/Combobox/Combobox.tsx +508 -0
- package/src/Combobox/ComboboxContext.ts +23 -0
- package/src/Combobox/README.md +170 -0
- package/src/Combobox/__tests__/Combobox.asChild.test.tsx +62 -0
- package/src/Combobox/__tests__/Combobox.basic-rendering.test.tsx +16 -0
- package/src/Combobox/__tests__/Combobox.content.test.tsx +36 -0
- package/src/Combobox/__tests__/Combobox.controlled-state.test.tsx +88 -0
- package/src/Combobox/__tests__/Combobox.empty.test.tsx +42 -0
- package/src/Combobox/__tests__/Combobox.ids.test.tsx +66 -0
- package/src/Combobox/__tests__/Combobox.item.test.tsx +76 -0
- package/src/Combobox/__tests__/Combobox.keyboard-interaction.test.tsx +196 -0
- package/src/Combobox/__tests__/Combobox.light-dismiss.test.tsx +101 -0
- package/src/Combobox/__tests__/Combobox.query.test.tsx +105 -0
- package/src/Combobox/hooks/index.ts +1 -0
- package/src/Combobox/hooks/useComboboxRoot.ts +2 -0
- package/src/Combobox/index.ts +3 -0
- package/src/Combobox/types.ts +133 -0
- package/src/ContextMenu/ContextMenu.tsx +3 -3
- package/src/ContextMenu/README.md +1 -1
- package/src/DirectionProvider/DirectionProvider.tsx +5 -5
- package/src/DirectionProvider/README.md +4 -4
- package/src/Divider/__tests__/Divider.contract.test.tsx +1 -1
- package/src/Drawer/Drawer.tsx +5 -5
- package/src/Drawer/README.md +2 -2
- package/src/Dropdown/Dropdown.tsx +2 -2
- package/src/Dropdown/README.md +7 -5
- package/src/EmptyState/README.md +2 -2
- package/src/Field/README.md +1 -1
- package/src/Field/hooks/useFieldProps.ts +1 -1
- package/src/Fieldset/Fieldset.tsx +3 -3
- package/src/Fieldset/README.md +2 -2
- package/src/Input/Input.tsx +1 -1
- package/src/Input/README.md +2 -2
- package/src/Listbox/Listbox.tsx +475 -0
- package/src/Listbox/ListboxContext.ts +48 -0
- package/src/Listbox/README.md +248 -0
- package/src/Listbox/__tests__/Listbox.active-descendant.test.tsx +128 -0
- package/src/Listbox/__tests__/Listbox.asChild.test.tsx +91 -0
- package/src/Listbox/__tests__/Listbox.basic-rendering.test.tsx +16 -0
- package/src/Listbox/__tests__/Listbox.controlled-state.test.tsx +114 -0
- package/src/Listbox/__tests__/Listbox.disabled.test.tsx +90 -0
- package/src/Listbox/__tests__/Listbox.fixtures.ts +27 -0
- package/src/Listbox/__tests__/Listbox.group-label.test.tsx +97 -0
- package/src/Listbox/__tests__/Listbox.group.test.tsx +45 -0
- package/src/Listbox/__tests__/Listbox.keyboard-edge-cases.test.tsx +144 -0
- package/src/Listbox/__tests__/Listbox.keyboard-interaction.test.tsx +112 -0
- package/src/Listbox/__tests__/Listbox.modifier-keys.test.tsx +86 -0
- package/src/Listbox/__tests__/Listbox.multi-select-keyboard.test.tsx +368 -0
- package/src/Listbox/__tests__/Listbox.multiple-mode.test.tsx +91 -0
- package/src/Listbox/__tests__/Listbox.mutation-hardening.test.tsx +480 -0
- package/src/Listbox/__tests__/Listbox.orientation.test.tsx +59 -0
- package/src/Listbox/__tests__/Listbox.reading-direction.test.tsx +65 -0
- package/src/Listbox/__tests__/Listbox.reorder.test.tsx +78 -0
- package/src/Listbox/__tests__/Listbox.selection-follows-focus.test.tsx +64 -0
- package/src/Listbox/__tests__/Listbox.strict-context.test.tsx +21 -0
- package/src/Listbox/__tests__/Listbox.typeahead.test.tsx +123 -0
- package/src/Listbox/__tests__/Listbox.uncontrolled-state.test.tsx +48 -0
- package/src/Listbox/constants.ts +2 -0
- package/src/Listbox/hooks/index.ts +1 -0
- package/src/Listbox/hooks/useListboxRoot.ts +407 -0
- package/src/Listbox/index.ts +3 -0
- package/src/Listbox/types.ts +238 -0
- package/src/MillerColumns/MillerColumns.tsx +104 -30
- package/src/MillerColumns/README.md +90 -20
- package/src/MillerColumns/__tests__/MillerColumns.aria.test.tsx +45 -0
- package/src/MillerColumns/__tests__/MillerColumns.auto-scroll.test.tsx +4 -2
- package/src/MillerColumns/__tests__/MillerColumns.basic-rendering.test.tsx +18 -3
- package/src/MillerColumns/__tests__/MillerColumns.data-attributes.test.tsx +48 -0
- package/src/MillerColumns/__tests__/MillerColumns.keyboard-interaction.test.tsx +35 -2
- package/src/MillerColumns/__tests__/MillerColumns.keyboard-selection-state.test.tsx +96 -0
- package/src/MillerColumns/__tests__/MillerColumns.preview-panel.test.tsx +29 -4
- package/src/MillerColumns/__tests__/MillerColumns.preview-visibility.test.tsx +79 -0
- package/src/MillerColumns/__tests__/MillerColumns.resize.test.tsx +189 -0
- package/src/MillerColumns/__tests__/MillerColumns.styled-parts.test.tsx +143 -0
- package/src/MillerColumns/__tests__/MillerColumns.typeahead.test.tsx +104 -0
- package/src/MillerColumns/constants.ts +2 -0
- package/src/MillerColumns/hooks/useMillerColumnsColumn.ts +5 -2
- package/src/MillerColumns/hooks/useMillerColumnsItem.ts +29 -7
- package/src/MillerColumns/hooks/useMillerColumnsResizeHandle.ts +80 -10
- package/src/MillerColumns/hooks/useMillerColumnsRoot.ts +0 -0
- package/src/MillerColumns/index.ts +4 -0
- package/src/MillerColumns/types.ts +56 -6
- package/src/MillerColumns/utils.ts +69 -3
- package/src/Modal/Modal.tsx +4 -4
- package/src/Modal/README.md +5 -5
- package/src/Modal/types.ts +1 -1
- package/src/NavigationMenu/NavigationMenu.tsx +6 -6
- package/src/NavigationMenu/README.md +3 -3
- package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +1 -1
- package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +1 -1
- package/src/Popover/Popover.tsx +2 -2
- package/src/Popover/README.md +1 -1
- package/src/Portal/Portal.tsx +2 -2
- package/src/Portal/README.md +2 -2
- package/src/Progress/README.md +1 -1
- package/src/Radio/Radio.tsx +1 -1
- package/src/RadioCard/README.md +2 -1
- package/src/RadioCard/RadioCard.tsx +5 -2
- package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +23 -0
- package/src/RadioGroup/README.md +2 -2
- package/src/RadioGroup/RadioGroup.tsx +1 -1
- package/src/SegmentedControl/README.md +2 -2
- package/src/SegmentedControl/SegmentedControl.tsx +1 -1
- package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +1 -0
- package/src/Select/README.md +2 -2
- package/src/Select/Select.tsx +8 -8
- package/src/Select/__tests__/Select.placeholder.test.tsx +3 -3
- package/src/Select/__tests__/Select.typeahead.test.tsx +3 -3
- package/src/Select/__tests__/Select.value-mirror.test.tsx +1 -1
- package/src/Select/types.ts +2 -2
- package/src/SkipNav/README.md +3 -3
- package/src/SkipNav/SkipNav.tsx +5 -5
- package/src/Slider/README.md +37 -7
- package/src/Slider/Slider.tsx +9 -3
- package/src/Slot/Slot.tsx +2 -2
- package/src/Slot/__tests__/Slot.test.tsx +2 -2
- package/src/Slot/types.ts +1 -1
- package/src/SplitButton/README.md +225 -0
- package/src/SplitButton/SplitButton.tsx +457 -0
- package/src/SplitButton/SplitButtonContext.ts +26 -0
- package/src/SplitButton/__tests__/SplitButton.accessibility.test.tsx +55 -0
- package/src/SplitButton/__tests__/SplitButton.asChild.test.tsx +51 -0
- package/src/SplitButton/__tests__/SplitButton.basic-rendering.test.tsx +57 -0
- package/src/SplitButton/__tests__/SplitButton.data-attributes.test.tsx +28 -0
- package/src/SplitButton/__tests__/SplitButton.disabled.test.tsx +79 -0
- package/src/SplitButton/__tests__/SplitButton.error-handling.test.tsx +29 -0
- package/src/SplitButton/__tests__/SplitButton.keyboard-interaction.test.tsx +56 -0
- package/src/SplitButton/__tests__/SplitButton.state-modes.test.tsx +68 -0
- package/src/SplitButton/index.ts +2 -0
- package/src/SplitButton/types.ts +186 -0
- package/src/Switch/README.md +1 -1
- package/src/Switch/Switch.tsx +2 -2
- package/src/Switch/types.ts +1 -1
- package/src/Table/README.md +110 -8
- package/src/Table/Table.tsx +206 -10
- package/src/Table/TableExpandableContext.ts +7 -0
- package/src/Table/__tests__/Table.DetailRow.test.tsx +83 -0
- package/src/Table/__tests__/Table.ExpandTrigger.test.tsx +102 -0
- package/src/Table/__tests__/Table.Expandable.test.tsx +121 -0
- package/src/Table/types.ts +125 -0
- package/src/Tabs/README.md +4 -4
- package/src/Tabs/Tabs.tsx +5 -4
- package/src/Tabs/__tests__/Tabs.activation-mode.test.tsx +58 -0
- package/src/Tabs/__tests__/Tabs.basic-rendering.test.tsx +15 -0
- package/src/Tabs/__tests__/Tabs.change-event-callbacks.test.tsx +24 -0
- package/src/Tabs/__tests__/Tabs.disabled-tabs.test.tsx +36 -0
- package/src/Tabs/__tests__/Tabs.lazy-mount.test.tsx +17 -0
- package/src/Tabs/hooks/useTabsContent.ts +5 -0
- package/src/Tabs/hooks/useTabsRoot.ts +29 -5
- package/src/Tabs/hooks/useTabsTrigger.ts +7 -2
- package/src/ToggleGroup/README.md +4 -4
- package/src/Tooltip/README.md +7 -7
- package/src/Tooltip/Tooltip.tsx +2 -2
- package/src/Tooltip/__tests__/Tooltip.basic-rendering.test.tsx +50 -0
- package/src/Tooltip/__tests__/Tooltip.error-handling.test.tsx +37 -0
- package/src/Tooltip/__tests__/Tooltip.escape-hatches.test.tsx +73 -0
- package/src/Tooltip/__tests__/Tooltip.keyboard-interaction.test.tsx +134 -0
- package/src/Tooltip/__tests__/Tooltip.skip-delay.test.tsx +216 -0
- package/src/Tooltip/__tests__/Tooltip.timer-coordination.test.tsx +223 -0
- package/src/Tooltip/hooks/useTooltipContent.ts +3 -0
- package/src/Tooltip/hooks/useTooltipProvider.ts +40 -14
- package/src/Tooltip/hooks/useTooltipRoot.ts +30 -6
- package/src/Tree/README.md +69 -5
- package/src/Tree/Tree.tsx +1 -1
- package/src/Tree/__tests__/Tree.styled-wrapper.test.tsx +159 -0
- package/src/Tree/hooks/useTreePath.ts +33 -1
- package/src/Tree/index.ts +6 -1
- package/src/Tree/utils.ts +46 -4
- package/src/VisuallyHidden/VisuallyHidden.tsx +1 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useTypeahead.ts +95 -0
- package/src/index.ts +6 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.reading.test.tsx +31 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.resilience.test.tsx +97 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.ssr.test.tsx +20 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.writing.test.tsx +72 -0
- package/src/useLocalStorage/index.ts +1 -0
- package/src/useLocalStorage/useLocalStorage.ts +140 -0
- package/src/useMediaQuery/README.md +66 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.matching.test.tsx +42 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.ssr.test.tsx +19 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.subscription.test.tsx +89 -0
- package/src/useMediaQuery/index.ts +1 -0
- package/src/useMediaQuery/useMediaQuery.ts +49 -0
- package/src/usePagination/README.md +170 -0
- package/src/usePagination/__tests__/usePagination.navigation.test.tsx +109 -0
- package/src/usePagination/__tests__/usePagination.page-count.test.tsx +56 -0
- package/src/usePagination/__tests__/usePagination.page-state.test.tsx +87 -0
- package/src/usePagination/__tests__/usePagination.range.test.tsx +82 -0
- package/src/usePagination/__tests__/usePagination.slice.test.tsx +59 -0
- package/src/usePagination/__tests__/usePagination.windowing.test.tsx +68 -0
- package/src/usePagination/index.ts +1 -0
- package/src/usePagination/usePagination.ts +292 -0
- package/src/utils/getKeyToActionMap.ts +1 -1
package/src/Table/Table.tsx
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
import type
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* `Table.contract.test.tsx` against the registry contract)
|
|
22
|
-
*
|
|
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
|
|
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
|
+
});
|