From 3bd0cb3d54b97e1f57bdc39523c31e89a62e7d03 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Sat, 24 Oct 2026 09:52:13 -0400 Subject: [PATCH 01/14] feat(mosaic): add headless Table primitive with row actions, selection and tree rows --- .changeset/mosaic-table-primitive.md | 2 + .../hooks/__tests__/use-data-table.test.ts | 144 ++++ packages/mosaic/src/primitives/hooks/index.ts | 2 + .../src/primitives/hooks/use-data-table.ts | 114 ++- .../mosaic/src/primitives/table/README.md | 139 ++++ packages/mosaic/src/primitives/table/index.ts | 13 + packages/mosaic/src/primitives/table/parts.ts | 11 + .../src/primitives/table/table-body.tsx | 20 + .../src/primitives/table/table-cell.tsx | 20 + .../src/primitives/table/table-column.tsx | 24 + .../src/primitives/table/table-context.ts | 41 + .../mosaic/src/primitives/table/table-dom.ts | 70 ++ .../primitives/table/table-expand-button.tsx | 49 ++ .../src/primitives/table/table-header.tsx | 30 + .../src/primitives/table/table-root.tsx | 76 ++ .../mosaic/src/primitives/table/table-row.tsx | 108 +++ .../table/table-selection-checkbox.tsx | 74 ++ .../src/primitives/table/table.test.tsx | 763 ++++++++++++++++++ .../primitives/table/use-grid-navigation.ts | 305 +++++++ 19 files changed, 1996 insertions(+), 9 deletions(-) create mode 100644 .changeset/mosaic-table-primitive.md create mode 100644 packages/mosaic/src/primitives/table/README.md create mode 100644 packages/mosaic/src/primitives/table/index.ts create mode 100644 packages/mosaic/src/primitives/table/parts.ts create mode 100644 packages/mosaic/src/primitives/table/table-body.tsx create mode 100644 packages/mosaic/src/primitives/table/table-cell.tsx create mode 100644 packages/mosaic/src/primitives/table/table-column.tsx create mode 100644 packages/mosaic/src/primitives/table/table-context.ts create mode 100644 packages/mosaic/src/primitives/table/table-dom.ts create mode 100644 packages/mosaic/src/primitives/table/table-expand-button.tsx create mode 100644 packages/mosaic/src/primitives/table/table-header.tsx create mode 100644 packages/mosaic/src/primitives/table/table-root.tsx create mode 100644 packages/mosaic/src/primitives/table/table-row.tsx create mode 100644 packages/mosaic/src/primitives/table/table-selection-checkbox.tsx create mode 100644 packages/mosaic/src/primitives/table/table.test.tsx create mode 100644 packages/mosaic/src/primitives/table/use-grid-navigation.ts diff --git a/.changeset/mosaic-table-primitive.md b/.changeset/mosaic-table-primitive.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-table-primitive.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/primitives/hooks/__tests__/use-data-table.test.ts b/packages/mosaic/src/primitives/hooks/__tests__/use-data-table.test.ts index 93bcf43db27..65c9b99d7dc 100644 --- a/packages/mosaic/src/primitives/hooks/__tests__/use-data-table.test.ts +++ b/packages/mosaic/src/primitives/hooks/__tests__/use-data-table.test.ts @@ -506,4 +506,148 @@ describe('useDataTable', () => { expect(result.current.rowSelection).toEqual({ '0': true, '1': false, '2': false }); }); }); + + describe('selection mode', () => { + it('ignores toggles when selectionMode is none', () => { + const { result } = renderHook(() => useDataTable({ data: DATA, selectionMode: 'none' })); + + act(() => result.current.rows[0].toggleSelected()); + act(() => result.current.toggleAllRowsSelected()); + + expect(result.current.rowSelection).toEqual({}); + }); + + it('replaces the selection when selectionMode is single', () => { + const { result } = renderHook(() => useDataTable({ data: DATA, selectionMode: 'single' })); + + act(() => result.current.rows[0].toggleSelected()); + act(() => result.current.rows[2].toggleSelected({ range: true })); + + expect(result.current.rowSelection).toEqual({ '2': true }); + + act(() => result.current.rows[2].toggleSelected()); + + expect(result.current.rowSelection).toEqual({}); + }); + + it('does not select all when selectionMode is single', () => { + const { result } = renderHook(() => useDataTable({ data: DATA, selectionMode: 'single' })); + + act(() => result.current.toggleAllRowsSelected()); + + expect(result.current.rowSelection).toEqual({}); + }); + + it('getCanSelect() is false for non-selectable rows and when selectionMode is none', () => { + const { result, rerender } = renderHook(props => useDataTable({ data: DATA, ...props }), { + initialProps: { isRowSelectable: (row: Person) => row.role === 'Admin' }, + }); + + expect(result.current.rows.map(row => row.getCanSelect())).toEqual([true, false, true]); + + rerender({ isRowSelectable: () => true, selectionMode: 'none' as const }); + + expect(result.current.rows.map(row => row.getCanSelect())).toEqual([false, false, false]); + }); + + it('defaults to multiple selection', () => { + const { result } = renderHook(() => useDataTable({ data: DATA })); + + expect(result.current.selectionMode).toBe('multiple'); + }); + }); + + describe('tree rows', () => { + type Node = { id: string; children?: Node[] }; + + const TREE: Node[] = [{ id: 'a', children: [{ id: 'a1' }, { id: 'a2', children: [{ id: 'a2x' }] }] }, { id: 'b' }]; + + const treeOptions = { data: TREE, getRowId: (row: Node) => row.id, getSubRows: (row: Node) => row.children }; + + const visibleIds = (rows: Array<{ id: string }>) => rows.map(row => row.id); + + it('shows only top-level rows until a row is expanded', () => { + const { result } = renderHook(() => useDataTable(treeOptions)); + + expect(visibleIds(result.current.rows)).toEqual(['a', 'b']); + }); + + it('describes where each row sits in the tree', () => { + const { result } = renderHook(() => useDataTable({ ...treeOptions, defaultExpanded: { a: true, a2: true } })); + + expect( + result.current.rows.map(({ id, depth, parentId, index, setSize }) => ({ id, depth, parentId, index, setSize })), + ).toEqual([ + { id: 'a', depth: 0, parentId: null, index: 0, setSize: 2 }, + { id: 'a1', depth: 1, parentId: 'a', index: 0, setSize: 2 }, + { id: 'a2', depth: 1, parentId: 'a', index: 1, setSize: 2 }, + { id: 'a2x', depth: 2, parentId: 'a2', index: 0, setSize: 1 }, + { id: 'b', depth: 0, parentId: null, index: 1, setSize: 2 }, + ]); + }); + + it('hides descendants of a collapsed row even when they are marked expanded', () => { + const { result } = renderHook(() => useDataTable({ ...treeOptions, defaultExpanded: { a2: true } })); + + expect(visibleIds(result.current.rows)).toEqual(['a', 'b']); + }); + + it('toggleExpanded() shows and hides child rows', () => { + const { result } = renderHook(() => useDataTable(treeOptions)); + + act(() => result.current.rows[0].toggleExpanded()); + + expect(result.current.rows[0].getIsExpanded()).toBe(true); + expect(visibleIds(result.current.rows)).toEqual(['a', 'a1', 'a2', 'b']); + + act(() => result.current.rows[0].toggleExpanded()); + + expect(result.current.rows[0].getIsExpanded()).toBe(false); + expect(visibleIds(result.current.rows)).toEqual(['a', 'b']); + }); + + it('can expand only rows with children unless getRowCanExpand says otherwise', () => { + const { result, rerender } = renderHook(props => useDataTable({ ...treeOptions, ...props }), { + initialProps: {}, + }); + + expect(result.current.rows.map(row => row.getCanExpand())).toEqual([true, false]); + + act(() => result.current.rows[1].toggleExpanded()); + + expect(result.current.expanded).toEqual({}); + + rerender({ getRowCanExpand: (row: Node) => row.id === 'b' }); + + expect(result.current.rows.map(row => row.getCanExpand())).toEqual([false, true]); + }); + + it('fires onExpandedChange and respects controlled expanded', () => { + const onExpandedChange = vi.fn(); + const { result } = renderHook(() => useDataTable({ ...treeOptions, expanded: {}, onExpandedChange })); + + act(() => result.current.rows[0].toggleExpanded()); + + expect(onExpandedChange).toHaveBeenCalledWith({ a: true }); + expect(visibleIds(result.current.rows)).toEqual(['a', 'b']); + }); + + it('gives nested rows default ids under their parent', () => { + const { result } = renderHook(() => + useDataTable({ data: TREE, getSubRows: row => row.children, defaultExpanded: { '0': true } }), + ); + + expect(visibleIds(result.current.rows)).toEqual(['0', '0.0', '0.1', '1']); + }); + + it('selects ranges and all rows across visible rows only', () => { + const { result } = renderHook(() => useDataTable({ ...treeOptions, defaultExpanded: { a: true } })); + + act(() => result.current.rows[0].toggleSelected()); + act(() => result.current.rows[3].toggleSelected({ range: true })); + + expect(result.current.rowSelection).toEqual({ a: true, a1: true, a2: true, b: true }); + expect(result.current.getIsAllRowsSelected()).toBe(true); + }); + }); }); diff --git a/packages/mosaic/src/primitives/hooks/index.ts b/packages/mosaic/src/primitives/hooks/index.ts index 8ce698dbe90..3bc6488b9b2 100644 --- a/packages/mosaic/src/primitives/hooks/index.ts +++ b/packages/mosaic/src/primitives/hooks/index.ts @@ -3,9 +3,11 @@ export { functionalUpdate, useDataTable } from './use-data-table'; export type { ColumnFiltersState, DataTableRow, + ExpandedState, OnChangeFn, PaginationState, RowSelectionState, + SelectionMode, SortingState, Updater, UseDataTableOptions, diff --git a/packages/mosaic/src/primitives/hooks/use-data-table.ts b/packages/mosaic/src/primitives/hooks/use-data-table.ts index 1d63bc184d5..8d407a53803 100644 --- a/packages/mosaic/src/primitives/hooks/use-data-table.ts +++ b/packages/mosaic/src/primitives/hooks/use-data-table.ts @@ -11,6 +11,8 @@ export type SortingState = Array<{ id: string; desc: boolean }>; export type ColumnFiltersState = Array<{ id: string; value: unknown }>; export type PaginationState = { pageIndex: number; pageSize: number }; export type RowSelectionState = Record; +export type ExpandedState = Record; +export type SelectionMode = 'none' | 'single' | 'multiple'; export function functionalUpdate(updater: Updater, old: T): T { return typeof updater === 'function' ? (updater as (old: T) => T)(old) : updater; @@ -19,7 +21,10 @@ export function functionalUpdate(updater: Updater, old: T): T { export interface UseDataTableOptions { data: TData[]; getRowId?: (row: TData, index: number) => string; + getSubRows?: (row: TData) => TData[] | undefined; + getRowCanExpand?: (row: TData) => boolean; isRowSelectable?: (row: TData) => boolean; + selectionMode?: SelectionMode; totalCount?: number; sorting?: SortingState; @@ -41,13 +46,25 @@ export interface UseDataTableOptions { rowSelection?: RowSelectionState; defaultRowSelection?: RowSelectionState; onRowSelectionChange?: OnChangeFn; + + expanded?: ExpandedState; + defaultExpanded?: ExpandedState; + onExpandedChange?: OnChangeFn; } export interface DataTableRow { id: string; original: TData; + depth: number; + parentId: string | null; + index: number; + setSize: number; + getCanSelect: () => boolean; getIsSelected: () => boolean; toggleSelected: (options?: { range?: boolean }) => void; + getCanExpand: () => boolean; + getIsExpanded: () => boolean; + toggleExpanded: () => void; } export interface UseDataTableReturn { @@ -71,11 +88,15 @@ export interface UseDataTableReturn { getCanPreviousPage: () => boolean; getPageCount: () => number; + selectionMode: SelectionMode; rowSelection: RowSelectionState; setRowSelection: OnChangeFn; getIsAllRowsSelected: () => boolean; getIsSomeRowsSelected: () => boolean; toggleAllRowsSelected: () => void; + + expanded: ExpandedState; + setExpanded: OnChangeFn; } export function useDataTable(opts: UseDataTableOptions): UseDataTableReturn { @@ -107,6 +128,12 @@ export function useDataTable(opts: UseDataTableOptions): UseDataTa opts.onRowSelectionChange, ); + const [expanded, setExpandedRaw] = useControllableState( + opts.expanded, + opts.defaultExpanded ?? {}, + opts.onExpandedChange, + ); + // ── Updater-aware public setters ──────────────────────────────────────────── const setSorting: OnChangeFn = useCallback( @@ -134,26 +161,38 @@ export function useDataTable(opts: UseDataTableOptions): UseDataTa [rowSelection, setRowSelectionRaw], ); + const setExpanded: OnChangeFn = useCallback( + u => setExpandedRaw(functionalUpdate(u, expanded)), + [expanded, setExpandedRaw], + ); + // ── Rows ──────────────────────────────────────────────────────────────────── - const { getRowId, isRowSelectable } = opts; + const { getRowId, getSubRows, getRowCanExpand, isRowSelectable } = opts; + const selectionMode = opts.selectionMode ?? 'multiple'; const selectionAnchor = useRef(null); const rows = useMemo[]>(() => { - const entries = opts.data.map((original, i) => ({ original, id: getRowId ? getRowId(original, i) : String(i) })); + const entries = flattenVisibleRows(opts.data, { getRowId, getSubRows, getRowCanExpand, expanded }); const selectableIds = entries .filter(entry => !isRowSelectable || isRowSelectable(entry.original)) .map(entry => entry.id); - return entries.map(({ original, id }) => { + return entries.map(({ canExpand, ...entry }) => { + const { id } = entry; + const canSelect = selectionMode !== 'none' && selectableIds.includes(id); return { - id, - original, + ...entry, + getCanSelect: () => canSelect, getIsSelected: () => !!rowSelection[id], toggleSelected: options => { - const index = selectableIds.indexOf(id); - if (index === -1) { + if (!canSelect) { + return; + } + if (selectionMode === 'single') { + setRowSelection(old => (old[id] ? {} : { [id]: true })); return; } const anchor = selectionAnchor.current; + const index = selectableIds.indexOf(id); const anchorIndex = options?.range && anchor !== null ? selectableIds.indexOf(anchor) : -1; selectionAnchor.current = id; setRowSelection(old => { @@ -165,9 +204,27 @@ export function useDataTable(opts: UseDataTableOptions): UseDataTa return { ...old, ...Object.fromEntries(rangeIds.map(rangeId => [rangeId, selected])) }; }); }, + getCanExpand: () => canExpand, + getIsExpanded: () => canExpand && !!expanded[id], + toggleExpanded: () => { + if (canExpand) { + setExpanded(old => ({ ...old, [id]: !old[id] })); + } + }, }; }); - }, [opts.data, getRowId, isRowSelectable, rowSelection, setRowSelection]); + }, [ + opts.data, + getRowId, + getSubRows, + getRowCanExpand, + isRowSelectable, + selectionMode, + rowSelection, + setRowSelection, + expanded, + setExpanded, + ]); // ── Pagination helpers ────────────────────────────────────────────────────── @@ -221,8 +278,11 @@ export function useDataTable(opts: UseDataTableOptions): UseDataTa ); const toggleAllRowsSelected = useCallback(() => { + if (selectionMode !== 'multiple') { + return; + } setRowSelection(getIsAllRowsSelected() ? {} : Object.fromEntries(selectableRows.map(r => [r.id, true]))); - }, [selectableRows, getIsAllRowsSelected, setRowSelection]); + }, [selectionMode, selectableRows, getIsAllRowsSelected, setRowSelection]); return { rows, @@ -245,10 +305,46 @@ export function useDataTable(opts: UseDataTableOptions): UseDataTa getCanPreviousPage, getPageCount, + selectionMode, rowSelection, setRowSelection, getIsAllRowsSelected, getIsSomeRowsSelected, toggleAllRowsSelected, + + expanded, + setExpanded, }; } + +interface VisibleRowEntry { + id: string; + original: TData; + depth: number; + parentId: string | null; + index: number; + setSize: number; + canExpand: boolean; +} + +function flattenVisibleRows( + data: TData[], + options: Pick, 'getRowId' | 'getSubRows' | 'getRowCanExpand'> & { + expanded: ExpandedState; + }, + parentId: string | null = null, + depth = 0, +): VisibleRowEntry[] { + const { getRowId, getSubRows, getRowCanExpand, expanded } = options; + return data.flatMap((original, index) => { + const fallbackId = parentId === null ? String(index) : `${parentId}.${index}`; + const id = getRowId ? getRowId(original, index) : fallbackId; + const subRows = getSubRows?.(original) ?? []; + const canExpand = getRowCanExpand ? getRowCanExpand(original) : subRows.length > 0; + const entry = { id, original, depth, parentId, index, setSize: data.length, canExpand }; + if (!canExpand || !expanded[id]) { + return [entry]; + } + return [entry, ...flattenVisibleRows(subRows, options, id, depth + 1)]; + }); +} diff --git a/packages/mosaic/src/primitives/table/README.md b/packages/mosaic/src/primitives/table/README.md new file mode 100644 index 00000000000..f389d74a348 --- /dev/null +++ b/packages/mosaic/src/primitives/table/README.md @@ -0,0 +1,139 @@ +# Table + +A native `` with optional row actions, row selection and expandable tree rows. Rows come from `useDataTable`, which owns selection and expanded state. Behavior follows [React Aria's Table](https://react-aria.adobe.com/Table) and [Tree grid](https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/) patterns. + +## When to Use + +- Tabular data where rows open a page, run an action, can be selected, or have child rows (for example, logs). +- A plain data table with none of these stays a plain `
` with no grid role or keyboard navigation. + +## Usage + +```tsx +import { useDataTable } from '@/primitives/hooks'; +import { Table } from '@/primitives/table'; + +const table = useDataTable({ + data: logs, + getRowId: log => log.id, + getSubRows: log => log.children, + selectionMode: 'multiple', +}); + + + + + + + Message + + + {table.rows.map(row => ( + + + + + + + {row.original.message} + + + ))} + +; +``` + +`table.rows` holds only the visible rows: a child row is in the list while its parent is expanded. Use `--table-row-depth` to indent child rows. + +### Row actions + +Pass `onClick` or `href` (with `target`, `rel` and `download`) to `Table.Row`. The row runs the action on click and on Enter, without a link or button in a cell. Clicks on buttons, links, inputs and portaled content inside the row do not run it. `href` keeps the modifier keys, so Cmd/Ctrl-click opens a new tab. + +### Selection + +Set `selectionMode` on `useDataTable` to `'single'`, `'multiple'` or `'none'`. With nothing selected, clicking an actionable row runs its action. Once any row is selected, clicking a row toggles its selection instead. Shift-click selects a range. + +## Parts + +| Part | Default Element | Description | +| ------------------------- | ----------------------- | ---------------------------------------------------------- | +| `Table.Root` | `
` | Root, provides context and keyboard navigation | +| `Table.Header` | `` | Header, wraps its columns in a `` | +| `Table.Column` | `` | Body | +| `Table.Row` | `` | Body row, with optional action, selection and tree state | +| `Table.Cell` | `{children}, + }; + + return ( + + {useRender({ + defaultTagName: 'thead', + render, + ref, + props: mergeProps<'thead'>(defaultProps, otherProps), + })} + + ); + }, +); diff --git a/packages/mosaic/src/primitives/table/table-root.tsx b/packages/mosaic/src/primitives/table/table-root.tsx new file mode 100644 index 00000000000..816dd5ce25f --- /dev/null +++ b/packages/mosaic/src/primitives/table/table-root.tsx @@ -0,0 +1,76 @@ +'use client'; + +import React from 'react'; + +import { type ComponentProps, mergeProps, useRender } from '../utils'; +import { type TableCollection, TableContext, type TableContextValue } from './table-context'; +import { isTextEntry } from './table-dom'; +import { useGridNavigation } from './use-grid-navigation'; + +export interface TableRootProps extends ComponentProps<'table'> { + /** The collection from `useDataTable`. It owns the rows, their selection and their expanded state. */ + table?: TableCollection; +} + +export const TableRoot = React.forwardRef(function TableRoot(props, ref) { + const { render, table = null, ...otherProps } = props; + const [element, setElement] = React.useState(null); + const [actionCount, setActionCount] = React.useState(0); + const excluded = React.useMemo(() => new WeakSet(), []); + + const selectionMode = table?.selectionMode ?? 'none'; + const tree = table?.rows.some(row => row.depth > 0 || row.getCanExpand()) ?? false; + const hasSelection = table ? Object.values(table.rowSelection).some(Boolean) : false; + const interactive = tree || selectionMode !== 'none' || actionCount > 0; + + useGridNavigation(element, interactive, excluded); + + const registerAction = React.useCallback(() => { + setActionCount(count => count + 1); + return () => setActionCount(count => count - 1); + }, []); + + const excludeFromNavigation = React.useCallback((node: Element) => excluded.add(node), [excluded]); + + const contextValue = React.useMemo( + () => ({ table, tree, hasSelection, registerAction, excludeFromNavigation }), + [table, tree, hasSelection, registerAction, excludeFromNavigation], + ); + + const defaultProps: Record = { + role: tree ? 'treegrid' : interactive ? 'grid' : undefined, + 'aria-multiselectable': selectionMode === 'multiple' || undefined, + onKeyDown: (event: React.KeyboardEvent) => { + const { target, currentTarget } = event; + if (!table || !(target instanceof Element) || !currentTarget.contains(target)) { + return; + } + if (event.key === 'Escape' && hasSelection) { + event.preventDefault(); + event.stopPropagation(); + table.setRowSelection({}); + } else if ( + event.key === 'a' && + (event.ctrlKey || event.metaKey) && + selectionMode === 'multiple' && + !isTextEntry(target) + ) { + event.preventDefault(); + if (!table.getIsAllRowsSelected()) { + table.toggleAllRowsSelected(); + } + } + }, + }; + + return ( + + {useRender({ + defaultTagName: 'table', + render, + ref: [setElement, ref], + props: mergeProps<'table'>(defaultProps, otherProps), + })} + + ); +}); diff --git a/packages/mosaic/src/primitives/table/table-row.tsx b/packages/mosaic/src/primitives/table/table-row.tsx new file mode 100644 index 00000000000..80c8ec33565 --- /dev/null +++ b/packages/mosaic/src/primitives/table/table-row.tsx @@ -0,0 +1,108 @@ +'use client'; + +import React from 'react'; + +import type { DataTableRow } from '../hooks/use-data-table'; +import { type ComponentProps, mergeProps, useRender } from '../utils'; +import { TableRowContext, type TableRowContextValue, useTableContext } from './table-context'; +import { isRowOwnClick, isRtl, openLink, type TableLink } from './table-dom'; + +export interface TableRowProps extends ComponentProps<'tr'>, Partial { + /** The row from `useDataTable`. It drives the row's selection and tree state. */ + row?: DataTableRow; + /** Removes the row from keyboard navigation, selection and its action. @default false */ + disabled?: boolean; +} + +export const TableRow = React.forwardRef(function TableRow(props, ref) { + const { render, row = null, disabled = false, href, target, rel, download, onClick, ...otherProps } = props; + const { tree, hasSelection, registerAction } = useTableContext(); + + const actionable = !disabled && (onClick !== undefined || href !== undefined); + const canSelect = !disabled && (row?.getCanSelect() ?? false); + const canExpand = row?.getCanExpand() ?? false; + const expanded = row?.getIsExpanded() ?? false; + const selected = row?.getIsSelected() ?? false; + + React.useLayoutEffect(() => (actionable ? registerAction() : undefined), [actionable, registerAction]); + + const runAction = (event: React.MouseEvent) => { + onClick?.(event); + if (href !== undefined && !event.defaultPrevented) { + openLink(event.currentTarget, { href, target, rel, download }, event); + } + }; + + const defaultProps: Record = { + 'aria-selected': row?.getCanSelect() ? selected : undefined, + 'aria-disabled': disabled || undefined, + ...(tree && row + ? { + 'aria-level': row.depth + 1, + 'aria-posinset': row.index + 1, + 'aria-setsize': row.setSize, + 'aria-expanded': canExpand ? expanded : undefined, + } + : {}), + style: row ? { '--table-row-depth': row.depth } : undefined, + onClick: (event: React.MouseEvent) => { + if (disabled || !isRowOwnClick(event)) { + return; + } + const pointer = event.detail !== 0; + if (canSelect && pointer && (hasSelection || !actionable)) { + row?.toggleSelected({ range: event.shiftKey }); + } else if (actionable) { + runAction(event); + } + }, + // eslint-disable-next-line sonarjs/cognitive-complexity -- One pass over the row's keydown; each branch handles a separate key. + onKeyDown: (event: React.KeyboardEvent) => { + const element = event.currentTarget; + const onRow = event.target === element; + const onCell = event.target instanceof HTMLTableCellElement && event.target.parentElement === element; + if (event.defaultPrevented || disabled || !(onRow || onCell)) { + return; + } + if (event.key === 'Enter') { + event.preventDefault(); + if (actionable) { + element.click(); + } else if (canSelect) { + row?.toggleSelected(); + } + } else if (event.key === ' ' && canSelect) { + event.preventDefault(); + row?.toggleSelected({ range: event.shiftKey }); + } else if (onRow && canExpand) { + const [expandKey, collapseKey] = isRtl(element) ? ['ArrowLeft', 'ArrowRight'] : ['ArrowRight', 'ArrowLeft']; + if ((event.key === expandKey && !expanded) || (event.key === collapseKey && expanded)) { + event.preventDefault(); + row?.toggleExpanded(); + } + } + }, + }; + + const rowContext = React.useMemo(() => ({ row, disabled }), [row, disabled]); + + const state = { selected, disabled, expanded, actionable }; + + return ( + + {useRender({ + defaultTagName: 'tr', + render, + ref, + state, + stateAttributesMapping: { + selected: (v: boolean) => (v ? { 'data-selected': '' } : null), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), + expanded: (v: boolean) => (v ? { 'data-expanded': '' } : null), + actionable: (v: boolean) => (v ? { 'data-actionable': '' } : null), + }, + props: mergeProps<'tr'>(defaultProps, otherProps), + })} + + ); +}); diff --git a/packages/mosaic/src/primitives/table/table-selection-checkbox.tsx b/packages/mosaic/src/primitives/table/table-selection-checkbox.tsx new file mode 100644 index 00000000000..30b6b212c9a --- /dev/null +++ b/packages/mosaic/src/primitives/table/table-selection-checkbox.tsx @@ -0,0 +1,74 @@ +'use client'; + +import React from 'react'; + +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../utils'; +import { TableHeaderContext, TableRowContext, useTableContext } from './table-context'; + +export type TableSelectionCheckboxProps = ComponentProps<'input'>; + +export const TableSelectionCheckbox = React.forwardRef( + function TableSelectionCheckbox(props, ref) { + const { render, ...otherProps } = props; + const { enabled, checked, indeterminate, disabled, toggle } = useSelectionCheckboxState(); + + const setIndeterminate = React.useCallback( + (node: HTMLInputElement | null) => { + if (node) { + node.indeterminate = indeterminate; + } + }, + [indeterminate], + ); + + const ownProps = { + type: 'checkbox', + checked, + disabled, + onChange: (event: React.ChangeEvent) => { + toggle({ range: 'shiftKey' in event.nativeEvent && event.nativeEvent.shiftKey === true }); + }, + } satisfies DefaultProps<'input'>; + + const state = { checked, indeterminate, disabled }; + + return useRender({ + defaultTagName: 'input', + render, + enabled, + ref: [setIndeterminate, ref], + state, + stateAttributesMapping: { + checked: (v: boolean) => (v ? { 'data-checked': '' } : null), + indeterminate: (v: boolean) => (v ? { 'data-indeterminate': '' } : null), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), + }, + props: mergeProps<'input'>(ownProps, otherProps), + }); + }, +); + +function useSelectionCheckboxState() { + const { table } = useTableContext(); + const rowContext = React.useContext(TableRowContext); + const inHeader = React.useContext(TableHeaderContext); + const row = rowContext?.row; + + if (row && rowContext) { + return { + enabled: row.getCanSelect() || table?.selectionMode !== 'none', + checked: row.getIsSelected(), + indeterminate: false, + disabled: rowContext.disabled || !row.getCanSelect(), + toggle: row.toggleSelected, + }; + } + const selectAll = inHeader && table?.selectionMode === 'multiple'; + return { + enabled: Boolean(selectAll), + checked: table?.getIsAllRowsSelected() ?? false, + indeterminate: table?.getIsSomeRowsSelected() ?? false, + disabled: table ? !table.rows.some(tableRow => tableRow.getCanSelect()) : true, + toggle: () => table?.toggleAllRowsSelected(), + }; +} diff --git a/packages/mosaic/src/primitives/table/table.test.tsx b/packages/mosaic/src/primitives/table/table.test.tsx new file mode 100644 index 00000000000..31eae869f01 --- /dev/null +++ b/packages/mosaic/src/primitives/table/table.test.tsx @@ -0,0 +1,763 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'; + +import { type ExpandedState, type SelectionMode, useDataTable, type UseDataTableOptions } from '../hooks'; +import { Menu } from '../menu'; +import { axe } from '../test-utils/axe'; +import { Table, type TableRowProps } from './index'; + +afterEach(() => cleanup()); + +type Log = { id: string; message: string; children?: Log[] }; + +const LOGS: Log[] = [ + { + id: 'a', + message: 'Alpha', + children: [ + { id: 'a1', message: 'Alpha one' }, + { id: 'a2', message: 'Alpha two' }, + ], + }, + { id: 'b', message: 'Bravo' }, + { id: 'c', message: 'Charlie' }, +]; + +const FLAT_LOGS = LOGS.map(({ id, message }) => ({ id, message })); + +function LogsTable({ + data = FLAT_LOGS, + selectionMode = 'none', + rowProps, + options, +}: { + data?: Log[]; + selectionMode?: SelectionMode; + rowProps?: (log: Log) => Omit; + options?: Partial>; +}) { + const table = useDataTable({ + data, + getRowId: log => log.id, + getSubRows: log => log.children, + selectionMode, + ...options, + }); + return ( + <> + + + + {selectionMode === 'none' ? null : ( + + + + )} + Message + Actions + + + {table.rows.map(row => ( + + {selectionMode === 'none' ? null : ( + + + + )} + + + {row.original.message} + + + + + + ))} + + + + + ); +} + +const row = (name: string) => { + const tableRow = screen.getByText(name).closest('tr'); + if (!tableRow) { + throw new Error(`No row for ${name}`); + } + return tableRow; +}; +const firstCell = (name: string) => row(name).cells[0]; +const checkbox = (name: string) => screen.getByRole('checkbox', { name: `Select ${name}` }); +const focused = () => document.activeElement; + +async function tabIntoTable(user: ReturnType) { + await user.click(screen.getByRole('button', { name: 'Before' })); + await user.tab(); +} + +describe('Table', () => { + describe('role', () => { + it('is a plain table with no tab stops when nothing is interactive', () => { + render(); + + expect(screen.getByRole('table', { name: 'Logs' })).not.toHaveAttribute('role'); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect(row('Alpha')).not.toHaveAttribute('tabindex'); + expect(screen.getByRole('button', { name: 'Copy Alpha' })).not.toHaveAttribute('tabindex'); + }); + + it('is a grid when rows can be selected', () => { + render(); + + expect(screen.getByRole('grid', { name: 'Logs' })).toBeInTheDocument(); + }); + + it('is a grid when a row has an action', () => { + render( (log.id === 'b' ? { onClick: () => {} } : {})} />); + + expect(screen.getByRole('grid', { name: 'Logs' })).toBeInTheDocument(); + }); + + it('is a treegrid when rows can expand', () => { + render(); + + expect(screen.getByRole('treegrid', { name: 'Logs' })).toBeInTheDocument(); + }); + }); + + describe('row actions', () => { + it('calls onClick when the row is clicked, but not from a nested control or portaled content', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( ({ onClick })} />); + + await user.click(screen.getByRole('button', { name: 'Copy Bravo' })); + expect(onClick).not.toHaveBeenCalled(); + + await user.click(screen.getByText('Bravo')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('ignores clicks from portaled content rendered inside the row', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + function PortalRow() { + const table = useDataTable({ data: FLAT_LOGS, getRowId: log => log.id, selectionMode: 'none' }); + return ( + + + + + + Actions + + + + Archive + + + + + + + + + ); + } + render(); + + await user.click(screen.getByRole('button', { name: 'Actions' })); + await user.click(await screen.findByRole('menuitem', { name: 'Archive' })); + + expect(onClick).not.toHaveBeenCalled(); + }); + + it('runs the action with Enter on a focused row or cell', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( ({ onClick })} />); + + await tabIntoTable(user); + expect(row('Alpha')).toHaveFocus(); + await user.keyboard('{Enter}'); + expect(onClick).toHaveBeenCalledTimes(1); + + await user.keyboard('{ArrowRight}'); + expect(firstCell('Alpha')).toHaveFocus(); + await user.keyboard('{Enter}'); + expect(onClick).toHaveBeenCalledTimes(2); + }); + + it('follows href with the same modifier keys, target, rel and download', async () => { + const user = userEvent.setup(); + const clicks: Array<{ href: string; target: string; rel: string; download: string | null; meta: boolean }> = []; + const onDocumentClick = (event: MouseEvent) => { + if (event.target instanceof HTMLAnchorElement) { + event.preventDefault(); + clicks.push({ + href: event.target.getAttribute('href') ?? '', + target: event.target.target, + rel: event.target.rel, + download: event.target.getAttribute('download'), + meta: event.metaKey, + }); + } + }; + document.addEventListener('click', onDocumentClick); + render( + + log.id === 'a' + ? { href: '/logs/a' } + : { href: `/logs/${log.id}`, target: '_blank', rel: 'noreferrer', download: 'log.json' } + } + />, + ); + + await user.click(screen.getByText('Alpha')); + await user.keyboard('{Meta>}'); + await user.click(screen.getByText('Alpha')); + await user.keyboard('{/Meta}'); + await user.click(screen.getByText('Bravo')); + document.removeEventListener('click', onDocumentClick); + + expect(clicks).toEqual([ + { href: '/logs/a', target: '', rel: '', download: null, meta: false }, + { href: '/logs/a', target: '', rel: '', download: null, meta: true }, + { href: '/logs/b', target: '_blank', rel: 'noreferrer', download: 'log.json', meta: false }, + ]); + }); + + it('does not follow href when onClick prevents default', async () => { + const user = userEvent.setup(); + const onDocumentClick = vi.fn((event: MouseEvent) => event.preventDefault()); + document.addEventListener('click', onDocumentClick); + render( ({ href: '/logs', onClick: event => event.preventDefault() })} />); + + await user.click(screen.getByText('Alpha')); + document.removeEventListener('click', onDocumentClick); + + expect(onDocumentClick).toHaveBeenCalledTimes(1); + }); + + it('does not run the action of a disabled row and skips it in keyboard navigation', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( ({ onClick, disabled: log.id === 'b' })} />); + + expect(row('Bravo')).toHaveAttribute('aria-disabled', 'true'); + await user.click(screen.getByText('Bravo')); + expect(onClick).not.toHaveBeenCalled(); + + await tabIntoTable(user); + await user.keyboard('{ArrowDown}'); + expect(row('Charlie')).toHaveFocus(); + }); + }); + + describe('selection', () => { + it('toggles selection with the checkbox', async () => { + const user = userEvent.setup(); + render(); + + await user.click(checkbox('Alpha')); + expect(checkbox('Alpha')).toBeChecked(); + expect(row('Alpha')).toHaveAttribute('aria-selected', 'true'); + expect(row('Bravo')).toHaveAttribute('aria-selected', 'false'); + + await user.click(checkbox('Alpha')); + expect(row('Alpha')).toHaveAttribute('aria-selected', 'false'); + }); + + it('toggles selection with Space and selects a range with Shift+Space', async () => { + const user = userEvent.setup(); + render(); + + await tabIntoTable(user); + await user.keyboard(' '); + expect(row('Alpha')).toHaveAttribute('aria-selected', 'true'); + + await user.keyboard('{ArrowDown}{ArrowDown}{Shift>} {/Shift}'); + expect(row('Bravo')).toHaveAttribute('aria-selected', 'true'); + expect(row('Charlie')).toHaveAttribute('aria-selected', 'true'); + }); + + it('selects a range with Shift-click on a checkbox', async () => { + const user = userEvent.setup(); + render(); + + await user.click(checkbox('Alpha')); + await user.keyboard('{Shift>}'); + await user.click(checkbox('Charlie')); + await user.keyboard('{/Shift}'); + + expect(checkbox('Bravo')).toBeChecked(); + }); + + it('toggles selection on row click when the row has no action', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Bravo')); + + expect(checkbox('Bravo')).toBeChecked(); + }); + + it('runs the action on click until a row is selected, then toggles selection', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + ({ onClick })} + />, + ); + + await user.click(screen.getByText('Alpha')); + expect(onClick).toHaveBeenCalledTimes(1); + expect(checkbox('Alpha')).not.toBeChecked(); + + await user.click(checkbox('Alpha')); + await user.click(screen.getByText('Bravo')); + expect(onClick).toHaveBeenCalledTimes(1); + expect(checkbox('Bravo')).toBeChecked(); + + await user.keyboard('{Enter}'); + expect(onClick).toHaveBeenCalledTimes(2); + }); + + it('does not select rows the table marks as not selectable', async () => { + const user = userEvent.setup(); + render( + log.id !== 'b' }} + />, + ); + + expect(checkbox('Bravo')).toBeDisabled(); + expect(row('Bravo')).not.toHaveAttribute('aria-selected'); + await tabIntoTable(user); + await user.keyboard('{ArrowDown} '); + await user.click(screen.getByText('Bravo')); + + expect(checkbox('Bravo')).not.toBeChecked(); + }); + + it('drives the select-all checkbox from the row selection', async () => { + const user = userEvent.setup(); + render( + log.id !== 'b' }} + />, + ); + const selectAll = screen.getByRole('checkbox', { name: 'Select all' }); + + expect(selectAll).not.toBeChecked(); + await user.click(checkbox('Alpha')); + expect(selectAll).toHaveProperty('indeterminate', true); + + await user.click(selectAll); + expect(selectAll).toBeChecked(); + expect(selectAll).toHaveProperty('indeterminate', false); + expect(checkbox('Charlie')).toBeChecked(); + expect(checkbox('Bravo')).not.toBeChecked(); + + await user.click(selectAll); + expect(checkbox('Alpha')).not.toBeChecked(); + }); + + it('replaces the selection in single mode and hides select all', async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByRole('checkbox', { name: 'Select all' })).not.toBeInTheDocument(); + await user.click(checkbox('Alpha')); + await user.click(checkbox('Bravo')); + + expect(checkbox('Alpha')).not.toBeChecked(); + expect(checkbox('Bravo')).toBeChecked(); + }); + + it('selects all with Ctrl+A and clears with Escape', async () => { + const user = userEvent.setup(); + const onKeyDown = vi.fn(); + const onDocumentKeyDown = (event: KeyboardEvent) => onKeyDown(event.key); + document.addEventListener('keydown', onDocumentKeyDown); + onTestFinished(() => document.removeEventListener('keydown', onDocumentKeyDown)); + render(); + + await tabIntoTable(user); + await user.keyboard('{Control>}a{/Control}'); + expect(screen.getAllByRole('checkbox').every(box => box instanceof HTMLInputElement && box.checked)).toBe(true); + + await user.keyboard('{Escape}'); + expect(screen.getAllByRole('checkbox').some(box => box instanceof HTMLInputElement && box.checked)).toBe(false); + expect(onKeyDown).not.toHaveBeenCalledWith('Escape'); + + await user.keyboard('{Escape}'); + expect(onKeyDown).toHaveBeenCalledWith('Escape'); + }); + + it('works with controlled row selection', async () => { + const user = userEvent.setup(); + function Controlled() { + const [rowSelection, setRowSelection] = useState>({ c: true }); + return ( + + ); + } + render(); + + expect(checkbox('Charlie')).toBeChecked(); + await user.click(checkbox('Alpha')); + + expect(checkbox('Alpha')).toBeChecked(); + expect(checkbox('Charlie')).toBeChecked(); + }); + }); + + describe('tree rows', () => { + it('describes each row in the tree and renders children only when expanded', async () => { + const user = userEvent.setup(); + render(); + + expect(row('Alpha')).toHaveAttribute('aria-expanded', 'false'); + expect(row('Alpha')).toHaveAttribute('aria-level', '1'); + expect(row('Alpha')).toHaveAttribute('aria-posinset', '1'); + expect(row('Alpha')).toHaveAttribute('aria-setsize', '3'); + expect(row('Bravo')).not.toHaveAttribute('aria-expanded'); + expect(screen.queryByText('Alpha one')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Expand Bravo' })).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Expand Alpha' })); + + expect(row('Alpha')).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('button', { name: 'Expand Alpha' })).toHaveAttribute('aria-expanded', 'true'); + expect(row('Alpha two')).toHaveAttribute('aria-level', '2'); + expect(row('Alpha two')).toHaveAttribute('aria-posinset', '2'); + expect(row('Alpha two')).toHaveAttribute('aria-setsize', '2'); + expect(row('Alpha two').style.getPropertyValue('--table-row-depth')).toBe('1'); + }); + + it('keeps the expand button out of the tab order and does not run the row action', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + ({ onClick })} + />, + ); + + expect(screen.getByRole('button', { name: 'Expand Alpha' })).toHaveAttribute('tabindex', '-1'); + await user.click(screen.getByRole('button', { name: 'Expand Alpha' })); + + expect(onClick).not.toHaveBeenCalled(); + }); + + it('expands with the forward arrow, collapses with the back arrow and moves to the parent', async () => { + const user = userEvent.setup(); + render(); + + await tabIntoTable(user); + await user.keyboard('{ArrowRight}'); + expect(row('Alpha')).toHaveAttribute('aria-expanded', 'true'); + expect(row('Alpha')).toHaveFocus(); + + await user.keyboard('{ArrowRight}'); + expect(firstCell('Alpha')).toHaveFocus(); + await user.keyboard('{ArrowLeft}{ArrowDown}{ArrowDown}'); + expect(row('Alpha two')).toHaveFocus(); + + await user.keyboard('{ArrowLeft}'); + expect(row('Alpha')).toHaveFocus(); + await user.keyboard('{ArrowLeft}'); + expect(row('Alpha')).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('Alpha two')).not.toBeInTheDocument(); + }); + + it('flips the arrow keys in a right-to-left table', async () => { + const user = userEvent.setup(); + render( +
+ +
, + ); + screen.getByRole('treegrid').style.direction = 'rtl'; + + await tabIntoTable(user); + await user.keyboard('{ArrowLeft}'); + expect(row('Alpha')).toHaveAttribute('aria-expanded', 'true'); + + await user.keyboard('{ArrowLeft}'); + expect(firstCell('Alpha')).toHaveFocus(); + await user.keyboard('{ArrowLeft}'); + expect(screen.getByRole('button', { name: 'Copy Alpha' })).toHaveFocus(); + }); + + it('works with controlled expanded state', async () => { + const user = userEvent.setup(); + const onExpandedChange = vi.fn(); + function Controlled() { + const [expanded, setExpanded] = useState({ a: true }); + return ( + { + onExpandedChange(next); + setExpanded(next); + }, + }} + /> + ); + } + render(); + + expect(screen.getByText('Alpha one')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Expand Alpha' })); + + expect(onExpandedChange).toHaveBeenCalledWith({ a: false }); + expect(screen.queryByText('Alpha one')).not.toBeInTheDocument(); + }); + }); + + describe('keyboard navigation', () => { + it('has a single tab stop that starts on the first body row', async () => { + const user = userEvent.setup(); + render(); + + await tabIntoTable(user); + expect(row('Alpha')).toHaveFocus(); + await user.tab(); + expect(screen.getByRole('button', { name: 'After' })).toHaveFocus(); + }); + + it('moves between rows and cells with the arrow keys', async () => { + const user = userEvent.setup(); + render(); + + await tabIntoTable(user); + await user.keyboard('{ArrowDown}'); + expect(row('Bravo')).toHaveFocus(); + await user.keyboard('{ArrowRight}'); + expect(checkbox('Bravo')).toHaveFocus(); + await user.keyboard('{ArrowRight}'); + expect(focused()?.tagName).toBe('TD'); + expect(focused()).toHaveTextContent('Bravo'); + await user.keyboard('{ArrowRight}'); + expect(screen.getByRole('button', { name: 'Copy Bravo' })).toHaveFocus(); + await user.keyboard('{ArrowRight}'); + expect(row('Bravo')).toHaveFocus(); + await user.keyboard('{ArrowLeft}'); + expect(screen.getByRole('button', { name: 'Copy Bravo' })).toHaveFocus(); + await user.keyboard('{ArrowUp}{ArrowUp}'); + expect(focused()).toHaveTextContent('Actions'); + }); + + it('moves from the first row to the first column header', async () => { + const user = userEvent.setup(); + render(); + + await tabIntoTable(user); + await user.keyboard('{ArrowUp}'); + + expect(screen.getByRole('checkbox', { name: 'Select all' })).toHaveFocus(); + }); + + it('jumps with Home and End, and to the corners with Ctrl', async () => { + const user = userEvent.setup(); + render(); + + await tabIntoTable(user); + await user.keyboard('{End}'); + expect(row('Charlie')).toHaveFocus(); + await user.keyboard('{Home}'); + expect(row('Alpha')).toHaveFocus(); + await user.keyboard('{ArrowRight}{End}'); + expect(screen.getByRole('button', { name: 'Copy Alpha' })).toHaveFocus(); + await user.keyboard('{Home}'); + expect(checkbox('Alpha')).toHaveFocus(); + await user.keyboard('{Control>}{End}{/Control}'); + expect(screen.getByRole('button', { name: 'Copy Charlie' })).toHaveFocus(); + await user.keyboard('{Control>}{Home}{/Control}'); + expect(screen.getByRole('checkbox', { name: 'Select all' })).toHaveFocus(); + }); + + it('returns to the last focused item when tabbed back into', async () => { + const user = userEvent.setup(); + render(); + + await tabIntoTable(user); + await user.keyboard('{ArrowDown}{ArrowRight}{ArrowRight}{ArrowRight}'); + await user.tab(); + expect(screen.getByRole('button', { name: 'After' })).toHaveFocus(); + await user.tab({ shift: true }); + + expect(screen.getByRole('button', { name: 'Copy Bravo' })).toHaveFocus(); + }); + + it('enters a cell of several controls with Enter and leaves it with Escape', async () => { + const user = userEvent.setup(); + function Controls() { + const table = useDataTable({ data: FLAT_LOGS, getRowId: log => log.id }); + return ( + + + {table.rows.map(tableRow => ( + + + + + + + + + + ))} + + + ); + } + render(); + + await user.tab(); + await user.keyboard('{ArrowRight}'); + const cell = focused(); + expect(cell).toContainElement(screen.getByRole('button', { name: 'Edit Alpha' })); + await user.keyboard('{Enter}'); + expect(screen.getByRole('button', { name: 'Edit Alpha' })).toHaveFocus(); + await user.tab(); + expect(screen.getByRole('button', { name: 'Remove Alpha' })).toHaveFocus(); + await user.keyboard('{ArrowDown}'); + expect(screen.getByRole('button', { name: 'Remove Alpha' })).toHaveFocus(); + await user.keyboard('{Escape}'); + expect(cell).toHaveFocus(); + + await user.keyboard('{ArrowRight}{F2}'); + const input = screen.getByRole('textbox', { name: 'Alpha note' }); + expect(input).toHaveFocus(); + await user.keyboard('hi{ArrowLeft}{ArrowDown}'); + expect(input).toHaveFocus(); + expect(input).toHaveValue('hi'); + }); + + it('opens a cell menu with Enter and leaves its arrow keys alone', async () => { + const user = userEvent.setup(); + function MenuTable() { + const table = useDataTable({ data: FLAT_LOGS, getRowId: log => log.id }); + return ( + + + {table.rows.map(tableRow => ( + + + + {`${tableRow.original.message} actions`} + + + Archive + + + + + + ))} + + + ); + } + render(); + + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(screen.getByRole('button', { name: 'Alpha actions' })).toHaveFocus(); + await user.keyboard('{Enter}'); + const item = await screen.findByRole('menuitem', { name: 'Archive' }); + await waitFor(() => expect(item).toHaveFocus()); + await user.keyboard('{ArrowDown}'); + + expect(screen.getByRole('menuitem', { name: 'Archive' })).toBeInTheDocument(); + }); + + it('keeps a tab stop when the focused row is removed', async () => { + const user = userEvent.setup(); + const { rerender } = render(); + + await tabIntoTable(user); + await user.keyboard('{End}'); + rerender( + , + ); + await tabIntoTable(user); + + expect(row('Bravo')).toHaveFocus(); + }); + }); + + describe('accessibility', () => { + it.each([ + ['plain', {}], + ['grid', { selectionMode: 'multiple' as const }], + ['treegrid', { data: LOGS, options: { defaultExpanded: { a: true } } }], + ['grid with actions', { rowProps: () => ({ href: '/logs' }) }], + ])('has no axe violations as a %s', async (_, props) => { + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); + }); + + it('throws when a part is rendered outside Table.Root', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => + render( +
` | Column header | +| `Table.Body` | `
` | Cell | +| `Table.SelectionCheckbox` | `` | Selects its row, or all rows when inside `Table.Header` | +| `Table.ExpandButton` | `
+ + + + + +
, + ), + ).toThrow('Table compound components must be used within '); + }); +}); diff --git a/packages/mosaic/src/primitives/table/use-grid-navigation.ts b/packages/mosaic/src/primitives/table/use-grid-navigation.ts new file mode 100644 index 00000000000..2d801202c6e --- /dev/null +++ b/packages/mosaic/src/primitives/table/use-grid-navigation.ts @@ -0,0 +1,305 @@ +import { contains } from '@floating-ui/react/utils'; +import React from 'react'; + +import { isRtl, isTextEntry } from './table-dom'; + +const WIDGET_SELECTOR = 'button, a[href], input, select, textarea, [tabindex]'; + +interface Position { + row: number; + column: number; +} + +const ROW_COLUMN = -1; + +export function useGridNavigation(table: HTMLTableElement | null, enabled: boolean, excluded: WeakSet) { + const activeRef = React.useRef(null); + const positionRef = React.useRef(null); + const editingRef = React.useRef(null); + + const sync = React.useCallback(() => { + if (!table) { + return; + } + if (!activeRef.current?.isConnected || !contains(table, activeRef.current)) { + activeRef.current = itemAt(table, positionRef.current ?? initialPosition(table), excluded) ?? null; + } + for (const row of Array.from(table.rows)) { + syncRow(row, activeRef.current, editingRef.current, excluded); + } + }, [table, excluded]); + + React.useLayoutEffect(() => { + if (enabled) { + sync(); + } + }); + + React.useLayoutEffect(() => { + if (!enabled || !table) { + return; + } + const observer = new MutationObserver(sync); + observer.observe(table, { childList: true, subtree: true }); + + const activate = (element: HTMLElement, position: Position, editing: HTMLTableCellElement | null) => { + activeRef.current = element; + positionRef.current = position; + editingRef.current = editing; + sync(); + }; + + const moveTo = (position: Position) => { + const element = itemAt(table, position, excluded); + if (element) { + activate(element, positionOf(element), null); + element.focus(); + } + }; + + const onFocusIn = (event: FocusEvent) => { + const { target } = event; + if (!(target instanceof HTMLElement)) { + return; + } + if (target instanceof HTMLTableRowElement && isRowItem(target) && target.closest('table') === table) { + activate(target, positionOf(target), null); + return; + } + const cell = owningCell(table, target); + if (!cell) { + return; + } + if (target === cell || target === focusTarget(cell, excluded)) { + activate(target, positionOf(cell), null); + } else { + activate(cell, positionOf(cell), cell); + } + }; + + // eslint-disable-next-line sonarjs/cognitive-complexity -- One pass over the grid's keydown: editing mode, entering a cell, then movement. + const onKeyDown = (event: KeyboardEvent) => { + const { target } = event; + if (event.defaultPrevented || event.altKey || !(target instanceof HTMLElement)) { + return; + } + const row = target instanceof HTMLTableRowElement && target.closest('table') === table ? target : null; + const cell = row ? null : owningCell(table, target); + + if (cell && editingRef.current === cell) { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + activate(cell, positionOf(cell), null); + cell.focus(); + } + return; + } + + if (cell && target === cell && (event.key === 'Enter' || event.key === 'F2')) { + const [firstWidget] = cellWidgets(cell, excluded); + if (firstWidget) { + event.preventDefault(); + activate(cell, positionOf(cell), cell); + firstWidget.focus(); + } + return; + } + + const next = row ? rowMove(table, row, event) : cell ? cellMove(table, cell, event) : null; + if (!next) { + return; + } + event.preventDefault(); + event.stopPropagation(); + moveTo(next); + }; + + table.addEventListener('focusin', onFocusIn); + table.addEventListener('keydown', onKeyDown, true); + return () => { + observer.disconnect(); + table.removeEventListener('focusin', onFocusIn); + table.removeEventListener('keydown', onKeyDown, true); + }; + }, [enabled, table, excluded, sync]); +} + +function syncRow( + row: HTMLTableRowElement, + active: HTMLElement | null, + editing: HTMLTableCellElement | null, + excluded: WeakSet, +) { + const disabled = isDisabledRow(row); + if (isRowItem(row)) { + setTabIndex(row, disabled ? null : row === active); + } + for (const cell of Array.from(row.cells)) { + setTabIndex(cell, disabled ? null : cell === active); + for (const widget of cellWidgets(cell, excluded)) { + widget.tabIndex = widget === active || cell === editing ? 0 : -1; + } + } +} + +function rowMove(table: HTMLTableElement, row: HTMLTableRowElement, event: KeyboardEvent): Position | null { + const { forward, backward } = arrowKeys(table); + const expanded = row.getAttribute('aria-expanded'); + switch (event.key) { + case forward: + return expanded === 'false' ? null : { row: row.rowIndex, column: 0 }; + case backward: { + if (expanded === 'true') { + return null; + } + const parent = parentRow(row); + return parent ? { row: parent.rowIndex, column: ROW_COLUMN } : { row: row.rowIndex, column: Infinity }; + } + case 'ArrowDown': + return { row: nextRowIndex(table, row.rowIndex, 1), column: ROW_COLUMN }; + case 'ArrowUp': + return { row: nextRowIndex(table, row.rowIndex, -1), column: ROW_COLUMN }; + case 'Home': + return { row: edgeRowIndex(table, 1) ?? row.rowIndex, column: ROW_COLUMN }; + case 'End': + return { row: edgeRowIndex(table, -1) ?? row.rowIndex, column: ROW_COLUMN }; + default: + return null; + } +} + +function cellMove(table: HTMLTableElement, cell: HTMLTableCellElement, event: KeyboardEvent): Position | null { + const { forward, backward } = arrowKeys(table); + const row = rowOf(cell); + const column = cell.cellIndex; + const last = row.cells.length - 1; + const corner = event.ctrlKey || event.metaKey; + switch (event.key) { + case forward: + return { row: row.rowIndex, column: column === last && isRowItem(row) ? ROW_COLUMN : column + 1 }; + case backward: + return { row: row.rowIndex, column: column === 0 && isRowItem(row) ? ROW_COLUMN : column - 1 }; + case 'ArrowDown': + return { row: nextRowIndex(table, row.rowIndex, 1), column }; + case 'ArrowUp': + return { row: nextRowIndex(table, row.rowIndex, -1), column }; + case 'Home': + return corner ? { row: 0, column: 0 } : { row: row.rowIndex, column: 0 }; + case 'End': + return corner ? { row: table.rows.length - 1, column: Infinity } : { row: row.rowIndex, column: Infinity }; + default: + return null; + } +} + +function itemAt(table: HTMLTableElement, position: Position, excluded: WeakSet): HTMLElement | undefined { + const rows = table.rows; + const row = rows[clamp(position.row, 0, rows.length - 1)]; + if (!row) { + return undefined; + } + if (isDisabledRow(row)) { + const fallback = initialPosition(table); + return fallback.row === row.rowIndex ? undefined : itemAt(table, fallback, excluded); + } + if (position.column === ROW_COLUMN && isRowItem(row)) { + return row; + } + const cell = row.cells[clamp(position.column, 0, row.cells.length - 1)]; + return cell ? focusTarget(cell, excluded) : undefined; +} + +function initialPosition(table: HTMLTableElement): Position { + const row = edgeRowIndex(table, 1); + return row === undefined ? { row: 0, column: 0 } : { row, column: ROW_COLUMN }; +} + +function positionOf(element: HTMLElement): Position { + if (element instanceof HTMLTableRowElement) { + return { row: element.rowIndex, column: ROW_COLUMN }; + } + const cell = element.closest('td, th'); + return cell instanceof HTMLTableCellElement + ? { row: rowOf(cell).rowIndex, column: cell.cellIndex } + : { row: 0, column: 0 }; +} + +function nextRowIndex(table: HTMLTableElement, from: number, step: 1 | -1) { + for (let index = from + step; index >= 0 && index < table.rows.length; index += step) { + const row = table.rows[index]; + if (row && !isDisabledRow(row)) { + return index; + } + } + return from; +} + +function edgeRowIndex(table: HTMLTableElement, step: 1 | -1) { + const rows = Array.from(table.rows).filter(row => isRowItem(row) && !isDisabledRow(row)); + return (step === 1 ? rows[0] : rows[rows.length - 1])?.rowIndex; +} + +function parentRow(row: HTMLTableRowElement) { + const level = Number(row.getAttribute('aria-level')); + if (!(level > 1)) { + return null; + } + for (let sibling = row.previousElementSibling; sibling; sibling = sibling.previousElementSibling) { + if (sibling instanceof HTMLTableRowElement && Number(sibling.getAttribute('aria-level')) === level - 1) { + return sibling; + } + } + return null; +} + +function cellWidgets(cell: HTMLTableCellElement, excluded: WeakSet): HTMLElement[] { + return Array.from(cell.querySelectorAll(WIDGET_SELECTOR)).filter( + widget => !excluded.has(widget) && !widget.matches(':disabled, [data-floating-ui-focus-guard]'), + ); +} + +function focusTarget(cell: HTMLTableCellElement, excluded: WeakSet): HTMLElement { + const widgets = cellWidgets(cell, excluded); + const [only] = widgets; + return widgets.length === 1 && only && !isTextEntry(only) ? only : cell; +} + +function owningCell(table: HTMLTableElement, node: Element): HTMLTableCellElement | null { + const cell = node.closest('td, th'); + return cell instanceof HTMLTableCellElement && cell.closest('table') === table ? cell : null; +} + +function rowOf(cell: HTMLTableCellElement) { + const row = cell.parentElement; + if (!(row instanceof HTMLTableRowElement)) { + throw new Error('Table cells must be rendered inside a table row'); + } + return row; +} + +function isRowItem(row: HTMLTableRowElement) { + return row.parentElement instanceof HTMLTableSectionElement && row.parentElement.tagName === 'TBODY'; +} + +function isDisabledRow(row: HTMLTableRowElement) { + return row.getAttribute('aria-disabled') === 'true'; +} + +function arrowKeys(table: HTMLTableElement) { + return isRtl(table) + ? { forward: 'ArrowLeft', backward: 'ArrowRight' } + : { forward: 'ArrowRight', backward: 'ArrowLeft' }; +} + +function setTabIndex(element: HTMLElement, active: boolean | null) { + if (active === null) { + element.removeAttribute('tabindex'); + } else { + element.tabIndex = active ? 0 : -1; + } +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} From 22931bfe97f41b0ff95cbf22a3425bae74be7ee8 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Sun, 25 Oct 2026 17:42:16 -0400 Subject: [PATCH 02/14] feat(mosaic): label table rows, checkboxes and expand buttons by row header cells --- .../mosaic/src/primitives/table/README.md | 23 +++++++---- .../src/primitives/table/table-cell.tsx | 25 +++++++++--- .../src/primitives/table/table-context.ts | 15 ++++++- .../primitives/table/table-expand-button.tsx | 4 +- .../mosaic/src/primitives/table/table-row.tsx | 13 +++++- .../table/table-selection-checkbox.tsx | 8 +++- .../src/primitives/table/table.test.tsx | 40 +++++++++++++++---- 7 files changed, 102 insertions(+), 26 deletions(-) diff --git a/packages/mosaic/src/primitives/table/README.md b/packages/mosaic/src/primitives/table/README.md index f389d74a348..c0a72afea12 100644 --- a/packages/mosaic/src/primitives/table/README.md +++ b/packages/mosaic/src/primitives/table/README.md @@ -26,7 +26,7 @@ const table = useDataTable({ > - + Message @@ -38,10 +38,10 @@ const table = useDataTable({ href={`/logs/${row.id}`} > - + - - + + ▸ {row.original.message} @@ -50,6 +50,8 @@ const table = useDataTable({ ; ``` +Mark the cell that names the row with `rowHeader`. The row, its selection checkbox ("Select …") and its expand button ("Expand …" / "Collapse …") take their accessible names from it. + `table.rows` holds only the visible rows: a child row is in the list while its parent is expanded. Use `--table-row-depth` to indent child rows. ### Row actions @@ -69,7 +71,7 @@ Set `selectionMode` on `useDataTable` to `'single'`, `'multiple'` or `'none'`. W | `Table.Column` | `` | Column header | | `Table.Body` | `` | Body | | `Table.Row` | `` | Body row, with optional action, selection and tree state | -| `Table.Cell` | `` | Cell | +| `Table.Cell` | `` | Cell. `` with `rowHeader`. | | `Table.SelectionCheckbox` | `` | Selects its row, or all rows when inside `Table.Header` | | `Table.ExpandButton` | `