// ~/src/siteplane/components/table/table.tsx "use client"; import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon, } from "lucide-react"; import { Cell as AriaCell, Checkbox as AriaCheckbox, CollectionRendererContext, Column as AriaColumn, composeRenderProps, Row as AriaRow, Table as AriaTable, TableBody as AriaTableBody, TableFooter as AriaTableFooter, TableHeader as AriaTableHeader, TableStateContext, type CellProps as AriaCellProps, type CheckboxProps as AriaCheckboxProps, type ColumnProps as AriaColumnProps, type Key, type PressEvent, type RowProps as AriaRowProps, type Selection as AriaSelection, type SortDescriptor as AriaSortDescriptor, type TableBodyProps as AriaTableBodyProps, type TableFooterProps as AriaTableFooterProps, type TableHeaderProps as AriaTableHeaderProps, type TableProps as AriaTableProps, } from "react-aria-components"; import { Children, type ComponentProps, createContext, isValidElement, type KeyboardEvent, type PointerEvent, type ReactElement, type ReactNode, type SyntheticEvent, useContext, useId, useMemo, useRef, useState, } from "react"; import { type SiteplaneControlSize, useResolvedControlSize, } from "../../system/provider/siteplane-ui-provider"; import { CheckboxGlyph } from "../../system/styles/checkbox-glyph"; import { cn } from "../../system/utils/cn"; import { tableAlignmentClassName, tableContainerSizeClassName, tableFooterClassName, type TableAlign, } from "./table-shared"; import "./table.css"; export type { SortDescriptor } from "react-aria-components"; export type { TableAlign }; export type TableSelectionTrigger = "checkbox" | "row"; /** The table's resolved size, which its selection checkboxes take as theirs. */ const TableSizeContext = createContext( undefined, ); /** * What a row needs from its table to run its action under * `selectionTrigger="checkbox"`: the root `onRowAction`, and a count of the * row actions React Aria ran, so a row tells whether a press ran one. */ type TableRowActions = { checkboxTrigger: boolean; onRowAction: ((key: Key) => void) | undefined; ran: { current: number }; }; const TableRowActionsContext = createContext({ checkboxTrigger: false, onRowAction: undefined, ran: { current: 0 }, }); export interface TableProps extends Omit< AriaTableProps, "className" | "sortDescriptor" > { className?: string | undefined; /** * Classes for the scrolling container. The heading row sticks to the top of * this container, so it stays in view only when the container has a height * limit, such as `max-h-80`. */ containerClassName?: string | undefined; hoverable?: boolean | undefined; selectionTrigger?: TableSelectionTrigger | undefined; size?: SiteplaneControlSize | undefined; sortDescriptor?: AriaSortDescriptor | undefined; } export interface TableCaptionProps { children: ReactNode; className?: string; } export type TableHeaderProps = Omit, "className"> & { className?: string; }; export interface TableHeadProps extends Omit { align?: TableAlign; className?: string; } export type TableBodyProps = Omit, "className"> & { className?: string; }; export type TableFooterProps = Omit, "className"> & { className?: string; }; export interface TableRowProps extends Omit, "className"> { className?: string | undefined; hoverable?: boolean | undefined; inactive?: boolean | undefined; } export interface TableCellProps extends Omit { align?: TableAlign; className?: string; } export interface TableSelectionCheckboxProps extends Omit< AriaCheckboxProps, "children" | "className" | "slot" > { className?: string; } function isTableCaption( child: ReactNode, ): child is ReactElement { return isValidElement(child) && child.type === TableCaption; } export function Table({ "aria-labelledby": ariaLabelledBy, children, className, containerClassName, defaultSelectedKeys, hoverable = true, onRowAction, onSelectionChange, selectedKeys, selectionBehavior, selectionTrigger = "row", size, sortDescriptor, ...props }: TableProps): ReactElement { const resolvedSize = useResolvedControlSize(size); const checkboxTrigger = selectionTrigger === "checkbox"; const actionsRan = useRef(0); const rowActions = useMemo( () => ({ checkboxTrigger, onRowAction, ran: actionsRan }), [checkboxTrigger, onRowAction], ); const generatedCaptionId = useId(); const childArray = Children.toArray(children); const caption = childArray.find(isTableCaption); const tableChildren = childArray.filter((child) => !isTableCaption(child)); const captionId = caption ? generatedCaptionId : undefined; const selectionOriginRef = useRef("row"); const [internalSelectedKeys, setInternalSelectedKeys] = useState(() => defaultSelectedKeys === "all" ? "all" : new Set(defaultSelectedKeys ?? []), ); const markSelectionOrigin = (target: EventTarget | null) => { selectionOriginRef.current = target instanceof Element && target.closest('[data-slot="table-selection-checkbox"]') ? "checkbox" : "row"; }; const handleSelectionChange = (keys: AriaSelection) => { if ( selectionTrigger === "checkbox" && selectionOriginRef.current !== "checkbox" ) { return; } if (selectedKeys === undefined) setInternalSelectedKeys(keys); onSelectionChange?.(keys); }; const labelledBy = ariaLabelledBy ?? captionId; return (
markSelectionOrigin(event.target)} onKeyDownCapture={(event: KeyboardEvent) => markSelectionOrigin(event.target) } onPointerDownCapture={(event: PointerEvent) => markSelectionOrigin(event.target) } > { actionsRan.current += 1; onRowAction(key); }, })} // Only the checkboxes select, so a press on a row never selects // it and there is no double click that runs the action instead. {...(checkboxTrigger ? { selectionBehavior: "toggle" as const } : selectionBehavior === undefined ? {} : { selectionBehavior })} className={cn( "sp-table", "w-full caption-bottom text-sm", className, )} data-hoverable={hoverable} data-selection-trigger={selectionTrigger} data-slot="table" onSelectionChange={handleSelectionChange} selectedKeys={selectedKeys ?? internalSelectedKeys} {...(sortDescriptor === undefined ? {} : { sortDescriptor })} > {tableChildren} {caption ? (
{caption.props.children}
) : null}
); } export function TableCaption(props: TableCaptionProps): null { void props; return null; } export function TableHeader({ className, ...props }: TableHeaderProps): ReactElement { return ( cn("sp-table-header", value), )} data-slot="table-header" /> ); } export function TableHead({ align = "start", allowsSorting, children, className, ...props }: TableHeadProps): ReactElement { return ( cn( "sp-table-head", "whitespace-nowrap align-middle", tableAlignmentClassName(align), value, ), )} data-align={align} data-slot="table-head" > {(renderProps) => ( {typeof children === "function" ? children(renderProps) : children} {allowsSorting ? ( renderProps.sortDirection === "ascending" ? ( )} ); } export function TableBody({ className, ...props }: TableBodyProps): ReactElement { return ( cn("sp-table-body", value), )} data-slot="table-body" /> ); } export function TableFooter({ className, ...props }: TableFooterProps): ReactElement { return ( ); } /** * A row of the table. Under `selectionTrigger="checkbox"` only the checkboxes * select, so a press on the row runs its action, also while rows are * selected: React Aria still takes such a press as a selection toggle, which * the table drops, and the row then runs the action itself when the press * ends. Space stays the selection key and runs no action; a press that React * Aria already ran an action for runs none again. */ export function TableRow({ className, hoverable = true, inactive = false, onAction, onPress, onPressStart, ...props }: TableRowProps): ReactElement { const interactive = Boolean(props.href || onAction); const actions = useContext(TableRowActionsContext); const actionsAtPressStart = useRef(0); const { id } = props; const runsOwnAction = actions.checkboxTrigger && (onAction !== undefined || actions.onRowAction !== undefined); const pressProps = runsOwnAction ? { ...(onAction === undefined ? {} : { onAction: () => { actions.ran.current += 1; onAction(); }, }), onPress: (event: PressEvent) => { onPress?.(event); if (actions.ran.current !== actionsAtPressStart.current) return; if (event.pointerType === "keyboard" && event.key !== "Enter") return; onAction?.(); if (id !== undefined) actions.onRowAction?.(id); }, onPressStart: (event: PressEvent) => { actionsAtPressStart.current = actions.ran.current; onPressStart?.(event); }, } : { ...(onAction === undefined ? {} : { onAction }), ...(onPress === undefined ? {} : { onPress }), ...(onPressStart === undefined ? {} : { onPressStart }), }; return ( cn("sp-table-row", "relative outline-none", value), )} data-hoverable={hoverable} data-inactive={inactive || undefined} data-interactive={interactive || undefined} data-slot="table-row" /> ); } // What a press or a key in a cell can start on besides the cell itself: an // element the browser activates or focuses, a label that passes a click on to // its control, and an item of another React Aria collection, such as a tag. const cellControlSelector = [ "a[href]", "area[href]", "audio[controls]", "button", "embed", "iframe", "input", "label", "object", "select", "summary", "textarea", "video[controls]", "[contenteditable]:not([contenteditable='false'])", "[data-collection]", "[tabindex]", ].join(", "); /** Whether the event started on a control inside the cell, not the cell. */ function startsOnCellControl(event: SyntheticEvent): boolean { const cell = event.currentTarget; const control = event.target instanceof Element ? event.target.closest(cellControlSelector) : null; return control !== null && control !== cell && cell.contains(control); } /** * React Aria's own controls stop their presses from reaching the row, and the * row ignores a press that starts on an element it can focus. A control that * is not React Aria's, such as a Menu trigger, gets the same: a press or a * click that starts in it, also on its icon, and Enter or Space while it has * focus stay with it, so they neither run the row's action nor change the * selection. Events from a popup of the control reach the cell through * React's tree but not through the document, and pass. */ function keepPressInControl(event: SyntheticEvent): void { if (startsOnCellControl(event)) event.stopPropagation(); } function keepKeyInControl(event: KeyboardEvent): void { if ( (event.key === "Enter" || event.key === " ") && startsOnCellControl(event) ) event.stopPropagation(); } function chain( own: (event: E) => void, given: ((event: E) => void) | undefined, ): (event: E) => void { return (event) => { given?.(event); own(event); }; } export function TableCell({ align = "start", className, render, ...props }: TableCellProps): ReactElement { // A virtualized table renders its cells as divs. const { isVirtualized } = useContext(CollectionRendererContext); return ( cn( "sp-table-cell", "whitespace-nowrap bg-clip-padding align-middle outline-none", tableAlignmentClassName(align), value, ), )} data-align={align} data-slot="table-cell" // React Aria takes no key handlers on a cell, so the cell element is // rendered here, with the handlers React Aria gives it kept. render={(elementProps, renderProps) => { const cellProps = elementProps as ComponentProps<"td">; const guarded: ComponentProps<"td"> = { ...cellProps, onClick: chain(keepPressInControl, cellProps.onClick), onDoubleClick: chain(keepPressInControl, cellProps.onDoubleClick), onKeyDown: chain(keepKeyInControl, cellProps.onKeyDown), onMouseDown: chain(keepPressInControl, cellProps.onMouseDown), onPointerDown: chain(keepPressInControl, cellProps.onPointerDown), onPointerUp: chain(keepPressInControl, cellProps.onPointerUp), }; if (render) return render(guarded, renderProps); return isVirtualized ? (
)} /> ) : ( ); }} /> ); } export function TableSelectionCheckbox({ className, ...props }: TableSelectionCheckboxProps): ReactElement { // The box reads the Checkbox's size scale, from the table it sits in. const size = useResolvedControlSize(useContext(TableSizeContext)); // React Aria counts every one of no rows as selected, so a table whose rows // are gone while keys stay selected showed its select-all box checked. With // no rows there is nothing to select: the box is unchecked, as TanStack // Table's getIsAllRowsSelected() reports for an empty table, and React Aria // keeps it disabled. Only the header box can render then. const tableState = useContext(TableStateContext); const noRows = tableState !== null && tableState.collection.size === 0; return ( cn("sp-table-selection-checkbox", value), )} data-size={size} data-slot="table-selection-checkbox" slot="selection" > {({ isIndeterminate, isSelected }) => ( // The indicator holds the state for the shared check motion in // motion.css (rulebook M1): both glyphs stay mounted, and the check // draws in and out as data-selected comes and goes, the dash as // data-indeterminate does. An indeterminate box shows the dash only. // The glyph takes no shared pop: that has no indeterminate state, so // the dash would rest at the pop scale. )} ); } // ~/src/siteplane/components/table/table-shared.ts /** * Presentation shared by Table (React Aria) and NativeTable (native HTML). * Internal: it is not exported from the package index, so both families keep * one footer treatment and one cell alignment rule. */ export type TableAlign = "center" | "end" | "start"; /** Summary rows: a top contour on the subtle surface at medium weight. */ export const tableFooterClassName = "border-t bg-surface-subtle font-medium [&>tr]:last:border-b-0"; /** * The size scale that table.css reads on the container: the heading row's * height and the cells' block padding, 32/4 px at sm, 36/6 at default, 40/8 at * lg and 48/12 at xl. */ export const tableContainerSizeClassName = "[--table-cell-padding-block:var(--sp-rw-space-1-5)] [--table-head-height:var(--sp-rw-space-9)] data-[size=sm]:[--table-cell-padding-block:var(--sp-rw-space-1)] data-[size=sm]:[--table-head-height:var(--sp-rw-space-8)] data-[size=lg]:[--table-cell-padding-block:var(--sp-rw-space-2)] data-[size=lg]:[--table-head-height:var(--sp-rw-space-10)] data-[size=xl]:[--table-cell-padding-block:var(--sp-rw-space-3)] data-[size=xl]:[--table-head-height:var(--sp-rw-space-12)]"; /** * Logical alignment follows the writing direction; end-aligned cells hold * numbers, so their digits are tabular. */ export function tableAlignmentClassName(align: TableAlign | undefined): string { if (align === "center") return "text-center"; if (align === "end") return "text-end tabular-nums"; return "text-start"; } // ~/src/siteplane/components/table/table.css @layer theme, base, components, siteplane, utilities; @layer siteplane { /* The table fills its container; cells don't wrap, so columns that need more * room scroll horizontally inside the container instead of the page. */ .sp-table { background: var(--sp-rw-bg-secondary); } /* The heading row sticks to the top of the scrolling container (the * container scrolls on both axes). It stays in view only when the container * has a height limit, such as containerClassName="max-h-80". */ .sp-table-header { position: sticky; z-index: 10; top: 0; background: var(--sp-rw-bg-secondary); color: var(--sp-rw-fg-tertiary); font-size: var(--sp-rw-font-14); font-weight: 480; line-height: var(--sp-rw-line-21); } /* The hover tint is a layer over the opaque fill, so a sticky heading row * never turns see-through while it is hovered. */ :where(.sp-table[data-hoverable="true"]) > .sp-table-header:hover { background: linear-gradient(var(--sp-rw-glow-hover), var(--sp-rw-glow-hover)), var(--sp-rw-bg-secondary); } .sp-table-header > tr { border-bottom: var(--sp-rw-border-default) solid var(--sp-rw-border-translucent); } /* The heading row's height and the cells' block padding follow the size: the * container sets them (tableContainerSizeClassName, table-shared.ts). */ .sp-table-head { height: var(--table-head-height, var(--sp-rw-space-10)); color: var(--sp-rw-fg-tertiary); font-weight: 480; line-height: var(--sp-rw-line-21); outline: none; } .sp-table-head { padding: 0 var(--sp-rw-space-2); } /* lg and xl set a minimum row height on the container (--table-row-height); * sm and the default size are as tall as their padding and line. */ .sp-table-cell { height: var(--table-row-height, auto); padding: var(--table-cell-padding-block, var(--sp-rw-space-2)) var(--sp-rw-space-2); } /* The kit focus token, as on the rows and the sortable headings. */ .sp-table-cell[data-focus-visible] { box-shadow: inset 0 0 0 2px var(--sp-rw-border-focus); } .sp-table .sp-table-head, .sp-table .sp-table-cell { line-height: var(--sp-rw-line-21); } /* The outer columns take the inset of the surrounding surface, such as a * CardFrameBleed, so a framed table lines up with the frame header and footer. */ .sp-table .sp-table-head:first-child, .sp-table .sp-table-cell:first-child { padding-inline-start: var(--table-edge-padding, var(--sp-rw-space-2)); } .sp-table .sp-table-head:last-child, .sp-table .sp-table-cell:last-child { padding-inline-end: var(--table-edge-padding, var(--sp-rw-space-2)); } .sp-table-row { border-color: var(--sp-rw-border-translucent); } .sp-table-body .sp-table-row:not(:last-child) { border-bottom-width: var(--sp-rw-border-default); } .sp-table-body .sp-table-row:last-child { border-bottom-width: 0; } /* Rows take the rows ladder of the ghost controls (hover and selected * steps of --sp-rw-interaction-*), in the control interaction colour. A * selected row keeps its fill under the pointer and in focus: the stronger * state wins over hover, as it does for pressed and open elsewhere. */ :where(.sp-table[data-hoverable="true"]) > .sp-table-body > .sp-table-row[data-hoverable="true"]:not([data-selected]):hover { background: color-mix( in srgb, var(--sp-rw-control-interaction) calc(var(--sp-rw-interaction-hover) * 100%), transparent ); } :where(.sp-table[data-hoverable="true"]) > .sp-table-body > .sp-table-row[data-interactive="true"]:not([data-selected]):is( :hover, [data-hovered] ), .sp-table-body .sp-table-row[data-interactive="true"]:not([data-selected]):is( :focus-within, [data-focus-visible] ) { background: var(--sp-rw-bg-base); } .sp-table-row[data-selected] { background: color-mix( in srgb, var(--sp-rw-control-interaction) calc(var(--sp-rw-interaction-selected) * 100%), transparent ); } .sp-table-row[data-focus-visible] { box-shadow: inset 0 0 0 2px var(--sp-rw-border-focus); } .sp-table-row[data-disabled] { cursor: not-allowed; opacity: 64%; } .sp-table-row[data-interactive="true"] { cursor: pointer; } .sp-table-row[data-inactive] { opacity: 72%; } .sp-table-head[data-allows-sorting] { cursor: default; pointer-events: none; } .sp-table-head-content { gap: var(--sp-rw-space-1); } .sp-table-head[data-allows-sorting] .sp-table-head-content { height: var(--sp-rw-space-8); border-radius: var(--sp-rw-radius-control); padding: var(--sp-rw-space-1-5) var(--sp-rw-space-2-5); padding-inline-start: 0; color: var(--sp-rw-fg-primary); cursor: pointer; pointer-events: auto; } .sp-table-head[data-focus-visible] .sp-table-head-content { outline: 2px solid var(--sp-rw-border-focus); outline-offset: 2px; } .sp-table-head[data-pressed] .sp-table-head-content { opacity: 80%; } .sp-table-head-content svg { width: var(--sp-rw-space-4); height: var(--sp-rw-space-4); } /* The selection box is the Checkbox: its box, states, focus ring and size * come from the Checkbox rules in design-rework.css. React Aria renders a * label here, so the box only needs its layout. */ .sp-table-selection-checkbox { display: inline-flex; align-items: center; justify-content: center; cursor: pointer; } .sp-table-selection-checkbox[data-disabled] { cursor: not-allowed; } .sp-table-selection-indicator { display: flex; width: 100%; height: 100%; align-items: center; justify-content: center; } html[data-motion-reduce="true"] .sp-table-selection-checkbox, [data-sp-motion="reduced"] .sp-table-selection-checkbox { transition-duration: 1ms; } } // ~/src/siteplane/components/table/index.ts export * from "./table";