@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.
Files changed (29) hide show
  1. package/README.md +80 -0
  2. package/dist/components/data-table/data-table-faceted-filter.js +11 -4
  3. package/dist/components/data-table/data-table-frame.d.ts +16 -1
  4. package/dist/components/data-table/data-table-frame.js +32 -7
  5. package/dist/components/data-table/data-table-selection-bar.d.ts +18 -0
  6. package/dist/components/data-table/data-table-selection-bar.js +15 -0
  7. package/dist/components/data-table/data-table-toolbar.d.ts +13 -4
  8. package/dist/components/data-table/data-table-toolbar.js +17 -4
  9. package/dist/components/data-table/data-table-view-options.d.ts +1 -1
  10. package/dist/components/data-table/data-table-view-options.js +1 -1
  11. package/dist/components/data-table/data-table.d.ts +43 -2
  12. package/dist/components/data-table/data-table.js +264 -28
  13. package/dist/components/data-table/date-buckets.d.ts +44 -0
  14. package/dist/components/data-table/date-buckets.js +178 -0
  15. package/dist/components/data-table/facets.d.ts +34 -2
  16. package/dist/components/data-table/facets.js +44 -2
  17. package/dist/components/data-table/use-data-table.d.ts +13 -1
  18. package/dist/components/data-table/use-data-table.js +47 -0
  19. package/dist/components/data-table/use-element-width.d.ts +7 -0
  20. package/dist/components/data-table/use-element-width.js +36 -0
  21. package/dist/components/data-table/use-pinned-offsets.d.ts +9 -0
  22. package/dist/components/data-table/use-pinned-offsets.js +79 -0
  23. package/dist/index.d.ts +6 -2
  24. package/dist/index.js +6 -2
  25. package/dist/lib/app-store.d.ts +11 -1
  26. package/dist/lib/app-store.js +42 -5
  27. package/dist/styles.css +91 -0
  28. package/dist/ui/dropdown-menu.js +1 -1
  29. 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
- const INTERACTIVE = "a, button, input, select, textarea, [role='menuitem'], [data-no-row-click]";
12
- /** Literal classes so Tailwind generates them: hide the column below the breakpoint. */
13
- const BREAKPOINT_CLASS = {
14
- sm: "max-sm:hidden",
15
- md: "max-md:hidden",
16
- lg: "max-lg:hidden",
17
- xl: "max-xl:hidden",
18
- "2xl": "max-2xl:hidden",
19
- };
20
- const breakpointClass = (breakpoint) => breakpoint ? BREAKPOINT_CLASS[breakpoint] : undefined;
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 columnCount = table.getVisibleLeafColumns().length;
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 (!onRowClick)
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
- onRowClick(row, event);
208
+ handler(row, event);
47
209
  };
48
- return (_jsx("div", { className: cn("overflow-hidden rounded-2xl border border-border/45 bg-card/60", className), children: _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full caption-bottom text-sm", "aria-label": ariaLabel, children: [_jsx("thead", { className: cn(stickyHeader && "sticky top-0 z-10 bg-card"), children: table.getHeaderGroups().map((headerGroup) => (_jsx("tr", { className: "border-b border-border/45", children: headerGroup.headers.map((header) => {
49
- const meta = header.column.columnDef.meta;
50
- return (_jsx("th", { colSpan: header.colSpan, scope: "col", style: header.getSize() !== 150 ? { width: header.getSize() } : undefined, 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", breakpointClass(meta?.breakpoint), meta?.headerClassName), children: header.isPlaceholder
51
- ? null
52
- : flexRender(header.column.columnDef.header, header.getContext()) }, header.id));
53
- }) }, headerGroup.id))) }), _jsx("tbody", { 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: "px-3 py-1.5", children: _jsxs("div", { className: "flex items-center gap-2", 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)) : (_jsx("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, tabIndex: onRowClick ? 0 : undefined, onClick: (event) => activate(row, event), onKeyDown: (event) => {
54
- if (event.key === "Enter" || event.key === " ") {
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
- "cursor-pointer hover:bg-muted/30 focus-visible:bg-muted/30 focus-visible:outline-none", "data-[state=selected]:bg-primary/6 data-[active]:bg-primary/8", rowClassName?.(row)), children: row.getVisibleCells().map((cell) => {
62
- const meta = cell.column.columnDef.meta;
63
- return (_jsx("td", { className: cn(cellPad, "align-middle", meta?.align === "right" && "text-right", meta?.align === "center" && "text-center", breakpointClass(meta?.breakpoint), meta?.className), children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id));
64
- }) }, row.id)))) })] }) }) }));
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
- /** Smallest viewport the column is shown at; below it the column is hidden (the View menu can still force it). */
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
- /** Tailwind breakpoints a column can be gated on. */
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[];