@crisp-ui-kit/crisp 0.33.1 → 0.33.3
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/dist/cjs/components/datatable/datatable.js +63 -22
- package/dist/cjs/components/datatable/datatable.recipe.js +5 -0
- package/dist/cjs/core/cell-date.js +37 -0
- package/dist/cjs/core/index.js +4 -1
- package/dist/cjs/product/recordstable/recordstable.js +12 -21
- package/dist/datatable.css +3 -1
- package/dist/datatable.layered.css +3 -1
- package/dist/esm/components/datatable/datatable.js +64 -23
- package/dist/esm/components/datatable/datatable.recipe.js +5 -0
- package/dist/esm/core/cell-date.js +33 -0
- package/dist/esm/core/index.js +1 -0
- package/dist/esm/product/recordstable/recordstable.js +8 -16
- package/dist/styles.css +3 -1
- package/dist/styles.layered.css +3 -1
- package/dist/types/components/datatable/datatable.d.ts +16 -1
- package/dist/types/core/cell-date.d.ts +17 -0
- package/dist/types/core/index.d.ts +1 -0
- package/dist/types/product/recordstable/recordstable.d.ts +13 -4
- package/package.json +1 -1
|
@@ -46,12 +46,15 @@ const datatable_recipe_1 = require("./datatable.recipe");
|
|
|
46
46
|
* a display string. Returns "" when it cannot be parsed, so the field opens
|
|
47
47
|
* empty rather than showing an invalid value. */
|
|
48
48
|
function toDateInputValue(v) {
|
|
49
|
+
// formatCellDate, not toISOString().slice(0, 10): the latter takes the UTC
|
|
50
|
+
// day of a local Date, so a value picked late in the evening east of UTC
|
|
51
|
+
// round-trips as the next day (#221).
|
|
49
52
|
if (v instanceof Date && !Number.isNaN(v.getTime()))
|
|
50
|
-
return
|
|
53
|
+
return (0, core_1.formatCellDate)(v);
|
|
51
54
|
if (typeof v === "string" || typeof v === "number") {
|
|
52
|
-
const d =
|
|
53
|
-
if (
|
|
54
|
-
return
|
|
55
|
+
const d = (0, core_1.parseCellDate)(String(v));
|
|
56
|
+
if (d)
|
|
57
|
+
return (0, core_1.formatCellDate)(d);
|
|
55
58
|
}
|
|
56
59
|
return "";
|
|
57
60
|
}
|
|
@@ -89,21 +92,34 @@ function initials(name) {
|
|
|
89
92
|
const p = name.trim().split(/\s+/);
|
|
90
93
|
return ((p[0]?.[0] ?? "") + (p[1]?.[0] ?? "")).toUpperCase() || "?";
|
|
91
94
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
// One Intl.DateTimeFormat per locale, built lazily and reused — constructing
|
|
96
|
+
// a formatter per cell render would be wasteful on a large grid.
|
|
97
|
+
const DATE_FMT_CACHE = new Map();
|
|
98
|
+
function dateFmt(locale) {
|
|
99
|
+
let fmt = DATE_FMT_CACHE.get(locale);
|
|
100
|
+
if (!fmt) {
|
|
101
|
+
fmt = new Intl.DateTimeFormat(locale, {
|
|
102
|
+
month: "short",
|
|
103
|
+
day: "numeric",
|
|
104
|
+
year: "numeric",
|
|
105
|
+
});
|
|
106
|
+
DATE_FMT_CACHE.set(locale, fmt);
|
|
107
|
+
}
|
|
108
|
+
return fmt;
|
|
109
|
+
}
|
|
110
|
+
function formatCell(type, v, symbol, locale = "en-US") {
|
|
98
111
|
if (type === "number")
|
|
99
|
-
return typeof v === "number" ? v.toLocaleString(
|
|
112
|
+
return typeof v === "number" ? v.toLocaleString(locale) : asText(v);
|
|
100
113
|
if (type === "currency")
|
|
101
114
|
return typeof v === "number"
|
|
102
|
-
? `${symbol ?? "$"}${v.toLocaleString(
|
|
115
|
+
? `${symbol ?? "$"}${v.toLocaleString(locale)}`
|
|
103
116
|
: asText(v);
|
|
104
117
|
if (type === "date") {
|
|
105
|
-
|
|
106
|
-
|
|
118
|
+
// parseCellDate, not `new Date(v)`: a date-only value is UTC midnight per
|
|
119
|
+
// ECMA-262, and formatting that in the reader's timezone renders it a day
|
|
120
|
+
// early west of UTC (#221, the same defect #220 fixed in RecordsTable).
|
|
121
|
+
const d = v ? (0, core_1.parseCellDate)(String(v)) : null;
|
|
122
|
+
return d ? dateFmt(locale).format(d) : asText(v);
|
|
107
123
|
}
|
|
108
124
|
return asText(v);
|
|
109
125
|
}
|
|
@@ -115,7 +131,7 @@ function formatCell(type, v, symbol) {
|
|
|
115
131
|
// silently drop the cell's data. Each branch decides for itself where a
|
|
116
132
|
// display string belongs -- as an entity's name, a status' label -- while the
|
|
117
133
|
// raw value keeps driving structure and tone.
|
|
118
|
-
function renderTyped(col, value, avatarTone, display) {
|
|
134
|
+
function renderTyped(col, value, avatarTone, display, locale = "en-US") {
|
|
119
135
|
const empty = (0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-muted", children: "\u2014" });
|
|
120
136
|
switch (col.type) {
|
|
121
137
|
case "entity": {
|
|
@@ -146,7 +162,7 @@ function renderTyped(col, value, avatarTone, display) {
|
|
|
146
162
|
return ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-tag", "data-tone": col.tones?.[s] ?? toneFor(s), children: display || s }));
|
|
147
163
|
}
|
|
148
164
|
default: {
|
|
149
|
-
const text = display || formatCell(col.type, value, col.symbol);
|
|
165
|
+
const text = display || formatCell(col.type, value, col.symbol, locale);
|
|
150
166
|
const isNum = col.type === "number" || col.type === "currency";
|
|
151
167
|
if (!text)
|
|
152
168
|
return empty;
|
|
@@ -155,9 +171,16 @@ function renderTyped(col, value, avatarTone, display) {
|
|
|
155
171
|
}
|
|
156
172
|
}
|
|
157
173
|
const CHECK_W = 40;
|
|
158
|
-
function DataTable({ columns: columnsProp, data, selectable = false, selectedIds, onSelectionChange, selectAllLabel = "Select all rows", selectRowLabel = (row) => `Select row ${row.id}`, columnMenu = false, onColumnsChange, groupBy, onRowsReorder, error, sort: sortProp, defaultSort, onSortChange, filterable = false, searchPlaceholder = "Search…", searchLabel = "Search table", onEdit, stickyFirst = true, avatarTone = "brand", surface, interactive = false, onRowClick, onAddRow, addRowLabel = "New record", empty = "No results.", showCount = true, countLabel = (shown, total) => shown === total
|
|
174
|
+
function DataTable({ columns: columnsProp, data, selectable = false, selectedIds, onSelectionChange, selectAllLabel = "Select all rows", selectRowLabel = (row) => `Select row ${row.id}`, selectionAnnouncement = (count) => count === 0 ? "Selection cleared" : `${count} selected`, columnMenu = false, onColumnsChange, groupBy, onRowsReorder, error, sort: sortProp, defaultSort, onSortChange, filterable = false, searchPlaceholder = "Search…", searchLabel = "Search table", onEdit, stickyFirst = true, locale: localeProp, avatarTone = "brand", surface, interactive = false, onRowClick, onAddRow, addRowLabel = "New record", empty = "No results.", showCount = true, countLabel = (shown, total) => shown === total
|
|
159
175
|
? `${total} ${total === 1 ? "row" : "rows"}`
|
|
160
176
|
: `${shown} of ${total}`, toolbar, className, reorderRowLabel = "Reorder row", ...rest }) {
|
|
177
|
+
// Two different defaults on purpose. FORMATTING falls back to "en-US", which
|
|
178
|
+
// is what the built-in date/number/currency renderers have always produced,
|
|
179
|
+
// so no existing output moves. SORTING falls back to `undefined`, which is
|
|
180
|
+
// the runtime's own collation — passing "en-US" there would silently change
|
|
181
|
+
// the row order for every consumer on a non-en-US runtime, which is exactly
|
|
182
|
+
// the kind of change this prop is supposed to be additive about.
|
|
183
|
+
const locale = localeProp ?? "en-US";
|
|
161
184
|
const [query, setQuery] = React.useState("");
|
|
162
185
|
const [internalSort, setInternalSort] = React.useState(defaultSort ?? null);
|
|
163
186
|
// Column list the table actually renders. Seeded from `columns`; the header
|
|
@@ -234,6 +257,18 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
|
|
|
234
257
|
if (!selectedIds)
|
|
235
258
|
setInternalSel(next);
|
|
236
259
|
}, [onSelectionChange, selectedIds]);
|
|
260
|
+
// The selection is announced from a region that is part of the table and
|
|
261
|
+
// outlives every selection, because a consumer's own bulk bar (mounted only
|
|
262
|
+
// once something is picked) cannot announce its own arrival (#234).
|
|
263
|
+
// Derived by adjusting state during render rather than in an effect: it
|
|
264
|
+
// must not fire on mount — nobody has selected anything yet — and it has to
|
|
265
|
+
// cover a `selectedIds` change the consumer makes as well as a click here.
|
|
266
|
+
const prevSelectedCount = React.useRef(selected.size);
|
|
267
|
+
const [selectionText, setSelectionText] = React.useState("");
|
|
268
|
+
if (prevSelectedCount.current !== selected.size) {
|
|
269
|
+
prevSelectedCount.current = selected.size;
|
|
270
|
+
setSelectionText(selectionAnnouncement(selected.size));
|
|
271
|
+
}
|
|
237
272
|
const [editing, setEditing] = React.useState(null);
|
|
238
273
|
const [scrolledX, setScrolledX] = React.useState(false);
|
|
239
274
|
// column widths (drag-to-resize) + fixed-layout sizing
|
|
@@ -269,13 +304,19 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
|
|
|
269
304
|
const bv = cellValue(col, b);
|
|
270
305
|
const cmp = typeof av === "number" && typeof bv === "number"
|
|
271
306
|
? av - bv
|
|
272
|
-
:
|
|
307
|
+
: // `localeProp`, NOT `locale`: the ?? "en-US" fallback belongs to
|
|
308
|
+
// formatting only. Passing it here would pin collation to en-US
|
|
309
|
+
// for every consumer on a non-en-US runtime and silently reorder
|
|
310
|
+
// their rows. No test can catch that substitution on a machine
|
|
311
|
+
// whose own default is en-US -- one was written, it could not
|
|
312
|
+
// fail, and it was removed -- so this comment is the guard.
|
|
313
|
+
asText(av).localeCompare(asText(bv), localeProp);
|
|
273
314
|
if (cmp !== 0)
|
|
274
315
|
return cmp * dir;
|
|
275
316
|
}
|
|
276
317
|
return 0;
|
|
277
318
|
});
|
|
278
|
-
}, [filtered, sorts, colByKey]);
|
|
319
|
+
}, [filtered, sorts, colByKey, localeProp]);
|
|
279
320
|
/**
|
|
280
321
|
* Rows bucketed under a heading, preserving first-seen group order (a
|
|
281
322
|
* grouped view without reordering within a group). One bucket with a null
|
|
@@ -352,7 +393,7 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
|
|
|
352
393
|
const row = rows.find((rw) => rw.id === selCell.id);
|
|
353
394
|
const col = columns.find((cc) => cc.key === selCell.key);
|
|
354
395
|
if (row && col)
|
|
355
|
-
navigator.clipboard?.writeText(formatCell(col.type, cellValue(col, row), col.symbol));
|
|
396
|
+
navigator.clipboard?.writeText(formatCell(col.type, cellValue(col, row), col.symbol, locale));
|
|
356
397
|
e.preventDefault();
|
|
357
398
|
return;
|
|
358
399
|
}
|
|
@@ -464,7 +505,7 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
|
|
|
464
505
|
return dir === "asc" ? ((0, jsx_runtime_1.jsx)(lucide_react_1.ArrowUp, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 })) : ((0, jsx_runtime_1.jsx)(lucide_react_1.ArrowDown, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 }));
|
|
465
506
|
return ((0, jsx_runtime_1.jsx)(lucide_react_1.ChevronsUpDown, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 }));
|
|
466
507
|
}
|
|
467
|
-
return ((0, jsx_runtime_1.jsxs)("div", { ref: gridRef, className: (0, datatable_recipe_1.datatableRecipe)({ className }), tabIndex: -1, onKeyDown: onGridKeyDown, ...rest, children: [(filterable || showCount || toolbar || columnMenu) && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-datatable-toolbar", children: [columnMenu && ((0, jsx_runtime_1.jsxs)(menu_1.Menu, { align: "start", trigger: (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-datatable-viewsettings", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.SlidersHorizontal, { size: 14, strokeWidth: 1.75 }), "View settings"] }), children: [(0, jsx_runtime_1.jsx)("div", { className: "crisp-datatable-group-label", children: "Columns" }), allColumns.map((c) => ((0, jsx_runtime_1.jsxs)(menu_1.MenuItem, { onSelect: () => c.hidden ? showColumn(c.key) : hideColumn(c.key), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Check, { size: 14, strokeWidth: 2, style: { opacity: c.hidden ? 0 : 1 } }), typeof c.header === "string" ? c.header : c.key] }, c.key)))] })), filterable && ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-search", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("input", { type: "search", placeholder: searchPlaceholder, value: query, onChange: (e) => setQuery(e.target.value), "aria-label": searchLabel })] })), showCount && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-count", children: countLabel(rows.length, data.length) })), toolbar] })), (0, jsx_runtime_1.jsxs)(table_1.Table, { interactive: interactive, surface: surface, style: { minWidth }, scrollProps: {
|
|
508
|
+
return ((0, jsx_runtime_1.jsxs)("div", { ref: gridRef, className: (0, datatable_recipe_1.datatableRecipe)({ className }), tabIndex: -1, onKeyDown: onGridKeyDown, ...rest, children: [(0, jsx_runtime_1.jsx)("div", { className: "crisp-datatable-live", role: "status", "aria-live": "polite", children: selectionText }), (filterable || showCount || toolbar || columnMenu) && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-datatable-toolbar", children: [columnMenu && ((0, jsx_runtime_1.jsxs)(menu_1.Menu, { align: "start", trigger: (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-datatable-viewsettings", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.SlidersHorizontal, { size: 14, strokeWidth: 1.75 }), "View settings"] }), children: [(0, jsx_runtime_1.jsx)("div", { className: "crisp-datatable-group-label", children: "Columns" }), allColumns.map((c) => ((0, jsx_runtime_1.jsxs)(menu_1.MenuItem, { onSelect: () => c.hidden ? showColumn(c.key) : hideColumn(c.key), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Check, { size: 14, strokeWidth: 2, style: { opacity: c.hidden ? 0 : 1 } }), typeof c.header === "string" ? c.header : c.key] }, c.key)))] })), filterable && ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-search", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("input", { type: "search", placeholder: searchPlaceholder, value: query, onChange: (e) => setQuery(e.target.value), "aria-label": searchLabel })] })), showCount && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-count", children: countLabel(rows.length, data.length) })), toolbar] })), (0, jsx_runtime_1.jsxs)(table_1.Table, { interactive: interactive, surface: surface, style: { minWidth }, scrollProps: {
|
|
468
509
|
onScroll: (e) => setScrolledX(e.currentTarget.scrollLeft > 0),
|
|
469
510
|
...(scrolledX ? { "data-scrolled-x": "" } : {}),
|
|
470
511
|
}, children: [(0, jsx_runtime_1.jsxs)("colgroup", { children: [showGrip && (0, jsx_runtime_1.jsx)("col", { style: { width: GRIP_W } }), selectable && (0, jsx_runtime_1.jsx)("col", { style: { width: CHECK_W } }), columns.map((col) => ((0, jsx_runtime_1.jsx)("col", { style: { width: colWidth(col) } }, col.key)))] }), (0, jsx_runtime_1.jsx)(table_1.TableHeader, { children: (0, jsx_runtime_1.jsxs)(table_1.TableRow, { children: [showGrip && ((0, jsx_runtime_1.jsx)(table_1.TableHead, { className: "crisp-datatable-col-grip", ...stickyCol(-2) })), selectable && ((0, jsx_runtime_1.jsx)(table_1.TableHead, { className: "crisp-datatable-col-check", ...stickyCol(-1), children: (0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-check", children: (0, jsx_runtime_1.jsx)(checkbox_1.Checkbox, { "aria-label": selectAllLabel, checked: allSelected, indeterminate: someSelected, onChange: toggleAll }) }) })), columns.map((col, i) => {
|
|
@@ -556,7 +597,7 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
|
|
|
556
597
|
}
|
|
557
598
|
}, children: col.render
|
|
558
599
|
? col.render(value, row)
|
|
559
|
-
: renderTyped(col, value, avatarTone, col.format?.(value, row)) }, col.key));
|
|
600
|
+
: renderTyped(col, value, avatarTone, col.format?.(value, row), locale) }, col.key));
|
|
560
601
|
})] }, row.id)))] }, group ?? "__ungrouped")))) }), (hasSummary || onAddRow) && ((0, jsx_runtime_1.jsxs)(table_1.TableFooter, { children: [hasSummary && ((0, jsx_runtime_1.jsxs)(table_1.TableRow, { children: [showGrip && (0, jsx_runtime_1.jsx)(table_1.TableCell, { ...stickyCol(-2) }), selectable && (0, jsx_runtime_1.jsx)(table_1.TableCell, { ...stickyCol(-1) }), columns.map((col, i) => ((0, jsx_runtime_1.jsx)(table_1.TableCell, { align: alignFor(col), ...stickyCol(i), children: col.summary ? col.summary(rows) : null }, col.key)))] })), onAddRow && ((0, jsx_runtime_1.jsx)(table_1.TableRow, { children: (0, jsx_runtime_1.jsx)(table_1.TableCell, { className: "crisp-datatable-addcell", colSpan: totalCols, children: (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-datatable-addrow", onClick: onAddRow, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), addRowLabel] }) }) }))] }))] })] }));
|
|
561
602
|
}
|
|
562
603
|
DataTable.displayName = "DataTable";
|
|
@@ -18,6 +18,11 @@ const config = (0, core_1.defineRecipe)({
|
|
|
18
18
|
& .crisp-datatable-search input:focus { box-shadow: inset 0 0 0 1px ${tokens_1.contract.bgBrandSolid}; }
|
|
19
19
|
& .crisp-datatable-count { margin-inline-start: auto; font-size: ${tokens_1.contract.text.md}; color: ${tokens_1.contract.fgMuted}; }
|
|
20
20
|
/* The header cell has no padding of its own -- the button owns the
|
|
21
|
+
/* announced, never drawn */
|
|
22
|
+
& .crisp-datatable-live {
|
|
23
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
24
|
+
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
|
25
|
+
}
|
|
21
26
|
full cell so the whole header is clickable (its own inset is 12px).
|
|
22
27
|
Mirrors the summary-row fix. */
|
|
23
28
|
& th { padding: 0; }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Date-only cell values, parsed and formatted so the calendar day survives the
|
|
4
|
+
* reader's timezone.
|
|
5
|
+
*
|
|
6
|
+
* `new Date("1998-09-04")` is **UTC midnight** — ECMA-262 gives the date-only
|
|
7
|
+
* ISO forms a UTC interpretation — so formatting it in the reader's timezone
|
|
8
|
+
* renders it a day early for everyone west of UTC. A cell value like that
|
|
9
|
+
* names a *day*, and a day does not move when the reader does.
|
|
10
|
+
*
|
|
11
|
+
* Shared by both tables on purpose. RecordsTable was fixed in #220 and
|
|
12
|
+
* DataTable was not, and it took #221 to notice — the fourth time a defect has
|
|
13
|
+
* been fixed in one of the pair and left in the other. One implementation is
|
|
14
|
+
* the only version of this that stays fixed.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.parseCellDate = parseCellDate;
|
|
18
|
+
exports.formatCellDate = formatCellDate;
|
|
19
|
+
/** `YYYY`, `YYYY-MM`, `YYYY-MM-DD` — the forms ECMA-262 reads as UTC. */
|
|
20
|
+
const DATE_ONLY = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/;
|
|
21
|
+
function parseCellDate(raw) {
|
|
22
|
+
if (!raw)
|
|
23
|
+
return null;
|
|
24
|
+
const m = DATE_ONLY.exec(raw);
|
|
25
|
+
// the local-midnight constructor, so the calendar day survives the render.
|
|
26
|
+
// A string that carries a time is left alone: it names an instant, and an
|
|
27
|
+
// instant genuinely does move with the reader.
|
|
28
|
+
const d = m
|
|
29
|
+
? new Date(Number(m[1]), Number(m[2] ?? "1") - 1, Number(m[3] ?? "1"))
|
|
30
|
+
: new Date(raw);
|
|
31
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
32
|
+
}
|
|
33
|
+
/** The inverse: the picked date's own calendar day, not the UTC one. */
|
|
34
|
+
function formatCellDate(d) {
|
|
35
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
36
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
37
|
+
}
|
package/dist/cjs/core/index.js
CHANGED
|
@@ -14,7 +14,10 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.usePrefersReducedMotion = exports.useLabelAssociation = exports.useFormReset = exports.useFocusAfterRemoval = exports.specToRecipe = exports.defineSpec = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.formValueStyle = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = void 0;
|
|
17
|
+
exports.usePrefersReducedMotion = exports.useLabelAssociation = exports.useFormReset = exports.useFocusAfterRemoval = exports.specToRecipe = exports.defineSpec = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.formValueStyle = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = exports.parseCellDate = exports.formatCellDate = void 0;
|
|
18
|
+
var cell_date_1 = require("./cell-date");
|
|
19
|
+
Object.defineProperty(exports, "formatCellDate", { enumerable: true, get: function () { return cell_date_1.formatCellDate; } });
|
|
20
|
+
Object.defineProperty(exports, "parseCellDate", { enumerable: true, get: function () { return cell_date_1.parseCellDate; } });
|
|
18
21
|
var cx_1 = require("./cx");
|
|
19
22
|
Object.defineProperty(exports, "cx", { enumerable: true, get: function () { return cx_1.cx; } });
|
|
20
23
|
var define_1 = require("./define");
|
|
@@ -33,10 +33,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.defaultRows = exports.defaultColumns = exports.recordsTableLabels = void 0;
|
|
36
|
+
exports.defaultRows = exports.defaultColumns = exports.parseCellDate = exports.formatCellDate = exports.recordsTableLabels = void 0;
|
|
37
37
|
exports.tagTone = tagTone;
|
|
38
|
-
exports.parseCellDate = parseCellDate;
|
|
39
|
-
exports.formatCellDate = formatCellDate;
|
|
40
38
|
exports.RecordsTable = RecordsTable;
|
|
41
39
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
42
40
|
const lucide_react_1 = require("lucide-react");
|
|
@@ -336,26 +334,19 @@ function TagsCell({ column, row, options, onChange, labels, }) {
|
|
|
336
334
|
* A string that carries a time is left alone: that one really is an instant,
|
|
337
335
|
* and shifting it would move the moment it names.
|
|
338
336
|
*/
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
return Number.isNaN(d.getTime()) ? null : d;
|
|
349
|
-
}
|
|
350
|
-
/** The inverse: the picked date's own calendar day, not the UTC one. */
|
|
351
|
-
function formatCellDate(d) {
|
|
352
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
353
|
-
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
354
|
-
}
|
|
337
|
+
// parseCellDate/formatCellDate moved to core/cell-date.ts so DataTable shares
|
|
338
|
+
// one implementation rather than a second copy -- #221 was this defect fixed
|
|
339
|
+
// here (#220) and left in DataTable. Re-exported because they were already
|
|
340
|
+
// reachable from this module's public surface.
|
|
341
|
+
// imported for use here AND re-exported, because a bare `export ... from` does
|
|
342
|
+
// not bind the names locally
|
|
343
|
+
const core_2 = require("../../core");
|
|
344
|
+
Object.defineProperty(exports, "formatCellDate", { enumerable: true, get: function () { return core_2.formatCellDate; } });
|
|
345
|
+
Object.defineProperty(exports, "parseCellDate", { enumerable: true, get: function () { return core_2.parseCellDate; } });
|
|
355
346
|
function DateCell({ column, row, onChange, }) {
|
|
356
347
|
const raw = row[column.key];
|
|
357
|
-
const value = parseCellDate(raw);
|
|
358
|
-
return ((0, jsx_runtime_1.jsx)(datepickerfield_1.DatePickerField, { variant: "ghost", "aria-label": column.label, value: value, onChange: (d) => onChange(formatCellDate(d)) }));
|
|
348
|
+
const value = (0, core_2.parseCellDate)(raw);
|
|
349
|
+
return ((0, jsx_runtime_1.jsx)(datepickerfield_1.DatePickerField, { variant: "ghost", "aria-label": column.label, value: value, onChange: (d) => onChange((0, core_2.formatCellDate)(d)) }));
|
|
359
350
|
}
|
|
360
351
|
/**
|
|
361
352
|
* The "New <object>" create modal — measured from a create-record dialog:
|
package/dist/datatable.css
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
& .crisp-datatable-search input::placeholder { color: var(--crisp-fg-muted); }
|
|
8
8
|
& .crisp-datatable-search input:focus { box-shadow: inset 0 0 0 1px var(--crisp-bg-brand-solid); }
|
|
9
9
|
& .crisp-datatable-count { margin-inline-start: auto; font-size: var(--crisp-text-md); color: var(--crisp-fg-muted); }
|
|
10
|
-
/* The header cell has no padding of its own -- the button owns the
|
|
10
|
+
/* The header cell has no padding of its own -- the button owns the /* announced, never drawn */
|
|
11
|
+
& .crisp-datatable-live { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
|
12
|
+
full cell so the whole header is clickable (its own inset is 12px). Mirrors the summary-row fix. */
|
|
11
13
|
& th { padding: 0; }
|
|
12
14
|
/* Same wrapper trap as AUTHORING.md Lesson 40 (ColumnCalc's summary trigger):
|
|
13
15
|
Menu wraps its trigger in a flex div that sizes to its content, so a
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
& .crisp-datatable-search input::placeholder { color: var(--crisp-fg-muted); }
|
|
9
9
|
& .crisp-datatable-search input:focus { box-shadow: inset 0 0 0 1px var(--crisp-bg-brand-solid); }
|
|
10
10
|
& .crisp-datatable-count { margin-inline-start: auto; font-size: var(--crisp-text-md); color: var(--crisp-fg-muted); }
|
|
11
|
-
/* The header cell has no padding of its own -- the button owns the
|
|
11
|
+
/* The header cell has no padding of its own -- the button owns the /* announced, never drawn */
|
|
12
|
+
& .crisp-datatable-live { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
|
13
|
+
full cell so the whole header is clickable (its own inset is 12px). Mirrors the summary-row fix. */
|
|
12
14
|
& th { padding: 0; }
|
|
13
15
|
/* Same wrapper trap as AUTHORING.md Lesson 40 (ColumnCalc's summary trigger):
|
|
14
16
|
Menu wraps its trigger in a flex div that sizes to its content, so a
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { ArrowDown, ArrowUp, Check, ChevronsUpDown, GripVertical, Plus, Search, SlidersHorizontal, } from "lucide-react";
|
|
3
3
|
import * as React from "react";
|
|
4
|
-
import { isRtlElement } from "../../core/index.js";
|
|
4
|
+
import { formatCellDate, isRtlElement, parseCellDate } from "../../core/index.js";
|
|
5
5
|
import { Checkbox } from "../checkbox/index.js";
|
|
6
6
|
import { Menu, MenuItem } from "../menu/index.js";
|
|
7
7
|
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "../table/index.js";
|
|
@@ -10,12 +10,15 @@ import { datatableRecipe } from "./datatable.recipe.js";
|
|
|
10
10
|
* a display string. Returns "" when it cannot be parsed, so the field opens
|
|
11
11
|
* empty rather than showing an invalid value. */
|
|
12
12
|
function toDateInputValue(v) {
|
|
13
|
+
// formatCellDate, not toISOString().slice(0, 10): the latter takes the UTC
|
|
14
|
+
// day of a local Date, so a value picked late in the evening east of UTC
|
|
15
|
+
// round-trips as the next day (#221).
|
|
13
16
|
if (v instanceof Date && !Number.isNaN(v.getTime()))
|
|
14
|
-
return v
|
|
17
|
+
return formatCellDate(v);
|
|
15
18
|
if (typeof v === "string" || typeof v === "number") {
|
|
16
|
-
const d =
|
|
17
|
-
if (
|
|
18
|
-
return d
|
|
19
|
+
const d = parseCellDate(String(v));
|
|
20
|
+
if (d)
|
|
21
|
+
return formatCellDate(d);
|
|
19
22
|
}
|
|
20
23
|
return "";
|
|
21
24
|
}
|
|
@@ -53,21 +56,34 @@ function initials(name) {
|
|
|
53
56
|
const p = name.trim().split(/\s+/);
|
|
54
57
|
return ((p[0]?.[0] ?? "") + (p[1]?.[0] ?? "")).toUpperCase() || "?";
|
|
55
58
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
// One Intl.DateTimeFormat per locale, built lazily and reused — constructing
|
|
60
|
+
// a formatter per cell render would be wasteful on a large grid.
|
|
61
|
+
const DATE_FMT_CACHE = new Map();
|
|
62
|
+
function dateFmt(locale) {
|
|
63
|
+
let fmt = DATE_FMT_CACHE.get(locale);
|
|
64
|
+
if (!fmt) {
|
|
65
|
+
fmt = new Intl.DateTimeFormat(locale, {
|
|
66
|
+
month: "short",
|
|
67
|
+
day: "numeric",
|
|
68
|
+
year: "numeric",
|
|
69
|
+
});
|
|
70
|
+
DATE_FMT_CACHE.set(locale, fmt);
|
|
71
|
+
}
|
|
72
|
+
return fmt;
|
|
73
|
+
}
|
|
74
|
+
function formatCell(type, v, symbol, locale = "en-US") {
|
|
62
75
|
if (type === "number")
|
|
63
|
-
return typeof v === "number" ? v.toLocaleString(
|
|
76
|
+
return typeof v === "number" ? v.toLocaleString(locale) : asText(v);
|
|
64
77
|
if (type === "currency")
|
|
65
78
|
return typeof v === "number"
|
|
66
|
-
? `${symbol ?? "$"}${v.toLocaleString(
|
|
79
|
+
? `${symbol ?? "$"}${v.toLocaleString(locale)}`
|
|
67
80
|
: asText(v);
|
|
68
81
|
if (type === "date") {
|
|
69
|
-
|
|
70
|
-
|
|
82
|
+
// parseCellDate, not `new Date(v)`: a date-only value is UTC midnight per
|
|
83
|
+
// ECMA-262, and formatting that in the reader's timezone renders it a day
|
|
84
|
+
// early west of UTC (#221, the same defect #220 fixed in RecordsTable).
|
|
85
|
+
const d = v ? parseCellDate(String(v)) : null;
|
|
86
|
+
return d ? dateFmt(locale).format(d) : asText(v);
|
|
71
87
|
}
|
|
72
88
|
return asText(v);
|
|
73
89
|
}
|
|
@@ -79,7 +95,7 @@ function formatCell(type, v, symbol) {
|
|
|
79
95
|
// silently drop the cell's data. Each branch decides for itself where a
|
|
80
96
|
// display string belongs -- as an entity's name, a status' label -- while the
|
|
81
97
|
// raw value keeps driving structure and tone.
|
|
82
|
-
function renderTyped(col, value, avatarTone, display) {
|
|
98
|
+
function renderTyped(col, value, avatarTone, display, locale = "en-US") {
|
|
83
99
|
const empty = _jsx("span", { className: "crisp-datatable-muted", children: "\u2014" });
|
|
84
100
|
switch (col.type) {
|
|
85
101
|
case "entity": {
|
|
@@ -110,7 +126,7 @@ function renderTyped(col, value, avatarTone, display) {
|
|
|
110
126
|
return (_jsx("span", { className: "crisp-datatable-tag", "data-tone": col.tones?.[s] ?? toneFor(s), children: display || s }));
|
|
111
127
|
}
|
|
112
128
|
default: {
|
|
113
|
-
const text = display || formatCell(col.type, value, col.symbol);
|
|
129
|
+
const text = display || formatCell(col.type, value, col.symbol, locale);
|
|
114
130
|
const isNum = col.type === "number" || col.type === "currency";
|
|
115
131
|
if (!text)
|
|
116
132
|
return empty;
|
|
@@ -119,9 +135,16 @@ function renderTyped(col, value, avatarTone, display) {
|
|
|
119
135
|
}
|
|
120
136
|
}
|
|
121
137
|
const CHECK_W = 40;
|
|
122
|
-
export function DataTable({ columns: columnsProp, data, selectable = false, selectedIds, onSelectionChange, selectAllLabel = "Select all rows", selectRowLabel = (row) => `Select row ${row.id}`, columnMenu = false, onColumnsChange, groupBy, onRowsReorder, error, sort: sortProp, defaultSort, onSortChange, filterable = false, searchPlaceholder = "Search…", searchLabel = "Search table", onEdit, stickyFirst = true, avatarTone = "brand", surface, interactive = false, onRowClick, onAddRow, addRowLabel = "New record", empty = "No results.", showCount = true, countLabel = (shown, total) => shown === total
|
|
138
|
+
export function DataTable({ columns: columnsProp, data, selectable = false, selectedIds, onSelectionChange, selectAllLabel = "Select all rows", selectRowLabel = (row) => `Select row ${row.id}`, selectionAnnouncement = (count) => count === 0 ? "Selection cleared" : `${count} selected`, columnMenu = false, onColumnsChange, groupBy, onRowsReorder, error, sort: sortProp, defaultSort, onSortChange, filterable = false, searchPlaceholder = "Search…", searchLabel = "Search table", onEdit, stickyFirst = true, locale: localeProp, avatarTone = "brand", surface, interactive = false, onRowClick, onAddRow, addRowLabel = "New record", empty = "No results.", showCount = true, countLabel = (shown, total) => shown === total
|
|
123
139
|
? `${total} ${total === 1 ? "row" : "rows"}`
|
|
124
140
|
: `${shown} of ${total}`, toolbar, className, reorderRowLabel = "Reorder row", ...rest }) {
|
|
141
|
+
// Two different defaults on purpose. FORMATTING falls back to "en-US", which
|
|
142
|
+
// is what the built-in date/number/currency renderers have always produced,
|
|
143
|
+
// so no existing output moves. SORTING falls back to `undefined`, which is
|
|
144
|
+
// the runtime's own collation — passing "en-US" there would silently change
|
|
145
|
+
// the row order for every consumer on a non-en-US runtime, which is exactly
|
|
146
|
+
// the kind of change this prop is supposed to be additive about.
|
|
147
|
+
const locale = localeProp ?? "en-US";
|
|
125
148
|
const [query, setQuery] = React.useState("");
|
|
126
149
|
const [internalSort, setInternalSort] = React.useState(defaultSort ?? null);
|
|
127
150
|
// Column list the table actually renders. Seeded from `columns`; the header
|
|
@@ -198,6 +221,18 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
|
|
|
198
221
|
if (!selectedIds)
|
|
199
222
|
setInternalSel(next);
|
|
200
223
|
}, [onSelectionChange, selectedIds]);
|
|
224
|
+
// The selection is announced from a region that is part of the table and
|
|
225
|
+
// outlives every selection, because a consumer's own bulk bar (mounted only
|
|
226
|
+
// once something is picked) cannot announce its own arrival (#234).
|
|
227
|
+
// Derived by adjusting state during render rather than in an effect: it
|
|
228
|
+
// must not fire on mount — nobody has selected anything yet — and it has to
|
|
229
|
+
// cover a `selectedIds` change the consumer makes as well as a click here.
|
|
230
|
+
const prevSelectedCount = React.useRef(selected.size);
|
|
231
|
+
const [selectionText, setSelectionText] = React.useState("");
|
|
232
|
+
if (prevSelectedCount.current !== selected.size) {
|
|
233
|
+
prevSelectedCount.current = selected.size;
|
|
234
|
+
setSelectionText(selectionAnnouncement(selected.size));
|
|
235
|
+
}
|
|
201
236
|
const [editing, setEditing] = React.useState(null);
|
|
202
237
|
const [scrolledX, setScrolledX] = React.useState(false);
|
|
203
238
|
// column widths (drag-to-resize) + fixed-layout sizing
|
|
@@ -233,13 +268,19 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
|
|
|
233
268
|
const bv = cellValue(col, b);
|
|
234
269
|
const cmp = typeof av === "number" && typeof bv === "number"
|
|
235
270
|
? av - bv
|
|
236
|
-
:
|
|
271
|
+
: // `localeProp`, NOT `locale`: the ?? "en-US" fallback belongs to
|
|
272
|
+
// formatting only. Passing it here would pin collation to en-US
|
|
273
|
+
// for every consumer on a non-en-US runtime and silently reorder
|
|
274
|
+
// their rows. No test can catch that substitution on a machine
|
|
275
|
+
// whose own default is en-US -- one was written, it could not
|
|
276
|
+
// fail, and it was removed -- so this comment is the guard.
|
|
277
|
+
asText(av).localeCompare(asText(bv), localeProp);
|
|
237
278
|
if (cmp !== 0)
|
|
238
279
|
return cmp * dir;
|
|
239
280
|
}
|
|
240
281
|
return 0;
|
|
241
282
|
});
|
|
242
|
-
}, [filtered, sorts, colByKey]);
|
|
283
|
+
}, [filtered, sorts, colByKey, localeProp]);
|
|
243
284
|
/**
|
|
244
285
|
* Rows bucketed under a heading, preserving first-seen group order (a
|
|
245
286
|
* grouped view without reordering within a group). One bucket with a null
|
|
@@ -316,7 +357,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
|
|
|
316
357
|
const row = rows.find((rw) => rw.id === selCell.id);
|
|
317
358
|
const col = columns.find((cc) => cc.key === selCell.key);
|
|
318
359
|
if (row && col)
|
|
319
|
-
navigator.clipboard?.writeText(formatCell(col.type, cellValue(col, row), col.symbol));
|
|
360
|
+
navigator.clipboard?.writeText(formatCell(col.type, cellValue(col, row), col.symbol, locale));
|
|
320
361
|
e.preventDefault();
|
|
321
362
|
return;
|
|
322
363
|
}
|
|
@@ -428,7 +469,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
|
|
|
428
469
|
return dir === "asc" ? (_jsx(ArrowUp, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 })) : (_jsx(ArrowDown, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 }));
|
|
429
470
|
return (_jsx(ChevronsUpDown, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 }));
|
|
430
471
|
}
|
|
431
|
-
return (_jsxs("div", { ref: gridRef, className: datatableRecipe({ className }), tabIndex: -1, onKeyDown: onGridKeyDown, ...rest, children: [(filterable || showCount || toolbar || columnMenu) && (_jsxs("div", { className: "crisp-datatable-toolbar", children: [columnMenu && (_jsxs(Menu, { align: "start", trigger: _jsxs("button", { type: "button", className: "crisp-datatable-viewsettings", children: [_jsx(SlidersHorizontal, { size: 14, strokeWidth: 1.75 }), "View settings"] }), children: [_jsx("div", { className: "crisp-datatable-group-label", children: "Columns" }), allColumns.map((c) => (_jsxs(MenuItem, { onSelect: () => c.hidden ? showColumn(c.key) : hideColumn(c.key), children: [_jsx(Check, { size: 14, strokeWidth: 2, style: { opacity: c.hidden ? 0 : 1 } }), typeof c.header === "string" ? c.header : c.key] }, c.key)))] })), filterable && (_jsxs("span", { className: "crisp-datatable-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), _jsx("input", { type: "search", placeholder: searchPlaceholder, value: query, onChange: (e) => setQuery(e.target.value), "aria-label": searchLabel })] })), showCount && (_jsx("span", { className: "crisp-datatable-count", children: countLabel(rows.length, data.length) })), toolbar] })), _jsxs(Table, { interactive: interactive, surface: surface, style: { minWidth }, scrollProps: {
|
|
472
|
+
return (_jsxs("div", { ref: gridRef, className: datatableRecipe({ className }), tabIndex: -1, onKeyDown: onGridKeyDown, ...rest, children: [_jsx("div", { className: "crisp-datatable-live", role: "status", "aria-live": "polite", children: selectionText }), (filterable || showCount || toolbar || columnMenu) && (_jsxs("div", { className: "crisp-datatable-toolbar", children: [columnMenu && (_jsxs(Menu, { align: "start", trigger: _jsxs("button", { type: "button", className: "crisp-datatable-viewsettings", children: [_jsx(SlidersHorizontal, { size: 14, strokeWidth: 1.75 }), "View settings"] }), children: [_jsx("div", { className: "crisp-datatable-group-label", children: "Columns" }), allColumns.map((c) => (_jsxs(MenuItem, { onSelect: () => c.hidden ? showColumn(c.key) : hideColumn(c.key), children: [_jsx(Check, { size: 14, strokeWidth: 2, style: { opacity: c.hidden ? 0 : 1 } }), typeof c.header === "string" ? c.header : c.key] }, c.key)))] })), filterable && (_jsxs("span", { className: "crisp-datatable-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), _jsx("input", { type: "search", placeholder: searchPlaceholder, value: query, onChange: (e) => setQuery(e.target.value), "aria-label": searchLabel })] })), showCount && (_jsx("span", { className: "crisp-datatable-count", children: countLabel(rows.length, data.length) })), toolbar] })), _jsxs(Table, { interactive: interactive, surface: surface, style: { minWidth }, scrollProps: {
|
|
432
473
|
onScroll: (e) => setScrolledX(e.currentTarget.scrollLeft > 0),
|
|
433
474
|
...(scrolledX ? { "data-scrolled-x": "" } : {}),
|
|
434
475
|
}, children: [_jsxs("colgroup", { children: [showGrip && _jsx("col", { style: { width: GRIP_W } }), selectable && _jsx("col", { style: { width: CHECK_W } }), columns.map((col) => (_jsx("col", { style: { width: colWidth(col) } }, col.key)))] }), _jsx(TableHeader, { children: _jsxs(TableRow, { children: [showGrip && (_jsx(TableHead, { className: "crisp-datatable-col-grip", ...stickyCol(-2) })), selectable && (_jsx(TableHead, { className: "crisp-datatable-col-check", ...stickyCol(-1), children: _jsx("span", { className: "crisp-datatable-check", children: _jsx(Checkbox, { "aria-label": selectAllLabel, checked: allSelected, indeterminate: someSelected, onChange: toggleAll }) }) })), columns.map((col, i) => {
|
|
@@ -520,7 +561,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
|
|
|
520
561
|
}
|
|
521
562
|
}, children: col.render
|
|
522
563
|
? col.render(value, row)
|
|
523
|
-
: renderTyped(col, value, avatarTone, col.format?.(value, row)) }, col.key));
|
|
564
|
+
: renderTyped(col, value, avatarTone, col.format?.(value, row), locale) }, col.key));
|
|
524
565
|
})] }, row.id)))] }, group ?? "__ungrouped")))) }), (hasSummary || onAddRow) && (_jsxs(TableFooter, { children: [hasSummary && (_jsxs(TableRow, { children: [showGrip && _jsx(TableCell, { ...stickyCol(-2) }), selectable && _jsx(TableCell, { ...stickyCol(-1) }), columns.map((col, i) => (_jsx(TableCell, { align: alignFor(col), ...stickyCol(i), children: col.summary ? col.summary(rows) : null }, col.key)))] })), onAddRow && (_jsx(TableRow, { children: _jsx(TableCell, { className: "crisp-datatable-addcell", colSpan: totalCols, children: _jsxs("button", { type: "button", className: "crisp-datatable-addrow", onClick: onAddRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), addRowLabel] }) }) }))] }))] })] }));
|
|
525
566
|
}
|
|
526
567
|
DataTable.displayName = "DataTable";
|
|
@@ -15,6 +15,11 @@ const config = defineRecipe({
|
|
|
15
15
|
& .crisp-datatable-search input:focus { box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; }
|
|
16
16
|
& .crisp-datatable-count { margin-inline-start: auto; font-size: ${c.text.md}; color: ${c.fgMuted}; }
|
|
17
17
|
/* The header cell has no padding of its own -- the button owns the
|
|
18
|
+
/* announced, never drawn */
|
|
19
|
+
& .crisp-datatable-live {
|
|
20
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
21
|
+
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
|
22
|
+
}
|
|
18
23
|
full cell so the whole header is clickable (its own inset is 12px).
|
|
19
24
|
Mirrors the summary-row fix. */
|
|
20
25
|
& th { padding: 0; }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date-only cell values, parsed and formatted so the calendar day survives the
|
|
3
|
+
* reader's timezone.
|
|
4
|
+
*
|
|
5
|
+
* `new Date("1998-09-04")` is **UTC midnight** — ECMA-262 gives the date-only
|
|
6
|
+
* ISO forms a UTC interpretation — so formatting it in the reader's timezone
|
|
7
|
+
* renders it a day early for everyone west of UTC. A cell value like that
|
|
8
|
+
* names a *day*, and a day does not move when the reader does.
|
|
9
|
+
*
|
|
10
|
+
* Shared by both tables on purpose. RecordsTable was fixed in #220 and
|
|
11
|
+
* DataTable was not, and it took #221 to notice — the fourth time a defect has
|
|
12
|
+
* been fixed in one of the pair and left in the other. One implementation is
|
|
13
|
+
* the only version of this that stays fixed.
|
|
14
|
+
*/
|
|
15
|
+
/** `YYYY`, `YYYY-MM`, `YYYY-MM-DD` — the forms ECMA-262 reads as UTC. */
|
|
16
|
+
const DATE_ONLY = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/;
|
|
17
|
+
export function parseCellDate(raw) {
|
|
18
|
+
if (!raw)
|
|
19
|
+
return null;
|
|
20
|
+
const m = DATE_ONLY.exec(raw);
|
|
21
|
+
// the local-midnight constructor, so the calendar day survives the render.
|
|
22
|
+
// A string that carries a time is left alone: it names an instant, and an
|
|
23
|
+
// instant genuinely does move with the reader.
|
|
24
|
+
const d = m
|
|
25
|
+
? new Date(Number(m[1]), Number(m[2] ?? "1") - 1, Number(m[3] ?? "1"))
|
|
26
|
+
: new Date(raw);
|
|
27
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
28
|
+
}
|
|
29
|
+
/** The inverse: the picked date's own calendar day, not the UTC one. */
|
|
30
|
+
export function formatCellDate(d) {
|
|
31
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
32
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
33
|
+
}
|
package/dist/esm/core/index.js
CHANGED
|
@@ -296,22 +296,14 @@ function TagsCell({ column, row, options, onChange, labels, }) {
|
|
|
296
296
|
* A string that carries a time is left alone: that one really is an instant,
|
|
297
297
|
* and shifting it would move the moment it names.
|
|
298
298
|
*/
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
: new Date(raw);
|
|
308
|
-
return Number.isNaN(d.getTime()) ? null : d;
|
|
309
|
-
}
|
|
310
|
-
/** The inverse: the picked date's own calendar day, not the UTC one. */
|
|
311
|
-
export function formatCellDate(d) {
|
|
312
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
313
|
-
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
314
|
-
}
|
|
299
|
+
// parseCellDate/formatCellDate moved to core/cell-date.ts so DataTable shares
|
|
300
|
+
// one implementation rather than a second copy -- #221 was this defect fixed
|
|
301
|
+
// here (#220) and left in DataTable. Re-exported because they were already
|
|
302
|
+
// reachable from this module's public surface.
|
|
303
|
+
// imported for use here AND re-exported, because a bare `export ... from` does
|
|
304
|
+
// not bind the names locally
|
|
305
|
+
import { formatCellDate, parseCellDate } from "../../core/index.js";
|
|
306
|
+
export { formatCellDate, parseCellDate };
|
|
315
307
|
function DateCell({ column, row, onChange, }) {
|
|
316
308
|
const raw = row[column.key];
|
|
317
309
|
const value = parseCellDate(raw);
|
package/dist/styles.css
CHANGED
|
@@ -559,7 +559,9 @@
|
|
|
559
559
|
& .crisp-datatable-search input::placeholder { color: var(--crisp-fg-muted); }
|
|
560
560
|
& .crisp-datatable-search input:focus { box-shadow: inset 0 0 0 1px var(--crisp-bg-brand-solid); }
|
|
561
561
|
& .crisp-datatable-count { margin-inline-start: auto; font-size: var(--crisp-text-md); color: var(--crisp-fg-muted); }
|
|
562
|
-
/* The header cell has no padding of its own -- the button owns the
|
|
562
|
+
/* The header cell has no padding of its own -- the button owns the /* announced, never drawn */
|
|
563
|
+
& .crisp-datatable-live { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
|
564
|
+
full cell so the whole header is clickable (its own inset is 12px). Mirrors the summary-row fix. */
|
|
563
565
|
& th { padding: 0; }
|
|
564
566
|
/* Same wrapper trap as AUTHORING.md Lesson 40 (ColumnCalc's summary trigger):
|
|
565
567
|
Menu wraps its trigger in a flex div that sizes to its content, so a
|
package/dist/styles.layered.css
CHANGED
|
@@ -560,7 +560,9 @@
|
|
|
560
560
|
& .crisp-datatable-search input::placeholder { color: var(--crisp-fg-muted); }
|
|
561
561
|
& .crisp-datatable-search input:focus { box-shadow: inset 0 0 0 1px var(--crisp-bg-brand-solid); }
|
|
562
562
|
& .crisp-datatable-count { margin-inline-start: auto; font-size: var(--crisp-text-md); color: var(--crisp-fg-muted); }
|
|
563
|
-
/* The header cell has no padding of its own -- the button owns the
|
|
563
|
+
/* The header cell has no padding of its own -- the button owns the /* announced, never drawn */
|
|
564
|
+
& .crisp-datatable-live { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
|
565
|
+
full cell so the whole header is clickable (its own inset is 12px). Mirrors the summary-row fix. */
|
|
564
566
|
& th { padding: 0; }
|
|
565
567
|
/* Same wrapper trap as AUTHORING.md Lesson 40 (ColumnCalc's summary trigger):
|
|
566
568
|
Menu wraps its trigger in a flex div that sizes to its content, so a
|
|
@@ -74,6 +74,15 @@ export type DataTableProps<T extends {
|
|
|
74
74
|
* Takes the row so the label can read back a human identifier (a name,
|
|
75
75
|
* a code) instead of the raw id. */
|
|
76
76
|
selectRowLabel?: (row: T) => string;
|
|
77
|
+
/**
|
|
78
|
+
* Announced when the selection changes. A formatter, not a string: the
|
|
79
|
+
* count moves within the sentence between languages. Return "" to say
|
|
80
|
+
* nothing. A consumer's own `<BulkBar>` — mounted only once something is
|
|
81
|
+
* selected — cannot carry this itself: its live region would be created by
|
|
82
|
+
* the very change it should announce (#234). This table's region is
|
|
83
|
+
* mounted unconditionally instead, so it outlives every selection.
|
|
84
|
+
*/
|
|
85
|
+
selectionAnnouncement?: (count: number) => string;
|
|
77
86
|
/** Controlled sort (omit for self-managed; seed with `defaultSort`). */
|
|
78
87
|
/**
|
|
79
88
|
* Active sort. Accepts a single sort or an ordered list — a table can sort by
|
|
@@ -120,6 +129,12 @@ export type DataTableProps<T extends {
|
|
|
120
129
|
onEdit?: (id: string, key: string, value: string) => void;
|
|
121
130
|
/** Sticky the first column(s) with a horizontal scroll shadow (default true). */
|
|
122
131
|
stickyFirst?: boolean;
|
|
132
|
+
/** BCP 47 locale for the built-in `date`/`number`/`currency` renderers
|
|
133
|
+
* (default "en-US"). Formats the date and groups the number the way that
|
|
134
|
+
* locale's readers expect — e.g. "mn-MN" renders a date as "2026.09.12"
|
|
135
|
+
* instead of "Sep 12, 2026". Currency's symbol still comes from the
|
|
136
|
+
* column's `symbol`; this only changes digit grouping/date order. */
|
|
137
|
+
locale?: string;
|
|
123
138
|
/** Tone for `entity`-type avatars: `"brand"` (uniform, matches the real
|
|
124
139
|
* records grid) or `"hashed"` (a stable colour per name — the tone `tags`/
|
|
125
140
|
* `status` pills already use, and entity's previous default). */
|
|
@@ -148,7 +163,7 @@ export type DataTableProps<T extends {
|
|
|
148
163
|
};
|
|
149
164
|
export declare function DataTable<T extends {
|
|
150
165
|
id: string;
|
|
151
|
-
}>({ columns: columnsProp, data, selectable, selectedIds, onSelectionChange, selectAllLabel, selectRowLabel, columnMenu, onColumnsChange, groupBy, onRowsReorder, error, sort: sortProp, defaultSort, onSortChange, filterable, searchPlaceholder, searchLabel, onEdit, stickyFirst, avatarTone, surface, interactive, onRowClick, onAddRow, addRowLabel, empty, showCount, countLabel, toolbar, className, reorderRowLabel, ...rest }: DataTableProps<T>): React.JSX.Element;
|
|
166
|
+
}>({ columns: columnsProp, data, selectable, selectedIds, onSelectionChange, selectAllLabel, selectRowLabel, selectionAnnouncement, columnMenu, onColumnsChange, groupBy, onRowsReorder, error, sort: sortProp, defaultSort, onSortChange, filterable, searchPlaceholder, searchLabel, onEdit, stickyFirst, locale: localeProp, avatarTone, surface, interactive, onRowClick, onAddRow, addRowLabel, empty, showCount, countLabel, toolbar, className, reorderRowLabel, ...rest }: DataTableProps<T>): React.JSX.Element;
|
|
152
167
|
export declare namespace DataTable {
|
|
153
168
|
var displayName: string;
|
|
154
169
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date-only cell values, parsed and formatted so the calendar day survives the
|
|
3
|
+
* reader's timezone.
|
|
4
|
+
*
|
|
5
|
+
* `new Date("1998-09-04")` is **UTC midnight** — ECMA-262 gives the date-only
|
|
6
|
+
* ISO forms a UTC interpretation — so formatting it in the reader's timezone
|
|
7
|
+
* renders it a day early for everyone west of UTC. A cell value like that
|
|
8
|
+
* names a *day*, and a day does not move when the reader does.
|
|
9
|
+
*
|
|
10
|
+
* Shared by both tables on purpose. RecordsTable was fixed in #220 and
|
|
11
|
+
* DataTable was not, and it took #221 to notice — the fourth time a defect has
|
|
12
|
+
* been fixed in one of the pair and left in the other. One implementation is
|
|
13
|
+
* the only version of this that stays fixed.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseCellDate(raw: string | undefined): Date | null;
|
|
16
|
+
/** The inverse: the picked date's own calendar day, not the UTC one. */
|
|
17
|
+
export declare function formatCellDate(d: Date): string;
|
|
@@ -201,9 +201,19 @@ export type RecordsTableLabelsInput = Partial<Omit<RecordsTableLabels, "agg" | "
|
|
|
201
201
|
op?: Partial<Record<FilterOp, string>>;
|
|
202
202
|
};
|
|
203
203
|
type FilterOp = "contains" | "is" | "isnot" | "empty" | "notempty";
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
204
|
+
/**
|
|
205
|
+
* A date-only cell is a CALENDAR DATE, not an instant. ECMA-262 parses the
|
|
206
|
+
* date-only ISO forms — `1998`, `1998-09`, `1998-09-04` — as UTC midnight, and
|
|
207
|
+
* the field then renders that instant in the reader's own timezone. A
|
|
208
|
+
* `founded: "1998"` field therefore read **Dec 31, 1997** for every reader west
|
|
209
|
+
* of UTC, at every hour of every day, and a date picked east of UTC
|
|
210
|
+
* round-tripped back a day short through `toISOString()` (#220).
|
|
211
|
+
*
|
|
212
|
+
* A string that carries a time is left alone: that one really is an instant,
|
|
213
|
+
* and shifting it would move the moment it names.
|
|
214
|
+
*/
|
|
215
|
+
import { formatCellDate, parseCellDate } from "../../core";
|
|
216
|
+
export { formatCellDate, parseCellDate };
|
|
207
217
|
/**
|
|
208
218
|
* A measured-style record grid — fully featured. Toolbar (search / sort status /
|
|
209
219
|
* count / New), sticky checkbox + entity columns, 36px rows, typed cells,
|
|
@@ -215,4 +225,3 @@ export declare function formatCellDate(d: Date): string;
|
|
|
215
225
|
export declare function RecordsTable({ labels, columns: columnsProp, rows: rowsProp, selectedIds, onSelectionChange, showNew, allowAddColumn, onOpenRecord, onOpenRecordPage, avatarTone, className, ...rest }: RecordsTableProps): React.JSX.Element;
|
|
216
226
|
export declare const defaultColumns: Column[];
|
|
217
227
|
export declare const defaultRows: Row[];
|
|
218
|
-
export {};
|