@tristan2828/ui-foundation 4.6.0 → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1088,3 +1088,70 @@ What the cell decides:
1088
1088
  count reads it.
1089
1089
  - **On the record's view**, the same `CountLink` beside its label (`Open
1090
1090
  tasks: 3`), with the record's name in `label` like the table's.
1091
+
1092
+ ## 21. Rows grouped under headings, and a pill filter
1093
+
1094
+ A feed, an activity log or a timeline reads best as rows under day
1095
+ headings (Today, Yesterday, Earlier this week, then each older week, then
1096
+ months), each heading with a count and a summary ("4 · 1 h 12 m"). The
1097
+ same screens usually want one quick filter as pills above the rows (All /
1098
+ Video / Audio / Text, one pressed at a time) rather than a dropdown: there
1099
+ are few values, and switching between them is the main thing people do.
1100
+ Raised for an app's feed (issue #135); the package's Storybook renders it
1101
+ (`app/DataTable` "Grouped" and "Grouped no matches"), with axe in both
1102
+ themes.
1103
+
1104
+ ```tsx
1105
+ import { byDay, DataTable, PillFilter } from '@tristan2828/ui-foundation'
1106
+
1107
+ const byPublished = byDay<FeedItem>('publishedAt') // module level: once, not every render
1108
+
1109
+ const { page, filters, setFilter, setPage } = useTableUrlState(['kind'])
1110
+
1111
+ <DataTable
1112
+ header={false}
1113
+ groupBy={byPublished}
1114
+ groupSummary={(items) => `${items.length} · ${duration(sum(items, 'minutes'))}`}
1115
+ emptyTitle={filters.kind ? `No ${KIND_LABELS[filters.kind].toLowerCase()} items` : 'Nothing in the feed yet'}
1116
+ emptyAction={filters.kind ? <Button variant="outline" onClick={() => setFilter('kind', '')}>Show all types</Button> : undefined}
1117
+ toolbar={
1118
+ <PillFilter label="Type" value={filters.kind} onValueChange={(kind) => setFilter('kind', kind)}
1119
+ options={KINDS.map((kind) => ({ value: kind, label: KIND_LABELS[kind] }))} />
1120
+ }
1121
+ … />
1122
+ ```
1123
+
1124
+ - **The server sorts, the table only heads.** `groupBy` says which
1125
+ group a row is in; a heading row starts wherever the key changes, so
1126
+ sort the list by the grouped field (newest first) or a key will come
1127
+ back with a second heading. Paging pages rows: a group split across
1128
+ pages repeats its heading on the next, and `groupSummary` is given the
1129
+ group's rows *on this page*. A summary that must count the whole group
1130
+ comes from the API.
1131
+ - **`byDay(field, { now, timeZone })`** counts calendar days in the
1132
+ zone (the browser's by default), so a time near midnight or a daylight
1133
+ saving change never moves a row to the wrong day. Weeks start on
1134
+ Monday (`weekStartsOn: 0` for Sunday). Four weeks back it switches to
1135
+ months ("September", "December 2025"). A later date is Upcoming, no
1136
+ date is No date. Pin `now` in a test, as for pattern 8.
1137
+ - **A heading is a row header for its group** (`<th scope="rowgroup">`
1138
+ in the group's own `<tbody>`), so a screen reader names a cell's group
1139
+ as it names its column. It stays in view while a wide table scrolls
1140
+ sideways.
1141
+ - **`header={false}`** for a list-like table whose columns explain
1142
+ themselves (a thumbnail with a title block, then actions). The header
1143
+ row takes no room but stays in the table, so the columns keep their
1144
+ names for a screen reader; sorting, if any, moves to the toolbar.
1145
+ Fewer, wider columns read as a list: put the thumbnail inside the
1146
+ title's cell rather than a column of its own.
1147
+ - **`PillFilter` is a filter like the others**: its value is in the URL
1148
+ (`useTableUrlState`, '' for All) and `setFilter` sets it, so it returns
1149
+ to page 1 and survives a refresh, back and forward. Pressing the
1150
+ pressed pill leaves it pressed; All takes the filter off
1151
+ (`allLabel={null}` drops All for a filter that always has a value). It
1152
+ is one tab stop, the arrows along it; the pressed pill is filled with
1153
+ `--primary`, not the toggle's faint tint.
1154
+ - **No rows for a filter** is the table's own empty state, saying which
1155
+ filter, with a button to take it off. An empty group never renders:
1156
+ groups come from the rows.
1157
+ - **Not built:** collapsing groups, grouping by more than one key.
@@ -1,6 +1,8 @@
1
+ import { type ReactNode } from 'react';
1
2
  import type { CellData, RowData, TableFeatures } from '@tanstack/react-table';
2
3
  import { type LegacyColumnDef } from '@tanstack/react-table/legacy';
3
4
  import type { AppError } from '../../api/contracts.js';
5
+ import { type RowGroup } from './table-groups.js';
4
6
  export type SortingState = {
5
7
  id: string;
6
8
  desc: boolean;
@@ -70,6 +72,24 @@ export type DataTableProps<TData extends Record<string, unknown>> = {
70
72
  * don't move it (only the first load can).
71
73
  */
72
74
  width?: DataTableWidth;
75
+ /**
76
+ * Rows under headings: each row's group (`byDay(field)` for a feed or a
77
+ * log). Rows keep the server's order, and a heading row spanning every
78
+ * column starts each run of one key. Paging pages rows, not groups, so a
79
+ * group split across pages repeats its heading on the next.
80
+ */
81
+ groupBy?: (row: TData) => RowGroup;
82
+ /**
83
+ * What a heading says after its label ("4 · 1 h 12 m"), from the group's
84
+ * rows on this page.
85
+ */
86
+ groupSummary?: (rows: TData[]) => ReactNode;
87
+ /**
88
+ * False hides the column header row, for a list-like table whose columns
89
+ * explain themselves (a thumbnail, a title block, actions). Screen readers
90
+ * still get the column names; sorting moves to the toolbar.
91
+ */
92
+ header?: boolean;
73
93
  };
74
94
  export type DataTableWidth = 'full' | 'content';
75
- export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width, }: DataTableProps<TData>): import("react").JSX.Element;
95
+ export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width, groupBy, groupSummary, header: showHeader, }: DataTableProps<TData>): import("react").JSX.Element;
@@ -22,6 +22,7 @@ import { Skeleton } from '../ui/skeleton.js';
22
22
  import { EditInPlaceContext } from './edit-in-place.js';
23
23
  import { EditInPlaceStore } from './edit-in-place-store.js';
24
24
  import { LeaveGuard } from './editable-value.js';
25
+ import { groupRuns } from './table-groups.js';
25
26
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '../ui/table.js';
26
27
  // A column def's id as TanStack resolves it (constructColumn): `id`, then
27
28
  // `accessorKey` with dots as underscores, then a string header. Group
@@ -74,6 +75,13 @@ const PINNED_FIRST_CONTENT_CLASS = 'max-md:max-w-[45cqw] max-md:whitespace-norma
74
75
  // background so it reads identically on striped and unstriped rows, and it
75
76
  // stays on screen when the table is scrolled right.
76
77
  const ROW_GROUP_CLASS = 'group/row hover:bg-transparent';
78
+ // `header={false}`: the header row stays in the table, so every cell keeps
79
+ // its column header for a screen reader, but takes no room: each header
80
+ // is its name in an sr-only span, in a cell with no height or padding.
81
+ const HIDDEN_HEAD_CLASS = 'h-0 p-0';
82
+ // A group's heading row (`groupBy`): a little air above, so the groups
83
+ // read apart, and the page's background, never the zebra stripe.
84
+ const GROUP_HEADING_CLASS = 'h-auto bg-background pt-4 pb-1.5 align-bottom';
77
85
  // `width="content"`: from a table area 80rem wide, as wide as the columns
78
86
  // need (`w-fit` over the table's own `w-full`), at least 36rem, or the
79
87
  // width it last had loaded while it shows another state
@@ -142,7 +150,7 @@ function useBottomScrollbar(containerRef, isSuccessView) {
142
150
  }, [containerRef, isSuccessView, isOverflowing]);
143
151
  return { barRef, isOverflowing, scrollWidth };
144
152
  }
145
- export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width = 'full', }) {
153
+ export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width = 'full', groupBy, groupSummary, header: showHeader = true, }) {
146
154
  // Editing in place in the rows (cell pattern 18, "In a table's rows"):
147
155
  // a cell that renders an <EditableValue> edits where it's shown, one at
148
156
  // a time, with the leave-page prompt while one is open, the same rules
@@ -200,6 +208,10 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
200
208
  }, [width, isSuccessView]);
201
209
  const containerRef = useRef(null);
202
210
  const { barRef, isOverflowing, scrollWidth } = useBottomScrollbar(containerRef, Boolean(pinFirstColumn) && isSuccessView);
211
+ const rows = table.getRowModel().rows;
212
+ const visibleColumnCount = table.getVisibleLeafColumns().length;
213
+ // One body row, the same in a plain table and a grouped one.
214
+ const renderRow = (row) => (_jsx(TableRow, { className: cn(pinFirstColumn && ROW_GROUP_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn && index === row.getVisibleCells().length - 1 && PINNED_LAST_COLUMN_CLASS, cell.column.columnDef.meta?.align === 'center' && 'text-center'), children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: flexRender(cell.column.columnDef.cell, cell.getContext()) })) : (flexRender(cell.column.columnDef.cell, cell.getContext())) }, cell.id))) }, row.id));
203
215
  const content = (_jsxs("div", { ref: root, className: cn('flex flex-col gap-4', width === 'content' && CONTENT_WIDTH_CLASS), "data-width": width, style: width === 'content' && !isSuccessView && lastWidth !== null
204
216
  ? { '--data-table-last-width': `${lastWidth}px` }
205
217
  : undefined, children: [toolbar, error ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : isLoading || isPastLastPage ? (_jsx(TableSkeleton, { columnCount: table.getVisibleLeafColumns().length })) : isEmpty ? (_jsxs(Empty, { "data-state": "empty", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: emptyTitle }), emptyDescription && _jsx(EmptyDescription, { children: emptyDescription })] }), emptyAction] })) : (_jsxs(_Fragment, { children: [_jsxs(Table, { "data-state": "success", containerRef: containerRef,
@@ -213,11 +225,13 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
213
225
  // `width="content"` couldn't fit the table. Its query only
214
226
  // serves a phone-width rule, and an 80rem table area never
215
227
  // is one.
216
- width === 'content' && '@7xl/data-table:[container-type:normal]'), children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { children: headerGroup.headers.map((header, index) => {
228
+ width === 'content' && '@7xl/data-table:[container-type:normal]'), children: [_jsx(TableHeader, { className: cn(!showHeader && '[&_tr]:border-0'), children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { children: headerGroup.headers.map((header, index) => {
217
229
  const canSort = header.column.getCanSort();
218
230
  const sortDirection = header.column.getIsSorted();
219
- const content = header.isPlaceholder ? null : canSort ? (_jsxs("button", { type: "button", className: cn('flex items-center gap-1 text-foreground', header.column.columnDef.meta?.align === 'center' && 'w-full justify-center'), onClick: header.column.getToggleSortingHandler(), children: [flexRender(header.column.columnDef.header, header.getContext()), sortDirection === 'asc' ? (_jsx(ArrowUpIcon, { className: "size-3.5 text-muted-foreground" })) : sortDirection === 'desc' ? (_jsx(ArrowDownIcon, { className: "size-3.5 text-muted-foreground" })) : (_jsx(ArrowUpDownIcon, { className: "size-3.5 text-muted-foreground/50" }))] })) : (flexRender(header.column.columnDef.header, header.getContext()));
220
- return (_jsx(TableHead, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn &&
231
+ // Hidden, a header is its name only: a sort button no
232
+ // one can see would still take focus.
233
+ const content = header.isPlaceholder ? null : canSort && showHeader ? (_jsxs("button", { type: "button", className: cn('flex items-center gap-1 text-foreground', header.column.columnDef.meta?.align === 'center' && 'w-full justify-center'), onClick: header.column.getToggleSortingHandler(), children: [flexRender(header.column.columnDef.header, header.getContext()), sortDirection === 'asc' ? (_jsx(ArrowUpIcon, { className: "size-3.5 text-muted-foreground" })) : sortDirection === 'desc' ? (_jsx(ArrowDownIcon, { className: "size-3.5 text-muted-foreground" })) : (_jsx(ArrowUpDownIcon, { className: "size-3.5 text-muted-foreground/50" }))] })) : (flexRender(header.column.columnDef.header, header.getContext()));
234
+ return (_jsx(TableHead, { className: cn(!showHeader && HIDDEN_HEAD_CLASS, pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn &&
221
235
  index === headerGroup.headers.length - 1 &&
222
236
  PINNED_LAST_COLUMN_CLASS, header.column.columnDef.meta?.align === 'center' && 'text-center'), "aria-sort": !canSort
223
237
  ? undefined
@@ -225,9 +239,7 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
225
239
  ? 'ascending'
226
240
  : sortDirection === 'desc'
227
241
  ? 'descending'
228
- : 'none', children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: content })) : (content) }, header.id));
229
- }) }, headerGroup.id))) }), _jsx(TableBody, { children: table.getRowModel().rows.map((row) => (_jsx(TableRow, { className: cn(pinFirstColumn && ROW_GROUP_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn &&
230
- index === row.getVisibleCells().length - 1 &&
231
- PINNED_LAST_COLUMN_CLASS, cell.column.columnDef.meta?.align === 'center' && 'text-center'), children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: flexRender(cell.column.columnDef.cell, cell.getContext()) })) : (flexRender(cell.column.columnDef.cell, cell.getContext())) }, cell.id))) }, row.id))) })] }), pinFirstColumn && isOverflowing && (_jsx("div", { ref: barRef, "aria-hidden": "true", tabIndex: -1, "data-slot": "bottom-scrollbar", className: "sticky bottom-0 z-20 h-4 overflow-x-auto border-t border-border bg-background", children: _jsx("div", { style: { width: scrollWidth, height: 1 } }) })), _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("p", { className: "type-body text-muted-foreground", children: [total === 0 ? 0 : (page - 1) * pageSize + 1, '–', Math.min(page * pageSize, total), " of ", total] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Previous page", disabled: page <= 1, onClick: () => onPageChange(page - 1), children: _jsx(ChevronLeftIcon, {}) }), _jsxs("span", { className: "type-body text-muted-foreground", children: ["Page ", page, " of ", pageCount] }), _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Next page", disabled: page >= pageCount, onClick: () => onPageChange(page + 1), children: _jsx(ChevronRightIcon, {}) })] })] })] }))] }));
242
+ : 'none', children: !showHeader ? (_jsx("span", { className: "sr-only", children: content })) : pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: content })) : (content) }, header.id));
243
+ }) }, headerGroup.id))) }), groupBy ? (groupRuns(rows, (row) => groupBy(row.original)).map((run, runIndex) => (_jsxs(TableBody, { "data-slot": "table-group", children: [_jsx(TableRow, { "data-slot": "table-group-heading", className: "hover:bg-transparent", children: _jsx(TableHead, { scope: "rowgroup", colSpan: visibleColumnCount, className: GROUP_HEADING_CLASS, children: _jsxs("span", { className: "sticky left-(--table-cell-px) inline-flex items-baseline gap-2", children: [_jsx("span", { className: "type-label text-foreground", children: run.label }), groupSummary && (_jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: groupSummary(run.rows.map((row) => row.original)) }))] }) }) }), _jsx("tr", { hidden: true, "aria-hidden": "true", "data-slot": "table-group-stripe-offset" }), run.rows.map(renderRow)] }, `${runIndex}:${run.key}`)))) : (_jsx(TableBody, { children: rows.map(renderRow) }))] }), pinFirstColumn && isOverflowing && (_jsx("div", { ref: barRef, "aria-hidden": "true", tabIndex: -1, "data-slot": "bottom-scrollbar", className: "sticky bottom-0 z-20 h-4 overflow-x-auto border-t border-border bg-background", children: _jsx("div", { style: { width: scrollWidth, height: 1 } }) })), _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("p", { className: "type-body text-muted-foreground", children: [total === 0 ? 0 : (page - 1) * pageSize + 1, '–', Math.min(page * pageSize, total), " of ", total] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Previous page", disabled: page <= 1, onClick: () => onPageChange(page - 1), children: _jsx(ChevronLeftIcon, {}) }), _jsxs("span", { className: "type-body text-muted-foreground", children: ["Page ", page, " of ", pageCount] }), _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Next page", disabled: page >= pageCount, onClick: () => onPageChange(page + 1), children: _jsx(ChevronRightIcon, {}) })] })] })] }))] }));
232
244
  return (_jsxs(EditInPlaceContext.Provider, { value: store, children: [editing && _jsx(LeaveGuard, { store: store }), width === 'content' ? _jsx("div", { className: "@container/data-table", children: content }) : content] }));
233
245
  }
@@ -0,0 +1,15 @@
1
+ export type PillFilterOption = {
2
+ value: string;
3
+ label: string;
4
+ };
5
+ export type PillFilterProps = {
6
+ /** What the pills choose ("Type"): the group's accessible name. */
7
+ label: string;
8
+ options: readonly PillFilterOption[];
9
+ /** The pressed option's value; '' is All. */
10
+ value: string;
11
+ onValueChange: (value: string) => void;
12
+ /** The first pill, which clears the filter. null leaves it out, for a filter that always has a value. */
13
+ allLabel?: string | null;
14
+ };
15
+ export declare function PillFilter({ label, options, value, onValueChange, allLabel }: PillFilterProps): import("react").JSX.Element;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // One quick filter as pills above a table's rows (All / Video / Audio /
3
+ // Text), for a filter with few values that switching between is the main
4
+ // thing people do; a dropdown hides the choices behind a click. One pill
5
+ // is pressed at a time, and pressing the pressed one again leaves it so:
6
+ // "All" is how the filter comes off.
7
+ //
8
+ // Holds no state: the app passes the value it keeps in the URL like any
9
+ // filter (useTableUrlState's `filters.<name>`, '' for All) and sets it
10
+ // with `setFilter`. Built on shadcn's ToggleGroup (Base UI): a group named
11
+ // by `label`, one tab stop, the arrow keys along it, each pill a toggle
12
+ // button with aria-pressed.
13
+ import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group.js';
14
+ // The All pill's value inside the group: Base UI's toggles need one that
15
+ // isn't empty, and '' is what the app's filter calls All.
16
+ const ALL = '__all__';
17
+ // Pressed is filled with the primary colour: the toggle's own pressed tint
18
+ // (bg-muted) is ~3% off the page, too faint to say which one is on.
19
+ const PILL_CLASS = 'rounded-full px-3 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground ' +
20
+ 'aria-pressed:hover:bg-primary/90 aria-pressed:hover:text-primary-foreground';
21
+ export function PillFilter({ label, options, value, onValueChange, allLabel = 'All' }) {
22
+ const pills = allLabel === null ? options : [{ value: ALL, label: allLabel }, ...options];
23
+ const pressed = value === '' && allLabel !== null ? ALL : value;
24
+ return (_jsx(ToggleGroup, { "aria-label": label, variant: "outline", size: "sm", spacing: 1, className: "flex-wrap", value: [pressed], onValueChange: (next) => {
25
+ // [] is the pressed pill pressed again: it stays on.
26
+ const chosen = next[0];
27
+ if (chosen === undefined || chosen === pressed)
28
+ return;
29
+ onValueChange(chosen === ALL ? '' : chosen);
30
+ }, children: pills.map((option) => (_jsx(ToggleGroupItem, { value: option.value, className: PILL_CLASS, children: option.label }, option.value))) }));
31
+ }
@@ -0,0 +1,9 @@
1
+ /** The group a row belongs to: `key` tells groups apart, `label` is the heading. */
2
+ export type RowGroup = {
3
+ key: string;
4
+ label: string;
5
+ };
6
+ export type GroupRun<T> = RowGroup & {
7
+ rows: T[];
8
+ };
9
+ export declare function groupRuns<T>(rows: readonly T[], groupOf: (row: T) => RowGroup): GroupRun<T>[];
@@ -0,0 +1,18 @@
1
+ // DataTable's grouped rows (`groupBy`), kept free of React so tests/ can
2
+ // unit-test it. Rows stay in the order the server sent them; a new group
3
+ // starts wherever the key changes, so a key that comes back later (the
4
+ // server sorted by something else) gets a second heading rather than rows
5
+ // moved out of order. Groups come from the rows alone, so a group with no
6
+ // rows on this page never exists.
7
+ export function groupRuns(rows, groupOf) {
8
+ const runs = [];
9
+ for (const row of rows) {
10
+ const { key, label } = groupOf(row);
11
+ const last = runs.at(-1);
12
+ if (last && last.key === key)
13
+ last.rows.push(row);
14
+ else
15
+ runs.push({ key, label, rows: [row] });
16
+ }
17
+ return runs;
18
+ }
@@ -0,0 +1,11 @@
1
+ import * as React from "react";
2
+ import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
3
+ import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group";
4
+ import { type VariantProps } from "class-variance-authority";
5
+ import { toggleVariants } from "./toggle.js";
6
+ declare function ToggleGroup({ className, variant, size, spacing, orientation, children, ...props }: ToggleGroupPrimitive.Props & VariantProps<typeof toggleVariants> & {
7
+ spacing?: number;
8
+ orientation?: "horizontal" | "vertical";
9
+ }): React.JSX.Element;
10
+ declare function ToggleGroupItem({ className, children, variant, size, ...props }: TogglePrimitive.Props & VariantProps<typeof toggleVariants>): React.JSX.Element;
11
+ export { ToggleGroup, ToggleGroupItem };
@@ -0,0 +1,25 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
5
+ import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group";
6
+ import {} from "class-variance-authority";
7
+ import { cn } from "cn";
8
+ import { toggleVariants } from "./toggle.js";
9
+ const ToggleGroupContext = React.createContext({
10
+ size: "default",
11
+ variant: "default",
12
+ spacing: 2,
13
+ orientation: "horizontal",
14
+ });
15
+ function ToggleGroup({ className, variant, size, spacing = 2, orientation = "horizontal", children, ...props }) {
16
+ return (_jsx(ToggleGroupPrimitive, { "data-slot": "toggle-group", "data-variant": variant, "data-size": size, "data-spacing": spacing, "data-orientation": orientation, style: { "--gap": spacing }, className: cn("group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch", className), ...props, children: _jsx(ToggleGroupContext.Provider, { value: { variant, size, spacing, orientation }, children: children }) }));
17
+ }
18
+ function ToggleGroupItem({ className, children, variant = "default", size = "default", ...props }) {
19
+ const context = React.useContext(ToggleGroupContext);
20
+ return (_jsx(TogglePrimitive, { "data-slot": "toggle-group-item", "data-variant": context.variant || variant, "data-size": context.size || size, "data-spacing": context.spacing, className: cn("shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t", toggleVariants({
21
+ variant: context.variant || variant,
22
+ size: context.size || size,
23
+ }), className), ...props, children: children }));
24
+ }
25
+ export { ToggleGroup, ToggleGroupItem };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { AppShell, type AppShellProps, type AppShellVersion, type NavEntry, type NavItem, type NavSection } from './components/app/app-shell.js';
2
2
  export { CountLink, type CountLinkProps } from './components/app/count-link.js';
3
3
  export { DataTable, type DataTableProps, type DataTableWidth, type SortingState } from './components/app/data-table.js';
4
+ export { type RowGroup } from './components/app/table-groups.js';
5
+ export { byDay, type ByDayOptions } from './lib/group-by-day.js';
4
6
  export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
5
7
  export { EntityView, type EntityViewField, type EntityViewLayout, type EntityViewPlacement, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
6
8
  export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceList, type EditInPlaceListOptions, type EditInPlaceOptions, type EditKind, type EditListItemProps, } from './components/app/edit-in-place.js';
@@ -17,6 +19,7 @@ export { ListEditor } from './components/app/list-editor.js';
17
19
  export { PasswordInput } from './components/app/password-input.js';
18
20
  export { RichTextEditor, type RichTextEditorProps } from './components/app/rich-text-editor.js';
19
21
  export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
22
+ export { PillFilter, type PillFilterOption, type PillFilterProps } from './components/app/pill-filter.js';
20
23
  export { SecondaryFilters, type ActiveFilter, type SecondaryFiltersProps } from './components/app/secondary-filters.js';
21
24
  export { StageCircle, type StageCircleProps, type StageCircleStage, type StageCircleTone, } from './components/app/stage-circle.js';
22
25
  export { WidgetCard, WidgetList, WidgetListItem, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
package/dist/index.js CHANGED
@@ -6,6 +6,8 @@
6
6
  export { AppShell } from './components/app/app-shell.js';
7
7
  export { CountLink } from './components/app/count-link.js';
8
8
  export { DataTable } from './components/app/data-table.js';
9
+ export {} from './components/app/table-groups.js';
10
+ export { byDay } from './lib/group-by-day.js';
9
11
  export { EntityForm } from './components/app/entity-form.js';
10
12
  export { EntityView, } from './components/app/entity-view.js';
11
13
  export { editInPlace, } from './components/app/edit-in-place.js';
@@ -22,6 +24,7 @@ export { ListEditor } from './components/app/list-editor.js';
22
24
  export { PasswordInput } from './components/app/password-input.js';
23
25
  export { RichTextEditor } from './components/app/rich-text-editor.js';
24
26
  export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
27
+ export { PillFilter } from './components/app/pill-filter.js';
25
28
  export { SecondaryFilters } from './components/app/secondary-filters.js';
26
29
  export { StageCircle, } from './components/app/stage-circle.js';
27
30
  export { WidgetCard, WidgetList, WidgetListItem, } from './components/app/widget-card.js';
@@ -0,0 +1,14 @@
1
+ import type { RowGroup } from '../components/app/table-groups.js';
2
+ export type ByDayOptions = {
3
+ /** What "today" is. Defaults to when byDay is called: pass it to pin a test. */
4
+ now?: Date;
5
+ /** The IANA zone whose calendar days count, e.g. "Europe/London". Defaults to the browser's. */
6
+ timeZone?: string;
7
+ /** 1 (Monday, the default) or 0 (Sunday). */
8
+ weekStartsOn?: 0 | 1;
9
+ /** The locale week and month names are written in. Defaults to "en-GB": "Week of 6 Oct". */
10
+ locale?: string;
11
+ };
12
+ type DateValue = string | number | Date | null | undefined;
13
+ export declare function byDay<TData>(field: keyof TData | ((row: TData) => DateValue), { now, timeZone, weekStartsOn, locale }?: ByDayOptions): (row: TData) => RowGroup;
14
+ export {};
@@ -0,0 +1,47 @@
1
+ const DAY_MS = 86_400_000;
2
+ const WEEKS_BY_WEEK = 4;
3
+ export function byDay(field, { now = new Date(), timeZone, weekStartsOn = 1, locale = 'en-GB' } = {}) {
4
+ const parts = new Intl.DateTimeFormat('en-US', {
5
+ timeZone,
6
+ year: 'numeric',
7
+ month: 'numeric',
8
+ day: 'numeric',
9
+ });
10
+ // The calendar day `date` falls on in the zone, as days since 1970-01-01.
11
+ const dayOf = (date) => {
12
+ const part = (type) => Number(parts.formatToParts(date).find((p) => p.type === type)?.value);
13
+ return Date.UTC(part('year'), part('month') - 1, part('day')) / DAY_MS;
14
+ };
15
+ // Day numbers are UTC midnights, so they're formatted in UTC.
16
+ const dayMonth = new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' });
17
+ const month = new Intl.DateTimeFormat(locale, { month: 'long', timeZone: 'UTC' });
18
+ const monthYear = new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric', timeZone: 'UTC' });
19
+ const weekStart = (day) => day - ((new Date(day * DAY_MS).getUTCDay() - weekStartsOn + 7) % 7);
20
+ const isoDay = (day) => new Date(day * DAY_MS).toISOString().slice(0, 10);
21
+ const today = dayOf(now);
22
+ const thisWeek = weekStart(today);
23
+ const thisYear = new Date(today * DAY_MS).getUTCFullYear();
24
+ const read = typeof field === 'function' ? field : (row) => row[field];
25
+ return (row) => {
26
+ const value = read(row);
27
+ const date = value === null || value === undefined || value === '' ? null : new Date(value);
28
+ if (date === null || Number.isNaN(date.getTime()))
29
+ return { key: 'none', label: 'No date' };
30
+ const day = dayOf(date);
31
+ if (day > today)
32
+ return { key: 'upcoming', label: 'Upcoming' };
33
+ if (day === today)
34
+ return { key: 'today', label: 'Today' };
35
+ if (day === today - 1)
36
+ return { key: 'yesterday', label: 'Yesterday' };
37
+ if (day >= thisWeek)
38
+ return { key: 'this-week', label: 'Earlier this week' };
39
+ const week = weekStart(day);
40
+ if (week >= thisWeek - WEEKS_BY_WEEK * 7) {
41
+ return { key: `week:${isoDay(week)}`, label: `Week of ${dayMonth.format(new Date(week * DAY_MS))}` };
42
+ }
43
+ const first = new Date(day * DAY_MS);
44
+ const label = (first.getUTCFullYear() === thisYear ? month : monthYear).format(first);
45
+ return { key: `month:${isoDay(day).slice(0, 7)}`, label };
46
+ };
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.6.0",
3
+ "version": "4.8.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {