@crisp-ui-kit/crisp 0.53.4 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/cjs/components/bulkbar/bulkbar.js +5 -1
  2. package/dist/cjs/components/fileupload/fileupload.js +19 -3
  3. package/dist/cjs/components/formfield/formfield.js +8 -1
  4. package/dist/cjs/components/heatmapchart/heatmapchart.js +14 -2
  5. package/dist/cjs/components/skiplink/index.js +17 -0
  6. package/dist/cjs/components/skiplink/skiplink.js +16 -0
  7. package/dist/cjs/components/skiplink/skiplink.recipe.js +33 -0
  8. package/dist/cjs/components/textlink/index.js +7 -0
  9. package/dist/cjs/components/textlink/textlink.js +16 -0
  10. package/dist/cjs/components/textlink/textlink.recipe.js +35 -0
  11. package/dist/cjs/components/visuallyhidden/index.js +17 -0
  12. package/dist/cjs/components/visuallyhidden/visuallyhidden.js +16 -0
  13. package/dist/cjs/components/visuallyhidden/visuallyhidden.recipe.js +20 -0
  14. package/dist/cjs/index.js +4 -0
  15. package/dist/cjs/product/board/board.js +11 -2
  16. package/dist/cjs/product/command/command.js +13 -1
  17. package/dist/cjs/product/flowcanvas/flowcanvas.js +9 -2
  18. package/dist/cjs/product/headerbar/headerbar.js +15 -0
  19. package/dist/cjs/product/headerbar/headerbar.recipe.js +35 -0
  20. package/dist/cjs/product/headerbar/index.js +17 -0
  21. package/dist/cjs/product/inbox/inbox.js +13 -1
  22. package/dist/cjs/product/onboarding/onboarding.js +22 -1
  23. package/dist/cjs/product/recordpage/index.js +2 -1
  24. package/dist/cjs/product/recordpage/recordpage.js +197 -123
  25. package/dist/cjs/product/recordpage/recordpage.recipe.js +5 -0
  26. package/dist/cjs/product/richtext/richtext.js +3 -3
  27. package/dist/cjs/product/settingssection/settingssection.recipe.js +11 -2
  28. package/dist/cjs/product/tasklist/tasklist.js +8 -1
  29. package/dist/esm/components/bulkbar/bulkbar.js +5 -1
  30. package/dist/esm/components/fileupload/fileupload.js +20 -4
  31. package/dist/esm/components/formfield/formfield.js +8 -1
  32. package/dist/esm/components/heatmapchart/heatmapchart.js +14 -2
  33. package/dist/esm/components/skiplink/index.js +1 -0
  34. package/dist/esm/components/skiplink/skiplink.js +13 -0
  35. package/dist/esm/components/skiplink/skiplink.recipe.js +30 -0
  36. package/dist/esm/components/textlink/index.js +2 -0
  37. package/dist/esm/components/textlink/textlink.js +13 -0
  38. package/dist/esm/components/textlink/textlink.recipe.js +32 -0
  39. package/dist/esm/components/visuallyhidden/index.js +1 -0
  40. package/dist/esm/components/visuallyhidden/visuallyhidden.js +13 -0
  41. package/dist/esm/components/visuallyhidden/visuallyhidden.recipe.js +17 -0
  42. package/dist/esm/index.js +4 -0
  43. package/dist/esm/product/board/board.js +12 -3
  44. package/dist/esm/product/command/command.js +14 -2
  45. package/dist/esm/product/flowcanvas/flowcanvas.js +9 -2
  46. package/dist/esm/product/headerbar/headerbar.js +12 -0
  47. package/dist/esm/product/headerbar/headerbar.recipe.js +32 -0
  48. package/dist/esm/product/headerbar/index.js +1 -0
  49. package/dist/esm/product/inbox/inbox.js +13 -1
  50. package/dist/esm/product/onboarding/onboarding.js +22 -1
  51. package/dist/esm/product/recordpage/index.js +1 -1
  52. package/dist/esm/product/recordpage/recordpage.js +197 -124
  53. package/dist/esm/product/recordpage/recordpage.recipe.js +5 -0
  54. package/dist/esm/product/richtext/richtext.js +3 -3
  55. package/dist/esm/product/settingssection/settingssection.recipe.js +11 -2
  56. package/dist/esm/product/tasklist/tasklist.js +8 -1
  57. package/dist/headerbar.css +7 -0
  58. package/dist/headerbar.d.ts +7 -0
  59. package/dist/headerbar.layered.css +9 -0
  60. package/dist/recordpage.css +17 -31
  61. package/dist/recordpage.layered.css +17 -31
  62. package/dist/settingssection.css +2 -2
  63. package/dist/settingssection.layered.css +2 -2
  64. package/dist/skiplink.css +5 -0
  65. package/dist/skiplink.d.ts +7 -0
  66. package/dist/skiplink.layered.css +7 -0
  67. package/dist/styles.css +49 -33
  68. package/dist/styles.layered.css +49 -33
  69. package/dist/textlink.css +11 -0
  70. package/dist/textlink.d.ts +7 -0
  71. package/dist/textlink.layered.css +13 -0
  72. package/dist/types/components/bulkbar/bulkbar.d.ts +1 -1
  73. package/dist/types/components/heatmapchart/heatmapchart.d.ts +1 -1
  74. package/dist/types/components/skiplink/index.d.ts +1 -0
  75. package/dist/types/components/skiplink/skiplink.d.ts +18 -0
  76. package/dist/types/components/skiplink/skiplink.recipe.d.ts +3 -0
  77. package/dist/types/components/textlink/index.d.ts +2 -0
  78. package/dist/types/components/textlink/textlink.d.ts +19 -0
  79. package/dist/types/components/textlink/textlink.recipe.d.ts +13 -0
  80. package/dist/types/components/visuallyhidden/index.d.ts +1 -0
  81. package/dist/types/components/visuallyhidden/visuallyhidden.d.ts +15 -0
  82. package/dist/types/components/visuallyhidden/visuallyhidden.recipe.d.ts +3 -0
  83. package/dist/types/index.d.ts +4 -0
  84. package/dist/types/product/board/board.d.ts +1 -1
  85. package/dist/types/product/command/command.d.ts +1 -1
  86. package/dist/types/product/flowcanvas/flowcanvas.d.ts +1 -1
  87. package/dist/types/product/headerbar/headerbar.d.ts +27 -0
  88. package/dist/types/product/headerbar/headerbar.recipe.d.ts +3 -0
  89. package/dist/types/product/headerbar/index.d.ts +1 -0
  90. package/dist/types/product/inbox/inbox.d.ts +1 -1
  91. package/dist/types/product/onboarding/onboarding.d.ts +1 -1
  92. package/dist/types/product/recordpage/index.d.ts +1 -1
  93. package/dist/types/product/recordpage/recordpage.d.ts +90 -1
  94. package/dist/types/product/richtext/richtext.d.ts +20 -0
  95. package/dist/types/product/tasklist/tasklist.d.ts +1 -1
  96. package/dist/visuallyhidden.css +3 -0
  97. package/dist/visuallyhidden.d.ts +7 -0
  98. package/dist/visuallyhidden.layered.css +5 -0
  99. package/package.json +21 -1
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.recordPageLabels = void 0;
37
+ exports.RecordDetails = RecordDetails;
37
38
  exports.RecordPage = RecordPage;
