@iloveagents/foundry-web-ui 0.19.0 → 0.20.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/README.md +80 -0
- package/dist/components/data-table/data-table-faceted-filter.js +11 -4
- package/dist/components/data-table/data-table-frame.d.ts +16 -1
- package/dist/components/data-table/data-table-frame.js +32 -7
- package/dist/components/data-table/data-table-selection-bar.d.ts +18 -0
- package/dist/components/data-table/data-table-selection-bar.js +15 -0
- package/dist/components/data-table/data-table-toolbar.d.ts +13 -4
- package/dist/components/data-table/data-table-toolbar.js +17 -4
- package/dist/components/data-table/data-table-view-options.d.ts +1 -1
- package/dist/components/data-table/data-table-view-options.js +1 -1
- package/dist/components/data-table/data-table.d.ts +43 -2
- package/dist/components/data-table/data-table.js +264 -28
- package/dist/components/data-table/date-buckets.d.ts +44 -0
- package/dist/components/data-table/date-buckets.js +178 -0
- package/dist/components/data-table/facets.d.ts +34 -2
- package/dist/components/data-table/facets.js +44 -2
- package/dist/components/data-table/use-data-table.d.ts +13 -1
- package/dist/components/data-table/use-data-table.js +47 -0
- package/dist/components/data-table/use-element-width.d.ts +7 -0
- package/dist/components/data-table/use-element-width.js +36 -0
- package/dist/components/data-table/use-pinned-offsets.d.ts +9 -0
- package/dist/components/data-table/use-pinned-offsets.js +79 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +6 -2
- package/dist/lib/app-store.d.ts +11 -1
- package/dist/lib/app-store.js +42 -5
- package/dist/styles.css +91 -0
- package/dist/ui/dropdown-menu.js +1 -1
- package/package.json +3 -3
|
@@ -7,17 +7,51 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
7
7
|
*/
|
|
8
8
|
import { flexRender } from "@tanstack/react-table";
|
|
9
9
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
10
|
+
import { useCallback, useEffect, useRef, useState, } from "react";
|
|
11
|
+
import { DATA_TABLE_MIN_WIDTH } from "./facets.js";
|
|
12
|
+
import { useDataTableFrame } from "./data-table-frame.js";
|
|
13
|
+
import { usePinnedOffsets } from "./use-pinned-offsets.js";
|
|
14
|
+
import { useElementWidth } from "./use-element-width.js";
|
|
10
15
|
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
// Native controls, the ARIA widgets that own the same keys (a slider or a
|
|
17
|
+
// listbox answers to arrows; a checkbox to space), and anything a host marks.
|
|
18
|
+
// A row's keyboard handling must never take a key out from under one of them.
|
|
19
|
+
const INTERACTIVE = [
|
|
20
|
+
"a",
|
|
21
|
+
"button",
|
|
22
|
+
"input",
|
|
23
|
+
"select",
|
|
24
|
+
"textarea",
|
|
25
|
+
"[contenteditable]:not([contenteditable='false'])",
|
|
26
|
+
"[role='menuitem']",
|
|
27
|
+
"[role='menuitemcheckbox']",
|
|
28
|
+
"[role='menuitemradio']",
|
|
29
|
+
"[role='checkbox']",
|
|
30
|
+
"[role='radio']",
|
|
31
|
+
"[role='switch']",
|
|
32
|
+
"[role='slider']",
|
|
33
|
+
"[role='spinbutton']",
|
|
34
|
+
"[role='textbox']",
|
|
35
|
+
"[role='combobox']",
|
|
36
|
+
"[role='listbox']",
|
|
37
|
+
"[role='option']",
|
|
38
|
+
"[role='tab']",
|
|
39
|
+
// Arrow-owning composites: a tree or a grid inside a cell navigates with
|
|
40
|
+
// exactly the keys this table wants.
|
|
41
|
+
"[role='tree']",
|
|
42
|
+
"[role='treeitem']",
|
|
43
|
+
"[role='grid']",
|
|
44
|
+
"[role='gridcell']",
|
|
45
|
+
"[role='menu']",
|
|
46
|
+
"[role='menubar']",
|
|
47
|
+
"[role='radiogroup']",
|
|
48
|
+
// The plain ones: a div acting as a button or a link is still a control,
|
|
49
|
+
// and its click must not also open the row behind it.
|
|
50
|
+
"[role='button']",
|
|
51
|
+
"[role='link']",
|
|
52
|
+
"[data-no-row-click]",
|
|
53
|
+
].join(", ");
|
|
54
|
+
/** Group header rows and their expanded children, in declared group order. */
|
|
21
55
|
function orderedRows(table, groupOrder) {
|
|
22
56
|
const rows = table.getRowModel().rows;
|
|
23
57
|
if (!table.getState().grouping.length)
|
|
@@ -33,35 +67,237 @@ function orderedRows(table, groupOrder) {
|
|
|
33
67
|
: top;
|
|
34
68
|
return sorted.flatMap((row) => row.getIsGrouped() && row.getIsExpanded() ? [row, ...row.subRows] : [row]);
|
|
35
69
|
}
|
|
36
|
-
export function DataTable({ table, onRowClick, activeRowId, rowClassName, renderGroup, groupOrder, emptyState = "No results.", stickyHeader = false, dense = false, className, "aria-label": ariaLabel, }) {
|
|
70
|
+
export function DataTable({ table, onRowClick, onRowOpen, activeRowId, onActiveRowChange, rowClassName, renderGroup, groupOrder, emptyState = "No results.", spacer = true, stickyHeader = false, fill, maxHeight, dense = false, onRenderedColumnsChange, className, "aria-label": ariaLabel, }) {
|
|
71
|
+
const frame = useDataTableFrame();
|
|
72
|
+
// In focus mode the layer is a fixed flex column: the table takes the
|
|
73
|
+
// remaining height (`min-h-0` lets a flex item shrink below its content)
|
|
74
|
+
// and scrolls in both directions, so the toolbar above stays put.
|
|
75
|
+
const filling = fill ?? frame?.focused ?? false;
|
|
76
|
+
const bounded = !filling && maxHeight !== undefined;
|
|
77
|
+
// A header only sticks inside a scroll container — which is exactly what
|
|
78
|
+
// `fill` and `maxHeight` create.
|
|
79
|
+
const sticky = stickyHeader || filling || bounded;
|
|
37
80
|
const rows = orderedRows(table, groupOrder);
|
|
38
|
-
const
|
|
81
|
+
const dataRows = rows.filter((row) => !row.getIsGrouped());
|
|
82
|
+
const rootRef = useRef(null);
|
|
83
|
+
const scrollerRef = useRef(null);
|
|
84
|
+
const [scrolledX, setScrolledX] = useState(false);
|
|
85
|
+
const width = useElementWidth(rootRef);
|
|
86
|
+
// A column has to earn its place twice: the mode it is meant for, and the
|
|
87
|
+
// width the list actually has.
|
|
88
|
+
const fits = (meta) => {
|
|
89
|
+
// Focus mode is the question, not `fill`: `fill` is a scroll-layout prop
|
|
90
|
+
// a host may set on an ordinary page, and it would otherwise reveal
|
|
91
|
+
// columns meant for the fullscreen layer (and hide them inside it when
|
|
92
|
+
// a host passed `fill={false}`).
|
|
93
|
+
if (meta?.showIn === "focus" && !(frame?.focused ?? false))
|
|
94
|
+
return false;
|
|
95
|
+
const breakpoint = meta?.breakpoint;
|
|
96
|
+
return !breakpoint || width === undefined || width >= DATA_TABLE_MIN_WIDTH[breakpoint];
|
|
97
|
+
};
|
|
98
|
+
// What the table actually draws: TanStack visibility, then the width and
|
|
99
|
+
// mode gates. Everything downstream — the pinned run, the empty-row colspan,
|
|
100
|
+
// what the host is told — is derived from this one list, so none of them can
|
|
101
|
+
// name a column that is not on screen.
|
|
102
|
+
const renderedColumns = table
|
|
103
|
+
.getVisibleLeafColumns()
|
|
104
|
+
.filter((column) => fits(column.columnDef.meta));
|
|
105
|
+
// Width of the leaves that survive the gates; `undefined` when none of them
|
|
106
|
+
// declares one, so the column keeps its natural width.
|
|
107
|
+
const fittingLeafWidth = (header) => {
|
|
108
|
+
// Walk `subHeaders` rather than `getLeafHeaders()`: the latter includes
|
|
109
|
+
// this header's own entry, whose size already sums the descendants, and
|
|
110
|
+
// the total would count everything twice.
|
|
111
|
+
const collect = (node) => node.subHeaders.length
|
|
112
|
+
? node.subHeaders.flatMap(collect)
|
|
113
|
+
: fits(node.column.columnDef.meta)
|
|
114
|
+
? [node]
|
|
115
|
+
: [];
|
|
116
|
+
const sizes = collect(header).map((leaf) => leaf.getSize());
|
|
117
|
+
if (!sizes.length)
|
|
118
|
+
return undefined;
|
|
119
|
+
return sizes.every((size) => size === 150) ? undefined : sizes.reduce((a, b) => a + b, 0);
|
|
120
|
+
};
|
|
121
|
+
const fittingLeafCount = (header) => header.subHeaders.length === 0
|
|
122
|
+
? fits(header.column.columnDef.meta)
|
|
123
|
+
? 1
|
|
124
|
+
: 0
|
|
125
|
+
: header.subHeaders.reduce((total, sub) => total + fittingLeafCount(sub), 0);
|
|
126
|
+
// Pinned columns: frozen against the left edge so a row keeps its identity
|
|
127
|
+
// while the rest of a wide table scrolls sideways.
|
|
128
|
+
// Freezing is a run from the left edge: everything up to the last pinned
|
|
129
|
+
// column comes along, so a leading checkbox never slides out from under the
|
|
130
|
+
// rows it belongs to. A pinned column the width gate dropped is not in the
|
|
131
|
+
// run at all — otherwise the seam would mark a column nobody can see.
|
|
132
|
+
const lastPinnedIndex = renderedColumns.reduce((last, column, index) => (column.columnDef.meta?.pinned === "left" ? index : last), -1);
|
|
133
|
+
const pinnedIds = renderedColumns.slice(0, lastPinnedIndex + 1).map((column) => column.id);
|
|
134
|
+
const { offsets, lastPinned, measure } = usePinnedOffsets(pinnedIds);
|
|
135
|
+
// A pinned cell is opaque — the other columns slide underneath it — and it
|
|
136
|
+
// restates the row's hover / selected / active tints. Those rules live in
|
|
137
|
+
// this package's `styles.css`, keyed off the attributes below, so a host
|
|
138
|
+
// needs no CSS wiring of its own.
|
|
139
|
+
const pinnedProps = (columnId, tone) => offsets[columnId] === undefined
|
|
140
|
+
? {}
|
|
141
|
+
: {
|
|
142
|
+
"data-pinned": tone,
|
|
143
|
+
"data-pinned-edge": columnId === lastPinned ? "" : undefined,
|
|
144
|
+
};
|
|
145
|
+
const pinnedStyle = (columnId) => offsets[columnId] === undefined ? undefined : { left: offsets[columnId] };
|
|
146
|
+
// Keyboard: the active row moves with the arrows and stays in view, so a
|
|
147
|
+
// long list can be walked without touching the mouse.
|
|
148
|
+
const bodyRef = useRef(null);
|
|
149
|
+
const moveActive = useCallback((delta) => {
|
|
150
|
+
if (!onActiveRowChange || dataRows.length === 0)
|
|
151
|
+
return;
|
|
152
|
+
const index = dataRows.findIndex((row) => row.id === activeRowId);
|
|
153
|
+
const next = delta === "first"
|
|
154
|
+
? 0
|
|
155
|
+
: delta === "last"
|
|
156
|
+
? dataRows.length - 1
|
|
157
|
+
: index < 0
|
|
158
|
+
? delta > 0
|
|
159
|
+
? 0
|
|
160
|
+
: dataRows.length - 1
|
|
161
|
+
: Math.min(dataRows.length - 1, Math.max(0, index + delta));
|
|
162
|
+
const row = dataRows[next];
|
|
163
|
+
if (row)
|
|
164
|
+
onActiveRowChange(row);
|
|
165
|
+
}, [activeRowId, dataRows, onActiveRowChange]);
|
|
166
|
+
// Sorting, filtering or a late-arriving page moves the active row without
|
|
167
|
+
// changing its id, and an id restored before the rows load points at nothing
|
|
168
|
+
// yet — so the position is part of what this watches.
|
|
169
|
+
const activeIndex = dataRows.findIndex((row) => row.id === activeRowId);
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (!activeRowId || activeIndex < 0)
|
|
172
|
+
return;
|
|
173
|
+
const element = Array.from(bodyRef.current?.querySelectorAll("tr[data-row-id]") ?? []).find((row) => row.dataset.rowId === activeRowId);
|
|
174
|
+
element?.scrollIntoView?.({ block: "nearest" });
|
|
175
|
+
// Take the keyboard along, but only when it is on a ROW: arrows move the
|
|
176
|
+
// active row, and leaving focus on the row it left means the next Enter
|
|
177
|
+
// opens the wrong one. Focus inside a cell's own control stays there —
|
|
178
|
+
// moving it would eject someone mid-edit when a re-sort happens to shift
|
|
179
|
+
// the row — and focus elsewhere on the page is never stolen.
|
|
180
|
+
const active = typeof document === "undefined" ? null : document.activeElement;
|
|
181
|
+
const onARow = active instanceof HTMLElement &&
|
|
182
|
+
active.matches("tr[data-row-id]") &&
|
|
183
|
+
bodyRef.current?.contains(active);
|
|
184
|
+
if (element && onARow && active !== element) {
|
|
185
|
+
element.focus?.({ preventScroll: true });
|
|
186
|
+
}
|
|
187
|
+
}, [activeRowId, activeIndex]);
|
|
188
|
+
const columnCount = renderedColumns.length + (spacer ? 1 : 0);
|
|
189
|
+
const renderedIds = renderedColumns.map((column) => column.id);
|
|
190
|
+
// JSON, not a comma-joined string: a column id may contain a comma, and
|
|
191
|
+
// splitting one back apart would report columns that do not exist (and let
|
|
192
|
+
// two different sets share a key, so the host is never told).
|
|
193
|
+
const renderedKey = JSON.stringify(renderedIds);
|
|
194
|
+
const renderedRef = useRef(onRenderedColumnsChange);
|
|
195
|
+
renderedRef.current = onRenderedColumnsChange;
|
|
196
|
+
const renderedIdsRef = useRef(renderedIds);
|
|
197
|
+
renderedIdsRef.current = renderedIds;
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
renderedRef.current?.(renderedIdsRef.current);
|
|
200
|
+
}, [renderedKey]);
|
|
39
201
|
const cellPad = dense ? "px-3 py-1.5" : "px-3 py-2.5";
|
|
40
|
-
const activate = (row, event) => {
|
|
41
|
-
if (!
|
|
202
|
+
const activate = (row, event, handler) => {
|
|
203
|
+
if (!handler)
|
|
42
204
|
return;
|
|
43
205
|
const target = event.target;
|
|
44
206
|
if (target?.closest(INTERACTIVE))
|
|
45
207
|
return;
|
|
46
|
-
|
|
208
|
+
handler(row, event);
|
|
47
209
|
};
|
|
48
|
-
return (_jsx("div", { className: cn("overflow-hidden rounded-2xl border border-border/45 bg-card/60",
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
210
|
+
return (_jsx("div", { ref: rootRef, "data-scrolled-x": scrolledX ? "" : undefined, className: cn("overflow-hidden rounded-2xl border border-border/45 bg-card/60", filling && "flex min-h-0 flex-1 flex-col", className), children: _jsx("div", { ref: scrollerRef, onScroll: (event) => {
|
|
211
|
+
const next = event.currentTarget.scrollLeft > 0;
|
|
212
|
+
setScrolledX((current) => (current === next ? current : next));
|
|
213
|
+
}, "data-list-scroller": "", style: bounded ? { maxHeight } : undefined, className: cn(filling ? "min-h-0 flex-1 overflow-auto" : bounded ? "overflow-auto" : "overflow-x-auto"), children: _jsxs("table", { className: "w-full caption-bottom text-sm", "aria-label": ariaLabel, onKeyDown: onActiveRowChange
|
|
214
|
+
? (event) => {
|
|
215
|
+
// Let a control that owns these keys keep them.
|
|
216
|
+
if (event.target.closest(INTERACTIVE))
|
|
217
|
+
return;
|
|
218
|
+
if (event.key === "ArrowDown")
|
|
219
|
+
moveActive(1);
|
|
220
|
+
else if (event.key === "ArrowUp")
|
|
221
|
+
moveActive(-1);
|
|
222
|
+
else if (event.key === "Home")
|
|
223
|
+
moveActive("first");
|
|
224
|
+
else if (event.key === "End")
|
|
225
|
+
moveActive("last");
|
|
226
|
+
else if (event.key === "Escape") {
|
|
227
|
+
// Only when there IS a row to let go of. One Escape, one
|
|
228
|
+
// effect: it clears the active row without also leaving
|
|
229
|
+
// focus mode — but with no active row it belongs to the
|
|
230
|
+
// frame, so it is left alone to bubble there.
|
|
231
|
+
if (!activeRowId)
|
|
232
|
+
return;
|
|
233
|
+
onActiveRowChange(null);
|
|
234
|
+
event.stopPropagation();
|
|
235
|
+
event.nativeEvent.stopImmediatePropagation();
|
|
236
|
+
}
|
|
237
|
+
else
|
|
238
|
+
return;
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
}
|
|
241
|
+
: undefined, children: [_jsx("thead", { className: cn(sticky && "sticky top-0 z-10 bg-card"), children: table.getHeaderGroups().map((headerGroup) => (_jsxs("tr", { className: "border-b border-border/45", children: [headerGroup.headers.map((header) => {
|
|
242
|
+
const meta = header.column.columnDef.meta;
|
|
243
|
+
// A grouping header spans its leaves; when the width gate
|
|
244
|
+
// drops some of them it has to narrow, and when it drops all
|
|
245
|
+
// of them the group header goes too.
|
|
246
|
+
const span = fittingLeafCount(header);
|
|
247
|
+
if (span === 0)
|
|
248
|
+
return null;
|
|
249
|
+
// `getSize()` sums EVERY descendant, including the ones the
|
|
250
|
+
// width gate just dropped, so a half-gated group would hold
|
|
251
|
+
// space for a column that is not there and push the table
|
|
252
|
+
// wider than its own columns.
|
|
253
|
+
const sized = header.subHeaders.length
|
|
254
|
+
? fittingLeafWidth(header)
|
|
255
|
+
: header.getSize();
|
|
256
|
+
return (_jsx("th", { colSpan: span, scope: "col",
|
|
257
|
+
// Every column in the run, not only the marked ones: the
|
|
258
|
+
// run can start with an unmarked leader (a selection
|
|
259
|
+
// checkbox), and an unmeasured leader leaves the column
|
|
260
|
+
// after it stacked at zero, overlapping it.
|
|
261
|
+
ref: pinnedIds.includes(header.column.id) ? measure(header.column.id) : undefined, ...pinnedProps(header.column.id, "header"), style: {
|
|
262
|
+
...(sized !== undefined && sized !== 150 ? { width: sized } : {}),
|
|
263
|
+
...pinnedStyle(header.column.id),
|
|
264
|
+
}, className: cn("h-10 px-3 text-left align-middle text-xs font-medium text-muted-foreground", meta?.align === "right" && "text-right", meta?.align === "center" && "text-center", meta?.headerClassName), children: header.isPlaceholder
|
|
265
|
+
? null
|
|
266
|
+
: flexRender(header.column.columnDef.header, header.getContext()) }, header.id));
|
|
267
|
+
}), spacer ? _jsx("th", { "aria-hidden": "true", className: "w-full p-0" }) : null] }, headerGroup.id))) }), _jsx("tbody", { ref: bodyRef, children: rows.length === 0 ? (_jsx("tr", { children: _jsx("td", { colSpan: columnCount, className: "h-24 text-center text-sm text-muted-foreground", children: emptyState }) })) : (rows.map((row) => row.getIsGrouped() ? (_jsx("tr", { "data-group-row": "", className: "border-t border-border/45 bg-muted/35 text-sm font-medium", children: _jsx("td", { colSpan: columnCount, className: "p-0", children: _jsxs("div", { "data-group-content": "", style: width ? { width } : undefined, className: "flex items-center gap-2 px-3 py-1.5", children: [_jsx("button", { type: "button", onClick: row.getToggleExpandedHandler(), "aria-expanded": row.getIsExpanded(), "aria-label": row.getIsExpanded() ? "Collapse group" : "Expand group", className: "rounded-md p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground", children: row.getIsExpanded() ? (_jsx(ChevronDown, { className: "size-4" })) : (_jsx(ChevronRight, { className: "size-4" })) }), _jsx("div", { className: "min-w-0 flex-1", children: renderGroup ? renderGroup(row) : _jsx(DefaultGroupLabel, { row: row }) })] }) }) }, row.id)) : (_jsxs("tr", { "data-row-id": row.id, "data-state": row.getIsSelected() ? "selected" : undefined, "data-active": activeRowId != null && row.id === activeRowId ? "" : undefined, "aria-current": activeRowId != null && row.id === activeRowId ? "true" : undefined, "aria-selected": row.getIsSelected() || undefined, "data-clickable": onRowClick || onRowOpen ? "" : undefined, "data-focusable": onRowClick || onRowOpen || onActiveRowChange ? "" : undefined, tabIndex: onRowClick || onRowOpen || onActiveRowChange ? 0 : undefined,
|
|
268
|
+
// `detail` counts the clicks in the sequence: the second
|
|
269
|
+
// one belongs to the double-click that follows, and
|
|
270
|
+
// letting it through would toggle a selection off again
|
|
271
|
+
// right before the row opens.
|
|
272
|
+
onClick: (event) => {
|
|
273
|
+
if (event.detail > 1)
|
|
274
|
+
return;
|
|
275
|
+
activate(row, event, onRowClick);
|
|
276
|
+
}, onDoubleClick: (event) => activate(row, event, onRowOpen), onKeyDown: (event) => {
|
|
277
|
+
if (event.key === "Enter") {
|
|
278
|
+
if (event.target.closest(INTERACTIVE))
|
|
279
|
+
return;
|
|
280
|
+
event.preventDefault();
|
|
281
|
+
activate(row, event, onRowOpen ?? onRowClick);
|
|
282
|
+
}
|
|
283
|
+
else if (event.key === " ") {
|
|
55
284
|
if (event.target.closest(INTERACTIVE))
|
|
56
285
|
return;
|
|
57
286
|
event.preventDefault();
|
|
58
|
-
activate(row, event);
|
|
287
|
+
activate(row, event, onRowClick);
|
|
59
288
|
}
|
|
60
|
-
}, className: cn("border-t border-border/35 transition-colors", onRowClick &&
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
289
|
+
}, className: cn("group/row border-t border-border/35 transition-colors", (onRowClick || onRowOpen) && "cursor-pointer hover:bg-muted/30",
|
|
290
|
+
// Anything focusable has to SHOW where the keyboard is —
|
|
291
|
+
// a list navigated with the arrows is focusable without
|
|
292
|
+
// being clickable, and silent focus leaves a keyboard
|
|
293
|
+
// user guessing which row the next key belongs to.
|
|
294
|
+
(onRowClick || onRowOpen || onActiveRowChange) &&
|
|
295
|
+
"focus-visible:bg-muted/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring", "data-[state=selected]:bg-primary/6 data-[active]:bg-primary/8", rowClassName?.(row)), children: [row.getVisibleCells().map((cell) => {
|
|
296
|
+
const meta = cell.column.columnDef.meta;
|
|
297
|
+
if (!fits(meta))
|
|
298
|
+
return null;
|
|
299
|
+
return (_jsx("td", { ...pinnedProps(cell.column.id, "cell"), style: pinnedStyle(cell.column.id), className: cn(cellPad, "align-middle", meta?.align === "right" && "text-right", meta?.align === "center" && "text-center", meta?.className), children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id));
|
|
300
|
+
}), spacer ? _jsx("td", { "aria-hidden": "true", className: "w-full p-0" }) : null] }, row.id)))) })] }) }) }));
|
|
65
301
|
}
|
|
66
302
|
function DefaultGroupLabel({ row }) {
|
|
67
303
|
const grouping = row
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dates as things you can tick. A timestamp is never a facet value — nobody
|
|
3
|
+
* filters by "2026-08-14T09:12:03Z" — so these turn one into the words a
|
|
4
|
+
* person actually means: how old it is, and whether it is late. Pair them
|
|
5
|
+
* with `meta.facetBuckets`, which is what the rest of the machinery counts,
|
|
6
|
+
* ORs, ANDs and puts in the URL.
|
|
7
|
+
*
|
|
8
|
+
* Every list with a date wants this, which is why it lives here rather than
|
|
9
|
+
* being written again in each one.
|
|
10
|
+
*/
|
|
11
|
+
export interface DateBucketOptions {
|
|
12
|
+
/** The moment to measure against (default: now). Pass it to keep a test still. */
|
|
13
|
+
now?: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function parseDateish(value: unknown): number | null;
|
|
16
|
+
export type AgeBucket = "today" | "week" | "month" | "older" | "never";
|
|
17
|
+
/** The narrowest bucket something falls in — the one to LABEL it with. */
|
|
18
|
+
export declare function ageBucket(value: unknown, options?: DateBucketOptions): AgeBucket;
|
|
19
|
+
/**
|
|
20
|
+
* Every bucket something belongs to — what a FILTER needs.
|
|
21
|
+
*
|
|
22
|
+
* "Last 7 days" includes today. Saying it does not is technically defensible
|
|
23
|
+
* and reads as broken: a register where everything had just been touched
|
|
24
|
+
* showed "Today 11, Last 7 days 8, Last 30 days 0", three numbers that
|
|
25
|
+
* cannot all be true of the same rows. Ranges nest, so the keys nest, and
|
|
26
|
+
* the counts come out as totals a person can check by eye.
|
|
27
|
+
*
|
|
28
|
+
* The tail ("older than 30 days") and "never" are states rather than
|
|
29
|
+
* ranges, so they stand alone.
|
|
30
|
+
*/
|
|
31
|
+
export declare function ageBuckets(value: unknown, options?: DateBucketOptions): AgeBucket[];
|
|
32
|
+
export interface DueStateOptions extends DateBucketOptions {
|
|
33
|
+
/** How near counts as "soon" (default 30 days). */
|
|
34
|
+
soonDays?: number;
|
|
35
|
+
}
|
|
36
|
+
export type DueState = "overdue" | "soon" | "later" | "undated";
|
|
37
|
+
/**
|
|
38
|
+
* Where a deadline stands. `undated` is its own answer, not a guess: plenty
|
|
39
|
+
* of real deadlines are written as "Contract signature (May)", and calling
|
|
40
|
+
* those neither late nor on time is the honest reading.
|
|
41
|
+
*/
|
|
42
|
+
export declare function dueState(value: unknown, options?: DueStateOptions): DueState;
|
|
43
|
+
/** "today" · "yesterday" · "6 d ago" · the date once that stops being useful. */
|
|
44
|
+
export declare function relativeAge(value: unknown, options?: DateBucketOptions): string;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dates as things you can tick. A timestamp is never a facet value — nobody
|
|
3
|
+
* filters by "2026-08-14T09:12:03Z" — so these turn one into the words a
|
|
4
|
+
* person actually means: how old it is, and whether it is late. Pair them
|
|
5
|
+
* with `meta.facetBuckets`, which is what the rest of the machinery counts,
|
|
6
|
+
* ORs, ANDs and puts in the URL.
|
|
7
|
+
*
|
|
8
|
+
* Every list with a date wants this, which is why it lives here rather than
|
|
9
|
+
* being written again in each one.
|
|
10
|
+
*/
|
|
11
|
+
const DAY = 86400000;
|
|
12
|
+
/**
|
|
13
|
+
* Milliseconds for a value that carries a date, `null` for one that does not.
|
|
14
|
+
*
|
|
15
|
+
* Only shapes whose meaning is not in doubt are read: ISO (`2026-08-22`, with
|
|
16
|
+
* an optional time and zone), the dotted European `d.m.yyyy`, and year-first
|
|
17
|
+
* `yyyy/m/d`. Everything else is `null` — including `03/04/2026` and
|
|
18
|
+
* `03-04-2026`, which are March in one country and April in another.
|
|
19
|
+
*
|
|
20
|
+
* The alternative, handing the leftovers to `Date.parse`, is what makes that
|
|
21
|
+
* guess quietly: it reads `03-04-2026` as March 4th and even turns `"1"` into
|
|
22
|
+
* a date. A field where people legitimately write "Contract signature (May)"
|
|
23
|
+
* needs a parser that says no.
|
|
24
|
+
*/
|
|
25
|
+
const ISO = /^(\d{4})-(\d{2})-(\d{2})(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?\s*(?:Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
26
|
+
const DOTTED = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/;
|
|
27
|
+
const YEAR_FIRST = /^(\d{4})\/(\d{1,2})\/(\d{1,2})$/;
|
|
28
|
+
/**
|
|
29
|
+
* Midnight at the start of that date, in the reader's own zone — and `null`
|
|
30
|
+
* if the date does not exist (2026-02-31 is a typo).
|
|
31
|
+
*
|
|
32
|
+
* Local, not UTC: a date somebody typed into a form means that day where
|
|
33
|
+
* they are. Reading it as UTC makes "22 August" start at 17:00 on the 21st
|
|
34
|
+
* for a reader in UTC−7, which then puts the row in the wrong bucket and
|
|
35
|
+
* marks a deadline overdue most of a day early.
|
|
36
|
+
*/
|
|
37
|
+
function localDay(year, month, day) {
|
|
38
|
+
if (month < 1 || month > 12 || day < 1 || day > 31)
|
|
39
|
+
return null;
|
|
40
|
+
const at = new Date(year, month - 1, day).getTime();
|
|
41
|
+
const check = new Date(at);
|
|
42
|
+
return check.getFullYear() === year && check.getMonth() === month - 1 && check.getDate() === day
|
|
43
|
+
? at
|
|
44
|
+
: null;
|
|
45
|
+
}
|
|
46
|
+
/** Midnight of the local calendar day an instant falls in. */
|
|
47
|
+
function startOfDay(at) {
|
|
48
|
+
const date = new Date(at);
|
|
49
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The last millisecond of that local day — built from the NEXT day's calendar
|
|
53
|
+
* fields, never by adding 24 hours. On a daylight-saving change the day is 23
|
|
54
|
+
* or 25 hours long, and a fixed offset would end it an hour early in autumn
|
|
55
|
+
* and an hour into tomorrow in spring.
|
|
56
|
+
*/
|
|
57
|
+
function endOfDay(at) {
|
|
58
|
+
const date = new Date(at);
|
|
59
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1).getTime() - 1;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Whole calendar days between two instants — 23:30 and 00:30 the next
|
|
63
|
+
* morning are ONE day apart even though barely an hour passed. Everything
|
|
64
|
+
* a person calls "today" or "yesterday" is a calendar question, so counting
|
|
65
|
+
* elapsed milliseconds gets it wrong twice a night.
|
|
66
|
+
*/
|
|
67
|
+
function calendarDaysBetween(from, to) {
|
|
68
|
+
return Math.round((startOfDay(to) - startOfDay(from)) / DAY);
|
|
69
|
+
}
|
|
70
|
+
function parseParts(value) {
|
|
71
|
+
if (value == null || value === "")
|
|
72
|
+
return null;
|
|
73
|
+
if (value instanceof Date)
|
|
74
|
+
return Number.isNaN(value.getTime()) ? null : { at: value.getTime(), dateOnly: false };
|
|
75
|
+
if (typeof value === "number")
|
|
76
|
+
return Number.isFinite(value) ? { at: value, dateOnly: false } : null;
|
|
77
|
+
const text = String(value).trim();
|
|
78
|
+
if (!text)
|
|
79
|
+
return null;
|
|
80
|
+
const iso = ISO.exec(text);
|
|
81
|
+
if (iso) {
|
|
82
|
+
// The calendar day has to be real; the clock and zone are `Date.parse`'s
|
|
83
|
+
// job, and it handles ISO without guessing.
|
|
84
|
+
const day = localDay(Number(iso[1]), Number(iso[2]), Number(iso[3]));
|
|
85
|
+
if (day === null)
|
|
86
|
+
return null;
|
|
87
|
+
// A bare date is that day where the reader is; one carrying a clock (and
|
|
88
|
+
// possibly a zone) is the instant it names.
|
|
89
|
+
if (text.length === 10)
|
|
90
|
+
return { at: day, dateOnly: true };
|
|
91
|
+
const parsed = Date.parse(text);
|
|
92
|
+
return Number.isNaN(parsed) ? null : { at: parsed, dateOnly: false };
|
|
93
|
+
}
|
|
94
|
+
const dotted = DOTTED.exec(text);
|
|
95
|
+
if (dotted) {
|
|
96
|
+
const at = localDay(Number(dotted[3]), Number(dotted[2]), Number(dotted[1]));
|
|
97
|
+
return at === null ? null : { at, dateOnly: true };
|
|
98
|
+
}
|
|
99
|
+
const yearFirst = YEAR_FIRST.exec(text);
|
|
100
|
+
if (yearFirst) {
|
|
101
|
+
const at = localDay(Number(yearFirst[1]), Number(yearFirst[2]), Number(yearFirst[3]));
|
|
102
|
+
return at === null ? null : { at, dateOnly: true };
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
export function parseDateish(value) {
|
|
107
|
+
return parseParts(value)?.at ?? null;
|
|
108
|
+
}
|
|
109
|
+
/** The narrowest bucket something falls in — the one to LABEL it with. */
|
|
110
|
+
export function ageBucket(value, options = {}) {
|
|
111
|
+
const at = parseDateish(value);
|
|
112
|
+
if (at === null)
|
|
113
|
+
return "never";
|
|
114
|
+
const days = calendarDaysBetween(at, options.now ?? Date.now());
|
|
115
|
+
if (days <= 0)
|
|
116
|
+
return "today";
|
|
117
|
+
if (days < 7)
|
|
118
|
+
return "week";
|
|
119
|
+
if (days < 30)
|
|
120
|
+
return "month";
|
|
121
|
+
return "older";
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Every bucket something belongs to — what a FILTER needs.
|
|
125
|
+
*
|
|
126
|
+
* "Last 7 days" includes today. Saying it does not is technically defensible
|
|
127
|
+
* and reads as broken: a register where everything had just been touched
|
|
128
|
+
* showed "Today 11, Last 7 days 8, Last 30 days 0", three numbers that
|
|
129
|
+
* cannot all be true of the same rows. Ranges nest, so the keys nest, and
|
|
130
|
+
* the counts come out as totals a person can check by eye.
|
|
131
|
+
*
|
|
132
|
+
* The tail ("older than 30 days") and "never" are states rather than
|
|
133
|
+
* ranges, so they stand alone.
|
|
134
|
+
*/
|
|
135
|
+
export function ageBuckets(value, options = {}) {
|
|
136
|
+
const narrowest = ageBucket(value, options);
|
|
137
|
+
if (narrowest === "today")
|
|
138
|
+
return ["today", "week", "month"];
|
|
139
|
+
if (narrowest === "week")
|
|
140
|
+
return ["week", "month"];
|
|
141
|
+
if (narrowest === "month")
|
|
142
|
+
return ["month"];
|
|
143
|
+
return [narrowest];
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Where a deadline stands. `undated` is its own answer, not a guess: plenty
|
|
147
|
+
* of real deadlines are written as "Contract signature (May)", and calling
|
|
148
|
+
* those neither late nor on time is the honest reading.
|
|
149
|
+
*/
|
|
150
|
+
export function dueState(value, options = {}) {
|
|
151
|
+
const parts = parseParts(value);
|
|
152
|
+
if (parts === null)
|
|
153
|
+
return "undated";
|
|
154
|
+
const now = options.now ?? Date.now();
|
|
155
|
+
// A date with no clock is a whole day, and someone with until Friday is not
|
|
156
|
+
// late on Friday morning — so it runs out at the end of that day where
|
|
157
|
+
// they are. A real timestamp is compared as the instant it is.
|
|
158
|
+
const deadline = parts.dateOnly ? endOfDay(parts.at) : parts.at;
|
|
159
|
+
if (deadline < now)
|
|
160
|
+
return "overdue";
|
|
161
|
+
return deadline - now <= (options.soonDays ?? 30) * DAY ? "soon" : "later";
|
|
162
|
+
}
|
|
163
|
+
/** "today" · "yesterday" · "6 d ago" · the date once that stops being useful. */
|
|
164
|
+
export function relativeAge(value, options = {}) {
|
|
165
|
+
const at = parseDateish(value);
|
|
166
|
+
if (at === null)
|
|
167
|
+
return "—";
|
|
168
|
+
const days = calendarDaysBetween(at, options.now ?? Date.now());
|
|
169
|
+
if (days <= 0)
|
|
170
|
+
return "today";
|
|
171
|
+
if (days === 1)
|
|
172
|
+
return "yesterday";
|
|
173
|
+
if (days < 30)
|
|
174
|
+
return `${days} d ago`;
|
|
175
|
+
const date = new Date(at);
|
|
176
|
+
const pad = (part) => String(part).padStart(2, "0");
|
|
177
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
178
|
+
}
|
|
@@ -21,16 +21,47 @@ declare module "@tanstack/react-table" {
|
|
|
21
21
|
searchText?: (row: TData) => string;
|
|
22
22
|
/** Labels for facet keys and group headers (`value → label`). */
|
|
23
23
|
facetLabels?: Record<string, string>;
|
|
24
|
-
/**
|
|
24
|
+
/** Narrowest list width the column is shown at; below it the column hides (the Columns menu still lists it). */
|
|
25
25
|
breakpoint?: DataTableBreakpoint;
|
|
26
|
+
/**
|
|
27
|
+
* Which mode the column earns its place in. `"always"` (default) means
|
|
28
|
+
* wherever it fits; `"focus"` keeps it for when the list has the screen
|
|
29
|
+
* to itself, so a page-embedded list stays scannable and going
|
|
30
|
+
* fullscreen actually shows you more.
|
|
31
|
+
*/
|
|
32
|
+
showIn?: "always" | "focus";
|
|
33
|
+
/** Start hidden: available in the Columns menu, off until asked for. */
|
|
34
|
+
defaultHidden?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Turn a raw value into the facet keys it belongs to — how a number or a
|
|
37
|
+
* date becomes something you can tick: "≥ 50 %", "overdue", "this week".
|
|
38
|
+
* The rest of the machinery (counts, OR within, AND across, the URL, the
|
|
39
|
+
* agent tools) works on those keys unchanged.
|
|
40
|
+
*/
|
|
41
|
+
facetBuckets?: (value: unknown) => string[];
|
|
42
|
+
/** Freeze the column against the left edge while the table scrolls sideways. */
|
|
43
|
+
pinned?: "left";
|
|
26
44
|
/** Keep unused type parameter referenced for declaration merging. */
|
|
27
45
|
__cellValue?: TValue;
|
|
28
46
|
/** Hosts may carry their own keys in `meta` (e.g. a server sort key). */
|
|
29
47
|
[key: string]: unknown;
|
|
30
48
|
}
|
|
31
49
|
}
|
|
32
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* How much room a column needs. Measured against the *list's* own width, not
|
|
52
|
+
* the window — so a list beside an open detail pane sheds columns and gets
|
|
53
|
+
* them back in focus mode, with no work from the host.
|
|
54
|
+
*
|
|
55
|
+
* `sm` 448px · `md` 672px · `lg` 896px · `xl` 1024px · `2xl` 1280px.
|
|
56
|
+
*/
|
|
33
57
|
export type DataTableBreakpoint = "sm" | "md" | "lg" | "xl" | "2xl";
|
|
58
|
+
/**
|
|
59
|
+
* The list width at which each band starts earning its columns. Measured on
|
|
60
|
+
* the list itself, not the viewport — a list in a side pane is narrow even on
|
|
61
|
+
* a wide screen. Exported so a host that derives breakpoints from a schema
|
|
62
|
+
* measures against the same numbers the table renders by.
|
|
63
|
+
*/
|
|
64
|
+
export declare const DATA_TABLE_MIN_WIDTH: Record<DataTableBreakpoint, number>;
|
|
34
65
|
/** Facet key for empty cells (`null`, `undefined`, `""`, `[]`). */
|
|
35
66
|
export declare const FACET_EMPTY = "__none__";
|
|
36
67
|
export interface DataTableFacetOption {
|
|
@@ -48,6 +79,7 @@ export interface DataTableFacetCount extends DataTableFacetOption {
|
|
|
48
79
|
* element. A real value that spells the empty sentinel is escaped with a
|
|
49
80
|
* leading backslash so it never masquerades as "no value".
|
|
50
81
|
*/
|
|
82
|
+
export declare function columnFacetKeys<T>(column: Column<T, unknown>, value: unknown): string[];
|
|
51
83
|
export declare function facetKeys(value: unknown): string[];
|
|
52
84
|
/** A facet filter value is a list of selected keys; anything else means "no filter". */
|
|
53
85
|
export declare function normalizeFacetValue(filterValue: unknown): string[];
|