@crisp-ui-kit/crisp 0.52.6 → 0.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/breadcrumb.css +2 -1
  2. package/dist/breadcrumb.layered.css +2 -1
  3. package/dist/button.css +2 -2
  4. package/dist/button.layered.css +2 -2
  5. package/dist/cjs/components/breadcrumb/breadcrumb.js +5 -2
  6. package/dist/cjs/components/breadcrumb/breadcrumb.recipe.js +5 -2
  7. package/dist/cjs/components/button/button.recipe.js +10 -2
  8. package/dist/cjs/components/datatable/datatable.js +55 -10
  9. package/dist/cjs/components/datatable/datatable.recipe.js +2 -0
  10. package/dist/cjs/components/datepickerfield/datepickerfield.js +5 -1
  11. package/dist/cjs/components/dialog/dialog.js +2 -1
  12. package/dist/cjs/components/drawer/drawer.js +2 -1
  13. package/dist/cjs/components/multiselect/multiselect.js +5 -1
  14. package/dist/cjs/components/quantitypicker/quantitypicker.js +4 -1
  15. package/dist/cjs/components/select/select.js +7 -1
  16. package/dist/cjs/components/statcard/statcard.js +3 -2
  17. package/dist/cjs/components/statcard/statcard.recipe.js +10 -0
  18. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +40 -7
  19. package/dist/cjs/components/toast/toast.js +3 -1
  20. package/dist/cjs/core/i18n.js +75 -0
  21. package/dist/cjs/core/index.js +5 -1
  22. package/dist/datatable.css +2 -0
  23. package/dist/datatable.layered.css +2 -0
  24. package/dist/esm/components/breadcrumb/breadcrumb.js +5 -2
  25. package/dist/esm/components/breadcrumb/breadcrumb.recipe.js +5 -2
  26. package/dist/esm/components/button/button.recipe.js +10 -2
  27. package/dist/esm/components/datatable/datatable.js +56 -11
  28. package/dist/esm/components/datatable/datatable.recipe.js +2 -0
  29. package/dist/esm/components/datepickerfield/datepickerfield.js +5 -1
  30. package/dist/esm/components/dialog/dialog.js +3 -2
  31. package/dist/esm/components/drawer/drawer.js +3 -2
  32. package/dist/esm/components/multiselect/multiselect.js +6 -2
  33. package/dist/esm/components/quantitypicker/quantitypicker.js +5 -2
  34. package/dist/esm/components/select/select.js +8 -2
  35. package/dist/esm/components/statcard/statcard.js +3 -2
  36. package/dist/esm/components/statcard/statcard.recipe.js +10 -0
  37. package/dist/esm/components/tabletoolbar/tabletoolbar.js +41 -8
  38. package/dist/esm/components/toast/toast.js +4 -2
  39. package/dist/esm/core/i18n.js +37 -0
  40. package/dist/esm/core/index.js +1 -0
  41. package/dist/statcard.css +8 -0
  42. package/dist/statcard.layered.css +8 -0
  43. package/dist/styles.css +14 -3
  44. package/dist/styles.layered.css +14 -3
  45. package/dist/types/components/breadcrumb/breadcrumb.d.ts +1 -1
  46. package/dist/types/components/button/button.recipe.d.ts +2 -2
  47. package/dist/types/components/datatable/datatable.d.ts +31 -1
  48. package/dist/types/components/datepickerfield/datepickerfield.d.ts +1 -1
  49. package/dist/types/components/dialog/dialog.d.ts +1 -1
  50. package/dist/types/components/drawer/drawer.d.ts +1 -1
  51. package/dist/types/components/multiselect/multiselect.d.ts +1 -1
  52. package/dist/types/components/quantitypicker/quantitypicker.d.ts +1 -1
  53. package/dist/types/components/select/select.d.ts +1 -1
  54. package/dist/types/components/statcard/statcard.d.ts +5 -1
  55. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +5 -1
  56. package/dist/types/components/toast/toast.d.ts +1 -1
  57. package/dist/types/core/i18n.d.ts +58 -0
  58. package/dist/types/core/index.d.ts +1 -0
  59. package/package.json +1 -1
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CrispMessagesProvider = CrispMessagesProvider;
37
+ exports.useCrispMessage = useCrispMessage;
38
+ exports.useCrispMessages = useCrispMessages;
39
+ const jsx_runtime_1 = require("react/jsx-runtime");
40
+ const React = __importStar(require("react"));
41
+ const CrispMessagesContext = React.createContext({});
42
+ function CrispMessagesProvider({ messages, children, }) {
43
+ return ((0, jsx_runtime_1.jsx)(CrispMessagesContext.Provider, { value: messages, children: children }));
44
+ }
45
+ /**
46
+ * Resolves one translatable string for a component: `explicit` is whatever
47
+ * the caller passed for that prop (`undefined` when they didn't), and
48
+ * `englishDefault` is the literal that shipped as that prop's default value
49
+ * before crisp/#471 — still what renders with no provider in the tree.
50
+ *
51
+ * A plain function, not a second context read inside a loop or a render
52
+ * helper — components call this once per string, the same shape
53
+ * `useLabelAssociation`/`useFormReset` and this codebase's other small
54
+ * `core/` hooks already take.
55
+ */
56
+ function useCrispMessage(explicit, englishDefault) {
57
+ const messages = React.useContext(CrispMessagesContext);
58
+ if (explicit !== undefined)
59
+ return explicit;
60
+ return messages[englishDefault] ?? englishDefault;
61
+ }
62
+ /**
63
+ * The same resolution as `useCrispMessage`, for a component that builds its
64
+ * own default text internally (TableToolbar's "Sorted by {field}", a
65
+ * DatePicker's month/weekday names) rather than taking one string prop per
66
+ * piece — every literal fragment of that built-in text still needs its own
67
+ * translation lookup, and a component with many such fragments (twelve
68
+ * month names, seven weekday names) would otherwise call `useCrispMessage`
69
+ * in a loop, which breaks the rules of hooks. `useCrispMessages` returns the
70
+ * raw dictionary once; call sites do their own `dict[english] ?? english`
71
+ * per fragment, same fallback order, no hook-in-a-loop.
72
+ */
73
+ function useCrispMessages() {
74
+ return React.useContext(CrispMessagesContext);
75
+ }
@@ -14,7 +14,7 @@ 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.useMentionTrigger = exports.useLabelAssociation = exports.useFormReset = exports.useFocusAfterRemoval = exports.specToRecipe = exports.defineSpec = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.matchMentionTrigger = exports.formValueStyle = exports.registerFloatingLayer = exports.isInsideLaterFloatingLayer = exports.hasOpenFloatingLayer = exports.FloatingPortal = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = exports.parseCellDate = exports.formatCellDate = exports.cellDatePrecision = exports.caretRectInField = void 0;
17
+ exports.usePrefersReducedMotion = exports.useMentionTrigger = exports.useLabelAssociation = exports.useFormReset = exports.useFocusAfterRemoval = exports.specToRecipe = exports.defineSpec = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.matchMentionTrigger = exports.useCrispMessages = exports.useCrispMessage = exports.CrispMessagesProvider = exports.formValueStyle = exports.registerFloatingLayer = exports.isInsideLaterFloatingLayer = exports.hasOpenFloatingLayer = exports.FloatingPortal = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = exports.parseCellDate = exports.formatCellDate = exports.cellDatePrecision = exports.caretRectInField = void 0;
18
18
  var caret_rect_1 = require("./caret-rect");
