@crisp-ui-kit/crisp 0.33.2 → 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}`, 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, 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
@@ -281,13 +304,19 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
281
304
  const bv = cellValue(col, b);
282
305
  const cmp = typeof av === "number" && typeof bv === "number"
283
306
  ? av - bv
284
- : 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);
285
314
  if (cmp !== 0)
286
315
  return cmp * dir;
287
316
  }
288
317
  return 0;
289
318
  });
290
- }, [filtered, sorts, colByKey]);
319
+ }, [filtered, sorts, colByKey, localeProp]);
291
320
  /**
292
321
  * Rows bucketed under a heading, preserving first-seen group order (a
293
322
  * grouped view without reordering within a group). One bucket with a null
@@ -364,7 +393,7 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
364
393
  const row = rows.find((rw) => rw.id === selCell.id);
365
394
  const col = columns.find((cc) => cc.key === selCell.key);
366
395
  if (row && col)
367
- navigator.clipboard?.writeText(formatCell(col.type, cellValue(col, row), col.symbol));
396
+ navigator.clipboard?.writeText(formatCell(col.type, cellValue(col, row), col.symbol, locale));
368
397
  e.preventDefault();
369
398
  return;
370
399
  }
@@ -568,7 +597,7 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
568
597
  }
569
598
  }, children: col.render
570
599
  ? col.render(value, row)
571
- : renderTyped(col, value, avatarTone, col.format?.(value, row)) }, col.key));
600
+ : renderTyped(col, value, avatarTone, col.format?.(value, row), locale) }, col.key));
572
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] }) }) }))] }))] })] }));
573
602
  }
574
603
  DataTable.displayName = "DataTable";
@@ -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:
@@ -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}`, 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, 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
@@ -245,13 +268,19 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
245
268
  const bv = cellValue(col, b);
246
269
  const cmp = typeof av === "number" && typeof bv === "number"
247
270
  ? av - bv
248
- : 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);
249
278
  if (cmp !== 0)
250
279
  return cmp * dir;
251
280
  }
252
281
  return 0;
253
282
  });
254
- }, [filtered, sorts, colByKey]);
283
+ }, [filtered, sorts, colByKey, localeProp]);
255
284
  /**
256
285
  * Rows bucketed under a heading, preserving first-seen group order (a
257
286
  * grouped view without reordering within a group). One bucket with a null
@@ -328,7 +357,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
328
357
  const row = rows.find((rw) => rw.id === selCell.id);
329
358
  const col = columns.find((cc) => cc.key === selCell.key);
330
359
  if (row && col)
331
- navigator.clipboard?.writeText(formatCell(col.type, cellValue(col, row), col.symbol));
360
+ navigator.clipboard?.writeText(formatCell(col.type, cellValue(col, row), col.symbol, locale));
332
361
  e.preventDefault();
333
362
  return;
334
363
  }
@@ -532,7 +561,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
532
561
  }
533
562
  }, children: col.render
534
563
  ? col.render(value, row)
535
- : renderTyped(col, value, avatarTone, col.format?.(value, row)) }, col.key));
564
+ : renderTyped(col, value, avatarTone, col.format?.(value, row), locale) }, col.key));
536
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] }) }) }))] }))] })] }));
537
566
  }
538
567
  DataTable.displayName = "DataTable";
@@ -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);
@@ -129,6 +129,12 @@ export type DataTableProps<T extends {
129
129
  onEdit?: (id: string, key: string, value: string) => void;
130
130
  /** Sticky the first column(s) with a horizontal scroll shadow (default true). */
131
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;
132
138
  /** Tone for `entity`-type avatars: `"brand"` (uniform, matches the real
133
139
  * records grid) or `"hashed"` (a stable colour per name — the tone `tags`/
134
140
  * `status` pills already use, and entity's previous default). */
@@ -157,7 +163,7 @@ export type DataTableProps<T extends {
157
163
  };
158
164
  export declare function DataTable<T extends {
159
165
  id: string;
160
- }>({ columns: columnsProp, data, selectable, selectedIds, onSelectionChange, selectAllLabel, selectRowLabel, selectionAnnouncement, 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;
161
167
  export declare namespace DataTable {
162
168
  var displayName: string;
163
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.2",
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",