@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
@@ -8,7 +8,7 @@ import { Select } from "../../components/select/index.js";
8
8
  import { Spinner } from "../../components/spinner/index.js";
9
9
  import { Switch } from "../../components/switch/index.js";
10
10
  import { Tabs } from "../../components/tabs/index.js";
11
- import { isRtlElement, useCrispComponentDefaults } from "../../core/index.js";
11
+ import { cx, isRtlElement, useCrispComponentDefaults } from "../../core/index.js";
12
12
  import { editableInputType, tagTone, useEditableField, } from "../recordstable/index.js";
13
13
  import { recordpageRecipe } from "./recordpage.recipe.js";
14
14
  function valueEmpty(v) {
@@ -87,7 +87,42 @@ export const recordPageLabels = {
87
87
  taggedCategories: (count) => (_jsxs(_Fragment, { children: ["Tagged ", _jsx("b", { children: count }), " categories"] })),
88
88
  recordCreated: (name) => (_jsxs(_Fragment, { children: [_jsx("b", { children: name }), " record was created"] })),
89
89
  noEmailsWith: (name) => _jsxs(_Fragment, { children: ["No emails logged with ", name, " yet."] }),
90
+ noFieldsMatch: (query) => _jsxs(_Fragment, { children: ["No fields match \u201C", query, "\u201D."] }),
90
91
  };
92
+ /**
93
+ * crisp/#471: a `CrispMessagesProvider`'s `componentDefaults.recordPage` sits
94
+ * BETWEEN the built-in English defaults and an instance's own explicit
95
+ * `labels` prop -- a provider translates every `RecordPage`/`RecordDetails`
96
+ * on the page at once, and a caller who still passes `labels` for one
97
+ * instance keeps overriding it there. Not `useCrispMessage`: several of
98
+ * these entries (`domainSetTo`, `noFieldsMatch`, …) are formatter FUNCTIONS,
99
+ * which a flat string dictionary can't hold. Shared by `RecordPage` and
100
+ * `RecordDetails` (#528) -- both merge the same three layers the same way,
101
+ * so this is the one place that merge is written, not two copies of it.
102
+ */
103
+ function useRecordPageLabels(labels) {
104
+ const providerLabels = useCrispComponentDefaults("recordPage");
105
+ return React.useMemo(() => ({
106
+ ...recordPageLabels,
107
+ ...providerLabels,
108
+ ...labels,
109
+ tabs: {
110
+ ...recordPageLabels.tabs,
111
+ ...providerLabels?.tabs,
112
+ ...labels?.tabs,
113
+ },
114
+ highlightLabels: {
115
+ ...recordPageLabels.highlightLabels,
116
+ ...providerLabels?.highlightLabels,
117
+ ...labels?.highlightLabels,
118
+ },
119
+ highlightEmpty: {
120
+ ...recordPageLabels.highlightEmpty,
121
+ ...providerLabels?.highlightEmpty,
122
+ ...labels?.highlightEmpty,
123
+ },
124
+ }), [labels, providerLabels]);
125
+ }
91
126
  /** `Select` owns its own closed-by-default `open` state with no prop or
92
127
  * imperative way to start it open -- the only thing that opens it is a real
93
128
  * click on its trigger button. Mounting one on click (rather than rendering
@@ -143,6 +178,16 @@ function TagsValue({ values }) {
143
178
  }
144
179
  function FieldValue({ col, row, labels, }) {
145
180
  const v = row[col.key];
181
+ // `render` (#528) -- the same full-custom-content escape hatch
182
+ // `RecordsTable`'s own columns already have (recordstable.tsx's own doc:
183
+ // "an escape hatch for a display crisp can't express via type"), reused
184
+ // here so a value this component has no built-in shape for (a reference
185
+ // field's link to another record, say) doesn't need a new `type`/`editor`
186
+ // of its own. Checked before the empty check -- like `RecordsTable`'s own
187
+ // `render`, it owns the cell's content fully, including what "empty"
188
+ // looks like for that column.
189
+ if (col.render)
190
+ return _jsx(_Fragment, { children: col.render(v, row) });
146
191
  if (valueEmpty(v))
147
192
  return _jsx("span", { className: "crisp-recordpage-empty", children: labels.setAValue });
148
193
  // `editor: "select"` at rest (#457) -- checked ahead of `col.type` since
@@ -210,6 +255,147 @@ function TasksSection({ tasks, onAdd, onToggle, labels, }) {
210
255
  function EmptyCard({ icon, children, }) {
211
256
  return (_jsxs("div", { className: "crisp-recordpage-emptycard", children: [icon, children] }));
212
257
  }
258
+ /**
259
+ * The Details column's own field list, on its own (#528) — `RecordPage`'s
260
+ * "Record Details" collapsible + searchable field list, the one piece a
261
+ * consumer with a fixed-width rail of its own (no resize handle, no entity
262
+ * header, no CRM Lists panel — GRC's own persistent 320px column, grc#66)
263
+ * actually needs, rather than rebuilding this same click-to-edit/search/
264
+ * `sections` machinery by hand or pulling in the whole page's chrome for a
265
+ * rail that isn't one. `RecordPage` composes this internally for its own
266
+ * resizable aside (same component either way, not a second copy) — this is
267
+ * that same block, exactly, just not wrapped in `RecordPage`'s own topbar/
268
+ * tabs/resizer.
269
+ *
270
+ * Not wrapped in `recordpageRecipe` itself: doing so would duplicate the
271
+ * recipe's own `display:flex;height:100%` base rule as a nested child
272
+ * inside `RecordPage`'s aside (which already carries that class from
273
+ * `RecordPage`'s own root), breaking that layout. A standalone caller
274
+ * needs exactly one `.crisp-recordpage`-scoped ancestor for this
275
+ * component's classes to resolve — wrap it in one, the same way
276
+ * `RecordPage`'s own root does: `<div className={recordpageRecipe()}>`
277
+ * (also exported from this package).
278
+ */
279
+ export function RecordDetails({ record, columns, sections, onEdit, labels, className, ...rest }) {
280
+ const t = useRecordPageLabels(labels);
281
+ const { editingKey, pendingKey, error, openEditing, closeEditing, commit } = useEditableField(onEdit);
282
+ const [detailsOpen, setDetailsOpen] = React.useState(true);
283
+ const [searchOpen, setSearchOpen] = React.useState(false);
284
+ const [fieldQuery, setFieldQuery] = React.useState("");
285
+ const [showAllValues, setShowAllValues] = React.useState(false);
286
+ const fields = columns.filter((col) => col.type !== "entity");
287
+ const q = fieldQuery.trim().toLowerCase();
288
+ const matches = (col) => !q || col.label.toLowerCase().includes(q);
289
+ // #528: `sections` groups fields under headings, in the caller's own
290
+ // order, and always shows every match — no 5-field reveal (see
291
+ // `RecordPageSection`'s own doc). Omitted, this stays the flat,
292
+ // Attio-measured list with its 5-field "View all values" reveal,
293
+ // unchanged from before #528.
294
+ const FIELD_LIMIT = 5;
295
+ let canRevealFields = false;
296
+ let groups;
297
+ if (sections) {
298
+ const byKey = new Map(fields.map((col) => [col.key, col]));
299
+ groups = sections
300
+ .map((s) => ({
301
+ title: s.title,
302
+ fields: s.fields
303
+ .map((key) => byKey.get(key))
304
+ .filter((col) => col !== undefined)
305
+ .filter(matches),
306
+ }))
307
+ // A section left with no matching fields (a search that matches
308
+ // nothing in it, or a `fields` key naming no real column) doesn't
309
+ // render at all -- heading included, not an empty group under a
310
+ // heading with nothing below it.
311
+ .filter((g) => g.fields.length > 0);
312
+ }
313
+ else {
314
+ const matched = q ? fields.filter(matches) : fields;
315
+ canRevealFields = !q && matched.length > FIELD_LIMIT;
316
+ const shown = canRevealFields && !showAllValues
317
+ ? matched.slice(0, FIELD_LIMIT)
318
+ : matched;
319
+ groups = [{ title: undefined, fields: shown }];
320
+ }
321
+ const totalShown = groups.reduce((n, g) => n + g.fields.length, 0);
322
+ return (_jsxs("div", { className: cx("crisp-recordpage-section", className), ...rest, children: [_jsxs("div", { className: "crisp-recordpage-detailhead", children: [searchOpen ? (_jsx("input", {
323
+ // biome-ignore lint/a11y/noAutofocus: focusing the just-opened field search is expected
324
+ 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) => {
325
+ if (e.key === "Escape") {
326
+ setSearchOpen(false);
327
+ setFieldQuery("");
328
+ }
329
+ } })) : (_jsxs("button", { type: "button", className: "crisp-recordpage-collapse", "data-collapsed": detailsOpen ? undefined : "", "aria-expanded": detailsOpen, onClick: () => setDetailsOpen((o) => !o), children: [t.recordDetails, _jsx(ChevronDown, { className: "crisp-recordpage-chev", size: 12, strokeWidth: 2.5 })] })), _jsx("button", { type: "button", className: "crisp-recordpage-detailsearch", "data-active": searchOpen ? "" : undefined, "aria-label": searchOpen ? t.closeSearch : t.searchFields, "aria-pressed": searchOpen, onClick: () => {
330
+ setSearchOpen((s) => !s);
331
+ setFieldQuery("");
332
+ if (!searchOpen)
333
+ setDetailsOpen(true);
334
+ }, children: searchOpen ? (_jsx(X, { size: 14, strokeWidth: 1.75 })) : (_jsx(Search, { size: 14, strokeWidth: 1.75 })) })] }), detailsOpen &&
335
+ (totalShown > 0 ? (_jsxs(_Fragment, { children: [groups.map((g, i) => (_jsxs(React.Fragment, { children: [sections && g.title != null && (_jsx("div", { className: "crisp-recordpage-section-title crisp-recordpage-fieldsection-title", children: g.title })), g.fields.map((col) => {
336
+ // #431: a field only actually opens when there's an
337
+ // `onEdit` to commit through -- `editable` with nothing
338
+ // to call would be a dead click.
339
+ const canEdit = Boolean(col.editable && onEdit);
340
+ const isPending = pendingKey === col.key;
341
+ const fieldError = error?.key === col.key ? error.message : null;
342
+ const label = (_jsxs("span", { className: "crisp-recordpage-field-label", children: [col.icon ? (_jsx(col.icon, { size: 15, strokeWidth: 1.75 })) : null, _jsx("span", { className: "crisp-recordpage-field-label-text", children: col.label })] }));
343
+ // `switch` is always its own live widget -- no
344
+ // click-to-open, no separate edit/view mode: flipping the
345
+ // toggle IS the commit gesture, and there's no
346
+ // meaningfully different "rest" display for a boolean
347
+ // (same exception `DataTable`'s own `editor` makes, #453).
348
+ if (canEdit && col.editor === "switch") {
349
+ const value = record[col.key];
350
+ return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsxs("span", { className: "crisp-recordpage-edit-picker", children: [_jsx(Switch, { "aria-label": `Edit ${col.label}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commit(record.id, col.key, String(checked)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-recordpage-edit-pending" }))] }), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
351
+ }
352
+ // `select` opens crisp's own `Select` on a SINGLE click
353
+ // (#457, corrects #431/#385's original always-live copy
354
+ // of the same regression #453 fixed on `DataTable`) --
355
+ // like every other editable type here, at rest it shows
356
+ // its value (see `FieldValue`'s own `col.editor ===
357
+ // "select"` case) via the generic click-to-open branch
358
+ // below, and only mounts `Select` once `editingKey`
359
+ // matches. `DataTable`'s own double-click gate doesn't
360
+ // apply: `RecordPage`'s plain text/date fields already
361
+ // open on a single click (measured live for #431), so
362
+ // `select` matches that, not `DataTable`.
363
+ if (canEdit &&
364
+ editingKey === col.key &&
365
+ col.editor === "select") {
366
+ const value = record[col.key];
367
+ return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsx(RecordPageSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commit(record.id, col.key, v), onCancel: closeEditing }), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
368
+ }
369
+ if (canEdit && editingKey === col.key) {
370
+ const value = record[col.key];
371
+ const editor = editableInputType(col.type);
372
+ return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsx("input", { className: "crisp-recordpage-edit",
373
+ // biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
374
+ autoFocus: true, ...editor, disabled: isPending, defaultValue: value == null ? "" : String(value), "aria-label": `Edit ${col.label}`, onKeyDown: (e) => {
375
+ if (isPending)
376
+ return;
377
+ if (e.key === "Enter")
378
+ commit(record.id, col.key, e.currentTarget.value);
379
+ else if (e.key === "Escape")
380
+ closeEditing();
381
+ }, onBlur: (e) => {
382
+ if (isPending)
383
+ return;
384
+ commit(record.id, col.key, e.currentTarget.value);
385
+ } }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-recordpage-edit-pending" })), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
386
+ }
387
+ return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", "data-editable": canEdit ? "" : undefined, children: [canEdit ? (_jsx("button", { type: "button", className: "crisp-recordpage-edit-trigger", onClick: () => openEditing(col.key), children: _jsx(FieldValue, { col: col, row: record, labels: t }) })) : (_jsx(FieldValue, { col: col, row: record, labels: t })), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
388
+ })] }, g.title != null ? String(g.title) : `section-${i}`))), canRevealFields && (_jsx("button", { type: "button", className: "crisp-recordpage-viewall crisp-recordpage-viewall--section", onClick: () => {
389
+ // Revealing all fields also opens the (focused) field
390
+ // search; collapsing closes it.
391
+ const next = !showAllValues;
392
+ setShowAllValues(next);
393
+ setSearchOpen(next);
394
+ if (!next)
395
+ setFieldQuery("");
396
+ }, children: showAllValues ? t.viewLess : t.viewAllValues }))] })) : (_jsx("div", { className: "crisp-recordpage-noresults", children: t.noFieldsMatch(fieldQuery) })))] }));
397
+ }
398
+ RecordDetails.displayName = "RecordDetails";
213
399
  /**
214
400
  * The full-page company record view — the page that opens when you click into a
215
401
  * record (as opposed to the peek [RecordPanel]). A top breadcrumb bar with
@@ -218,36 +404,8 @@ function EmptyCard({ icon, children, }) {
218
404
  * Emails sections (Notes & Tasks are live — add and check things off). Drop it
219
405
  * into the app's main content area.
220
406
  */
221
- export function RecordPage({ record, columns, collection = "Records", labels, onBack, onToggleFull, full, className, onEdit, tabs: tabsProp, defaultTab, notes: notesProp, onAddNote, tasks: tasksProp, onAddTask, onToggleTask, ...rest }) {
222
- const { editingKey, pendingKey, error, openEditing, closeEditing, commit } = useEditableField(onEdit);
223
- // crisp/#471: a CrispMessagesProvider's `componentDefaults.recordPage` sits
224
- // BETWEEN the built-in English defaults and this instance's own explicit
225
- // `labels` prop — a provider translates every RecordPage on the page at
226
- // once, and a caller who still passes `labels` for one instance keeps
227
- // overriding it there, same as before this existed. Not `useCrispMessage`:
228
- // several of these entries (`domainSetTo`, `sortedBy`, …) are formatter
229
- // FUNCTIONS, which a flat string dictionary can't hold.
230
- const providerLabels = useCrispComponentDefaults("recordPage");
231
- const t = React.useMemo(() => ({
232
- ...recordPageLabels,
233
- ...providerLabels,
234
- ...labels,
235
- tabs: {
236
- ...recordPageLabels.tabs,
237
- ...providerLabels?.tabs,
238
- ...labels?.tabs,
239
- },
240
- highlightLabels: {
241
- ...recordPageLabels.highlightLabels,
242
- ...providerLabels?.highlightLabels,
243
- ...labels?.highlightLabels,
244
- },
245
- highlightEmpty: {
246
- ...recordPageLabels.highlightEmpty,
247
- ...providerLabels?.highlightEmpty,
248
- ...labels?.highlightEmpty,
249
- },
250
- }), [labels, providerLabels]);
407
+ export 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 }) {
408
+ const t = useRecordPageLabels(labels);
251
409
  const [favorite, setFavorite] = React.useState(false);
252
410
  // Notes/Tasks are controlled when the caller supplies `notes`/`tasks` (even
253
411
  // `[]`) -- otherwise RecordPage keeps its own self-contained state, as
@@ -259,11 +417,6 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
259
417
  const notes = notesProp ?? internalNotes;
260
418
  const tasks = tasksProp ?? internalTasks;
261
419
  const [showAllActivity, setShowAllActivity] = React.useState(false);
262
- // Collapsible + searchable Record Details.
263
- const [detailsOpen, setDetailsOpen] = React.useState(true);
264
- const [searchOpen, setSearchOpen] = React.useState(false);
265
- const [fieldQuery, setFieldQuery] = React.useState("");
266
- const [showAllValues, setShowAllValues] = React.useState(false);
267
420
  // Drag-resizable left details column.
268
421
  const [detailsW, setDetailsW] = React.useState(320);
269
422
  const [resizing, setResizing] = React.useState(false);
@@ -300,17 +453,6 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
300
453
  ? record[entityCol.key]
301
454
  : undefined;
302
455
  const name = entity?.name ?? "Record";
303
- const fields = columns.filter((col) => col.type !== "entity");
304
- const q = fieldQuery.trim().toLowerCase();
305
- const matchedFields = q
306
- ? fields.filter((col) => col.label.toLowerCase().includes(q))
307
- : fields;
308
- // Shows the first 5 fields; "View all values" reveals the rest.
309
- const FIELD_LIMIT = 5;
310
- const canRevealFields = !q && matchedFields.length > FIELD_LIMIT;
311
- const shownFields = canRevealFields && !showAllValues
312
- ? matchedFields.slice(0, FIELD_LIMIT)
313
- : matchedFields;
314
456
  const byKey = React.useMemo(() => new Map(columns.map((col) => [col.key, col])), [columns]);
315
457
  // The Overview "Highlights" — a fixed set of enrichment stats (measured
316
458
  // from the live app), a couple derived from the record, the rest empty.
@@ -328,7 +470,11 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
328
470
  : n >= 1000
329
471
  ? "1K–10K"
330
472
  : "<1K";
331
- const highlights = [
473
+ // #528: the built-in CRM Highlights set, used when the caller omits
474
+ // `highlights` entirely -- a caller passing `highlights` (an array, or
475
+ // `false` to omit the section) replaces this wholesale, the same
476
+ // "caller's own list, not a merge" choice `tabs` already makes (#379).
477
+ const defaultHighlights = [
332
478
  {
333
479
  label: t.highlightLabels.connectionStrength,
334
480
  icon: Zap,
@@ -366,6 +512,7 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
366
512
  empty: t.highlightEmpty.employeeRange,
367
513
  },
368
514
  ];
515
+ const highlights = highlightsProp === undefined ? defaultHighlights : highlightsProp;
369
516
  const ti = { size: 15, strokeWidth: 1.75 };
370
517
  const builtinTabs = [
371
518
  { value: "overview", label: t.tabs.overview, icon: _jsx(LayoutGrid, { ...ti }) },
@@ -448,7 +595,7 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
448
595
  const builtinPanel = (active) => {
449
596
  switch (active) {
450
597
  case "overview":
451
- return (_jsxs("div", { className: "crisp-recordpage-panel", children: [_jsxs("section", { children: [_jsx("div", { className: "crisp-recordpage-section-title", children: t.highlights }), _jsx("div", { className: "crisp-recordpage-hl-card", children: _jsx("div", { className: "crisp-recordpage-hl-grid", children: highlights.map((h) => (_jsxs("div", { className: "crisp-recordpage-hl-cell", children: [_jsx(h.icon, { className: "crisp-recordpage-hl-icon", size: 15, strokeWidth: 1.75 }), _jsx("div", { className: "crisp-recordpage-hl-label", children: h.label }), _jsx("div", { className: "crisp-recordpage-hl-value", children: h.value ?? (_jsx("span", { className: "crisp-recordpage-hl-value--empty", children: h.empty })) })] }, h.label))) }) })] }), activityFeed, _jsxs("section", { children: [_jsx(SectionHead, { title: t.tabs.emails, count: 0 }), emailsEmpty] }), _jsx(NotesSection, { notes: notes, onAdd: addNote, labels: t }), _jsx(TasksSection, { tasks: tasks, onAdd: addTask, onToggle: toggleTask, labels: t })] }));
598
+ return (_jsxs("div", { className: "crisp-recordpage-panel", children: [highlights !== false && (_jsxs("section", { children: [_jsx("div", { className: "crisp-recordpage-section-title", children: t.highlights }), _jsx("div", { className: "crisp-recordpage-hl-card", children: _jsx("div", { className: "crisp-recordpage-hl-grid", children: highlights.map((h) => (_jsxs("div", { className: "crisp-recordpage-hl-cell", children: [h.icon && (_jsx(h.icon, { className: "crisp-recordpage-hl-icon", size: 15, strokeWidth: 1.75 })), _jsx("div", { className: "crisp-recordpage-hl-label", children: h.label }), _jsx("div", { className: "crisp-recordpage-hl-value", children: h.value ?? (_jsx("span", { className: "crisp-recordpage-hl-value--empty", children: h.empty })) })] }, h.label))) }) })] })), activityFeed, _jsxs("section", { children: [_jsx(SectionHead, { title: t.tabs.emails, count: 0 }), emailsEmpty] }), _jsx(NotesSection, { notes: notes, onAdd: addNote, labels: t }), _jsx(TasksSection, { tasks: tasks, onAdd: addTask, onToggle: toggleTask, labels: t })] }));
452
599
  case "activity":
453
600
  return _jsx("div", { className: "crisp-recordpage-panel", children: activityFeed });
454
601
  case "notes":
@@ -474,81 +621,7 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
474
621
  const effectivePanel = tabsProp
475
622
  ? (active) => tabsProp.find((tb) => tb.id === active)?.render()
476
623
  : builtinPanel;
477
- return (_jsxs("div", { className: recordpageRecipe({ className }), ...rest, children: [_jsxs("div", { className: "crisp-recordpage-topbar", children: [_jsxs("nav", { className: "crisp-recordpage-crumbs", "aria-label": t.breadcrumb, children: [_jsx("button", { type: "button", className: "crisp-recordpage-crumb", onClick: onBack, "aria-label": `Back to ${collection}`, children: collection }), _jsx("span", { className: "crisp-recordpage-crumb-sep", children: "/" }), _jsx("span", { className: "crisp-recordpage-crumb-name", children: name })] }), _jsxs("div", { className: "crisp-recordpage-actions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--ghost", children: [_jsx(Sparkles, { size: 14, strokeWidth: 1.75, color: "var(--crisp-bg-brand-solid)" }), t.askAttio] }), onToggleFull && (_jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", onClick: onToggleFull, "aria-label": full ? t.exitFullScreen : t.fullScreen, children: full ? (_jsx(Minimize2, { size: 15, strokeWidth: 1.75 })) : (_jsx(Maximize2, { size: 15, strokeWidth: 1.75 })) })), _jsxs(Menu, { align: "end", "aria-label": t.recordActions, trigger: _jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", "aria-label": t.recordActions, children: _jsx(MoreHorizontal, { size: 16, strokeWidth: 1.75 }) }), children: [_jsx(MenuItem, { icon: _jsx(Copy, { size: 15, strokeWidth: 1.75 }), shortcut: "\u2318C", children: t.copyRecordLink }), _jsx(MenuItem, { icon: _jsx(Plus, { size: 15, strokeWidth: 1.75 }), children: t.addToList }), _jsx(MenuItem, { icon: _jsx(Star, { size: 15, strokeWidth: 1.75 }), onSelect: () => setFavorite((f) => !f), children: favorite ? t.removeFromFavourites : t.addToFavourites }), _jsx(MenuSeparator, {}), _jsx(MenuItem, { icon: _jsx(Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, shortcut: "\u232B", children: t.deleteRecord })] })] })] }), _jsxs("div", { className: "crisp-recordpage-body", children: [_jsxs("aside", { className: "crisp-recordpage-details", style: { width: detailsW }, children: [_jsxs("div", { className: "crisp-recordpage-entity", children: [entity?.avatar ? (_jsx("img", { className: "crisp-recordpage-entity-logo", src: entity.avatar, alt: "" })) : (_jsx(Avatar, { size: "lg", shape: "rounded", tone: "brand", children: name.charAt(0) })), _jsx("span", { className: "crisp-recordpage-entity-name", children: name }), _jsx("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "aria-label": t.editRecord, children: _jsx(Pencil, { size: 15, strokeWidth: 1.75 }) }), _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: _jsx(Star, { size: 15, strokeWidth: 1.75 }) })] }), _jsxs("div", { className: "crisp-recordpage-quickactions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-compose", children: [_jsx(Mail, { size: 16, strokeWidth: 1.75 }), t.composeEmail] }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.copyRecordLink, children: _jsx(Copy, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.logANote, children: _jsx(FileText, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.linkRecords, children: _jsx(Share2, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.createATask, children: _jsx(SquareCheck, { size: 15, strokeWidth: 1.75 }) })] }), _jsxs("div", { className: "crisp-recordpage-section", children: [_jsxs("div", { className: "crisp-recordpage-detailhead", children: [searchOpen ? (_jsx("input", {
478
- // biome-ignore lint/a11y/noAutofocus: focusing the just-opened field search is expected
479
- 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) => {
480
- if (e.key === "Escape") {
481
- setSearchOpen(false);
482
- setFieldQuery("");
483
- }
484
- } })) : (_jsxs("button", { type: "button", className: "crisp-recordpage-collapse", "data-collapsed": detailsOpen ? undefined : "", "aria-expanded": detailsOpen, onClick: () => setDetailsOpen((o) => !o), children: [t.recordDetails, _jsx(ChevronDown, { className: "crisp-recordpage-chev", size: 12, strokeWidth: 2.5 })] })), _jsx("button", { type: "button", className: "crisp-recordpage-detailsearch", "data-active": searchOpen ? "" : undefined, "aria-label": searchOpen ? t.closeSearch : t.searchFields, "aria-pressed": searchOpen, onClick: () => {
485
- setSearchOpen((s) => !s);
486
- setFieldQuery("");
487
- if (!searchOpen)
488
- setDetailsOpen(true);
489
- }, children: searchOpen ? (_jsx(X, { size: 14, strokeWidth: 1.75 })) : (_jsx(Search, { size: 14, strokeWidth: 1.75 })) })] }), detailsOpen &&
490
- (shownFields.length > 0 ? (_jsxs(_Fragment, { children: [shownFields.map((col) => {
491
- // #431: a field only actually opens when there's an
492
- // `onEdit` to commit through -- `editable` with nothing
493
- // to call would be a dead click.
494
- const canEdit = Boolean(col.editable && onEdit);
495
- const isPending = pendingKey === col.key;
496
- const fieldError = error?.key === col.key ? error.message : null;
497
- const label = (_jsxs("span", { className: "crisp-recordpage-field-label", children: [col.icon ? (_jsx(col.icon, { size: 15, strokeWidth: 1.75 })) : null, _jsx("span", { className: "crisp-recordpage-field-label-text", children: col.label })] }));
498
- // `switch` is always its own live widget -- no
499
- // click-to-open, no separate edit/view mode: flipping the
500
- // toggle IS the commit gesture, and there's no
501
- // meaningfully different "rest" display for a boolean
502
- // (same exception `DataTable`'s own `editor` makes, #453).
503
- if (canEdit && col.editor === "switch") {
504
- const value = record[col.key];
505
- return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsxs("span", { className: "crisp-recordpage-edit-picker", children: [_jsx(Switch, { "aria-label": `Edit ${col.label}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commit(record.id, col.key, String(checked)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-recordpage-edit-pending" }))] }), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
506
- }
507
- // `select` opens crisp's own `Select` on a SINGLE click
508
- // (#457, corrects #431/#385's original always-live
509
- // copy of the same regression #453 fixed on `DataTable`)
510
- // -- like every other editable type here, at rest it
511
- // shows its value (see `FieldValue`'s own `col.editor
512
- // === "select"` case) via the generic click-to-open
513
- // branch below, and only mounts `Select` once
514
- // `editingKey` matches. `DataTable`'s own double-click
515
- // gate doesn't apply: `RecordPage`'s plain text/date
516
- // fields already open on a single click (measured live
517
- // for #431), so `select` matches that, not `DataTable`.
518
- if (canEdit &&
519
- editingKey === col.key &&
520
- col.editor === "select") {
521
- const value = record[col.key];
522
- return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsx(RecordPageSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commit(record.id, col.key, v), onCancel: closeEditing }), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
523
- }
524
- if (canEdit && editingKey === col.key) {
525
- const value = record[col.key];
526
- const editor = editableInputType(col.type);
527
- return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsx("input", { className: "crisp-recordpage-edit",
528
- // biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
529
- autoFocus: true, ...editor, disabled: isPending, defaultValue: value == null ? "" : String(value), "aria-label": `Edit ${col.label}`, onKeyDown: (e) => {
530
- if (isPending)
531
- return;
532
- if (e.key === "Enter")
533
- commit(record.id, col.key, e.currentTarget.value);
534
- else if (e.key === "Escape")
535
- closeEditing();
536
- }, onBlur: (e) => {
537
- if (isPending)
538
- return;
539
- commit(record.id, col.key, e.currentTarget.value);
540
- } }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-recordpage-edit-pending" })), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
541
- }
542
- return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", "data-editable": canEdit ? "" : undefined, children: [canEdit ? (_jsx("button", { type: "button", className: "crisp-recordpage-edit-trigger", onClick: () => openEditing(col.key), children: _jsx(FieldValue, { col: col, row: record, labels: t }) })) : (_jsx(FieldValue, { col: col, row: record, labels: t })), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
543
- }), canRevealFields && (_jsx("button", { type: "button", className: "crisp-recordpage-viewall crisp-recordpage-viewall--section", onClick: () => {
544
- // Revealing all fields also opens the (focused)
545
- // field search; collapsing closes it.
546
- const next = !showAllValues;
547
- setShowAllValues(next);
548
- setSearchOpen(next);
549
- if (!next)
550
- setFieldQuery("");
551
- }, children: showAllValues ? t.viewLess : t.viewAllValues }))] })) : (_jsxs("div", { className: "crisp-recordpage-noresults", children: ["No fields match \u201C", fieldQuery, "\u201D."] })))] }), _jsxs("div", { className: "crisp-recordpage-section", children: [_jsx(SectionHead, { title: t.tabs.lists, action: _jsxs("button", { type: "button", className: "crisp-recordpage-addbtn", children: [_jsx(Plus, { size: 14, strokeWidth: 2 }), t.addToList] }) }), _jsx("div", { className: "crisp-recordpage-emptystate", children: t.noLists })] })] }), _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) => {
624
+ return (_jsxs("div", { className: recordpageRecipe({ className }), ...rest, children: [_jsxs("div", { className: "crisp-recordpage-topbar", children: [_jsxs("nav", { className: "crisp-recordpage-crumbs", "aria-label": t.breadcrumb, children: [_jsx("button", { type: "button", className: "crisp-recordpage-crumb", onClick: onBack, "aria-label": `Back to ${collection}`, children: collection }), _jsx("span", { className: "crisp-recordpage-crumb-sep", children: "/" }), _jsx("span", { className: "crisp-recordpage-crumb-name", children: name })] }), _jsxs("div", { className: "crisp-recordpage-actions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--ghost", children: [_jsx(Sparkles, { size: 14, strokeWidth: 1.75, color: "var(--crisp-bg-brand-solid)" }), t.askAttio] }), onToggleFull && (_jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", onClick: onToggleFull, "aria-label": full ? t.exitFullScreen : t.fullScreen, children: full ? (_jsx(Minimize2, { size: 15, strokeWidth: 1.75 })) : (_jsx(Maximize2, { size: 15, strokeWidth: 1.75 })) })), _jsxs(Menu, { align: "end", "aria-label": t.recordActions, trigger: _jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", "aria-label": t.recordActions, children: _jsx(MoreHorizontal, { size: 16, strokeWidth: 1.75 }) }), children: [_jsx(MenuItem, { icon: _jsx(Copy, { size: 15, strokeWidth: 1.75 }), shortcut: "\u2318C", children: t.copyRecordLink }), _jsx(MenuItem, { icon: _jsx(Plus, { size: 15, strokeWidth: 1.75 }), children: t.addToList }), _jsx(MenuItem, { icon: _jsx(Star, { size: 15, strokeWidth: 1.75 }), onSelect: () => setFavorite((f) => !f), children: favorite ? t.removeFromFavourites : t.addToFavourites }), _jsx(MenuSeparator, {}), _jsx(MenuItem, { icon: _jsx(Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, shortcut: "\u232B", children: t.deleteRecord })] })] })] }), _jsxs("div", { className: "crisp-recordpage-body", children: [_jsxs("aside", { className: "crisp-recordpage-details", style: { width: detailsW }, children: [_jsxs("div", { className: "crisp-recordpage-entity", children: [entity?.avatar ? (_jsx("img", { className: "crisp-recordpage-entity-logo", src: entity.avatar, alt: "" })) : (_jsx(Avatar, { size: "lg", shape: "rounded", tone: "brand", children: name.charAt(0) })), _jsx("span", { className: "crisp-recordpage-entity-name", children: name }), _jsx("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "aria-label": t.editRecord, children: _jsx(Pencil, { size: 15, strokeWidth: 1.75 }) }), _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: _jsx(Star, { size: 15, strokeWidth: 1.75 }) })] }), _jsxs("div", { className: "crisp-recordpage-quickactions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-compose", children: [_jsx(Mail, { size: 16, strokeWidth: 1.75 }), t.composeEmail] }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.copyRecordLink, children: _jsx(Copy, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.logANote, children: _jsx(FileText, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.linkRecords, children: _jsx(Share2, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.createATask, children: _jsx(SquareCheck, { size: 15, strokeWidth: 1.75 }) })] }), _jsx(RecordDetails, { record: record, columns: columns, sections: sections, onEdit: onEdit, labels: labels }), _jsxs("div", { className: "crisp-recordpage-section", children: [_jsx(SectionHead, { title: t.tabs.lists, action: _jsxs("button", { type: "button", className: "crisp-recordpage-addbtn", children: [_jsx(Plus, { size: 14, strokeWidth: 2 }), t.addToList] }) }), _jsx("div", { className: "crisp-recordpage-emptystate", children: t.noLists })] })] }), _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) => {
552
625
  if (e.key !== "ArrowLeft" && e.key !== "ArrowRight")
553
626
  return;
554
627
  // The pointer drag learned its direction in #212; this did not.
@@ -63,6 +63,11 @@ const config = defineRecipe({
63
63
  & .crisp-recordpage-detailsearch { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: ${c.size.chip}; flex-shrink: 0; padding: 0; margin: 0; border: none; border-radius: ${c.radius.sm}; background: none; color: ${c.fgMuted}; font-family: inherit; cursor: pointer; transition: background-color .12s, color .12s; }
64
64
  & .crisp-recordpage-detailsearch:hover, & .crisp-recordpage-detailsearch[data-active] { background: ${c.bgRaised}; color: ${c.fgPrimary}; }
65
65
  & .crisp-recordpage-noresults { padding: ${c.space["1.5"]} ${c.space["1"]}; font-size: ${c.text.md}; color: ${c.fgMuted}; }
66
+ /* A caller-supplied \`sections\` grouping (#528) is not an Attio-measured
67
+ shape (its own Details column is flat) -- this heading reuses
68
+ .crisp-recordpage-section-title's own type treatment verbatim, just
69
+ spaced from the group above it. */
70
+ & .crisp-recordpage-fieldsection-title { margin-top: ${c.space["3"]}; }
66
71
  & .crisp-recordpage-morevalues { display: inline-flex; align-items: center; padding: 0; margin: 0; border: none; background: none; font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgMuted}; cursor: pointer; }
67
72
  & .crisp-recordpage-morevalues:hover { color: ${c.fgPrimary}; }
68
73
  & .crisp-recordpage-viewall { flex-basis: 100%; margin-top: ${c.space["0.5"]}; border: none; background: none; padding: 0; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); font-family: inherit; font-size: ${c.text.sm}; font-weight: ${c.weight.regular}; cursor: pointer; text-align: start; }
@@ -113,7 +113,7 @@ const exec = (cmd, value) => document.execCommand(cmd, false, value);
113
113
  * reach the identical menu, sitting at the end of the body's last line) —
114
114
  * only the two entry points #426 asked for.
115
115
  */
116
- export const 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) {
116
+ export const 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) {
117
117
  const titleRef = React.useRef(null);
118
118
  const bodyRef = React.useRef(null);
119
119
  const [bar, setBar] = React.useState(null);
@@ -514,12 +514,12 @@ export const RichTextEditor = React.forwardRef(function RichTextEditor({ default
514
514
  ol: _jsx(ListOrdered, { ...menuIc }),
515
515
  image: _jsx(ImageIcon, { ...menuIc }),
516
516
  };
517
- return (_jsxs("div", { className: richtextRecipe({ className }), ...rest, children: [_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
517
+ return (_jsxs("div", { className: richtextRecipe({ className }), ...rest, children: [showTitle && (_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
518
518
  ? undefined
519
519
  : () => {
520
520
  syncEmpty(titleRef.current);
521
521
  emit();
522
- } }), _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
522
+ } })), _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
523
523
  ? undefined
524
524
  : () => {
525
525
  syncEmpty(bodyRef.current);
@@ -3,13 +3,22 @@ import { contract as c } from "../../tokens/index.js";
3
3
  // A settings section — measured from Settings pages: a 16px/600 title
4
4
  // with an optional subtitle, then a stack of rows (form fields, toggles…). Rows
5
5
  // gap evenly; an optional trailing action sits on the title row.
6
+ //
7
+ // Title line-height/letter-spacing and subtitle font-size/line-height
8
+ // re-measured live at app.attio.com/<workspace>/settings/account against a
9
+ // subsection heading ("Time preferences") — crisp#522. The subtitle's own
10
+ // colour (measured `rgba(0,0,0,0.63)`, a black-alpha value) is left as
11
+ // `fgMuted` pending a dark-mode measurement: a raw alpha-black has no
12
+ // existing token match and would need its own light/dark pair to stay
13
+ // legible in dark mode (Lesson: theme-constant vs theme-flipping colours
14
+ // must each be independently verified).
6
15
  const config = defineRecipe({
7
16
  name: "settingssection",
8
17
  base: `display: flex; flex-direction: column; box-sizing: border-box; font-family: ${c.font.sans};
9
18
  & .crisp-settingssection-head { display: flex; align-items: flex-start; gap: ${c.space["2"]}; }
10
19
  & .crisp-settingssection-heading { flex: 1; min-width: 0; }
11
- & .crisp-settingssection-title { font-size: ${c.text.xl}; line-height: 24px; font-weight: ${c.weight.semibold}; letter-spacing: -0.2px; color: ${c.fgPrimary}; }
12
- & .crisp-settingssection-sub { margin-top: ${c.space["0.5"]}; font-size: ${c.text.md}; line-height: 18px; color: ${c.fgMuted}; }
20
+ & .crisp-settingssection-title { font-size: ${c.text.xl}; line-height: 20px; font-weight: ${c.weight.semibold}; letter-spacing: -0.16px; color: ${c.fgPrimary}; }
21
+ & .crisp-settingssection-sub { margin-top: ${c.space["0.5"]}; font-size: ${c.text.lg}; line-height: 20px; color: ${c.fgMuted}; }
13
22
  & .crisp-settingssection-action { flex-shrink: 0; }
14
23
  & .crisp-settingssection-body { display: flex; flex-direction: column; gap: ${c.space["4"]}; margin-top: ${c.space["3"]}; }`,
15
24
  variants: {},
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCrispMessage, useCrispMessages } from "../../core/index.js";
2
3
  import { tasklistRecipe } from "./tasklist.recipe.js";
3
4
  function Row({ task, onToggle, completeLabel, incompleteLabel, }) {
4
5
  return (_jsxs("div", { className: "crisp-tasklist-row", "data-done": task.done ? "" : undefined, children: [_jsx("input", { type: "checkbox", className: "crisp-tasklist-check", checked: !!task.done, "aria-label": task.done ? incompleteLabel : completeLabel, onChange: () => onToggle?.(task.id, !task.done) }), _jsx("span", { className: "crisp-tasklist-title", children: task.title }), (task.due != null || task.meta != null) && (_jsxs("span", { className: "crisp-tasklist-meta", children: [task.due != null && (_jsx("span", { className: "crisp-tasklist-due", "data-overdue": task.overdue ? "" : undefined, children: task.due })), task.meta] }))] }));
@@ -8,9 +9,15 @@ function Row({ task, onToggle, completeLabel, incompleteLabel, }) {
8
9
  * count, then rows of a round complete-checkbox + title + trailing meta.
9
10
  * Completed rows strike through; overdue dates go red.
10
11
  */
11
- export function TaskList({ tasks, groups, onToggle, emptyLabel = "No tasks", className, completeLabel = "Mark complete", incompleteLabel = "Mark incomplete", ...rest }) {
12
+ export function TaskList({ tasks, groups, onToggle, emptyLabel: emptyLabelProp, className, completeLabel: completeLabelProp, incompleteLabel: incompleteLabelProp, ...rest }) {
12
13
  const resolved = groups ?? (tasks?.length ? [{ label: null, tasks }] : []);
13
14
  const total = resolved.reduce((s, g) => s + g.tasks.length, 0);
15
+ // crisp/#471: `emptyLabel` is typed `React.ReactNode`, so it resolves by
16
+ // hand rather than through `useCrispMessage`, which is string-only.
17
+ const crispMessages = useCrispMessages();
18
+ const emptyLabel = emptyLabelProp ?? crispMessages["No tasks"] ?? "No tasks";
19
+ const completeLabel = useCrispMessage(completeLabelProp, "Mark complete");
20
+ const incompleteLabel = useCrispMessage(incompleteLabelProp, "Mark incomplete");
14
21
  return (_jsx("div", { className: tasklistRecipe({ className }), ...rest, children: total === 0 ? (_jsx("div", { className: "crisp-tasklist-empty", children: emptyLabel })) : (resolved.map((g, gi) => (_jsxs("div", { children: [g.label != null && (_jsxs("div", { className: "crisp-tasklist-group", children: [g.label, _jsx("span", { className: "crisp-tasklist-group-count", children: g.tasks.length })] })), g.tasks.map((t) => (_jsx(Row, { task: t, onToggle: onToggle, completeLabel: completeLabel, incompleteLabel: incompleteLabel }, t.id)))] }, gi)))) }));
15
22
  }
16
23
  TaskList.displayName = "TaskList";
@@ -0,0 +1,7 @@
1
+ .crisp-headerbar {
2
+ box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: var(--crisp-space-2); height: var(--crisp-size-header); flex-shrink: 0; padding: 0 var(--crisp-space-3); font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
3
+ &[data-bordered] { border-bottom: 1px solid var(--crisp-border-subtle); }
4
+ & .crisp-headerbar-leading { display: flex; align-items: center; gap: var(--crisp-space-2); flex-shrink: 0; min-width: 0; }
5
+ & .crisp-headerbar-title { flex: 1; min-width: 0; display: flex; align-items: center; gap: var(--crisp-space-2); overflow: hidden; }
6
+ & .crisp-headerbar-trailing { display: flex; align-items: center; gap: var(--crisp-space-2); flex-shrink: 0; }
7
+ }
@@ -0,0 +1,7 @@
1
+ import type { RecipeFn } from "../core/types";
2
+
3
+ export interface HeaderbarVariants {
4
+
5
+ }
6
+
7
+ export declare const headerbarRecipe: RecipeFn<{ }>;
@@ -0,0 +1,9 @@
1
+ @layer crisp-components {
2
+ .crisp-headerbar {
3
+ box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: var(--crisp-space-2); height: var(--crisp-size-header); flex-shrink: 0; padding: 0 var(--crisp-space-3); font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
4
+ &[data-bordered] { border-bottom: 1px solid var(--crisp-border-subtle); }
5
+ & .crisp-headerbar-leading { display: flex; align-items: center; gap: var(--crisp-space-2); flex-shrink: 0; min-width: 0; }
6
+ & .crisp-headerbar-title { flex: 1; min-width: 0; display: flex; align-items: center; gap: var(--crisp-space-2); overflow: hidden; }
7
+ & .crisp-headerbar-trailing { display: flex; align-items: center; gap: var(--crisp-space-2); flex-shrink: 0; }
8
+ }
9
+ }