@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.
@@ -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 v.toISOString().slice(0, 10);
53
+ return (0, core_1.formatCellDate)(v);
51
54
  if (typeof v === "string" || typeof v === "number") {
52
- const d = new Date(v);
53
- if (!Number.isNaN(d.getTime()))
54
- return d.toISOString().slice(0, 10);
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
- const DATE_FMT = new Intl.DateTimeFormat("en-US", {
93
- month: "short",
94
- day: "numeric",
95
- year: "numeric",
96
- });
97
- function formatCell(type, v, symbol) {
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("en-US") : asText(v);
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("en-US")}`
115
+ ? `${symbol ?? "$"}${v.toLocaleString(locale)}`
103
116
  : asText(v);
104
117
  if (type === "date") {
105
- const d = v ? new Date(v) : null;
106
- return d && !Number.isNaN(d.getTime()) ? DATE_FMT.format(d) : asText(v);
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
- : asText(av).localeCompare(asText(bv));
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
+ }
@@ -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
- const DATE_ONLY = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/;
340
- function parseCellDate(raw) {
341
- if (!raw)
342
- return null;
343
- const m = DATE_ONLY.exec(raw);
344
- // the local-midnight constructor, so the calendar day survives the render
345
- const d = m
346
- ? new Date(Number(m[1]), Number(m[2] ?? "1") - 1, Number(m[3] ?? "1"))
347
- : new Date(raw);
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:
@@ -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 full cell so the whole header is clickable (its own inset is 12px). Mirrors the summary-row fix. */
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 full cell so the whole header is clickable (its own inset is 12px). Mirrors the summary-row fix. */
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.toISOString().slice(0, 10);
17
+ return formatCellDate(v);
15
18
  if (typeof v === "string" || typeof v === "number") {
16
- const d = new Date(v);
17
- if (!Number.isNaN(d.getTime()))
18
- return d.toISOString().slice(0, 10);
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
- const DATE_FMT = new Intl.DateTimeFormat("en-US", {
57
- month: "short",
58
- day: "numeric",
59
- year: "numeric",
60
- });
61
- function formatCell(type, v, symbol) {
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("en-US") : asText(v);
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("en-US")}`
79
+ ? `${symbol ?? "$"}${v.toLocaleString(locale)}`
67
80
  : asText(v);
68
81
  if (type === "date") {
69
- const d = v ? new Date(v) : null;
70
- return d && !Number.isNaN(d.getTime()) ? DATE_FMT.format(d) : asText(v);
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
- : asText(av).localeCompare(asText(bv));
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
+ }
@@ -1,3 +1,4 @@
1
+ export { formatCellDate, parseCellDate } from "./cell-date.js";
1
2
  export { cx } from "./cx.js";
2
3
  export { defineRecipe } from "./define.js";
3
4
  export { withDescribedBy } from "./dialog-description.js";
@@ -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
- const DATE_ONLY = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/;
300
- export function parseCellDate(raw) {
301
- if (!raw)
302
- return null;
303
- const m = DATE_ONLY.exec(raw);
304
- // the local-midnight constructor, so the calendar day survives the render
305
- const d = m
306
- ? new Date(Number(m[1]), Number(m[2] ?? "1") - 1, Number(m[3] ?? "1"))
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 full cell so the whole header is clickable (its own inset is 12px). Mirrors the summary-row fix. */
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
@@ -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 full cell so the whole header is clickable (its own inset is 12px). Mirrors the summary-row fix. */
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;
@@ -1,3 +1,4 @@
1
+ export { formatCellDate, parseCellDate } from "./cell-date";
1
2
  export { cx } from "./cx";
2
3
  export { defineRecipe } from "./define";
3
4
  export { withDescribedBy } from "./dialog-description";
@@ -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
- export declare function parseCellDate(raw: string | undefined): Date | null;
205
- /** The inverse: the picked date's own calendar day, not the UTC one. */
206
- export declare function formatCellDate(d: Date): string;
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 {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crisp-ui-kit/crisp",
3
- "version": "0.33.1",
3
+ "version": "0.33.3",
4
4
  "description": "A lean React design system, measured pixel-for-pixel from Attio.",
5
5
  "license": "MIT",
6
6
  "type": "module",