Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions apps/frontend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,21 @@ Import direction is one-way and must stay that way: `api.ts` ← `authClient.ts`
- Tailwind v4 via `@tailwindcss/postcss` (`postcss.config.mjs`), `@import "tailwindcss"` in `globals.css`. Custom theme tokens in the `@theme` block (`--color-core-green`, `--color-primary-*`, fonts Roboto Slab / PT Sans, heading/body sizes).
- Chakra UI v3 unstyled components (`Table.Root`, `Dialog`, `Field`, `Button`, `Input`, ...) under `ChakraProvider defaultSystem`. Emotion is a Chakra dep. Inline styles appear alongside Tailwind classes in layout components.

## Shared UI

Two families of component are **the** way to do their job — don't hand-roll a second one.

**Tables — `components/DataTable.tsx`.** Every list view (expenses, reports, donors, donations) renders through it, so the green header row, column widths, empty state, row-click behaviour and loading skeleton stay identical. Columns are data: `{ key, header, width, align, cell, skeleton }`. Pass `selection` (see `reports/page.tsx`) for the leading checkbox column — the page keeps owning the selected ids, since that is what its bulk actions need. `ExpensesTable` is a thin wrapper that fixes the expense column set; add domain wrappers like that rather than re-deriving columns per page.

**Loading — `Spinner` / `LoadingState` / `Skeleton` / `TableSkeletonRows`.** No more `<p>Loading…</p>`.

- `LoadingState` for a region whose content has not arrived (`variant="section"` reserves height; `"inline"` for menus and dialog bodies). The label is the accessible name and is hidden unless `showLabel`.
- `DataTable isLoading` for tables — skeleton rows keep the header and column widths on screen. Set `skeletonRows` to the page size so nothing resizes when data lands.
- Chakra's `Button loading` prop for in-flight actions; it renders its own spinner.
- `Spinner` is the primitive; it takes its colour from `currentColor` and only gets a `label` when nothing around it is already `role="status"`.

The animations live in `globals.css` (`.branch-spinner`, `.branch-skeleton`, and their keyframes), not in the components — one timing curve for the whole app, and `FullPageSpinner` can render before any component library is mounted. Both honour `prefers-reduced-motion`.

## Conventions

- Page/interactive components start with `'use client'`.
Expand Down
195 changes: 195 additions & 0 deletions apps/frontend/src/app/components/DataTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
'use client';

import type React from 'react';
import { Checkbox, Table } from '@chakra-ui/react';
import TableSkeletonRows, { type SkeletonColumn } from './TableSkeletonRows';

export interface DataTableColumn<T> {
/** Stable identity for the column; doubles as the React key. */
key: string;
header: React.ReactNode;
/** Width for the `<colgroup>`; percentages keep the table fluid. */
width?: string;
align?: 'left' | 'center' | 'right';
cell: (row: T) => React.ReactNode;
/** Shape of this column's loading placeholder — a pill, a short bar, etc. */
skeleton?: SkeletonColumn;
}

/**
* Row selection is driven from outside: the pages that support it already own
* the selected ids because that is what their bulk actions operate on.
*/
export interface DataTableSelection<T> {
isSelected: (row: T) => boolean;
onToggleRow: (row: T) => void;
allSelected: boolean;
someSelected: boolean;
onToggleAll: () => void;
/** Accessible name for the header checkbox, e.g. "Select all reports". */
label?: string;
disabled?: boolean;
}

interface DataTableProps<T> {
columns: DataTableColumn<T>[];
rows: T[];
rowKey: (row: T) => React.Key;
/** Replaces the body with skeleton rows, keeping the header and widths. */
isLoading?: boolean;
loadingLabel?: string;
/** Ideally the page size, so the table does not resize when data lands. */
skeletonRows?: number;
emptyMessage?: React.ReactNode;
onRowClick?: (row: T) => void;
selection?: DataTableSelection<T>;
variant?: 'line' | 'outline';
}

