/**
* Table — rows and columns that stay lined up.
*
* React Native has no table layout: no `
`, no column model, nothing that
* makes the third cell of one row the same width as the third cell of the next.
* A table here is therefore a stack of flex rows, and the columns exist only
* because every row divides its width the same way.
*
* `columns` is the column model that closes that gap. Give the root one entry
* per column and every `Table.Head` and `Table.Cell` takes its `flex`, `width`
* and `align` from the entry at its own position in the row — so a column is
* described once, at the top, and a row is just its contents. Without it the
* sizing has to be repeated on every head and every cell, and the column drifts
* the moment two of them disagree.
*
* ```tsx
*
* ```
*
* Props set on a head or a cell still win, for the one row that has to differ.
*
* Everything else it does own: the hairlines between rows and the missing one
* under the last, the muted header and footer, the striping, the alignment of a
* numeric column, and the sort arrow that turns over rather than swapping.
*
* ```tsx
*
*
*
* Invoice
* Amount
*
*
*
*
* INV-001
* $250.00
*
*
*
* ```
*
* `Table.Frame` is the same table in a widget shell, with the column headings
* lifted onto the tray above the card. It takes the whole table and does the
* lift itself, so the columns are still declared once.
*
* A table wider than the phone belongs in a horizontal scroller with a
* `minWidth` on the table, not squeezed until the columns are unreadable — wrap
* it in `ScrollFade` and the cut edge tells you there is more to the right.
*
* Long tables belong in a `FlatList` rather than in `Table.Body`, which renders
* every row it is given. `Table.Row` takes `index` and `last` directly for that
* case, since a virtualised row has no parent to read them from.
*/
import {
Children,
cloneElement,
createContext,
forwardRef,
isValidElement,
useContext,
useEffect,
useMemo,
type ReactElement,
type ReactNode,
} from 'react';
import { View, type ViewProps } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { tv, type VariantProps } from 'tailwind-variants';
import { useCSSVariable } from 'uniwind';
import { ChevronUpIcon } from '../../icons';
import {
AnimatedPressable,
type AnimatedPressableProps,
} from '../../primitives/animated-pressable';
import { Text, type TextProps, textChildren } from '../../primitives/text';
import { cn } from '../../utils/cn';
import { selectionTick } from '../../utils/haptics';
import { Frame } from '../frame';
/** Long enough to read as the arrow turning over, short enough not to lag a tap. */
const SORT_DURATION = 160;
type TableSize = 'default' | 'sm';
type TableSection = 'header' | 'body' | 'footer';
type CellAlign = 'start' | 'center' | 'end';
export type TableSortDirection = 'asc' | 'desc';
const tableVariants = tv({
slots: {
root: 'w-full',
row: 'w-full flex-row items-center',
head: 'flex-row items-center gap-1.5',
headLabel: 'font-medium text-muted-foreground',
cell: 'flex-row items-center',
cellLabel: 'text-foreground',
caption: 'text-muted-foreground',
},
variants: {
variant: {
/** Hairlines only — the table sits directly on the page. */
default: {},
/** Framed and clipped, for a table that reads as its own card. */
outline: { root: 'overflow-hidden rounded-xl border border-border' },
},
size: {
default: {
row: 'min-h-12 gap-3 px-4',
headLabel: 'text-xs',
cellLabel: 'text-sm',
caption: 'text-sm',
},
sm: {
row: 'min-h-10 gap-2 px-3',
headLabel: 'text-[11px]',
cellLabel: 'text-xs',
caption: 'text-xs',
},
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
});
const alignment: Record = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
};
export interface TableColumn {
/**
* Share of the leftover width, relative to the other columns. Defaults to 1,
* so columns divide the row evenly.
*/
flex?: number;
/**
* Fixed width in pixels, for a column that must not move — an icon, a state
* dot. Wins over `flex`.
*/
width?: number;
/**
* Which edge the column's content sits against. Use `end` for numbers: a
* money column reads as a column only when the digits line up.
*/
align?: CellAlign;
}
/**
* Density, striping and the column model are set once on the root; every part
* below reads them from here rather than being told again per row and per cell.
*/
const TableContext = createContext<{
size: TableSize;
striped: boolean;
columns?: TableColumn[];
}>({
size: 'default',
striped: false,
});
/**
* Which column a head or a cell is in — its position among its row's children,
* counted by `Table.Row`. Only meaningful under a `columns` root, and the
* counting is skipped entirely without one, so a table that sizes its cells by
* hand pays nothing for a model it is not using.
*/
const TableColumnContext = createContext(-1);
/**
* Resolves a head's or a cell's sizing against the column it sits in. What the
* part was given always wins: the model is the default for the column, not a
* rule about it, so the one row that has to differ still can.
*/
function useColumn(own: TableColumn): Required> & TableColumn {
const { columns } = useContext(TableContext);
const index = useContext(TableColumnContext);
const column = index >= 0 ? columns?.[index] : undefined;
return {
flex: own.flex ?? column?.flex,
width: own.width ?? column?.width,
align: own.align ?? column?.align ?? 'start',
};
}
/**
* Numbers a row's children so each one knows its column. Runs only when the
* root carries a `columns` model — otherwise the children are handed straight
* through, with no extra provider per cell on every row of the table.
*/
function useRowCells(children: ReactNode, enabled: boolean): ReactNode {
return useMemo(() => {
const cells = textChildren(children);
if (!enabled) return cells;
return Children.map(cells, (child, index) =>
isValidElement(child) ? (
{child}
) : (
child
)
);
}, [children, enabled]);
}
/**
* Which band of the table a row is in, and where in that band. A row needs all
* three: the section decides its text and its borders, the index decides
* whether it is striped, and being last is what removes the hairline that would
* otherwise double up with the table's own bottom edge.
*/
const TableRowContext = createContext<{
section: TableSection;
index: number;
last: boolean;
}>({
section: 'body',
index: 0,
last: false,
});
export interface TableProps extends ViewProps, VariantProps {
className?: string;
/**
* Row density. `Table.Row`, `Table.Head` and `Table.Cell` follow it, so it
* only needs setting here.
*/
size?: TableSize;
/**
* Tint every other body row. Helps the eye track across a wide row; drop it
* for a short table, where the stripes are louder than the data.
*/
striped?: boolean;
/**
* The column model: one entry per column, in order. Every `Table.Head` and
* `Table.Cell` takes its `flex`, `width` and `align` from the entry at its
* own position in the row, so a column is described once instead of on every
* row. Anything set on a head or a cell still wins.
*
* Declare it outside render — a new array each frame renumbers every cell.
*/
columns?: TableColumn[];
children?: ReactNode;
}
const TableRoot = forwardRef(
(
{ className, variant, size = 'default', striped = false, columns, children, ...props },
ref
) => {
const { root } = tableVariants({ variant, size });
const context = useMemo(() => ({ size, striped, columns }), [size, striped, columns]);
return (
{textChildren(children)}
);
}
);
TableRoot.displayName = 'Table';
/**
* Wraps each row of a section in its position, so a row does not have to be
* told where it sits. Wrapping rather than cloning: a row is often produced by
* a `.map()` through a component of your own, and props set on that wrapper
* would never reach the row itself.
*/
function useSectionRows(children: ReactNode, section: TableSection): ReactNode {
return useMemo(() => {
const rows = Children.toArray(children).filter((child) => isValidElement(child));
return rows.map((child, index) => (
{child}
));
}, [children, section]);
}
export interface TableHeaderProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/** The band of column headers. Draws the rule that separates it from the body. */
const TableHeader = forwardRef(
({ className, children, ...props }, ref) => {
const rows = useSectionRows(children, 'header');
return (
{rows}
);
}
);
TableHeader.displayName = 'Table.Header';
export interface TableBodyProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/** The data rows. Renders every child it is given — see `FlatList` for long tables. */
const TableBody = forwardRef(
({ className, children, ...props }, ref) => {
const rows = useSectionRows(children, 'body');
return (
{rows}
);
}
);
TableBody.displayName = 'Table.Body';
export interface TableFooterProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/** Totals band. Tinted and ruled off, because a sum is not another row of data. */
const TableFooter = forwardRef(
({ className, children, ...props }, ref) => {
const rows = useSectionRows(children, 'footer');
return (
{rows}
);
}
);
TableFooter.displayName = 'Table.Footer';
export interface TableRowProps
extends Omit {
className?: string;
/** Marks the row as the chosen one — for a table you pick from. */
selected?: boolean;
disabled?: boolean;
/**
* Position in the section, for a row rendered outside `Table.Body` — a
* `FlatList` item, say. Decides which rows a striped table tints.
*/
index?: number;
/**
* Whether this is the section's final row, for a row rendered outside
* `Table.Body`. The last row drops its hairline so it does not double up with
* the table's own bottom edge.
*/
last?: boolean;
children?: ReactNode;
}
/**
* Renders as a pressable when given `onPress`, and as a plain view otherwise,
* so a table you only read does not announce every row as a button.
*/
const TableRow = forwardRef(
({ className, selected, disabled, index, last, children, onPress, ...props }, ref) => {
const { size, striped, columns } = useContext(TableContext);
const row = useContext(TableRowContext);
const { row: rowClass } = tableVariants({ size });
const cells = useRowCells(children, !!columns);
const position = index ?? row.index;
const isLast = last ?? row.last;
// The header is one row above a rule of its own and the footer one below
// another, so neither wants a hairline; in the body every row but the last
// is separated from the one under it.
const ruled = row.section === 'body' && !isLast;
// Stripe the odd rows, so the first row of a table reads on the table's own
// background rather than in a band.
const striping =
striped && row.section === 'body' && position % 2 === 1 && !selected;
const classes = rowClass({
className: cn(
ruled && 'border-b border-border',
striping && 'bg-muted/40',
selected && 'bg-accent',
disabled && 'opacity-[0.64]',
className
),
});
if (!onPress) {
return (
{cells}
);
}
return (
{cells}
);
}
);
TableRow.displayName = 'Table.Row';
/**
* Column sizing, shared by a head and the cells beneath it. Written out on both
* rather than inherited from one place, so the props table on each says what it
* actually takes.
*/
function columnStyle({ flex, width }: { flex?: number; width?: number }) {
if (width !== undefined) return { width, flexGrow: 0, flexShrink: 0 };
return { flex: flex ?? 1 };
}
export interface TableHeadProps extends Omit {
className?: string;
/**
* Share of the leftover width, relative to the other cells in the row.
* Defaults to 1, so columns divide the row evenly. Without a `columns` model
* on the root it must match the `flex` on the cells beneath it.
*/
flex?: number;
/**
* Fixed width in pixels, for a column that must not move — an icon, a state
* dot. Without a `columns` model on the root it must match the `width` on the
* cells beneath it.
*/
width?: number;
/**
* Which edge the column's content sits against. Use `end` for numbers: a
* money column reads as a column only when the digits line up.
*/
align?: CellAlign;
/**
* Show the sort arrow without committing to a direction — the column can be
* sorted, but is not the one being sorted by. Implied by `sortDirection`.
*/
sortable?: boolean;
/** The direction this column is currently sorted in. Turns the arrow over. */
sortDirection?: TableSortDirection;
/** Called on a tap. Supplying it makes the header a button. */
onPress?: AnimatedPressableProps['onPress'];
/** Styles the header's text. */
labelClassName?: string;
children?: ReactNode;
}
/**
* A column header. Given `onPress` it becomes the handle for sorting by that
* column, and the arrow rotates between the two directions rather than being
* replaced, so it is clear it is the same arrow pointing the other way.
*
* The sorted column is stated twice — a full-strength arrow *and* a label that
* takes the foreground colour and a heavier weight. One signal at the size of a
* sort arrow is too quiet to notice: a press that only nudges a dim 14px chevron
* reads as a press that did nothing, even when the rows behind it did move.
*
* The component never sorts. It renders the direction it is told and reports
* the press; which column, which way and in what order stay with the caller,
* because the data being sorted is theirs.
*/
const TableHead = forwardRef(
(
{
className,
labelClassName,
align: ownAlign,
sortable,
sortDirection,
onPress,
flex: ownFlex,
width: ownWidth,
children,
style,
...props
},
ref
) => {
const { size } = useContext(TableContext);
const { flex, width, align } = useColumn({
flex: ownFlex,
width: ownWidth,
align: ownAlign,
});
const { head, headLabel } = tableVariants({ size });
const arrowColor = useCSSVariable('--color-muted-foreground');
const activeArrowColor = useCSSVariable('--color-foreground');
const showArrow = sortable || !!sortDirection;
const turn = useSharedValue(sortDirection === 'desc' ? 1 : 0);
useEffect(() => {
turn.value = withTiming(sortDirection === 'desc' ? 1 : 0, {
duration: SORT_DURATION,
});
}, [sortDirection, turn]);
const arrowStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${turn.value * 180}deg` }],
}));
const classes = head({ className: cn(alignment[align], className) });
const label = (
<>
{textChildren(children, (text) => (
{text}
))}
{showArrow ? (
) : null}
>
);
if (!onPress) {
return (
{label}
);
}
return (
{
// The rows re-order under the finger; the tick is what ties that to
// the press, on the frame of the press rather than after the sort.
selectionTick();
onPress?.(event);
}}
className={classes}
style={[columnStyle({ flex, width }), style]}
{...props}
>
{label}
);
}
);
TableHead.displayName = 'Table.Head';
export interface TableCellProps extends ViewProps {
className?: string;
/**
* Share of the leftover width, relative to the other cells in the row.
* Defaults to 1. Without a `columns` model on the root it must match the
* `flex` on the head above it.
*/
flex?: number;
/**
* Fixed width in pixels, for a column that must not move. Without a `columns`
* model on the root it must match the `width` on the head above it.
*/
width?: number;
/**
* Which edge the cell's content sits against. Without a `columns` model on
* the root, match the head above it.
*/
align?: CellAlign;
/** Styles the cell's text. */
labelClassName?: string;
children?: ReactNode;
}
/**
* One cell of a row. Bare text is wrapped in the cell's own type style, so a
* row of strings needs no `Text` around each one; anything else — a Badge, an
* Avatar, a Button — is rendered as given.
*/
const TableCell = forwardRef(
(
{
className,
labelClassName,
align: ownAlign,
flex: ownFlex,
width: ownWidth,
children,
style,
...props
},
ref
) => {
const { size } = useContext(TableContext);
const { flex, width, align } = useColumn({
flex: ownFlex,
width: ownWidth,
align: ownAlign,
});
const { cell, cellLabel } = tableVariants({ size });
return (
{textChildren(children, (text) => (
{text}
))}
);
}
);
TableCell.displayName = 'Table.Cell';
export interface TableCaptionProps extends TextProps {
className?: string;
}
/**
* A line about the table as a whole — what it counts, when it was last read.
* Place it after the body: a caption read before the columns is a heading, and
* a heading is not this component's job.
*/
const TableCaption = forwardRef, TableCaptionProps>(
({ className, ...props }, ref) => {
const { size } = useContext(TableContext);
const { caption } = tableVariants({ size });
return ;
}
);
TableCaption.displayName = 'Table.Caption';
export interface TableEmptyProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* Stands in for the body when there is nothing to show. A table with a header
* and no rows under it looks broken rather than empty, and the header is worth
* keeping: it says what would be there.
*/
const TableEmpty = forwardRef(
({ className, children, ...props }, ref) => {
const { size } = useContext(TableContext);
const { caption } = tableVariants({ size });
return (
{textChildren(children, (text) => (
{text}
))}
);
}
);
TableEmpty.displayName = 'Table.Empty';
export interface TableFrameProps extends Omit {
className?: string;
/** Caption on the tray, above the column headings. */
title?: ReactNode;
/** Trailing slot on the title row — a button, a badge, a menu. */
action?: ReactNode;
/** A line under the title, for what the table is counting. */
description?: ReactNode;
/** Row density, as on `Table`. */
size?: TableSize;
/** Tint every other body row, as on `Table`. */
striped?: boolean;
/** The column model, as on `Table`. */
columns?: TableColumn[];
children?: ReactNode;
}
/**
* The table in a widget shell: column headings on the tray, rows in the card
* below.
*
* The headings are lifted out of the card because they are not data. Sitting on
* the tray they read as the label for the block, the way the muted caption above
* any other panel does, and the card underneath holds nothing but rows — so the
* first row is a row rather than the thing after the header.
*
* The lift is done here rather than by the caller because the alternative is
* declaring every column twice: a heading row outside the table and a body
* inside it, with `flex` and `width` kept in agreement across the gap by hand.
* Given the whole table, this can take the `Table.Header` out of it and leave
* everything measuring against the same padding.
*
* ```tsx
* 5}>
*
*
* Invoice
* Amount
*
*
* …
*
* ```
*/
const TableFrame = forwardRef(
(
{
className,
title,
action,
description,
size = 'default',
striped = false,
columns,
children,
...props
},
ref
) => {
const context = useMemo(() => ({ size, striped, columns }), [size, striped, columns]);
const { heading, body } = useMemo(() => {
let found: ReactElement | null = null;
const remaining: ReactNode[] = [];
for (const child of Children.toArray(children)) {
if (!found && isValidElement(child) && child.type === TableHeader) {
// The tray already draws the rule the panel's top border is, so the
// header's own would double it.
found = cloneElement(child as ReactElement, {
className: cn('border-b-0', (child.props as TableHeaderProps).className),
});
continue;
}
remaining.push(child);
}
return { heading: found, body: remaining };
}, [children]);
const captioned = title !== undefined || description !== undefined || !!action;
return (
{captioned ? (
{textChildren(title, (text) => (
{text}
))}
{textChildren(description, (text) => (
{text}
))}
{action ? {action} : null}
) : null}
{heading ? (
// No horizontal padding of its own: `Table.Row` brings the same
// `px-4` the rows in the panel do, which is the whole reason the
// headings still line up with their cells from out here.
{heading}
) : null}
{body}
);
}
);
TableFrame.displayName = 'Table.Frame';
export const Table = Object.assign(TableRoot, {
Frame: TableFrame,
Header: TableHeader,
Body: TableBody,
Footer: TableFooter,
Row: TableRow,
Head: TableHead,
Cell: TableCell,
Caption: TableCaption,
Empty: TableEmpty,
});