19
19
  Object.defineProperty(exports, "caretRectInField", { enumerable: true, get: function () { return caret_rect_1.caretRectInField; } });
20
20
  var cell_date_1 = require("./cell-date");
@@ -37,6 +37,10 @@ Object.defineProperty(exports, "isInsideLaterFloatingLayer", { enumerable: true,
37
37
  Object.defineProperty(exports, "registerFloatingLayer", { enumerable: true, get: function () { return floating_stack_1.registerFloatingLayer; } });
38
38
  var form_value_1 = require("./form-value");
39
39
  Object.defineProperty(exports, "formValueStyle", { enumerable: true, get: function () { return form_value_1.formValueStyle; } });
40
+ var i18n_1 = require("./i18n");
41
+ Object.defineProperty(exports, "CrispMessagesProvider", { enumerable: true, get: function () { return i18n_1.CrispMessagesProvider; } });
42
+ Object.defineProperty(exports, "useCrispMessage", { enumerable: true, get: function () { return i18n_1.useCrispMessage; } });
43
+ Object.defineProperty(exports, "useCrispMessages", { enumerable: true, get: function () { return i18n_1.useCrispMessages; } });
40
44
  var mention_1 = require("./mention");
41
45
  Object.defineProperty(exports, "matchMentionTrigger", { enumerable: true, get: function () { return mention_1.matchMentionTrigger; } });
42
46
  var place_floating_1 = require("./place-floating");
@@ -93,6 +93,8 @@
93
93
  /* typed cells — entity (avatar + name), colour-coded tags, numbers, dates */
94
94
  & .crisp-datatable-entity { display: flex; align-items: center; gap: var(--crisp-space-1-5); min-width: 0; color: var(--crisp-fg-primary); font-weight: var(--crisp-weight-medium); }
95
95
  & .crisp-datatable-entity-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
96
+ & .crisp-datatable-entity:hover .crisp-datatable-entity-name { text-decoration: underline; }
97
+ & button.crisp-datatable-entity-name--link { margin: 0; padding: 0; border: none; background: none; font: inherit; color: inherit; letter-spacing: inherit; text-align: start; cursor: pointer; max-width: 100%; }
96
98
  & .crisp-datatable-avatar { width: var(--crisp-size-icon); height: var(--crisp-size-icon); border-radius: var(--crisp-radius-sm); flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 10px; line-height: 1; font-weight: var(--crisp-weight-semibold); text-transform: uppercase; color: var(--crisp-fg-on-brand); background: var(--crisp-bg-brand-solid); }
97
99
  & .crisp-datatable-avatar[data-tone="success"] { background: var(--crisp-bg-success); }
98
100
  & .crisp-datatable-avatar[data-tone="warning"] { background: var(--crisp-bg-warning); }
@@ -94,6 +94,8 @@
94
94
  /* typed cells — entity (avatar + name), colour-coded tags, numbers, dates */
95
95
  & .crisp-datatable-entity { display: flex; align-items: center; gap: var(--crisp-space-1-5); min-width: 0; color: var(--crisp-fg-primary); font-weight: var(--crisp-weight-medium); }
96
96
  & .crisp-datatable-entity-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
97
+ & .crisp-datatable-entity:hover .crisp-datatable-entity-name { text-decoration: underline; }
98
+ & button.crisp-datatable-entity-name--link { margin: 0; padding: 0; border: none; background: none; font: inherit; color: inherit; letter-spacing: inherit; text-align: start; cursor: pointer; max-width: 100%; }
97
99
  & .crisp-datatable-avatar { width: var(--crisp-size-icon); height: var(--crisp-size-icon); border-radius: var(--crisp-radius-sm); flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 10px; line-height: 1; font-weight: var(--crisp-weight-semibold); text-transform: uppercase; color: var(--crisp-fg-on-brand); background: var(--crisp-bg-brand-solid); }
98
100
  & .crisp-datatable-avatar[data-tone="success"] { background: var(--crisp-bg-success); }
99
101
  & .crisp-datatable-avatar[data-tone="warning"] { background: var(--crisp-bg-warning); }
@@ -1,5 +1,4 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ChevronRight } from "lucide-react";
3
2
  import * as React from "react";
4
3
  import { breadcrumbRecipe } from "./breadcrumb.recipe.js";
5
4
  /**
@@ -7,7 +6,11 @@ import { breadcrumbRecipe } from "./breadcrumb.recipe.js";
7
6
  * marked `aria-current="page"`; earlier items render as links or buttons.
8
7
  */
9
8
  export function Breadcrumb({ items, separator, "aria-label": ariaLabel = "Breadcrumb", className, ...rest }) {
10
- const sep = separator ?? (_jsx(ChevronRight, { "data-rtl-flip": "", size: 14, strokeWidth: 1.75, "aria-hidden": "true" }));
9
+ // Live-measured (app.attio.com/<ws>/home, /companies,
10
+ // /company/<id>/overview): a plain rounded divider, not a chevron -- a
11
+ // symmetric shape needs no RTL mirroring, unlike the ChevronRight this
12
+ // used to default to.
13
+ const sep = separator ?? (_jsx("span", { className: "crisp-breadcrumb-sep-dot", "aria-hidden": "true" }));
11
14
  return (_jsx("nav", { "aria-label": ariaLabel, className: breadcrumbRecipe({ className }), ...rest, children: items.map((item, i) => {
12
15
  const last = i === items.length - 1;
13
16
  const content = (_jsxs(_Fragment, { children: [item.icon, item.label] }));
@@ -1,6 +1,8 @@
1
1
  import { defineRecipe, resolveRecipe, rtlFlipCss } from "../../core/index.js";
2
2
  import { contract as c } from "../../tokens/index.js";
3
- // A breadcrumb trail — record-page nav ("Companies / Google").
3
+ // A breadcrumb trail — record-page nav ("Companies · Google"), the segments
4
+ // joined by a small rounded divider (crisp#498), not the literal "/" this
5
+ // comment's own example used to imply.
4
6
  const config = defineRecipe({
5
7
  name: "breadcrumb",
6
8
  base: `display: flex; align-items: center; gap: ${c.space["0.5"]}; font-family: ${c.font.sans}; font-size: ${c.text.lg};
@@ -9,7 +11,8 @@ const config = defineRecipe({
9
11
  & a.crisp-breadcrumb-item:hover, & button.crisp-breadcrumb-item:hover { color: ${c.fgPrimary}; background: color-mix(in srgb, ${c.fgPrimary} 5%, transparent); }
10
12
  & button.crisp-breadcrumb-item { border: none; background: none; font: inherit; cursor: pointer; }
11
13
  & .crisp-breadcrumb-item[aria-current] { color: ${c.fgPrimary}; font-weight: ${c.weight.medium}; }
12
- & .crisp-breadcrumb-sep { flex-shrink: 0; color: color-mix(in srgb, ${c.fgPrimary} 28%, transparent); }
14
+ & .crisp-breadcrumb-sep { flex-shrink: 0; display: inline-flex; }
15
+ & .crisp-breadcrumb-sep-dot { flex-shrink: 0; width: 3px; height: 10px; border-radius: ${c.radius.full}; background: color-mix(in srgb, ${c.fgPrimary} 28%, transparent); }
13
16
  ${rtlFlipCss}
14
17
  `,
15
18
  variants: {},
@@ -25,7 +25,12 @@ const spec = defineSpec({
25
25
  decls: {
26
26
  background: c.bgBrandSolid,
27
27
  color: c.fgOnBrand,
28
- boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.07), 0 2px 4px -2px rgba(38,109,240,0.12), 0 3px 6px -2px rgba(38,109,240,0.08)",
28
+ // crisp#494: the inset ring is a black tint, not white -- live-
29
+ // measured on Attio's own "New Company" button as
30
+ // rgba(0,0,0,0.06), not the rgba(255,255,255,0.07) both solid
31
+ // intents shared. The other two shadow layers already matched
32
+ // exactly.
33
+ boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.06), 0 2px 4px -2px rgba(38,109,240,0.12), 0 3px 6px -2px rgba(38,109,240,0.08)",
29
34
  },
30
35
  parts: [
31
36
  { selector: "&:hover", decls: { background: c.bgBrandSolidPressed } },
@@ -47,7 +52,10 @@ const spec = defineSpec({
47
52
  decls: {
48
53
  background: c.bgDangerSolid,
49
54
  color: c.fgOnBrand,
50
- boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.07), 0 2px 4px -2px rgba(186,37,37,0.20), 0 3px 6px -2px rgba(186,37,37,0.12)",
55
+ // Same corrected inset ring as primary (crisp#494) -- shared
56
+ // literal, same solid-fill button family; the coloured layers
57
+ // themselves weren't independently re-measured this round.
58
+ boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.06), 0 2px 4px -2px rgba(186,37,37,0.20), 0 3px 6px -2px rgba(186,37,37,0.12)",
51
59
  },
52
60
  parts: [
53
61
  { selector: "&:hover", decls: { filter: "brightness(0.96)" } },
@@ -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, CircleUserRound, GripVertical, Plus, Search, SlidersHorizontal, } from "lucide-react";
3
3
  import * as React from "react";
4
- import { cellDatePrecision, formatCellDate, isRtlElement, parseCellDate, } from "../../core/index.js";
4
+ import { cellDatePrecision, formatCellDate, isRtlElement, parseCellDate, useCrispMessage, useCrispMessages, } from "../../core/index.js";
5
5
  import { Avatar } from "../avatar/index.js";
6
6
  import { Checkbox } from "../checkbox/index.js";
7
7
  import { Menu, MenuItem } from "../menu/index.js";
@@ -165,7 +165,7 @@ function formatCell(type, v, symbol, locale = "en-US") {
165
165
  // silently drop the cell's data. Each branch decides for itself where a
166
166
  // display string belongs -- as an entity's name, a status' label -- while the
167
167
  // raw value keeps driving structure and tone.
168
- function renderTyped(col, value, avatarTone, display, locale = "en-US") {
168
+ function renderTyped(col, value, avatarTone, row, display, locale = "en-US") {
169
169
  // Attio's own empty cell is genuinely blank -- no placeholder character at
170
170
  // all, for any column type (#412).
171
171
  const empty = null;
@@ -177,7 +177,14 @@ function renderTyped(col, value, avatarTone, display, locale = "en-US") {
177
177
  const e = display ? { ...raw, name: display } : raw;
178
178
  if (!e.name)
179
179
  return empty;
180
- return (_jsxs("span", { className: "crisp-datatable-entity", children: [_jsx("span", { className: "crisp-datatable-avatar", "data-tone": avatarTone === "hashed" ? toneFor(raw.name) : undefined, children: e.avatar ? (_jsx("img", { src: e.avatar, alt: "" })) : (initials(e.name).slice(0, 2)) }), _jsx("span", { className: "crisp-datatable-entity-name", children: e.name })] }));
180
+ return (_jsxs("span", { className: "crisp-datatable-entity", children: [_jsx("span", { className: "crisp-datatable-avatar", "data-tone": avatarTone === "hashed" ? toneFor(raw.name) : undefined, children: e.avatar ? (_jsx("img", { src: e.avatar, alt: "" })) : (initials(e.name).slice(0, 2)) }), col.onEntityClick ? (_jsx("button", { type: "button", className: "crisp-datatable-entity-name crisp-datatable-entity-name--link", onClick: (ev) => {
181
+ // the cell's own click already just selects (see the
182
+ // TableCell onClick above) -- this must additionally stop
183
+ // the row's onRowClick from firing, since this name points
184
+ // at a DIFFERENT record than the row itself (#501).
185
+ ev.stopPropagation();
186
+ col.onEntityClick?.(row);
187
+ }, children: e.name })) : (_jsx("span", { className: "crisp-datatable-entity-name", children: e.name }))] }));
181
188
  }
182
189
  case "tags": {
183
190
  // asking for a format on a tag column means "render it your way" -- show
@@ -294,9 +301,47 @@ function DataReferenceEditor({ col, value, isPending, onCommit, onCancel, }) {
294
301
  onCommit(v);
295
302
  } }) }));
296
303
  }
297
- 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, stickyLast = false, locale: localeProp, avatarTone = "brand", surface, interactive = false, onRowClick, onAddRow, addRowLabel = "New record", empty = "No results.", showCount = true, countLabel = (shown, total) => shown === total
298
- ? `${total} ${total === 1 ? "row" : "rows"}`
299
- : `${shown} of ${total}`, toolbar, className, reorderRowLabel = "Reorder row", ...rest }) {
304
+ export function DataTable({ columns: columnsProp, data, selectable = false, selectedIds, onSelectionChange, selectAllLabel: selectAllLabelProp, selectRowLabel = (row) => `Select row ${row.id}`, selectionAnnouncement, columnMenu = false, onColumnsChange, groupBy, onRowsReorder, error, sort: sortProp, defaultSort, onSortChange, filterable = false, searchPlaceholder: searchPlaceholderProp, searchLabel: searchLabelProp, onEdit, stickyFirst = true, stickyLast = false, locale: localeProp, avatarTone = "brand", surface, interactive = false, onRowClick, onAddRow, addRowLabel: addRowLabelProp, empty: emptyProp, showCount = true, countLabel, toolbar, className, reorderRowLabel: reorderRowLabelProp, sortAscendingLabel: sortAscendingLabelProp, sortDescendingLabel: sortDescendingLabelProp, moveColumnLeftLabel: moveColumnLeftLabelProp, moveColumnRightLabel: moveColumnRightLabelProp, editColumnLabel: editColumnLabelProp, hideColumnLabel: hideColumnLabelProp, renameColumnLabel: renameColumnLabelProp, viewSettingsLabel: viewSettingsLabelProp, columnsGroupLabel: columnsGroupLabelProp, ...rest }) {
305
+ // crisp/#471: same resolution order as TableToolbar's own — an explicit
306
+ // prop wins, then the provider's translation of the built-in English
307
+ // default, then that default itself with no provider in the tree.
308
+ const crispMessages = useCrispMessages();
309
+ const selectAllLabel = useCrispMessage(selectAllLabelProp, "Select all rows");
310
+ const searchPlaceholder = useCrispMessage(searchPlaceholderProp, "Search…");
311
+ const searchLabel = useCrispMessage(searchLabelProp, "Search table");
312
+ // Also `React.ReactNode`-typed, same reasoning as `empty` below.
313
+ const addRowLabel = addRowLabelProp ?? crispMessages["New record"] ?? "New record";
314
+ // `empty` is typed `React.ReactNode`, not `string` — a consumer can pass a
315
+ // whole custom empty-state element, not just replacement text, so it
316
+ // can't go through `useCrispMessage` (string-only). A ReactNode override
317
+ // passes straight through unchanged (it's already whatever the consumer
318
+ // wants shown); only the built-in string default translates.
319
+ const empty = emptyProp ?? crispMessages["No results."] ?? "No results.";
320
+ const reorderRowLabel = useCrispMessage(reorderRowLabelProp, "Reorder row");
321
+ const sortAscendingLabel = useCrispMessage(sortAscendingLabelProp, "Sort ascending");
322
+ const sortDescendingLabel = useCrispMessage(sortDescendingLabelProp, "Sort descending");
323
+ const moveColumnLeftLabel = useCrispMessage(moveColumnLeftLabelProp, "Move left");
324
+ const moveColumnRightLabel = useCrispMessage(moveColumnRightLabelProp, "Move right");
325
+ const editColumnLabel = useCrispMessage(editColumnLabelProp, "Edit column label");
326
+ const hideColumnLabel = useCrispMessage(hideColumnLabelProp, "Hide from view");
327
+ const renameColumnLabel = useCrispMessage(renameColumnLabelProp, "Rename column");
328
+ const viewSettingsLabel = useCrispMessage(viewSettingsLabelProp, "View settings");
329
+ const columnsGroupLabel = useCrispMessage(columnsGroupLabelProp, "Columns");
330
+ // countLabel/selectionAnnouncement stay formatter props, same escape hatch
331
+ // as TableToolbar's sortTriggerLabel — word order and pluralisation rules
332
+ // differ too much across languages for a template. Their BUILT-IN default
333
+ // (when the consumer supplies neither) still translates its own static
334
+ // fragments through the provider, same as TableToolbar's "Sorted by".
335
+ const resolvedCountLabel = countLabel ??
336
+ ((shown, total) => shown === total
337
+ ? `${total} ${total === 1
338
+ ? (crispMessages.row ?? "row")
339
+ : (crispMessages.rows ?? "rows")}`
340
+ : `${shown} ${crispMessages.of ?? "of"} ${total}`);
341
+ const resolvedSelectionAnnouncement = selectionAnnouncement ??
342
+ ((count) => count === 0
343
+ ? (crispMessages["Selection cleared"] ?? "Selection cleared")
344
+ : `${count} ${crispMessages.selected ?? "selected"}`);
300
345
  // Two different defaults on purpose. FORMATTING falls back to "en-US", which
301
346
  // is what the built-in date/number/currency renderers have always produced,
302
347
  // so no existing output moves. SORTING falls back to `undefined`, which is
@@ -390,7 +435,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
390
435
  const [selectionText, setSelectionText] = React.useState("");
391
436
  if (prevSelectedCount.current !== selected.size) {
392
437
  prevSelectedCount.current = selected.size;
393
- setSelectionText(selectionAnnouncement(selected.size));
438
+ setSelectionText(resolvedSelectionAnnouncement(selected.size));
394
439
  }
395
440
  const [editing, setEditing] = React.useState(null);
396
441
  // Set only while an async `onEdit` is in flight (#385) -- a sync `onEdit`
@@ -736,7 +781,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
736
781
  return dir === "asc" ? (_jsx(ArrowUp, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 })) : (_jsx(ArrowDown, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 }));
737
782
  return (_jsx(ChevronsUpDown, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 }));
738
783
  }
739
- 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: {
784
+ 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 }), viewSettingsLabel] }), children: [_jsx("div", { className: "crisp-datatable-group-label", children: columnsGroupLabel }), 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: resolvedCountLabel(rows.length, data.length) })), toolbar] })), _jsxs(Table, { interactive: interactive, surface: surface, style: { minWidth }, scrollProps: {
740
785
  ref: scrollRef,
741
786
  onScroll: (e) => {
742
787
  const el = e.currentTarget;
@@ -766,7 +811,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
766
811
  const inner = (_jsxs("span", { className: "crisp-datatable-th-inner", children: [Icon ? _jsx(Icon, { size: 16, strokeWidth: 1.75 }) : null, _jsx("span", { className: "crisp-datatable-th-label", children: col.header }), col.sortable ? _jsx(ChevronFor, { colKey: col.key }) : null] }));
767
812
  return (_jsxs(TableHead, { align: alignFor(col), "aria-sort": ariaSort, ...dataStickyCol(i), children: [renaming === col.key ? (_jsx("input", {
768
813
  // biome-ignore lint/a11y/noAutofocus: the rename item opens this field
769
- autoFocus: true, className: "crisp-datatable-th-rename", defaultValue: typeof col.header === "string" ? col.header : "", "aria-label": `Rename column`, onBlur: (e) => {
814
+ autoFocus: true, className: "crisp-datatable-th-rename", defaultValue: typeof col.header === "string" ? col.header : "", "aria-label": renameColumnLabel, onBlur: (e) => {
770
815
  renameColumn(col.key, e.currentTarget.value);
771
816
  setRenaming(null);
772
817
  }, onKeyDown: (e) => {
@@ -778,7 +823,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
778
823
  setRenaming(null);
779
824
  } })) : columnMenu ? (_jsxs(Menu, { align: "start", trigger: _jsx("button", { type: "button", className: "crisp-datatable-sort", "data-sorted": sortDirByKey.get(col.key), "aria-label": `${typeof col.header === "string"
780
825
  ? col.header
781
- : col.key} column options`, children: inner }), children: [_jsx(MenuItem, { onSelect: () => setSort({ key: col.key, dir: "asc" }), children: "Sort ascending" }), _jsx(MenuItem, { onSelect: () => setSort({ key: col.key, dir: "desc" }), children: "Sort descending" }), col.managed === false ? null : (_jsxs(_Fragment, { children: [_jsx(MenuItem, { onSelect: () => moveColumn(col.key, -1), children: "Move left" }), _jsx(MenuItem, { onSelect: () => moveColumn(col.key, 1), children: "Move right" }), _jsx(MenuItem, { onSelect: () => setRenaming(col.key), children: "Edit column label" }), _jsx(MenuItem, { onSelect: () => hideColumn(col.key), children: "Hide from view" })] }))] })) : col.sortable ? (_jsx("button", { type: "button", className: "crisp-datatable-sort", "data-sorted": sortDirByKey.get(col.key), onClick: () => cycleSort(col.key), children: inner })) : (inner), col.resizable !== false && (_jsx("span", { className: "crisp-datatable-resize", "data-resizing": resizingKey === col.key ? "" : undefined, onPointerDown: (e) => startResize(col, e), onPointerMove: moveResize, onPointerUp: endResize }))] }, col.key));
826
+ : col.key} ${crispMessages["column options"] ?? "column options"}`, children: inner }), children: [_jsx(MenuItem, { onSelect: () => setSort({ key: col.key, dir: "asc" }), children: sortAscendingLabel }), _jsx(MenuItem, { onSelect: () => setSort({ key: col.key, dir: "desc" }), children: sortDescendingLabel }), col.managed === false ? null : (_jsxs(_Fragment, { children: [_jsx(MenuItem, { onSelect: () => moveColumn(col.key, -1), children: moveColumnLeftLabel }), _jsx(MenuItem, { onSelect: () => moveColumn(col.key, 1), children: moveColumnRightLabel }), _jsx(MenuItem, { onSelect: () => setRenaming(col.key), children: editColumnLabel }), _jsx(MenuItem, { onSelect: () => hideColumn(col.key), children: hideColumnLabel })] }))] })) : col.sortable ? (_jsx("button", { type: "button", className: "crisp-datatable-sort", "data-sorted": sortDirByKey.get(col.key), onClick: () => cycleSort(col.key), children: inner })) : (inner), col.resizable !== false && (_jsx("span", { className: "crisp-datatable-resize", "data-resizing": resizingKey === col.key ? "" : undefined, onPointerDown: (e) => startResize(col, e), onPointerMove: moveResize, onPointerUp: endResize }))] }, col.key));
782
827
  })] }) }), _jsx(TableBody, { children: error ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: totalCols, children: _jsx("div", { className: "crisp-datatable-empty", children: error }) }) })) : rows.length === 0 ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: totalCols, children: _jsx("div", { className: "crisp-datatable-empty", children: empty }) }) })) : (groupedRows.map(({ group, rows: groupRows }) => (_jsxs(React.Fragment, { children: [group !== null && (_jsx(TableRow, { children: _jsxs(TableCell, { colSpan: totalCols, className: "crisp-datatable-group", children: [_jsx("span", { className: "crisp-datatable-group-label", children: group }), _jsx("span", { className: "crisp-datatable-group-count", children: groupRows.length })] }) })), groupRows.map((row) => (_jsxs(TableRow, { "data-selected": selected.has(row.id) ? "" : undefined, "data-drop-target": showGrip &&
783
828
  dragRowId &&
784
829
  dragRowId !== row.id &&
@@ -876,7 +921,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
876
921
  ? () => openEditing(row.id, col.key)
877
922
  : undefined, children: [col.render
878
923
  ? col.render(value, row)
879
- : renderTyped(col, value, avatarTone, col.format?.(value, row), locale), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
924
+ : renderTyped(col, value, avatarTone, row, col.format?.(value, row), locale), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
880
925
  })] }, 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), ...dataStickyCol(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] }) }) }))] }))] })] }));
881
926
  }
882
927
  DataTable.displayName = "DataTable";
@@ -121,6 +121,8 @@ const config = defineRecipe({
121
121
  /* typed cells — entity (avatar + name), colour-coded tags, numbers, dates */
122
122
  & .crisp-datatable-entity { display: flex; align-items: center; gap: ${c.space["1.5"]}; min-width: 0; color: ${c.fgPrimary}; font-weight: ${c.weight.medium}; }
123
123
  & .crisp-datatable-entity-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
124
+ & .crisp-datatable-entity:hover .crisp-datatable-entity-name { text-decoration: underline; }
125
+ & button.crisp-datatable-entity-name--link { margin: 0; padding: 0; border: none; background: none; font: inherit; color: inherit; letter-spacing: inherit; text-align: start; cursor: pointer; max-width: 100%; }
124
126
  & .crisp-datatable-avatar { width: ${c.size.icon}; height: ${c.size.icon}; border-radius: ${c.radius.sm}; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 10px; line-height: 1; font-weight: ${c.weight.semibold}; text-transform: uppercase; color: ${c.fgOnBrand}; background: ${c.bgBrandSolid}; }
125
127
  & .crisp-datatable-avatar[data-tone="success"] { background: ${c.bgSuccess}; } & .crisp-datatable-avatar[data-tone="warning"] { background: ${c.bgWarning}; } & .crisp-datatable-avatar[data-tone="danger"] { background: ${c.bgDanger}; } & .crisp-datatable-avatar[data-tone="purple"] { background: ${c.bgPurple}; } & .crisp-datatable-avatar[data-tone="cyan"] { background: ${c.bgCyan}; } & .crisp-datatable-avatar[data-tone="orange"] { background: ${c.bgOrange}; }
126
128
  & .crisp-datatable-avatar img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { CalendarDays, X } from "lucide-react";
3
3
  import * as React from "react";
4
+ import { useCrispMessage } from "../../core/index.js";
4
5
  import { DatePicker, } from "../datepicker/index.js";
5
6
  import { Popover } from "../popover/index.js";
6
7
  import { datepickerfieldRecipe } from "./datepickerfield.recipe.js";
@@ -21,7 +22,10 @@ const fromDate = (d) => ({
21
22
  * popover. This is what `DatePicker` used to be; `DatePicker` is now
22
23
  * seed-design's bare calendar, and this composes it.
23
24
  */
24
- export function DatePickerField({ value, defaultValue, onValueChange, onChange, placeholder = "Set date…", variant = "default", format = defaultFormat, clearable = true, clearLabel = "Clear date", presets, calendarProps, "aria-label": ariaLabel, className, ...rest }) {
25
+ export function DatePickerField({ value, defaultValue, onValueChange, onChange, placeholder: placeholderProp, variant = "default", format = defaultFormat, clearable = true, clearLabel: clearLabelProp, presets, calendarProps, "aria-label": ariaLabel, className, ...rest }) {
26
+ // crisp/#471: same resolution order as Select's own.
27
+ const placeholder = useCrispMessage(placeholderProp, "Set date…");
28
+ const clearLabel = useCrispMessage(clearLabelProp, "Clear date");
25
29
  const [internal, setInternal] = React.useState(defaultValue ?? null);
26
30
  const selected = value ?? internal;
27
31
  const [open, setOpen] = React.useState(false);
@@ -3,7 +3,7 @@ import * as dialog from "@zag-js/dialog";
3
3
  import { normalizeProps, Portal, useMachine } from "@zag-js/react";
4
4
  import { X } from "lucide-react";
5
5
  import * as React from "react";
6
- import { cx, hasOpenFloatingLayer, withDescribedBy } from "../../core/index.js";
6
+ import { cx, hasOpenFloatingLayer, useCrispMessage, withDescribedBy, } from "../../core/index.js";
7
7
  import { dialogRecipe } from "./dialog.recipe.js";
8
8
  // A globally unique id per mounted instance (see command.tsx for the rationale —
9
9
  // the machine defers its dismissal listeners a frame, so tree-relative useId
@@ -22,7 +22,8 @@ function useDialogInstanceId() {
22
22
  * Escape / outside-click dismissal) with the measured modal surface.
23
23
  * Compose the body with `DialogBody` and actions with `DialogFooter`.
24
24
  */
25
- export function Dialog({ open, onOpenChange, title, icon, width = 640, "aria-label": ariaLabel, closeLabel = "Close", children, className, style, "aria-describedby": describedBy, ...rest }) {
25
+ export function Dialog({ open, onOpenChange, title, icon, width = 640, "aria-label": ariaLabel, closeLabel: closeLabelProp, children, className, style, "aria-describedby": describedBy, ...rest }) {
26
+ const closeLabel = useCrispMessage(closeLabelProp, "Close");
26
27
  const id = useDialogInstanceId();
27
28
  const service = useMachine(dialog.machine, {
28
29
  id,
@@ -3,7 +3,7 @@ import * as dialog from "@zag-js/dialog";
3
3
  import { normalizeProps, Portal, useMachine } from "@zag-js/react";
4
4
  import { X } from "lucide-react";
5
5
  import * as React from "react";
6
- import { cx, hasOpenFloatingLayer, withDescribedBy } from "../../core/index.js";
6
+ import { cx, hasOpenFloatingLayer, useCrispMessage, withDescribedBy, } from "../../core/index.js";
7
7
  import { drawerRecipe } from "./drawer.recipe.js";
8
8
  let instanceCounter = 0;
9
9
  function useDrawerInstanceId() {
@@ -19,7 +19,8 @@ function useDrawerInstanceId() {
19
19
  * Escape / outside-click dismissal) with a panel that slides in from the edge.
20
20
  * Compose the body with `DrawerBody` and actions with `DrawerFooter`.
21
21
  */
22
- export function Drawer({ open, onOpenChange, side = "right", title, icon, "aria-label": ariaLabel, closeLabel = "Close", children, className, "aria-describedby": describedBy, ...rest }) {
22
+ export function Drawer({ open, onOpenChange, side = "right", title, icon, "aria-label": ariaLabel, closeLabel: closeLabelProp, children, className, "aria-describedby": describedBy, ...rest }) {
23
+ const closeLabel = useCrispMessage(closeLabelProp, "Close");
23
24
  const id = useDrawerInstanceId();
24
25
  const service = useMachine(dialog.machine, {
25
26
  id,
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Check, ChevronDown, Search } from "lucide-react";
3
3
  import * as React from "react";
4
- import { FloatingPortal, formValueStyle, isInsideLaterFloatingLayer, placeFloating, registerFloatingLayer, useFormReset, } from "../../core/index.js";
4
+ import { FloatingPortal, formValueStyle, isInsideLaterFloatingLayer, placeFloating, registerFloatingLayer, useCrispMessage, useFormReset, } from "../../core/index.js";
5
5
  import { useListboxKeyboard } from "../select/index.js";
6
6
  import { multiselectRecipe } from "./multiselect.recipe.js";
7
7
  const TONES = [
@@ -33,7 +33,11 @@ export function MultiSelect({ options, value: valueProp, defaultValue, onValueCh
33
33
  // The id belongs on the trigger, not the wrapper: `<label for>` only names a
34
34
  // LABELABLE element, so an id left on the root div meant a FormField label
35
35
  // above this control named nothing (#247). Select already did this.
36
- id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, invalid, className, "aria-label": ariaLabel, ...rest }) {
36
+ id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPlaceholderProp, emptyLabel: emptyLabelProp, disabled, invalid, className, "aria-label": ariaLabel, ...rest }) {
37
+ // crisp/#471: same resolution order as Select's own.
38
+ const placeholder = useCrispMessage(placeholderProp, "Select…");
39
+ const searchPlaceholder = useCrispMessage(searchPlaceholderProp, "Search…");
40
+ const emptyLabel = useCrispMessage(emptyLabelProp, "No results");
37
41
  // Virtual focus: DOM focus stays on the trigger (or the search field) and the
38
42
  // active option is named by `aria-activedescendant`. `data-highlighted` is a
39
43
  // styling hook only -- a screen reader cannot see it.
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { QuantityPickerDecrementButton, QuantityPickerHiddenInput, QuantityPickerIncrementButton, QuantityPickerRoot, useQuantityPickerContext, } from "@seed-design/react-quantity-picker";
4
4
  import { Minus, Plus, Trash2 } from "lucide-react";
5
5
  import * as React from "react";
6
- import { useLabelAssociation } from "../../core/index.js";
6
+ import { useCrispMessage, useLabelAssociation } from "../../core/index.js";
7
7
  import { Spinner } from "../spinner/index.js";
8
8
  import { quantitypickerRecipe } from "./quantitypicker.recipe.js";
9
9
  /** seed's icon box per size, read from their stylesheet. */
@@ -55,7 +55,10 @@ function IncrementIcon({ size }) {
55
55
  * Slider documents. Here it also decides child ORDER, since the engine
56
56
  * reverses its visible children rather than relying on CSS.
57
57
  */
58
- export function QuantityPicker({ size = "medium", fill = false, className, name, decrementLabel = "Decrease quantity", incrementLabel = "Increase quantity", ...props }) {
58
+ export function QuantityPicker({ size = "medium", fill = false, className, name, decrementLabel: decrementLabelProp, incrementLabel: incrementLabelProp, ...props }) {
59
+ // crisp/#471: same resolution order as Select's own.
60
+ const decrementLabel = useCrispMessage(decrementLabelProp, "Decrease quantity");
61
+ const incrementLabel = useCrispMessage(incrementLabelProp, "Increase quantity");
59
62
  // A `<label for>` cannot name a div, and this root is a role="group" (#247).
60
63
  const rootRef = React.useRef(null);
61
64
  useLabelAssociation(rootRef);
@@ -1,7 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Check, ChevronDown, Search } from "lucide-react";
3
3
  import * as React from "react";
4
- import { FloatingPortal, formValueStyle, isInsideLaterFloatingLayer, placeFloating, registerFloatingLayer, useFormReset, } from "../../core/index.js";
4
+ import { FloatingPortal, formValueStyle, isInsideLaterFloatingLayer, placeFloating, registerFloatingLayer, useCrispMessage, useFormReset, } from "../../core/index.js";
5
5
  import { useListboxKeyboard } from "./listbox-keyboard.js";
6
6
  import { selectRecipe } from "./select.recipe.js";
7
7
  function optionText(o) {
@@ -16,7 +16,13 @@ function optionText(o) {
16
16
  * "No results" empty state). Full keyboard model — ↑/↓ move, Enter/Space pick,
17
17
  * Esc close, Home/End jump, opens upward when tight. Controlled or uncontrolled.
18
18
  */
19
- export function Select({ options, value, defaultValue, onValueChange, name, required, placeholder = "Select…", variant = "default", width = "full", searchable, searchPlaceholder = "Search…", onSearchChange, emptyLabel = "No results", disabled, invalid, className, "aria-label": ariaLabel, id, ...rest }) {
19
+ export function Select({ options, value, defaultValue, onValueChange, name, required, placeholder: placeholderProp, variant = "default", width = "full", searchable, searchPlaceholder: searchPlaceholderProp, onSearchChange, emptyLabel: emptyLabelProp, disabled, invalid, className, "aria-label": ariaLabel, id, ...rest }) {
20
+ // crisp/#471: an explicit prop always wins; missing, it reads the nearest
21
+ // CrispMessagesProvider's translation of the English default, or that
22
+ // default itself with no provider — unchanged from before this existed.
23
+ const placeholder = useCrispMessage(placeholderProp, "Select…");
24
+ const searchPlaceholder = useCrispMessage(searchPlaceholderProp, "Search…");
25
+ const emptyLabel = useCrispMessage(emptyLabelProp, "No results");
20
26
  // Virtual focus: DOM focus stays on the trigger (or the search field), and the
21
27
  // active option is named by `aria-activedescendant`. That is the only thing a
22
28
  // screen reader has to go on -- `data-highlighted` is invisible to it -- so the
@@ -5,8 +5,9 @@ import { statcardRecipe } from "./statcard.recipe.js";
5
5
  * A single-metric KPI tile — measured from a reports number/insight card.
6
6
  * Label, a large value, and an optional up/down delta pill with a caption.
7
7
  */
8
- export function StatCard({ label, icon, value, delta, deltaSuffix = "%", caption, card, className, ...rest }) {
8
+ export function StatCard({ label, icon, value, delta, deltaSuffix = "%", caption, captionTone, card, className, ...rest }) {
9
9
  const trend = delta == null ? null : delta > 0 ? "up" : delta < 0 ? "down" : "flat";
10
- return (_jsxs("div", { className: statcardRecipe({ card, className }), ...rest, children: [_jsxs("span", { className: "crisp-statcard-label", children: [icon, label] }), _jsx("span", { className: "crisp-statcard-value", children: value }), (trend != null || caption != null) && (_jsxs("span", { className: "crisp-statcard-foot", children: [trend != null && (_jsxs("span", { className: "crisp-statcard-delta", "data-trend": trend, children: [trend === "up" && _jsx(ArrowUp, { size: 12, strokeWidth: 2.5 }), trend === "down" && _jsx(ArrowDown, { size: 12, strokeWidth: 2.5 }), Math.abs(delta), deltaSuffix] })), caption] }))] }));
10
+ return (_jsxs("div", { className: statcardRecipe({ card, className }), ...rest, children: [_jsxs("span", { className: "crisp-statcard-label", children: [icon, label] }), _jsx("span", { className: "crisp-statcard-value", children: value }), (trend != null || caption != null) && (_jsxs("span", { className: "crisp-statcard-foot", children: [trend != null && (_jsxs("span", { className: "crisp-statcard-delta", "data-trend": trend, children: [trend === "up" && _jsx(ArrowUp, { size: 12, strokeWidth: 2.5 }), trend === "down" && _jsx(ArrowDown, { size: 12, strokeWidth: 2.5 }), Math.abs(delta), deltaSuffix] })), caption != null &&
11
+ (captionTone ? (_jsx("span", { className: "crisp-statcard-caption", "data-tone": captionTone, children: caption })) : (caption))] }))] }));
11
12
  }
12
13
  StatCard.displayName = "StatCard";
@@ -11,6 +11,16 @@ const config = defineRecipe({
11
11
  & .crisp-statcard-label svg { color: ${c.fgMuted}; }
12
12
  & .crisp-statcard-value { font-size: 28px; line-height: 32px; font-weight: ${c.weight.semibold}; letter-spacing: -0.4px; color: ${c.fgPrimary}; }
13
13
  & .crisp-statcard-foot { display: inline-flex; align-items: center; gap: ${c.space["1.5"]}; font-size: ${c.text.sm}; color: ${c.fgMuted}; }
14
+ /* caption's own status colour, opt-in via data-tone: not a new measured
15
+ value — the same fg*Soft tokens Badge's tone already uses (and Badge's
16
+ own doc comment records why: a dedicated soft-text token, not the
17
+ tone's accent colour, is the one that clears AA). No "ok" reading on
18
+ Badge itself; fgSuccessSoft is Badge's own "success" tone's text
19
+ colour, reused here under the name a caption's own good-news state
20
+ reads as. */
21
+ & .crisp-statcard-caption[data-tone="ok"] { color: ${c.fgSuccessSoft}; }
22
+ & .crisp-statcard-caption[data-tone="warning"] { color: ${c.fgWarningSoft}; }
23
+ & .crisp-statcard-caption[data-tone="critical"] { color: ${c.fgDangerSoft}; }
14
24
  & .crisp-statcard-delta { display: inline-flex; align-items: center; gap: 2px; height: 20px; padding: 0 ${c.space["1"]}; border-radius: ${c.radius.sm}; font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; }
15
25
  & .crisp-statcard-delta svg { flex-shrink: 0; }
16
26
  & .crisp-statcard-delta[data-trend="up"] { background: color-mix(in srgb, ${c.bgSuccess} 16%, transparent); color: color-mix(in srgb, ${c.bgSuccess} 80%, ${c.fgPrimary}); }