Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-table-primitive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
182 changes: 182 additions & 0 deletions packages/mosaic/src/primitives/table/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# Table

A native `<table>` with optional row actions, selection, sorting and expandable tree rows. `Table.Root` owns the selection, expanded and sort state; `Table.Body` renders a row per item. Behavior follows [React Aria's Table](https://react-aria.adobe.com/Table) and the [Tree grid](https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/) pattern.

## When to Use

- Tabular data where rows open a page, run an action, can be selected or sorted, or have child rows (for example, logs).
- A plain data table with none of these stays a plain `<table>` with no grid role or keyboard navigation.

## Usage

```tsx
import { Table } from '@/primitives/table';

<Table.Root
aria-label='Logs'
selectionMode='multiple'
sort={sort}
onSortChange={setSort}
>
<Table.Header>
<Table.Column>
<Table.SelectionCheckbox aria-label='Select all' />
</Table.Column>
<Table.Column
value='message'
sortable
rowHeader
>
Message
</Table.Column>
</Table.Header>
<Table.Body
items={logs}
itemToStringValue={log => log.id}
itemToChildren={log => log.children}
renderEmptyState={() => 'No logs'}
>
{log => (
<Table.Row href={`/logs/${log.id}`}>
<Table.Cell>
<Table.SelectionCheckbox aria-label='Select' />
</Table.Cell>
<Table.Cell>
<Table.ExpandButton aria-label='Expand'>▸</Table.ExpandButton>
{log.message}
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table.Root>;
```

Mark the column that names each row with `rowHeader`. Its cells render as `<th scope="row">`, and each row takes its accessible name from them. The selection checkbox and expand button add the row header after their own `aria-label`, so `aria-label='Select'` reads "Select GET /v1/me". Without an `aria-label` they are named by the row header alone. The header checkbox has no row header, so give it an `aria-label`.

### Tree rows

Pass `itemToChildren` to `Table.Body` to make the table a tree grid. The children of an expanded row render right after it. Use `--table-row-depth` to indent them, and `expandable` on `Table.Row` for a row whose children are not loaded yet.

### 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` 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. Select all covers the visible rows that are not disabled.

### Sorting

Give a column a `value` and `sortable`. Activating its header sorts by it ascending, and activating it again flips the direction. The table does not reorder the items; sort them yourself or on the server.

## Parts

| Part | Default Element | Description |
| ------------------------- | ----------------------- | ---------------------------------------------------------- |
| `Table.Root` | `<table>` | Owns selection, expanded and sort state, and keyboard nav |
| `Table.Header` | `<thead>` | Header, wraps its columns in a `<tr>` |
| `Table.Column` | `<th scope="col">` | Column header, optionally sortable |
| `Table.Body` | `<tbody>` | Renders a row per visible item, or the empty state |
| `Table.Row` | `<tr>` | Body row, with optional action, selection and tree state |
| `Table.Cell` | `<td>` | Cell. `<th scope="row">` in a `rowHeader` column. |
| `Table.SelectionCheckbox` | `<input type=checkbox>` | Selects its row, or all rows when inside `Table.Header` |
| `Table.ExpandButton` | `<button>` | Toggles its row's children. Renders only if it can expand. |

## Props

### `Table.Root`

| Prop | Type | Default | Description |
| ------------------- | ---------------------------------- | -------- | ------------------------------------------- |
| `selectionMode` | `'none' \| 'single' \| 'multiple'` | `'none'` | How many rows can be selected |
| `selection` | `string[]` | — | Selected row values (controlled) |
| `defaultSelection` | `string[]` | `[]` | Selected row values on mount (uncontrolled) |
| `onSelectionChange` | `(selection: string[]) => void` | — | Called when the selection changes |
| `expanded` | `string[]` | — | Expanded row values (controlled) |
| `defaultExpanded` | `string[]` | `[]` | Expanded row values on mount (uncontrolled) |
| `onExpandedChange` | `(expanded: string[]) => void` | — | Called when a row expands or collapses |
| `sort` | `TableSort \| null` | — | Sorted column and direction (controlled) |
| `defaultSort` | `TableSort \| null` | `null` | Sorted column and direction on mount |
| `onSortChange` | `(sort: TableSort) => void` | — | Called when a sortable column is activated |

`TableSort` is `{ column: string; direction: 'ascending' | 'descending' }`.

### `Table.Column`

| Prop | Type | Default | Description |
| ----------- | --------- | ------- | -------------------------------------------------------------- |
| `value` | `string` | — | Identifies the column in the sort state |
| `sortable` | `boolean` | `false` | Sorts by this column on activation. Needs `value`. |
| `rowHeader` | `boolean` | `false` | Renders the column's cells as row headers that name their rows |

### `Table.Body`

| Prop | Type | Default | Description |
| ------------------- | ------------------------------------- | ------------ | ----------------------------------------------------- |
| `items` | `Item[]` | — (required) | The records to render |
| `itemToStringValue` | `(item: Item) => string` | — (required) | The row's value in the selection and expanded state |
| `itemToChildren` | `(item: Item) => Item[] \| undefined` | — | An item's child items. Makes the table a tree grid. |
| `children` | `(item: Item) => ReactNode` | — (required) | Renders the row for an item |
| `renderEmptyState` | `() => ReactNode` | — | Rendered in one cell spanning every column when empty |

### `Table.Row`

| Prop | Type | Default | Description |
| ------------ | ------------------- | ------- | ---------------------------------------------------------- |
| `disabled` | `boolean` | `false` | Removes the row from navigation, selection and its action |
| `expandable` | `boolean` | `false` | Lets the row expand before its children are loaded |
| `onClick` | `(event) => void` | — | Row action |
| `href` | `string` | — | Row link. `onClick` runs first and can `preventDefault()`. |
| `target` | `string` | — | Link target |
| `rel` | `string` | — | Link rel |
| `download` | `boolean \| string` | — | Link download |

All parts accept a `render` prop for polymorphic rendering and standard HTML attributes for their default element. Every part throws outside `Table.Root`, and `Table.Row` also throws when it is not rendered by the children function of `Table.Body`.

## Keyboard Navigation

Navigation turns on when the table has row actions, selection, sortable columns or tree rows. Rows and cells are both focusable, and the table is one tab stop.

| Key | Action |
| ------------------------ | ---------------------------------------------------------------------------------------------------------- |
| `ArrowDown` / `ArrowUp` | Next / previous row (or cell in the same column). Skips disabled rows. |
| `ArrowRight` | On a collapsed row, expand it. Otherwise move into the first cell, then along the cells. |
| `ArrowLeft` | On an expanded row, collapse it. On a child row, move to its parent. From the first cell, back to the row. |
| `Home` / `End` | First / last row, or first / last cell in the row |
| `Ctrl+Home` / `Ctrl+End` | First / last cell in the table |
| `Enter` | Run the row action, or toggle selection if the row has none. On a cell with controls, focus them. |
| `Enter` / `Space` | On a sortable column header, sort by it |
| `F2` | Focus the controls in the focused cell |
| `Escape` | Leave a cell's controls, or clear the selection |
| `Space` | Toggle the row's selection. `Shift+Space` selects a range. |
| `Ctrl+A` / `Cmd+A` | Select all rows (multiple mode) |

Left and right swap in right-to-left layouts. A cell with a single button or link focuses that control directly.

## Data Attributes

| Attribute | Applies To | Description |
| --------------------- | ---------------------- | ------------------------------------------------------------------ |
| `data-selected` | Row | Present when the row is selected |
| `data-disabled` | Row, SelectionCheckbox | Present when disabled |
| `data-expanded` | Row, ExpandButton | Present when the row is expanded |
| `data-collapsed` | ExpandButton | Present when the row is collapsed |
| `data-actionable` | Row | Present when the row has an action |
| `data-checked` | SelectionCheckbox | Present when checked |
| `data-indeterminate` | SelectionCheckbox | Present when some rows are selected |
| `data-sortable` | Column | Present when the column is sortable |
| `data-sort-direction` | Column | `ascending` or `descending` when the table is sorted by the column |

## CSS Variables

| Property | Applies To | Value |
| ------------------- | ---------- | --------------------------------------- |
| `--table-row-depth` | Row | The row's depth, `0` for top-level rows |

## ARIA

- Root: `role="treegrid"` with `itemToChildren`, `role="grid"` with actions, selection or sortable columns, none otherwise. `aria-multiselectable` in multiple mode.
- Row: `aria-labelledby` its row header cells, `aria-selected` when selectable, `aria-disabled`. In a tree grid also `aria-level`, `aria-posinset`, `aria-setsize` and `aria-expanded`.
- Column: `aria-sort` when sortable: the direction on the sorted column, `none` on the others.
- SelectionCheckbox: labelled by its `aria-label` plus the row header.
- ExpandButton: labelled by its `aria-label` plus the row header, `aria-expanded`, and `tabIndex={-1}` because the row's arrow keys expand and collapse it.
13 changes: 13 additions & 0 deletions packages/mosaic/src/primitives/table/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export * as Table from './parts';

export type {
TableRootProps,
TableHeaderProps,
TableColumnProps,
TableBodyProps,
TableRowProps,
TableCellProps,
TableSelectionCheckboxProps,
TableExpandButtonProps,
} from './parts';
export type { TableSelectionMode, TableSort } from './table-context';
11 changes: 11 additions & 0 deletions packages/mosaic/src/primitives/table/parts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export { type TableRootProps, TableRoot as Root } from './table-root';
export { type TableHeaderProps, TableHeader as Header } from './table-header';
export { type TableColumnProps, TableColumn as Column } from './table-column';
export { type TableBodyProps, TableBody as Body } from './table-body';
export { type TableRowProps, TableRow as Row } from './table-row';
export { type TableCellProps, TableCell as Cell } from './table-cell';
export {
type TableSelectionCheckboxProps,
TableSelectionCheckbox as SelectionCheckbox,
} from './table-selection-checkbox';
export { type TableExpandButtonProps, TableExpandButton as ExpandButton } from './table-expand-button';
97 changes: 97 additions & 0 deletions packages/mosaic/src/primitives/table/table-body.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use client';

import React from 'react';

import { type ComponentProps, mergeProps, useRender } from '../utils';
import { type TableItem, TableItemContext, useTableContext } from './table-context';

export interface TableBodyProps<Item> extends Omit<ComponentProps<'tbody'>, 'children'> {
/** The records to render, one row each. */
items: readonly Item[];
/** Returns the value that identifies an item's row in the selection and expanded state. */
itemToStringValue: (item: Item) => string;
/** Returns an item's child items. Passing it makes the table a tree grid. */
itemToChildren?: (item: Item) => readonly Item[] | undefined;
/** Renders the row for an item. */
children: (item: Item) => React.ReactNode;
/** Renders the content of a single cell that spans every column when there are no items. */
renderEmptyState?: () => React.ReactNode;
}

interface VisibleRow<Item> extends TableItem {
item: Item;
}

type TableBodyComponent = <Item>(
props: TableBodyProps<Item> & React.RefAttributes<HTMLTableSectionElement>,
) => React.ReactElement;

// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- forwardRef drops the Item type parameter; React 18 support rules out a generic ref prop.
export const TableBody = React.forwardRef(TableBodyImpl) as TableBodyComponent;

function TableBodyImpl<Item>(props: TableBodyProps<Item>, ref: React.ForwardedRef<HTMLTableSectionElement>) {
const { render, items, itemToStringValue, itemToChildren, children, renderEmptyState, ...otherProps } = props;
const { expanded, syncBodyRows, columnCount, excludeFromNavigation } = useTableContext();
const tree = itemToChildren !== undefined;

const rows = React.useMemo(
() => visibleRows(items, { itemToStringValue, itemToChildren, expanded }),
[items, itemToStringValue, itemToChildren, expanded],
);

React.useLayoutEffect(() => {
syncBodyRows({ values: rows.map(row => row.value), tree });
}, [rows, tree, syncBodyRows]);

const excludeEmptyRow = React.useCallback(
(node: HTMLTableRowElement | null) => {
if (node) {
excludeFromNavigation(node);
}
},
[excludeFromNavigation],
);

const content =
rows.length === 0 && renderEmptyState ? (
<tr ref={excludeEmptyRow}>
<td colSpan={columnCount || undefined}>{renderEmptyState()}</td>
</tr>
) : (
rows.map(({ item, ...tableItem }) => (
<TableItemContext.Provider
key={tableItem.value}
value={tableItem}
>
{children(item)}
</TableItemContext.Provider>
))
);

return useRender({
defaultTagName: 'tbody',
render,
ref,
props: mergeProps<'tbody'>({ children: content }, otherProps),
});
}

function visibleRows<Item>(
items: readonly Item[],
options: Pick<TableBodyProps<Item>, 'itemToStringValue' | 'itemToChildren'> & { expanded: string[] },
depth = 0,
): VisibleRow<Item>[] {
return items.flatMap((item, index) => {
const value = options.itemToStringValue(item);
const childItems = options.itemToChildren?.(item) ?? [];
const row: VisibleRow<Item> = {
item,
value,
depth,
index,
setSize: items.length,
hasChildren: childItems.length > 0,
};
return options.expanded.includes(value) ? [row, ...visibleRows(childItems, options, depth + 1)] : [row];
});
}
32 changes: 32 additions & 0 deletions packages/mosaic/src/primitives/table/table-cell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client';

import React from 'react';

import { type ComponentProps, mergeProps, useRender } from '../utils';
import { TableRowContext, useCellIndex, useTableContext } from './table-context';

export type TableCellProps = ComponentProps<'td'>;

export const TableCell = React.forwardRef<HTMLTableCellElement, TableCellProps>(function TableCell(props, ref) {
const { render, ...otherProps } = props;
const { rowHeaderColumns } = useTableContext();
const [elementRef, index] = useCellIndex();
const rowHeader = rowHeaderColumns.includes(index);
const registerRowHeader = React.useContext(TableRowContext)?.registerRowHeader;
const generatedId = React.useId();
const id = otherProps.id ?? generatedId;

React.useLayoutEffect(
() => (rowHeader && registerRowHeader ? registerRowHeader(id) : undefined),
[rowHeader, registerRowHeader, id],
);

const defaultProps: Record<string, unknown> = rowHeader ? { id, scope: 'row' } : {};

return useRender({
defaultTagName: rowHeader ? 'th' : 'td',
render,
ref: [elementRef, ref],
props: mergeProps<'td'>(defaultProps, otherProps),
});
});
Loading
Loading