38
39
  const jsx_runtime_1 = require("react/jsx-runtime");
39
40
  const lucide_react_1 = require("lucide-react");
@@ -124,7 +125,42 @@ exports.recordPageLabels = {
124
125
  taggedCategories: (count) => ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: ["Tagged ", (0, jsx_runtime_1.jsx)("b", { children: count }), " categories"] })),
125
126
  recordCreated: (name) => ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("b", { children: name }), " record was created"] })),
126
127
  noEmailsWith: (name) => (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: ["No emails logged with ", name, " yet."] }),
128
+ noFieldsMatch: (query) => (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: ["No fields match \u201C", query, "\u201D."] }),
127
129
  };
130
+ /**
131
+ * crisp/#471: a `CrispMessagesProvider`'s `componentDefaults.recordPage` sits
132
+ * BETWEEN the built-in English defaults and an instance's own explicit
133
+ * `labels` prop -- a provider translates every `RecordPage`/`RecordDetails`
134
+ * on the page at once, and a caller who still passes `labels` for one
135
+ * instance keeps overriding it there. Not `useCrispMessage`: several of
136
+ * these entries (`domainSetTo`, `noFieldsMatch`, …) are formatter FUNCTIONS,
137
+ * which a flat string dictionary can't hold. Shared by `RecordPage` and
138
+ * `RecordDetails` (#528) -- both merge the same three layers the same way,
139
+ * so this is the one place that merge is written, not two copies of it.
140
+ */
141
+ function useRecordPageLabels(labels) {
142
+ const providerLabels = (0, core_1.useCrispComponentDefaults)("recordPage");
143
+ return React.useMemo(() => ({
144
+ ...exports.recordPageLabels,
145
+ ...providerLabels,
146
+ ...labels,
147
+ tabs: {
148
+ ...exports.recordPageLabels.tabs,
149
+ ...providerLabels?.tabs,
150
+ ...labels?.tabs,
151
+ },
152
+ highlightLabels: {
153
+ ...exports.recordPageLabels.highlightLabels,
154
+ ...providerLabels?.highlightLabels,
155
+ ...labels?.highlightLabels,
156
+ },
157
+ highlightEmpty: {
158
+ ...exports.recordPageLabels.highlightEmpty,
159
+ ...providerLabels?.highlightEmpty,
160
+ ...labels?.highlightEmpty,
161
+ },
162
+ }), [labels, providerLabels]);
163
+ }
128
164
  /** `Select` owns its own closed-by-default `open` state with no prop or
129
165
  * imperative way to start it open -- the only thing that opens it is a real
130
166
  * click on its trigger button. Mounting one on click (rather than rendering
@@ -180,6 +216,16 @@ function TagsValue({ values }) {
180
216
  }
181
217
  function FieldValue({ col, row, labels, }) {
182
218
  const v = row[col.key];
219
+ // `render` (#528) -- the same full-custom-content escape hatch
220
+ // `RecordsTable`'s own columns already have (recordstable.tsx's own doc:
221
+ // "an escape hatch for a display crisp can't express via type"), reused
222
+ // here so a value this component has no built-in shape for (a reference
223
+ // field's link to another record, say) doesn't need a new `type`/`editor`
224
+ // of its own. Checked before the empty check -- like `RecordsTable`'s own
225
+ // `render`, it owns the cell's content fully, including what "empty"
226
+ // looks like for that column.
227
+ if (col.render)
228
+ return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: col.render(v, row) });
183
229
  if (valueEmpty(v))
184
230
  return (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-empty", children: labels.setAValue });
185
231
  // `editor: "select"` at rest (#457) -- checked ahead of `col.type` since
@@ -247,6 +293,147 @@ function TasksSection({ tasks, onAdd, onToggle, labels, }) {
247
293
  function EmptyCard({ icon, children, }) {
248
294
  return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-emptycard", children: [icon, children] }));
249
295
  }
296
+ /**
297
+ * The Details column's own field list, on its own (#528) — `RecordPage`'s
298
+ * "Record Details" collapsible + searchable field list, the one piece a
299
+ * consumer with a fixed-width rail of its own (no resize handle, no entity
300
+ * header, no CRM Lists panel — GRC's own persistent 320px column, grc#66)
301
+ * actually needs, rather than rebuilding this same click-to-edit/search/
302
+ * `sections` machinery by hand or pulling in the whole page's chrome for a
303
+ * rail that isn't one. `RecordPage` composes this internally for its own
304
+ * resizable aside (same component either way, not a second copy) — this is
305
+ * that same block, exactly, just not wrapped in `RecordPage`'s own topbar/
306
+ * tabs/resizer.
307
+ *
308
+ * Not wrapped in `recordpageRecipe` itself: doing so would duplicate the
309
+ * recipe's own `display:flex;height:100%` base rule as a nested child
310
+ * inside `RecordPage`'s aside (which already carries that class from
311
+ * `RecordPage`'s own root), breaking that layout. A standalone caller
312
+ * needs exactly one `.crisp-recordpage`-scoped ancestor for this
313
+ * component's classes to resolve — wrap it in one, the same way
314
+ * `RecordPage`'s own root does: `<div className={recordpageRecipe()}>`
315
+ * (also exported from this package).
316
+ */
317
+ function RecordDetails({ record, columns, sections, onEdit, labels, className, ...rest }) {
318
+ const t = useRecordPageLabels(labels);
319
+ const { editingKey, pendingKey, error, openEditing, closeEditing, commit } = (0, recordstable_1.useEditableField)(onEdit);
320
+ const [detailsOpen, setDetailsOpen] = React.useState(true);
321
+ const [searchOpen, setSearchOpen] = React.useState(false);
322
+ const [fieldQuery, setFieldQuery] = React.useState("");
323
+ const [showAllValues, setShowAllValues] = React.useState(false);
324
+ const fields = columns.filter((col) => col.type !== "entity");
325
+ const q = fieldQuery.trim().toLowerCase();
326
+ const matches = (col) => !q || col.label.toLowerCase().includes(q);
327
+ // #528: `sections` groups fields under headings, in the caller's own
328
+ // order, and always shows every match — no 5-field reveal (see
329
+ // `RecordPageSection`'s own doc). Omitted, this stays the flat,
330
+ // Attio-measured list with its 5-field "View all values" reveal,
331
+ // unchanged from before #528.
332
+ const FIELD_LIMIT = 5;
333
+ let canRevealFields = false;
334
+ let groups;
335
+ if (sections) {
336
+ const byKey = new Map(fields.map((col) => [col.key, col]));
337
+ groups = sections
338
+ .map((s) => ({
339
+ title: s.title,
340
+ fields: s.fields
341
+ .map((key) => byKey.get(key))
342
+ .filter((col) => col !== undefined)
343
+ .filter(matches),
344
+ }))
345
+ // A section left with no matching fields (a search that matches
346
+ // nothing in it, or a `fields` key naming no real column) doesn't
347
+ // render at all -- heading included, not an empty group under a
348
+ // heading with nothing below it.
349
+ .filter((g) => g.fields.length > 0);
350
+ }
351
+ else {
352
+ const matched = q ? fields.filter(matches) : fields;
353
+ canRevealFields = !q && matched.length > FIELD_LIMIT;
354
+ const shown = canRevealFields && !showAllValues
355
+ ? matched.slice(0, FIELD_LIMIT)
356
+ : matched;
357
+ groups = [{ title: undefined, fields: shown }];
358
+ }
359
+ const totalShown = groups.reduce((n, g) => n + g.fields.length, 0);
360
+ return ((0, jsx_runtime_1.jsxs)("div", { className: (0, core_1.cx)("crisp-recordpage-section", className), ...rest, children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-detailhead", children: [searchOpen ? ((0, jsx_runtime_1.jsx)("input", {
361
+ // biome-ignore lint/a11y/noAutofocus: focusing the just-opened field search is expected
362
+ autoFocus: true, type: "text", className: "crisp-recordpage-fieldsearch", placeholder: t.searchPlaceholder, "aria-label": t.searchFields, value: fieldQuery, onChange: (e) => setFieldQuery(e.target.value), onKeyDown: (e) => {
363
+ if (e.key === "Escape") {
364
+ setSearchOpen(false);
365
+ setFieldQuery("");
366
+ }
367
+ } })) : ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordpage-collapse", "data-collapsed": detailsOpen ? undefined : "", "aria-expanded": detailsOpen, onClick: () => setDetailsOpen((o) => !o), children: [t.recordDetails, (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "crisp-recordpage-chev", size: 12, strokeWidth: 2.5 })] })), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-detailsearch", "data-active": searchOpen ? "" : undefined, "aria-label": searchOpen ? t.closeSearch : t.searchFields, "aria-pressed": searchOpen, onClick: () => {
368
+ setSearchOpen((s) => !s);
369
+ setFieldQuery("");
370
+ if (!searchOpen)
371
+ setDetailsOpen(true);
372
+ }, children: searchOpen ? ((0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 14, strokeWidth: 1.75 })) : ((0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 14, strokeWidth: 1.75 })) })] }), detailsOpen &&
373
+ (totalShown > 0 ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [groups.map((g, i) => ((0, jsx_runtime_1.jsxs)(React.Fragment, { children: [sections && g.title != null && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpage-section-title crisp-recordpage-fieldsection-title", children: g.title })), g.fields.map((col) => {
374
+ // #431: a field only actually opens when there's an
375
+ // `onEdit` to commit through -- `editable` with nothing
376
+ // to call would be a dead click.
377
+ const canEdit = Boolean(col.editable && onEdit);
378
+ const isPending = pendingKey === col.key;
379
+ const fieldError = error?.key === col.key ? error.message : null;
380
+ const label = ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-field-label", children: [col.icon ? ((0, jsx_runtime_1.jsx)(col.icon, { size: 15, strokeWidth: 1.75 })) : null, (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-field-label-text", children: col.label })] }));
381
+ // `switch` is always its own live widget -- no
382
+ // click-to-open, no separate edit/view mode: flipping the
383
+ // toggle IS the commit gesture, and there's no
384
+ // meaningfully different "rest" display for a boolean
385
+ // (same exception `DataTable`'s own `editor` makes, #453).
386
+ if (canEdit && col.editor === "switch") {
387
+ const value = record[col.key];
388
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-field", children: [label, (0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-field-value", children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-edit-picker", children: [(0, jsx_runtime_1.jsx)(switch_1.Switch, { "aria-label": `Edit ${col.label}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commit(record.id, col.key, String(checked)) }), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-recordpage-edit-pending" }))] }), fieldError && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
389
+ }
390
+ // `select` opens crisp's own `Select` on a SINGLE click
391
+ // (#457, corrects #431/#385's original always-live copy
392
+ // of the same regression #453 fixed on `DataTable`) --
393
+ // like every other editable type here, at rest it shows
394
+ // its value (see `FieldValue`'s own `col.editor ===
395
+ // "select"` case) via the generic click-to-open branch
396
+ // below, and only mounts `Select` once `editingKey`
397
+ // matches. `DataTable`'s own double-click gate doesn't
398
+ // apply: `RecordPage`'s plain text/date fields already
399
+ // open on a single click (measured live for #431), so
400
+ // `select` matches that, not `DataTable`.
401
+ if (canEdit &&
402
+ editingKey === col.key &&
403
+ col.editor === "select") {
404
+ const value = record[col.key];
405
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-field", children: [label, (0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-field-value", children: [(0, jsx_runtime_1.jsx)(RecordPageSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commit(record.id, col.key, v), onCancel: closeEditing }), fieldError && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
406
+ }
407
+ if (canEdit && editingKey === col.key) {
408
+ const value = record[col.key];
409
+ const editor = (0, recordstable_1.editableInputType)(col.type);
410
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-field", children: [label, (0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-field-value", children: [(0, jsx_runtime_1.jsx)("input", { className: "crisp-recordpage-edit",
411
+ // biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
412
+ autoFocus: true, ...editor, disabled: isPending, defaultValue: value == null ? "" : String(value), "aria-label": `Edit ${col.label}`, onKeyDown: (e) => {
413
+ if (isPending)
414
+ return;
415
+ if (e.key === "Enter")
416
+ commit(record.id, col.key, e.currentTarget.value);
417
+ else if (e.key === "Escape")
418
+ closeEditing();
419
+ }, onBlur: (e) => {
420
+ if (isPending)
421
+ return;
422
+ commit(record.id, col.key, e.currentTarget.value);
423
+ } }), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-recordpage-edit-pending" })), fieldError && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
424
+ }
425
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-field", children: [label, (0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-field-value", "data-editable": canEdit ? "" : undefined, children: [canEdit ? ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-edit-trigger", onClick: () => openEditing(col.key), children: (0, jsx_runtime_1.jsx)(FieldValue, { col: col, row: record, labels: t }) })) : ((0, jsx_runtime_1.jsx)(FieldValue, { col: col, row: record, labels: t })), fieldError && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
426
+ })] }, g.title != null ? String(g.title) : `section-${i}`))), canRevealFields && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-viewall crisp-recordpage-viewall--section", onClick: () => {
427
+ // Revealing all fields also opens the (focused) field
428
+ // search; collapsing closes it.
429
+ const next = !showAllValues;
430
+ setShowAllValues(next);
431
+ setSearchOpen(next);
432
+ if (!next)
433
+ setFieldQuery("");
434
+ }, children: showAllValues ? t.viewLess : t.viewAllValues }))] })) : ((0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpage-noresults", children: t.noFieldsMatch(fieldQuery) })))] }));
435
+ }
436
+ RecordDetails.displayName = "RecordDetails";
250
437
  /**
251
438
  * The full-page company record view — the page that opens when you click into a
252
439
  * record (as opposed to the peek [RecordPanel]). A top breadcrumb bar with
@@ -255,36 +442,8 @@ function EmptyCard({ icon, children, }) {
255
442
  * Emails sections (Notes & Tasks are live — add and check things off). Drop it
256
443
  * into the app's main content area.
257
444
  */
258
- function RecordPage({ record, columns, collection = "Records", labels, onBack, onToggleFull, full, className, onEdit, tabs: tabsProp, defaultTab, notes: notesProp, onAddNote, tasks: tasksProp, onAddTask, onToggleTask, ...rest }) {
259
- const { editingKey, pendingKey, error, openEditing, closeEditing, commit } = (0, recordstable_1.useEditableField)(onEdit);
260
- // crisp/#471: a CrispMessagesProvider's `componentDefaults.recordPage` sits
261
- // BETWEEN the built-in English defaults and this instance's own explicit
262
- // `labels` prop — a provider translates every RecordPage on the page at
263
- // once, and a caller who still passes `labels` for one instance keeps
264
- // overriding it there, same as before this existed. Not `useCrispMessage`:
265
- // several of these entries (`domainSetTo`, `sortedBy`, …) are formatter
266
- // FUNCTIONS, which a flat string dictionary can't hold.
267
- const providerLabels = (0, core_1.useCrispComponentDefaults)("recordPage");
268
- const t = React.useMemo(() => ({
269
- ...exports.recordPageLabels,
270
- ...providerLabels,
271
- ...labels,
272
- tabs: {
273
- ...exports.recordPageLabels.tabs,
274
- ...providerLabels?.tabs,
275
- ...labels?.tabs,
276
- },
277
- highlightLabels: {
278
- ...exports.recordPageLabels.highlightLabels,
279
- ...providerLabels?.highlightLabels,
280
- ...labels?.highlightLabels,
281
- },
282
- highlightEmpty: {
283
- ...exports.recordPageLabels.highlightEmpty,
284
- ...providerLabels?.highlightEmpty,
285
- ...labels?.highlightEmpty,
286
- },
287
- }), [labels, providerLabels]);
445
+ function RecordPage({ record, columns, collection = "Records", labels, onBack, onToggleFull, full, className, onEdit, tabs: tabsProp, defaultTab, notes: notesProp, onAddNote, tasks: tasksProp, onAddTask, onToggleTask, sections, highlights: highlightsProp, ...rest }) {
446
+ const t = useRecordPageLabels(labels);
288
447
  const [favorite, setFavorite] = React.useState(false);
289
448
  // Notes/Tasks are controlled when the caller supplies `notes`/`tasks` (even
290
449
  // `[]`) -- otherwise RecordPage keeps its own self-contained state, as
@@ -296,11 +455,6 @@ function RecordPage({ record, columns, collection = "Records", labels, onBack, o
296
455
  const notes = notesProp ?? internalNotes;
297
456
  const tasks = tasksProp ?? internalTasks;
298
457
  const [showAllActivity, setShowAllActivity] = React.useState(false);
299
- // Collapsible + searchable Record Details.
300
- const [detailsOpen, setDetailsOpen] = React.useState(true);
301
- const [searchOpen, setSearchOpen] = React.useState(false);
302
- const [fieldQuery, setFieldQuery] = React.useState("");
303
- const [showAllValues, setShowAllValues] = React.useState(false);
304
458
  // Drag-resizable left details column.
305
459
  const [detailsW, setDetailsW] = React.useState(320);
306
460
  const [resizing, setResizing] = React.useState(false);
@@ -337,17 +491,6 @@ function RecordPage({ record, columns, collection = "Records", labels, onBack, o
337
491
  ? record[entityCol.key]
338
492
  : undefined;
339
493
  const name = entity?.name ?? "Record";
340
- const fields = columns.filter((col) => col.type !== "entity");
341
- const q = fieldQuery.trim().toLowerCase();
342
- const matchedFields = q
343
- ? fields.filter((col) => col.label.toLowerCase().includes(q))
344
- : fields;
345
- // Shows the first 5 fields; "View all values" reveals the rest.
346
- const FIELD_LIMIT = 5;
347
- const canRevealFields = !q && matchedFields.length > FIELD_LIMIT;
348
- const shownFields = canRevealFields && !showAllValues
349
- ? matchedFields.slice(0, FIELD_LIMIT)
350
- : matchedFields;
351
494
  const byKey = React.useMemo(() => new Map(columns.map((col) => [col.key, col])), [columns]);
352
495
  // The Overview "Highlights" — a fixed set of enrichment stats (measured
353
496
  // from the live app), a couple derived from the record, the rest empty.
@@ -365,7 +508,11 @@ function RecordPage({ record, columns, collection = "Records", labels, onBack, o
365
508
  : n >= 1000
366
509
  ? "1K–10K"
367
510
  : "<1K";
368
- const highlights = [
511
+ // #528: the built-in CRM Highlights set, used when the caller omits
512
+ // `highlights` entirely -- a caller passing `highlights` (an array, or
513
+ // `false` to omit the section) replaces this wholesale, the same
514
+ // "caller's own list, not a merge" choice `tabs` already makes (#379).
515
+ const defaultHighlights = [
369
516
  {
370
517
  label: t.highlightLabels.connectionStrength,
371
518
  icon: lucide_react_1.Zap,
@@ -403,6 +550,7 @@ function RecordPage({ record, columns, collection = "Records", labels, onBack, o
403
550
  empty: t.highlightEmpty.employeeRange,
404
551
  },
405
552
  ];
553
+ const highlights = highlightsProp === undefined ? defaultHighlights : highlightsProp;
406
554
  const ti = { size: 15, strokeWidth: 1.75 };
407
555
  const builtinTabs = [
408
556
  { value: "overview", label: t.tabs.overview, icon: (0, jsx_runtime_1.jsx)(lucide_react_1.LayoutGrid, { ...ti }) },
@@ -485,7 +633,7 @@ function RecordPage({ record, columns, collection = "Records", labels, onBack, o
485
633
  const builtinPanel = (active) => {
486
634
  switch (active) {
487
635
  case "overview":
488
- return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-panel", children: [(0, jsx_runtime_1.jsxs)("section", { children: [(0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpage-section-title", children: t.highlights }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpage-hl-card", children: (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpage-hl-grid", children: highlights.map((h) => ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-hl-cell", children: [(0, jsx_runtime_1.jsx)(h.icon, { className: "crisp-recordpage-hl-icon", size: 15, strokeWidth: 1.75 }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpage-hl-label", children: h.label }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpage-hl-value", children: h.value ?? ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-hl-value--empty", children: h.empty })) })] }, h.label))) }) })] }), activityFeed, (0, jsx_runtime_1.jsxs)("section", { children: [(0, jsx_runtime_1.jsx)(SectionHead, { title: t.tabs.emails, count: 0 }), emailsEmpty] }), (0, jsx_runtime_1.jsx)(NotesSection, { notes: notes, onAdd: addNote, labels: t }), (0, jsx_runtime_1.jsx)(TasksSection, { tasks: tasks, onAdd: addTask, onToggle: toggleTask, labels: t })] }));
636
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-panel", children: [highlights !== false && ((0, jsx_runtime_1.jsxs)("section", { children: [(0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpage-section-title", children: t.highlights }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpage-hl-card", children: (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpage-hl-grid", children: highlights.map((h) => ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-hl-cell", children: [h.icon && ((0, jsx_runtime_1.jsx)(h.icon, { className: "crisp-recordpage-hl-icon", size: 15, strokeWidth: 1.75 })), (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpage-hl-label", children: h.label }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpage-hl-value", children: h.value ?? ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-hl-value--empty", children: h.empty })) })] }, h.label))) }) })] })), activityFeed, (0, jsx_runtime_1.jsxs)("section", { children: [(0, jsx_runtime_1.jsx)(SectionHead, { title: t.tabs.emails, count: 0 }), emailsEmpty] }), (0, jsx_runtime_1.jsx)(NotesSection, { notes: notes, onAdd: addNote, labels: t }), (0, jsx_runtime_1.jsx)(TasksSection, { tasks: tasks, onAdd: addTask, onToggle: toggleTask, labels: t })] }));
489
637
  case "activity":
