@tristan2828/ui-foundation 4.7.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.
- package/conventions/docs/cell-patterns.md +67 -0
- package/dist/components/app/data-table.d.ts +21 -1
- package/dist/components/app/data-table.js +20 -8
- package/dist/components/app/pill-filter.d.ts +15 -0
- package/dist/components/app/pill-filter.js +31 -0
- package/dist/components/app/table-groups.d.ts +9 -0
- package/dist/components/app/table-groups.js +18 -0
- package/dist/components/ui/toggle-group.d.ts +11 -0
- package/dist/components/ui/toggle-group.js +25 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/lib/group-by-day.d.ts +14 -0
- package/dist/lib/group-by-day.js +47 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
220
|
-
|
|
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))) }),
|
|
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.
|
|
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": {
|