const CHECKBOX_CONTROL_CSS = {
backgroundColor: 'var(--color-core-white)',
borderColor: 'var(--color-core-green)',
'&[data-state="checked"]': {
backgroundColor: 'var(--color-primary-800)',
borderColor: 'var(--color-core-green)',
},
};

/**
* The app's one table. Every list view goes through here so the green header
* row, column sizing, empty state and loading skeleton stay identical
* everywhere — previously each page rebuilt all four by hand and they drifted.
*
* Columns are data, not markup: give each one a `cell` renderer and, where the
* default bar is wrong, a `skeleton` shape.
*/
export default function DataTable<T>({
columns,
rows,
rowKey,
isLoading = false,
loadingLabel = 'Loading…',
skeletonRows = 5,
emptyMessage = 'Nothing to show yet.',
onRowClick,
selection,
variant,
}: DataTableProps<T>) {
const columnCount = columns.length + (selection ? 1 : 0);
const hasWidths = columns.some((column) => column.width);

const skeletonColumns: SkeletonColumn[] = [
// The checkbox slot gets a square rather than a bar, so the loading table
// reads as the same shape as the loaded one.
...(selection ? [{ width: '18px', height: 18 } as SkeletonColumn] : []),
...columns.map((column) => ({
align: column.align,
...column.skeleton,
})),
];

return (
<Table.Root variant={variant} width="100%">
{hasWidths && (
<Table.ColumnGroup>
{selection && <Table.Column width="48px" />}
{columns.map((column) => (
<Table.Column key={column.key} width={column.width} />
))}
</Table.ColumnGroup>
)}

<Table.Header>
<Table.Row backgroundColor="var(--color-primary-800)">
{selection && (
<Table.ColumnHeader width="48px" paddingY="12px">
<Checkbox.Root
checked={
selection.allSelected ? true : selection.someSelected ? 'indeterminate' : false
}
onCheckedChange={selection.onToggleAll}
disabled={selection.disabled}
aria-label={selection.label ?? 'Select all rows'}
>
<Checkbox.HiddenInput />
<Checkbox.Control borderRadius="md" css={CHECKBOX_CONTROL_CSS} />
</Checkbox.Root>
</Table.ColumnHeader>
)}
{columns.map((column) => (
<Table.ColumnHeader
key={column.key}
color="var(--color-core-white)"
textAlign={column.align}
>
<h5>{column.header}</h5>
</Table.ColumnHeader>
))}
</Table.Row>
</Table.Header>

<Table.Body>
{isLoading ? (
<TableSkeletonRows
rows={skeletonRows}
columns={skeletonColumns}
label={loadingLabel}
/>
) : rows.length === 0 ? (
<Table.Row>
<Table.Cell
colSpan={columnCount}
textAlign="center"
paddingY="32px"
color="var(--color-black-500)"
>
{emptyMessage}
</Table.Cell>
</Table.Row>
) : (
rows.map((row) => (
<Table.Row
key={rowKey(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
// Rows that act like buttons have to be reachable without a
// mouse; the target check keeps Enter on a nested control (a
// receipt link, a checkbox) from also opening the row.
tabIndex={onRowClick ? 0 : undefined}
onKeyDown={
onRowClick
? (event) => {
if (event.target !== event.currentTarget) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onRowClick(row);
}
}
: undefined
}
cursor={onRowClick ? 'pointer' : undefined}
_hover={onRowClick ? { backgroundColor: 'var(--color-primary-100)' } : undefined}
>
{selection && (
<Table.Cell onClick={(event) => event.stopPropagation()}>
<Checkbox.Root
checked={selection.isSelected(row)}
onCheckedChange={() => selection.onToggleRow(row)}
disabled={selection.disabled}
>
<Checkbox.HiddenInput />
<Checkbox.Control borderRadius="md" css={CHECKBOX_CONTROL_CSS} />
</Checkbox.Root>
</Table.Cell>
)}
{columns.map((column) => (
<Table.Cell key={column.key} textAlign={column.align}>
{column.cell(row)}
</Table.Cell>
))}
</Table.Row>
))
)}
</Table.Body>
</Table.Root>
);
}
Loading
Loading