490
638
  return (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpage-panel", children: activityFeed });
491
639
  case "notes":
@@ -511,81 +659,7 @@ function RecordPage({ record, columns, collection = "Records", labels, onBack, o
511
659
  const effectivePanel = tabsProp
512
660
  ? (active) => tabsProp.find((tb) => tb.id === active)?.render()
513
661
  : builtinPanel;
514
- return ((0, jsx_runtime_1.jsxs)("div", { className: (0, recordpage_recipe_1.recordpageRecipe)({ className }), ...rest, children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-topbar", children: [(0, jsx_runtime_1.jsxs)("nav", { className: "crisp-recordpage-crumbs", "aria-label": t.breadcrumb, children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-crumb", onClick: onBack, "aria-label": `Back to ${collection}`, children: collection }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-crumb-sep", children: "/" }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-crumb-name", children: name })] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-actions", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--ghost", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Sparkles, { size: 14, strokeWidth: 1.75, color: "var(--crisp-bg-brand-solid)" }), t.askAttio] }), onToggleFull && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", onClick: onToggleFull, "aria-label": full ? t.exitFullScreen : t.fullScreen, children: full ? ((0, jsx_runtime_1.jsx)(lucide_react_1.Minimize2, { size: 15, strokeWidth: 1.75 })) : ((0, jsx_runtime_1.jsx)(lucide_react_1.Maximize2, { size: 15, strokeWidth: 1.75 })) })), (0, jsx_runtime_1.jsxs)(menu_1.Menu, { align: "end", "aria-label": t.recordActions, trigger: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", "aria-label": t.recordActions, children: (0, jsx_runtime_1.jsx)(lucide_react_1.MoreHorizontal, { size: 16, strokeWidth: 1.75 }) }), children: [(0, jsx_runtime_1.jsx)(menu_1.MenuItem, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.Copy, { size: 15, strokeWidth: 1.75 }), shortcut: "\u2318C", children: t.copyRecordLink }), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 15, strokeWidth: 1.75 }), children: t.addToList }), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.Star, { size: 15, strokeWidth: 1.75 }), onSelect: () => setFavorite((f) => !f), children: favorite ? t.removeFromFavourites : t.addToFavourites }), (0, jsx_runtime_1.jsx)(menu_1.MenuSeparator, {}), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, shortcut: "\u232B", children: t.deleteRecord })] })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-body", children: [(0, jsx_runtime_1.jsxs)("aside", { className: "crisp-recordpage-details", style: { width: detailsW }, children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-entity", children: [entity?.avatar ? ((0, jsx_runtime_1.jsx)("img", { className: "crisp-recordpage-entity-logo", src: entity.avatar, alt: "" })) : ((0, jsx_runtime_1.jsx)(avatar_1.Avatar, { size: "lg", shape: "rounded", tone: "brand", children: name.charAt(0) })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-entity-name", children: name }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "aria-label": t.editRecord, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Pencil, { size: 15, strokeWidth: 1.75 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "data-active": favorite ? "" : undefined, "aria-pressed": favorite, onClick: () => setFavorite((f) => !f), "aria-label": favorite ? t.removeFromFavourites : t.addToFavourites, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Star, { size: 15, strokeWidth: 1.75 }) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-quickactions", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordpage-compose", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Mail, { size: 16, strokeWidth: 1.75 }), t.composeEmail] }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.copyRecordLink, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Copy, { size: 15, strokeWidth: 1.75 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.logANote, children: (0, jsx_runtime_1.jsx)(lucide_react_1.FileText, { size: 15, strokeWidth: 1.75 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.linkRecords, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Share2, { size: 15, strokeWidth: 1.75 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.createATask, children: (0, jsx_runtime_1.jsx)(lucide_react_1.SquareCheck, { size: 15, strokeWidth: 1.75 }) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-section", children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-detailhead", children: [searchOpen ? ((0, jsx_runtime_1.jsx)("input", {
515
- // biome-ignore lint/a11y/noAutofocus: focusing the just-opened field search is expected
516
- autoFocus: true, type: "text", className: "crisp-recordpage-fieldsearch", placeholder: t.searchPlaceholder, "aria-label": t.searchFields, value: fieldQuery, onChange: (e) => setFieldQuery(e.target.value), onKeyDown: (e) => {
517
- if (e.key === "Escape") {
518
- setSearchOpen(false);
519
- setFieldQuery("");
520
- }
521
- } })) : ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordpage-collapse", "data-collapsed": detailsOpen ? undefined : "", "aria-expanded": detailsOpen, onClick: () => setDetailsOpen((o) => !o), children: [t.recordDetails, (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "crisp-recordpage-chev", size: 12, strokeWidth: 2.5 })] })), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-detailsearch", "data-active": searchOpen ? "" : undefined, "aria-label": searchOpen ? t.closeSearch : t.searchFields, "aria-pressed": searchOpen, onClick: () => {
522
- setSearchOpen((s) => !s);
523
- setFieldQuery("");
524
- if (!searchOpen)
525
- setDetailsOpen(true);
526
- }, children: searchOpen ? ((0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 14, strokeWidth: 1.75 })) : ((0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 14, strokeWidth: 1.75 })) })] }), detailsOpen &&
527
- (shownFields.length > 0 ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [shownFields.map((col) => {
528
- // #431: a field only actually opens when there's an
529
- // `onEdit` to commit through -- `editable` with nothing
530
- // to call would be a dead click.
531
- const canEdit = Boolean(col.editable && onEdit);
532
- const isPending = pendingKey === col.key;
533
- const fieldError = error?.key === col.key ? error.message : null;
534
- const label = ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-field-label", children: [col.icon ? ((0, jsx_runtime_1.jsx)(col.icon, { size: 15, strokeWidth: 1.75 })) : null, (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-field-label-text", children: col.label })] }));
535
- // `switch` is always its own live widget -- no
536
- // click-to-open, no separate edit/view mode: flipping the
537
- // toggle IS the commit gesture, and there's no
538
- // meaningfully different "rest" display for a boolean
539
- // (same exception `DataTable`'s own `editor` makes, #453).
540
- if (canEdit && col.editor === "switch") {
541
- const value = record[col.key];
542
- return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-field", children: [label, (0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-field-value", children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-edit-picker", children: [(0, jsx_runtime_1.jsx)(switch_1.Switch, { "aria-label": `Edit ${col.label}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commit(record.id, col.key, String(checked)) }), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-recordpage-edit-pending" }))] }), fieldError && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
543
- }
544
- // `select` opens crisp's own `Select` on a SINGLE click
545
- // (#457, corrects #431/#385's original always-live
546
- // copy of the same regression #453 fixed on `DataTable`)
547
- // -- like every other editable type here, at rest it
548
- // shows its value (see `FieldValue`'s own `col.editor
549
- // === "select"` case) via the generic click-to-open
550
- // branch below, and only mounts `Select` once
551
- // `editingKey` matches. `DataTable`'s own double-click
552
- // gate doesn't apply: `RecordPage`'s plain text/date
553
- // fields already open on a single click (measured live
554
- // for #431), so `select` matches that, not `DataTable`.
555
- if (canEdit &&
556
- editingKey === col.key &&
557
- col.editor === "select") {
558
- const value = record[col.key];
559
- return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-field", children: [label, (0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-field-value", children: [(0, jsx_runtime_1.jsx)(RecordPageSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commit(record.id, col.key, v), onCancel: closeEditing }), fieldError && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
560
- }
561
- if (canEdit && editingKey === col.key) {
562
- const value = record[col.key];
563
- const editor = (0, recordstable_1.editableInputType)(col.type);
564
- return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-field", children: [label, (0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-field-value", children: [(0, jsx_runtime_1.jsx)("input", { className: "crisp-recordpage-edit",
565
- // biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
566
- autoFocus: true, ...editor, disabled: isPending, defaultValue: value == null ? "" : String(value), "aria-label": `Edit ${col.label}`, onKeyDown: (e) => {
567
- if (isPending)
568
- return;
569
- if (e.key === "Enter")
570
- commit(record.id, col.key, e.currentTarget.value);
571
- else if (e.key === "Escape")
572
- closeEditing();
573
- }, onBlur: (e) => {
574
- if (isPending)
575
- return;
576
- commit(record.id, col.key, e.currentTarget.value);
577
- } }), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-recordpage-edit-pending" })), fieldError && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
578
- }
579
- return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-field", children: [label, (0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-field-value", "data-editable": canEdit ? "" : undefined, children: [canEdit ? ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-edit-trigger", onClick: () => openEditing(col.key), children: (0, jsx_runtime_1.jsx)(FieldValue, { col: col, row: record, labels: t }) })) : ((0, jsx_runtime_1.jsx)(FieldValue, { col: col, row: record, labels: t })), fieldError && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
580
- }), canRevealFields && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-viewall crisp-recordpage-viewall--section", onClick: () => {
581
- // Revealing all fields also opens the (focused)
582
- // field search; collapsing closes it.
583
- const next = !showAllValues;
584
- setShowAllValues(next);
585
- setSearchOpen(next);
586
- if (!next)
587
- setFieldQuery("");
588
- }, children: showAllValues ? t.viewLess : t.viewAllValues }))] })) : ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-noresults", children: ["No fields match \u201C", fieldQuery, "\u201D."] })))] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-section", children: [(0, jsx_runtime_1.jsx)(SectionHead, { title: t.tabs.lists, action: (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordpage-addbtn", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 14, strokeWidth: 2 }), t.addToList] }) }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpage-emptystate", children: t.noLists })] })] }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpage-resizer", "data-active": resizing ? "" : undefined, role: "separator", "aria-orientation": "vertical", "aria-label": t.resizeDetails, "aria-valuenow": Math.round(detailsW), "aria-valuemin": 300, "aria-valuemax": 560, tabIndex: 0, onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onKeyDown: (e) => {
662
+ return ((0, jsx_runtime_1.jsxs)("div", { className: (0, recordpage_recipe_1.recordpageRecipe)({ className }), ...rest, children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-topbar", children: [(0, jsx_runtime_1.jsxs)("nav", { className: "crisp-recordpage-crumbs", "aria-label": t.breadcrumb, children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-crumb", onClick: onBack, "aria-label": `Back to ${collection}`, children: collection }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-crumb-sep", children: "/" }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-crumb-name", children: name })] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-actions", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--ghost", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Sparkles, { size: 14, strokeWidth: 1.75, color: "var(--crisp-bg-brand-solid)" }), t.askAttio] }), onToggleFull && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", onClick: onToggleFull, "aria-label": full ? t.exitFullScreen : t.fullScreen, children: full ? ((0, jsx_runtime_1.jsx)(lucide_react_1.Minimize2, { size: 15, strokeWidth: 1.75 })) : ((0, jsx_runtime_1.jsx)(lucide_react_1.Maximize2, { size: 15, strokeWidth: 1.75 })) })), (0, jsx_runtime_1.jsxs)(menu_1.Menu, { align: "end", "aria-label": t.recordActions, trigger: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", "aria-label": t.recordActions, children: (0, jsx_runtime_1.jsx)(lucide_react_1.MoreHorizontal, { size: 16, strokeWidth: 1.75 }) }), children: [(0, jsx_runtime_1.jsx)(menu_1.MenuItem, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.Copy, { size: 15, strokeWidth: 1.75 }), shortcut: "\u2318C", children: t.copyRecordLink }), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 15, strokeWidth: 1.75 }), children: t.addToList }), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.Star, { size: 15, strokeWidth: 1.75 }), onSelect: () => setFavorite((f) => !f), children: favorite ? t.removeFromFavourites : t.addToFavourites }), (0, jsx_runtime_1.jsx)(menu_1.MenuSeparator, {}), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, shortcut: "\u232B", children: t.deleteRecord })] })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-body", children: [(0, jsx_runtime_1.jsxs)("aside", { className: "crisp-recordpage-details", style: { width: detailsW }, children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-entity", children: [entity?.avatar ? ((0, jsx_runtime_1.jsx)("img", { className: "crisp-recordpage-entity-logo", src: entity.avatar, alt: "" })) : ((0, jsx_runtime_1.jsx)(avatar_1.Avatar, { size: "lg", shape: "rounded", tone: "brand", children: name.charAt(0) })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-entity-name", children: name }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "aria-label": t.editRecord, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Pencil, { size: 15, strokeWidth: 1.75 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "data-active": favorite ? "" : undefined, "aria-pressed": favorite, onClick: () => setFavorite((f) => !f), "aria-label": favorite ? t.removeFromFavourites : t.addToFavourites, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Star, { size: 15, strokeWidth: 1.75 }) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-quickactions", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordpage-compose", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Mail, { size: 16, strokeWidth: 1.75 }), t.composeEmail] }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.copyRecordLink, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Copy, { size: 15, strokeWidth: 1.75 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.logANote, children: (0, jsx_runtime_1.jsx)(lucide_react_1.FileText, { size: 15, strokeWidth: 1.75 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.linkRecords, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Share2, { size: 15, strokeWidth: 1.75 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.createATask, children: (0, jsx_runtime_1.jsx)(lucide_react_1.SquareCheck, { size: 15, strokeWidth: 1.75 }) })] }), (0, jsx_runtime_1.jsx)(RecordDetails, { record: record, columns: columns, sections: sections, onEdit: onEdit, labels: labels }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-section", children: [(0, jsx_runtime_1.jsx)(SectionHead, { title: t.tabs.lists, action: (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordpage-addbtn", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 14, strokeWidth: 2 }), t.addToList] }) }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpage-emptystate", children: t.noLists })] })] }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpage-resizer", "data-active": resizing ? "" : undefined, role: "separator", "aria-orientation": "vertical", "aria-label": t.resizeDetails, "aria-valuenow": Math.round(detailsW), "aria-valuemin": 300, "aria-valuemax": 560, tabIndex: 0, onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onKeyDown: (e) => {
589
663
  if (e.key !== "ArrowLeft" && e.key !== "ArrowRight")
