/**
* GridItem — a tile in a bento grid, and the grid that lays it out.
*
* ```tsx
*
*
*
* Deploys
* 1,284
* this week
*
* …
* …
*
* ```
*
* ## Why the group places the tiles
*
* `Item` next door is a row, and a list of rows is a column of views — the
* layout falls out of the flexbox and nothing has to be measured. A bento is
* not that. Its whole idea is that tiles are different sizes and the grid still
* lines up: a wide tile and the two square ones beside it share a left edge, a
* tall one runs past the tile next to it and the next tile fills in underneath.
*
* A wrapping flex row cannot do the last of those. Wrapping puts everything
* that did not fit on a *new line*, so nothing ever tucks under a tall tile,
* and `rowSpan` in a wrapping row is a prop that quietly does nothing.
*
* So the group measures itself, walks its children into the first free cell
* that fits each one — row by row, the way a grid places anything it is not
* told where to put — and positions them absolutely. Every tile is a whole
* number of cells, which is what makes the edges line up, and the group's own
* height is the number of rows it ended up needing.
*
* The consequence worth knowing: **a tile's height is its cells, not its
* content**. That is the right way round for a bento — a grid of boxes that
* each grew to fit its own text is not a grid — but it does mean long text
* needs `numberOfLines`, a smaller `size`, or a taller `rowSpan`.
*
* ## Nesting
*
* A group inside a tile is a group like any other, and it measures the cell it
* was put in. That is how a bento gets a sub-rhythm — two small tiles stacked
* inside one cell of the outer grid — without the outer grid needing a notion
* of half a row.
*/
import {
Children,
createContext,
forwardRef,
isValidElement,
useContext,
useMemo,
useState,
type ReactNode,
} from 'react';
import { View, type LayoutChangeEvent, type ViewProps } from 'react-native';
import { tv, type VariantProps } from 'tailwind-variants';
import {
AnimatedPressable,
type AnimatedPressableProps,
} from '../../primitives/animated-pressable';
import { Text, type TextProps, textChildren } from '../../primitives/text';
import { cn } from '../../utils/cn';
type GridItemSize = 'default' | 'sm';
const gridItemVariants = tv({
slots: {
// Full-bleed inside its cell: the group has already decided how big the
// tile is, and a tile that sized itself would break the grid it is in.
root: 'h-full w-full overflow-hidden rounded-2xl',
title: 'font-medium text-foreground',
value: 'font-semibold text-foreground',
description: 'text-muted-foreground',
},
variants: {
variant: {
/** A card in a tray — the tile that reads as a tile. */
default: { root: 'border border-border bg-card' },
/** Outline only, for a grid over a coloured or patterned page. */
outline: { root: 'border border-border' },
/** Filled and unbordered, for a quieter tile among louder ones. */
muted: { root: 'bg-muted' },
/** Nothing at all: the tile is whatever is put inside it. */
plain: { root: '' },
},
size: {
default: { root: 'gap-2 p-4', title: 'text-sm', value: 'text-2xl', description: 'text-xs' },
sm: { root: 'gap-1.5 p-3', title: 'text-xs', value: 'text-xl', description: 'text-xs' },
},
disabled: {
true: { root: 'opacity-[0.64]' },
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
});
const mediaVariants = tv({
base: 'shrink-0 items-center justify-center',
variants: {
variant: {
/** No box — for an Avatar or anything that styles itself. */
default: '',
/** Rounded square tile sized for an icon. */
icon: 'rounded-lg border border-border bg-muted',
/** Clipped frame for an image or thumbnail. */
image: 'overflow-hidden rounded-lg bg-muted',
},
size: {
default: '',
sm: '',
},
},
compoundVariants: [
{ variant: 'icon', size: 'default', class: 'h-9 w-9' },
{ variant: 'icon', size: 'sm', class: 'h-7 w-7' },
{ variant: 'image', size: 'default', class: 'h-11 w-11' },
{ variant: 'image', size: 'sm', class: 'h-9 w-9' },
],
defaultVariants: {
variant: 'default',
size: 'default',
},
});
/** Density, set once on the group and read by every part inside a tile. */
const GridItemContext = createContext<{ size: GridItemSize }>({ size: 'default' });
export interface GridItemGroupProps extends ViewProps {
className?: string;
/** How many tracks wide the grid is. */
columns?: number;
/** Gutter between tiles, in points — both ways. */
gap?: number;
/**
* The shape of one cell, as width ÷ height. `1` is square; below one the
* cells are taller than they are wide. Ignored when `rowHeight` is given.
*/
aspect?: number;
/** Cell height in points, when the grid should not be driven by its width. */
rowHeight?: number;
/** Density for every tile in the grid. Set here rather than on each one. */
size?: GridItemSize;
children?: ReactNode;
}
/** One tile's place in the grid, in cells. */
interface Placement {
row: number;
column: number;
colSpan: number;
rowSpan: number;
}
/**
* The first cell a tile of this size fits in, scanning row by row.
*
* The same rule a grid uses for anything it is not told where to put: take the
* earliest place it fits, never move anything already placed, and open a new
* row when nothing on the existing ones will do. Row-major, so the order the
* tiles are written in is the order they are read in — which is the order a
* screen reader will walk them in too, since that is the child order.
*/
function findSpot(
occupied: boolean[][],
columns: number,
colSpan: number,
rowSpan: number
): { row: number; column: number } {
for (let row = 0; ; row += 1) {
for (let column = 0; column + colSpan <= columns; column += 1) {
let free = true;
for (let y = row; y < row + rowSpan && free; y += 1) {
for (let x = column; x < column + colSpan; x += 1) {
if (occupied[y]?.[x]) {
free = false;
break;
}
}
}
if (free) return { row, column };
}
}
}
/**
* The grid. Measures its own width, places its tiles, and stands as tall as the
* rows they ended up needing.
*/
const GridItemGroup = forwardRef(
(
{
className,
columns = 2,
gap = 12,
aspect = 1,
rowHeight,
size = 'default',
children,
...props
},
ref
) => {
const [width, setWidth] = useState(0);
const tracks = Math.max(1, Math.floor(columns));
const track = (width - gap * (tracks - 1)) / tracks;
const cell = rowHeight ?? track / (aspect || 1);
const { placements, rows } = useMemo(() => {
const occupied: boolean[][] = [];
const result: Placement[] = [];
let used = 0;
Children.forEach(children, (child) => {
if (!isValidElement(child)) return;
const spans = child.props as { colSpan?: number; rowSpan?: number };
const colSpan = Math.max(1, Math.min(Math.floor(spans.colSpan ?? 1), tracks));
const rowSpan = Math.max(1, Math.floor(spans.rowSpan ?? 1));
const { row, column } = findSpot(occupied, tracks, colSpan, rowSpan);
for (let y = row; y < row + rowSpan; y += 1) {
const line = (occupied[y] ??= []);
for (let x = column; x < column + colSpan; x += 1) line[x] = true;
}
result.push({ row, column, colSpan, rowSpan });
used = Math.max(used, row + rowSpan);
});
return { placements: result, rows: used };
}, [children, tracks]);
const onLayout = (event: LayoutChangeEvent) => {
const next = event.nativeEvent.layout.width;
if (Math.abs(next - width) > 0.5) setWidth(next);
props.onLayout?.(event);
};
const height = rows > 0 ? rows * cell + gap * (rows - 1) : 0;
let index = -1;
return (
0 ? { height } : undefined}
className={cn('w-full', className)}
{...props}
>
{/* Nothing is drawn until the width is known: every tile is placed
from it, and one frame of tiles piled on the origin is worse than
one frame of an empty box. */}
{width > 0
? Children.map(children, (child) => {
if (!isValidElement(child)) return null;
index += 1;
const place = placements[index];
if (!place) return null;
return (
{child}
);
})
: null}
);
}
);
GridItemGroup.displayName = 'GridItem.Group';
export interface GridItemProps
extends Omit,
Omit, 'disabled' | 'size'> {
className?: string;
disabled?: boolean;
/**
* How many tracks wide the tile is. Clamped to the group's column count, so
* a tile asking for three columns of a two-column grid is two wide rather
* than overflowing it.
*
* Read by `GridItem.Group`, which does the placing — a tile does not size
* itself, because a tile that sized itself would not be in a grid.
*/
colSpan?: number;
/** How many rows tall it is. Also read by the group. */
rowSpan?: number;
children?: ReactNode;
}
/**
* One tile.
*
* Renders as a pressable when given `onPress`, and as a plain view otherwise,
* so a tile that is only showing a number does not announce itself as a button.
*/
const GridItemRoot = forwardRef(
(
{
className,
variant,
disabled,
children,
onPress,
// Declared so they are part of the tile's API and typed at the call site,
// read by the group, and deliberately not applied here.
colSpan: _colSpan,
rowSpan: _rowSpan,
...props
},
ref
) => {
const { size } = useContext(GridItemContext);
const { root } = gridItemVariants({ variant, size, disabled: !!disabled });
if (!onPress) {
return (
{textChildren(children)}
);
}
return (
{textChildren(children)}
);
}
);
GridItemRoot.displayName = 'GridItem';
export interface GridItemBackgroundProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* The layer behind the tile's content — a chart, a gradient, an image, a
* pattern.
*
* Absolutely filling the tile and taking no touches, so it can be written
* anywhere among the children without moving anything. It is what makes a bento
* grid read as a bento grid rather than as a wall of stat cards, and it is the
* one part that is meant to be cropped: the tile clips it.
*/
const GridItemBackground = forwardRef(
({ className, children, ...props }, ref) => (
{textChildren(children)}
)
);
GridItemBackground.displayName = 'GridItem.Background';
export interface GridItemMediaProps
extends ViewProps,
VariantProps {
className?: string;
children?: ReactNode;
}
/** Leading slot: an icon tile, a thumbnail, or an avatar passed through. */
const GridItemMedia = forwardRef(
({ className, variant, size, children, ...props }, ref) => {
const grid = useContext(GridItemContext);
return (
{textChildren(children)}
);
}
);
GridItemMedia.displayName = 'GridItem.Media';
export interface GridItemTitleProps extends TextProps {
className?: string;
}
/** What the tile is of. Quiet, because the number under it is the message. */
const GridItemTitle = forwardRef, GridItemTitleProps>(
({ className, ...props }, ref) => {
const { size } = useContext(GridItemContext);
const { title } = gridItemVariants({ size });
return ;
}
);
GridItemTitle.displayName = 'GridItem.Title';
export interface GridItemValueProps extends TextProps {
className?: string;
}
/** The figure. The largest thing on the tile, and the reason it is there. */
const GridItemValue = forwardRef, GridItemValueProps>(
({ className, ...props }, ref) => {
const { size } = useContext(GridItemContext);
const { value } = gridItemVariants({ size });
return ;
}
);
GridItemValue.displayName = 'GridItem.Value';
export interface GridItemDescriptionProps extends TextProps {
className?: string;
}
const GridItemDescription = forwardRef<
React.ElementRef,
GridItemDescriptionProps
>(({ className, ...props }, ref) => {
const { size } = useContext(GridItemContext);
const { description } = gridItemVariants({ size });
return ;
});
GridItemDescription.displayName = 'GridItem.Description';
export interface GridItemFooterProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* The strip along the bottom of the tile.
*
* `mt-auto` rather than `absolute`: a tile's height is fixed by its cells, so
* pushing the footer down with the space left over pins it to the bottom
* without taking it out of the layout the rest of the tile is sharing.
*/
const GridItemFooter = forwardRef(
({ className, children, ...props }, ref) => (
{textChildren(children)}
)
);
GridItemFooter.displayName = 'GridItem.Footer';
export interface GridItemActionsProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/** Trailing slot: buttons, a chip, a chevron. */
const GridItemActions = forwardRef(
({ className, children, ...props }, ref) => (
{textChildren(children)}
)
);
GridItemActions.displayName = 'GridItem.Actions';
export const GridItem = Object.assign(GridItemRoot, {
Group: GridItemGroup,
Background: GridItemBackground,
Media: GridItemMedia,
Title: GridItemTitle,
Value: GridItemValue,
Description: GridItemDescription,
Footer: GridItemFooter,
Actions: GridItemActions,
});