590
664
  return;
591
665
  // The pointer drag learned its direction in #212; this did not.
@@ -66,6 +66,11 @@ const config = (0, core_1.defineRecipe)({
66
66
  & .crisp-recordpage-detailsearch { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: ${tokens_1.contract.size.chip}; flex-shrink: 0; padding: 0; margin: 0; border: none; border-radius: ${tokens_1.contract.radius.sm}; background: none; color: ${tokens_1.contract.fgMuted}; font-family: inherit; cursor: pointer; transition: background-color .12s, color .12s; }
67
67
  & .crisp-recordpage-detailsearch:hover, & .crisp-recordpage-detailsearch[data-active] { background: ${tokens_1.contract.bgRaised}; color: ${tokens_1.contract.fgPrimary}; }
68
68
  & .crisp-recordpage-noresults { padding: ${tokens_1.contract.space["1.5"]} ${tokens_1.contract.space["1"]}; font-size: ${tokens_1.contract.text.md}; color: ${tokens_1.contract.fgMuted}; }
69
+ /* A caller-supplied \`sections\` grouping (#528) is not an Attio-measured
70
+ shape (its own Details column is flat) -- this heading reuses
71
+ .crisp-recordpage-section-title's own type treatment verbatim, just
72
+ spaced from the group above it. */
73
+ & .crisp-recordpage-fieldsection-title { margin-top: ${tokens_1.contract.space["3"]}; }
69
74
  & .crisp-recordpage-morevalues { display: inline-flex; align-items: center; padding: 0; margin: 0; border: none; background: none; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; color: ${tokens_1.contract.fgMuted}; cursor: pointer; }
70
75
  & .crisp-recordpage-morevalues:hover { color: ${tokens_1.contract.fgPrimary}; }
71
76
  & .crisp-recordpage-viewall { flex-basis: 100%; margin-top: ${tokens_1.contract.space["0.5"]}; border: none; background: none; padding: 0; color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 55%, transparent); font-family: inherit; font-size: ${tokens_1.contract.text.sm}; font-weight: ${tokens_1.contract.weight.regular}; cursor: pointer; text-align: start; }
@@ -149,7 +149,7 @@ const exec = (cmd, value) => document.execCommand(cmd, false, value);
149
149
  * reach the identical menu, sitting at the end of the body's last line) —
150
150
  * only the two entry points #426 asked for.
151
151
  */
152
- exports.RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlaceholder = "Untitled", placeholder = "Start writing, or press a toolbar button…", onChange, className, titleLabel = "Title", bodyLabel = "Note body", linkPromptLabel = "Link URL", imagePromptLabel = "Image URL", textColorLabel = "Text", highlightColorLabel = "Highlight", clearColorLabel = "Default", blockMenuLabel = "Body", blockMenuSearchPlaceholder = "Search items…", blockMenuEmptyLabel = "No matching blocks", mentionTrigger = "@", renderMentionPicker, readOnly = false, ...rest }, ref) {
152
+ exports.RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlaceholder = "Untitled", placeholder = "Start writing, or press a toolbar button…", onChange, className, titleLabel = "Title", bodyLabel = "Note body", linkPromptLabel = "Link URL", imagePromptLabel = "Image URL", textColorLabel = "Text", highlightColorLabel = "Highlight", clearColorLabel = "Default", blockMenuLabel = "Body", blockMenuSearchPlaceholder = "Search items…", blockMenuEmptyLabel = "No matching blocks", mentionTrigger = "@", renderMentionPicker, readOnly = false, showTitle = true, ...rest }, ref) {
153
153
  const titleRef = React.useRef(null);
154
154
  const bodyRef = React.useRef(null);
155
155
  const [bar, setBar] = React.useState(null);
@@ -550,12 +550,12 @@ exports.RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle
550
550
  ol: (0, jsx_runtime_1.jsx)(lucide_react_1.ListOrdered, { ...menuIc }),
551
551
  image: (0, jsx_runtime_1.jsx)(lucide_react_1.Image, { ...menuIc }),
552
552
  };
553
- return ((0, jsx_runtime_1.jsxs)("div", { className: (0, richtext_recipe_1.richtextRecipe)({ className }), ...rest, children: [(0, jsx_runtime_1.jsx)("div", { ref: titleRef, className: "crisp-rte-title", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-label": titleLabel, "aria-readonly": readOnly || undefined, "data-placeholder": titlePlaceholder, onInput: readOnly
553
+ return ((0, jsx_runtime_1.jsxs)("div", { className: (0, richtext_recipe_1.richtextRecipe)({ className }), ...rest, children: [showTitle && ((0, jsx_runtime_1.jsx)("div", { ref: titleRef, className: "crisp-rte-title", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-label": titleLabel, "aria-readonly": readOnly || undefined, "data-placeholder": titlePlaceholder, onInput: readOnly
554
554
  ? undefined
555
555
  : () => {
556
556
  syncEmpty(titleRef.current);
557
557
  emit();
558
- } }), (0, jsx_runtime_1.jsx)("div", { ref: bodyRef, className: "crisp-rte-body", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-multiline": "true", "aria-label": bodyLabel, "aria-readonly": readOnly || undefined, "data-placeholder": placeholder, onInput: readOnly
558
+ } })), (0, jsx_runtime_1.jsx)("div", { ref: bodyRef, className: "crisp-rte-body", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-multiline": "true", "aria-label": bodyLabel, "aria-readonly": readOnly || undefined, "data-placeholder": placeholder, onInput: readOnly
559
559
  ? undefined
560
560
  : () => {
561
561
  syncEmpty(bodyRef.current);
@@ -6,13 +6,22 @@ const tokens_1 = require("../../tokens");
6
6
  // A settings section — measured from Settings pages: a 16px/600 title
7
7
  // with an optional subtitle, then a stack of rows (form fields, toggles…). Rows
8
8
  // gap evenly; an optional trailing action sits on the title row.
9
+ //
10
+ // Title line-height/letter-spacing and subtitle font-size/line-height
11
+ // re-measured live at app.attio.com/<workspace>/settings/account against a
12
+ // subsection heading ("Time preferences") — crisp#522. The subtitle's own
13
+ // colour (measured `rgba(0,0,0,0.63)`, a black-alpha value) is left as
14
+ // `fgMuted` pending a dark-mode measurement: a raw alpha-black has no
15
+ // existing token match and would need its own light/dark pair to stay
16
+ // legible in dark mode (Lesson: theme-constant vs theme-flipping colours
17
+ // must each be independently verified).
9
18
  const config = (0, core_1.defineRecipe)({
10
19
  name: "settingssection",
11
20
  base: `display: flex; flex-direction: column; box-sizing: border-box; font-family: ${tokens_1.contract.font.sans};
12
21
  & .crisp-settingssection-head { display: flex; align-items: flex-start; gap: ${tokens_1.contract.space["2"]}; }
13
22
  & .crisp-settingssection-heading { flex: 1; min-width: 0; }
14
- & .crisp-settingssection-title { font-size: ${tokens_1.contract.text.xl}; line-height: 24px; font-weight: ${tokens_1.contract.weight.semibold}; letter-spacing: -0.2px; color: ${tokens_1.contract.fgPrimary}; }
15
- & .crisp-settingssection-sub { margin-top: ${tokens_1.contract.space["0.5"]}; font-size: ${tokens_1.contract.text.md}; line-height: 18px; color: ${tokens_1.contract.fgMuted}; }
23
+ & .crisp-settingssection-title { font-size: ${tokens_1.contract.text.xl}; line-height: 20px; font-weight: ${tokens_1.contract.weight.semibold}; letter-spacing: -0.16px; color: ${tokens_1.contract.fgPrimary}; }
24
+ & .crisp-settingssection-sub { margin-top: ${tokens_1.contract.space["0.5"]}; font-size: ${tokens_1.contract.text.lg}; line-height: 20px; color: ${tokens_1.contract.fgMuted}; }
16
25
  & .crisp-settingssection-action { flex-shrink: 0; }
17
26
  & .crisp-settingssection-body { display: flex; flex-direction: column; gap: ${tokens_1.contract.space["4"]}; margin-top: ${tokens_1.contract.space["3"]}; }`,
18
27
  variants: {},
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TaskList = TaskList;
4
4
  const jsx_runtime_1 = require("react/jsx-runtime");
5
+ const core_1 = require("../../core");
5
6
  const tasklist_recipe_1 = require("./tasklist.recipe");
6
7
  function Row({ task, onToggle, completeLabel, incompleteLabel, }) {
7
8
  return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-tasklist-row", "data-done": task.done ? "" : undefined, children: [(0, jsx_runtime_1.jsx)("input", { type: "checkbox", className: "crisp-tasklist-check", checked: !!task.done, "aria-label": task.done ? incompleteLabel : completeLabel, onChange: () => onToggle?.(task.id, !task.done) }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-tasklist-title", children: task.title }), (task.due != null || task.meta != null) && ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-tasklist-meta", children: [task.due != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tasklist-due", "data-overdue": task.overdue ? "" : undefined, children: task.due })), task.meta] }))] }));
@@ -11,9 +12,15 @@ function Row({ task, onToggle, completeLabel, incompleteLabel, }) {
11
12
  * count, then rows of a round complete-checkbox + title + trailing meta.
12
13
  * Completed rows strike through; overdue dates go red.
13
14
  */
14
- function TaskList({ tasks, groups, onToggle, emptyLabel = "No tasks", className, completeLabel = "Mark complete", incompleteLabel = "Mark incomplete", ...rest }) {
15
+ function TaskList({ tasks, groups, onToggle, emptyLabel: emptyLabelProp, className, completeLabel: completeLabelProp, incompleteLabel: incompleteLabelProp, ...rest }) {
15
16
  const resolved = groups ?? (tasks?.length ? [{ label: null, tasks }] : []);
16
17
  const total = resolved.reduce((s, g) => s + g.tasks.length, 0);
18
+ // crisp/#471: `emptyLabel` is typed `React.ReactNode`, so it resolves by
19
+ // hand rather than through `useCrispMessage`, which is string-only.
20
+ const crispMessages = (0, core_1.useCrispMessages)();
21
+ const emptyLabel = emptyLabelProp ?? crispMessages["No tasks"] ?? "No tasks";
22
+ const completeLabel = (0, core_1.useCrispMessage)(completeLabelProp, "Mark complete");
23
+ const incompleteLabel = (0, core_1.useCrispMessage)(incompleteLabelProp, "Mark incomplete");
17
24
  return ((0, jsx_runtime_1.jsx)("div", { className: (0, tasklist_recipe_1.tasklistRecipe)({ className }), ...rest, children: total === 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "crisp-tasklist-empty", children: emptyLabel })) : (resolved.map((g, gi) => ((0, jsx_runtime_1.jsxs)("div", { children: [g.label != null && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-tasklist-group", children: [g.label, (0, jsx_runtime_1.jsx)("span", { className: "crisp-tasklist-group-count", children: g.tasks.length })] })), g.tasks.map((t) => ((0, jsx_runtime_1.jsx)(Row, { task: t, onToggle: onToggle, completeLabel: completeLabel, incompleteLabel: incompleteLabel }, t.id)))] }, gi)))) }));
18
25
  }
19
26
  TaskList.displayName = "TaskList";
@@ -1,13 +1,17 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { X } from "lucide-react";
3
3
  import * as React from "react";
4
+ import { useCrispMessage } from "../../core/index.js";
4
5
  import { bulkbarRecipe } from "./bulkbar.recipe.js";
5
6
  /**
6
7
  * A floating bulk-action bar. Rises from the bottom-centre of its nearest
7
8
  * positioned ancestor when a selection is active; render it conditionally
8
9
  * (e.g. `count > 0 && <BulkBar …/>`). Measured from a records table.
9
10
  */
10
- export function BulkBar({ count, onClear, countLabel = "Selected", countText = (count, label) => (_jsxs(_Fragment, { children: [count, " ", label] })), children, className, clearLabel = "Clear selection", ...rest }) {
11
+ export function BulkBar({ count, onClear, countLabel: countLabelProp, countText = (count, label) => (_jsxs(_Fragment, { children: [count, " ", label] })), children, className, clearLabel: clearLabelProp, ...rest }) {
12
+ // crisp/#471: same resolution order as every other component's own.
13
+ const countLabel = useCrispMessage(countLabelProp, "Selected");
14
+ const clearLabel = useCrispMessage(clearLabelProp, "Clear selection");
11
15
  return (_jsx("div", { className: bulkbarRecipe({ className }), ...rest, children: _jsxs("div", { className: "crisp-bulkbar-bar", children: [count != null && (_jsxs("button", { type: "button", className: "crisp-bulkbar-count", onClick: onClear, title: clearLabel, children: [countText(count, countLabel), _jsx("span", { className: "crisp-bulkbar-clearlabel", children: clearLabel }), _jsx(X, { size: 13, strokeWidth: 2, "aria-hidden": "true" })] })), children] }) }));
12
16
  }
13
17
  BulkBar.displayName = "BulkBar";
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useFileUpload, } from "@seed-design/react-file-upload";
4
4
  import { X } from "lucide-react";
5
5
  import * as React from "react";
6
- import { useFocusAfterRemoval } from "../../core/index.js";
6
+ import { useCrispMessage, useCrispMessages, useFocusAfterRemoval, } from "../../core/index.js";
7
7
  import { fileuploadRecipe } from "./fileupload.recipe.js";
8
8
  export const fileUploadLabels = {
9
9
  // Matches the copy on the record Files pane.
@@ -56,10 +56,26 @@ export function FileUpload({ className, labels, "aria-label": ariaLabel, id, acc
56
56
  onAcceptedFileEntriesChange,
57
57
  };
58
58
  const [rejections, setRejections] = React.useState([]);
59
+ // crisp/#471: same resolution order as every other component's own —
60
+ // explicit `labels` entry wins, then a CrispMessagesProvider's
61
+ // translation of the built-in English default, then that default itself.
62
+ const title = useCrispMessage(labels?.title, fileUploadLabels.title);
63
+ const hint = useCrispMessage(labels?.hint, fileUploadLabels.hint);
64
+ const remove = useCrispMessage(labels?.remove, fileUploadLabels.remove);
65
+ const crispMessages = useCrispMessages();
59
66
  const t = {
60
- ...fileUploadLabels,
61
- ...labels,
62
- errors: { ...fileUploadLabels.errors, ...labels?.errors },
67
+ title,
68
+ hint,
69
+ remove,
70
+ errors: Object.fromEntries(Object.keys(fileUploadLabels.errors).map((code) => {
71
+ const englishDefault = fileUploadLabels.errors[code];
72
+ return [
73
+ code,
74
+ labels?.errors?.[code] ??
75
+ crispMessages[englishDefault] ??
76
+ englishDefault,
77
+ ];
78
+ })),
63
79
  };
64
80
  const api = useFileUpload({
65
81
  ...props,