@crisp-ui-kit/crisp 0.49.0 → 0.51.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 (104) hide show
  1. package/dist/cjs/components/composewindow/composewindow.js +144 -0
  2. package/dist/cjs/components/composewindow/composewindow.recipe.js +45 -0
  3. package/dist/cjs/components/composewindow/index.js +8 -0
  4. package/dist/cjs/components/datatable/datatable.js +224 -43
  5. package/dist/cjs/components/datatable/datatable.recipe.js +38 -4
  6. package/dist/cjs/components/datatable/index.js +6 -0
  7. package/dist/cjs/components/datepickerfield/datepickerfield.js +25 -15
  8. package/dist/cjs/components/datepickerfield/datepickerfield.recipe.js +25 -8
  9. package/dist/cjs/components/dialog/dialog.recipe.js +9 -1
  10. package/dist/cjs/components/menu/menu.js +4 -2
  11. package/dist/cjs/components/multiselect/multiselect.recipe.js +14 -8
  12. package/dist/cjs/components/select/select.js +2 -1
  13. package/dist/cjs/components/table/table.recipe.js +12 -3
  14. package/dist/cjs/components/tabletoolbar/tabletoolbar.chrome.js +2 -1
  15. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +39 -8
  16. package/dist/cjs/core/cell-date.js +11 -0
  17. package/dist/cjs/core/index.js +2 -1
  18. package/dist/cjs/index.js +1 -0
  19. package/dist/cjs/product/recordpage/recordpage.js +88 -10
  20. package/dist/cjs/product/recordpage/recordpage.recipe.js +30 -0
  21. package/dist/cjs/product/recordpanel/recordpanel.js +54 -4
  22. package/dist/cjs/product/recordpanel/recordpanel.recipe.js +29 -0
  23. package/dist/cjs/product/recordstable/editable-field.js +110 -0
  24. package/dist/cjs/product/recordstable/index.js +4 -1
  25. package/dist/cjs/product/recordstable/recordstable.js +67 -11
  26. package/dist/cjs/product/recordstable/recordstable.recipe.js +9 -1
  27. package/dist/cjs/product/settingsshell/settingsshell.recipe.js +13 -4
  28. package/dist/cjs/product/sidebar/sidebar.js +15 -3
  29. package/dist/cjs/product/sidebar/sidebar.recipe.js +21 -2
  30. package/dist/composewindow.css +24 -0
  31. package/dist/composewindow.d.ts +7 -0
  32. package/dist/composewindow.layered.css +26 -0
  33. package/dist/datatable.css +34 -15
  34. package/dist/datatable.layered.css +34 -15
  35. package/dist/datepickerfield.css +9 -4
  36. package/dist/datepickerfield.layered.css +9 -4
  37. package/dist/dialog.css +2 -1
  38. package/dist/dialog.layered.css +2 -1
  39. package/dist/esm/components/composewindow/composewindow.js +107 -0
  40. package/dist/esm/components/composewindow/composewindow.recipe.js +42 -0
  41. package/dist/esm/components/composewindow/index.js +2 -0
  42. package/dist/esm/components/datatable/datatable.js +226 -45
  43. package/dist/esm/components/datatable/datatable.recipe.js +38 -4
  44. package/dist/esm/components/datatable/index.js +6 -0
  45. package/dist/esm/components/datepickerfield/datepickerfield.js +26 -16
  46. package/dist/esm/components/datepickerfield/datepickerfield.recipe.js +25 -8
  47. package/dist/esm/components/dialog/dialog.recipe.js +9 -1
  48. package/dist/esm/components/menu/menu.js +4 -2
  49. package/dist/esm/components/multiselect/multiselect.recipe.js +14 -8
  50. package/dist/esm/components/select/select.js +2 -1
  51. package/dist/esm/components/table/table.recipe.js +12 -3
  52. package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +2 -1
  53. package/dist/esm/components/tabletoolbar/tabletoolbar.js +38 -8
  54. package/dist/esm/core/cell-date.js +10 -0
  55. package/dist/esm/core/index.js +1 -1
  56. package/dist/esm/index.js +1 -0
  57. package/dist/esm/product/recordpage/recordpage.js +89 -11
  58. package/dist/esm/product/recordpage/recordpage.recipe.js +30 -0
  59. package/dist/esm/product/recordpanel/recordpanel.js +55 -5
  60. package/dist/esm/product/recordpanel/recordpanel.recipe.js +29 -0
  61. package/dist/esm/product/recordstable/editable-field.js +73 -0
  62. package/dist/esm/product/recordstable/index.js +1 -0
  63. package/dist/esm/product/recordstable/recordstable.js +69 -13
  64. package/dist/esm/product/recordstable/recordstable.recipe.js +9 -1
  65. package/dist/esm/product/settingsshell/settingsshell.recipe.js +13 -4
  66. package/dist/esm/product/sidebar/sidebar.js +15 -3
  67. package/dist/esm/product/sidebar/sidebar.recipe.js +21 -2
  68. package/dist/multiselect.css +8 -8
  69. package/dist/multiselect.layered.css +8 -8
  70. package/dist/recordpage.css +25 -13
  71. package/dist/recordpage.layered.css +25 -13
  72. package/dist/recordpanel.css +22 -5
  73. package/dist/recordpanel.layered.css +22 -5
  74. package/dist/recordstable.css +10 -2
  75. package/dist/recordstable.layered.css +10 -2
  76. package/dist/settingsshell.css +2 -2
  77. package/dist/settingsshell.layered.css +2 -2
  78. package/dist/sidebar.css +7 -2
  79. package/dist/sidebar.layered.css +7 -2
  80. package/dist/styles.css +150 -55
  81. package/dist/styles.layered.css +150 -55
  82. package/dist/table.css +4 -2
  83. package/dist/table.layered.css +4 -2
  84. package/dist/tabletoolbar.css +2 -1
  85. package/dist/tabletoolbar.layered.css +2 -1
  86. package/dist/types/components/composewindow/composewindow.d.ts +80 -0
  87. package/dist/types/components/composewindow/composewindow.recipe.d.ts +3 -0
  88. package/dist/types/components/composewindow/index.d.ts +2 -0
  89. package/dist/types/components/datatable/datatable.d.ts +58 -4
  90. package/dist/types/components/datatable/index.d.ts +1 -1
  91. package/dist/types/components/datepickerfield/datepickerfield.d.ts +18 -4
  92. package/dist/types/components/menu/menu.d.ts +10 -1
  93. package/dist/types/components/select/select.d.ts +6 -1
  94. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +62 -5
  95. package/dist/types/core/cell-date.d.ts +17 -0
  96. package/dist/types/core/index.d.ts +1 -1
  97. package/dist/types/index.d.ts +1 -0
  98. package/dist/types/product/recordpage/recordpage.d.ts +59 -1
  99. package/dist/types/product/recordpanel/recordpanel.d.ts +11 -1
  100. package/dist/types/product/recordstable/editable-field.d.ts +45 -0
  101. package/dist/types/product/recordstable/index.d.ts +1 -0
  102. package/dist/types/product/recordstable/recordstable.d.ts +29 -0
  103. package/dist/types/product/sidebar/sidebar.d.ts +60 -4
  104. package/package.json +6 -1
@@ -4,9 +4,12 @@ import * as React from "react";
4
4
  import { Avatar } from "../../components/avatar/index.js";
5
5
  import { Checkbox } from "../../components/checkbox/index.js";
6
6
  import { Menu, MenuItem, MenuSeparator } from "../../components/menu/index.js";
7
+ import { Select } from "../../components/select/index.js";
8
+ import { Spinner } from "../../components/spinner/index.js";
9
+ import { Switch } from "../../components/switch/index.js";
7
10
  import { Tabs } from "../../components/tabs/index.js";
8
11
  import { isRtlElement } from "../../core/index.js";
9
- import { tagTone, } from "../recordstable/index.js";
12
+ import { editableInputType, tagTone, useEditableField, } from "../recordstable/index.js";
10
13
  import { recordpageRecipe } from "./recordpage.recipe.js";
11
14
  function valueEmpty(v) {
12
15
  return v == null || v === "" || (Array.isArray(v) && v.length === 0);
@@ -153,7 +156,8 @@ function EmptyCard({ icon, children, }) {
153
156
  * Emails sections (Notes & Tasks are live — add and check things off). Drop it
154
157
  * into the app's main content area.
155
158
  */
156
- export function RecordPage({ record, columns, collection = "Records", labels, onBack, onToggleFull, full, className, ...rest }) {
159
+ export function RecordPage({ record, columns, collection = "Records", labels, onBack, onToggleFull, full, className, onEdit, tabs: tabsProp, defaultTab, notes: notesProp, onAddNote, tasks: tasksProp, onAddTask, onToggleTask, ...rest }) {
160
+ const { editingKey, pendingKey, error, openEditing, closeEditing, commit } = useEditableField(onEdit);
157
161
  const t = React.useMemo(() => ({
158
162
  ...recordPageLabels,
159
163
  ...labels,
@@ -168,8 +172,15 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
168
172
  },
169
173
  }), [labels]);
170
174
  const [favorite, setFavorite] = React.useState(false);
171
- const [notes, setNotes] = React.useState([]);
172
- const [tasks, setTasks] = React.useState([]);
175
+ // Notes/Tasks are controlled when the caller supplies `notes`/`tasks` (even
176
+ // `[]`) -- otherwise RecordPage keeps its own self-contained state, as
177
+ // before (#379). "Controlled" here means the array prop alone decides the
178
+ // mode; there's no meaningful "default seed" worth a separate
179
+ // defaultNotes/defaultTasks prop the way Tabs' defaultValue is.
180
+ const [internalNotes, setInternalNotes] = React.useState([]);
181
+ const [internalTasks, setInternalTasks] = React.useState([]);
182
+ const notes = notesProp ?? internalNotes;
183
+ const tasks = tasksProp ?? internalTasks;
173
184
  const [showAllActivity, setShowAllActivity] = React.useState(false);
174
185
  // Collapsible + searchable Record Details.
175
186
  const [detailsOpen, setDetailsOpen] = React.useState(true);
@@ -279,7 +290,7 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
279
290
  },
280
291
  ];
281
292
  const ti = { size: 15, strokeWidth: 1.75 };
282
- const tabs = [
293
+ const builtinTabs = [
283
294
  { value: "overview", label: t.tabs.overview, icon: _jsx(LayoutGrid, { ...ti }) },
284
295
  {
285
296
  value: "activity",
@@ -309,9 +320,27 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
309
320
  badge: 0,
310
321
  },
311
322
  ];
312
- const addNote = (text) => setNotes((n) => [{ id: nextId(), text, time: t.justNow }, ...n]);
313
- const addTask = (text) => setTasks((t) => [{ id: nextId(), text, done: false }, ...t]);
314
- const toggleTask = (id) => setTasks((t) => t.map((x) => (x.id === id ? { ...x, done: !x.done } : x)));
323
+ const addNote = (text) => {
324
+ if (notesProp) {
325
+ onAddNote?.(text);
326
+ return;
327
+ }
328
+ setInternalNotes((n) => [{ id: nextId(), text, time: t.justNow }, ...n]);
329
+ };
330
+ const addTask = (text) => {
331
+ if (tasksProp) {
332
+ onAddTask?.(text);
333
+ return;
334
+ }
335
+ setInternalTasks((n) => [{ id: nextId(), text, done: false }, ...n]);
336
+ };
337
+ const toggleTask = (id) => {
338
+ if (tasksProp) {
339
+ onToggleTask?.(id);
340
+ return;
341
+ }
342
+ setInternalTasks((n) => n.map((x) => (x.id === id ? { ...x, done: !x.done } : x)));
343
+ };
315
344
  // A small, plausible activity feed derived from the record's own fields.
316
345
  const domain = byKey.get("domain");
317
346
  const cats = byKey.get("categories");
@@ -339,7 +368,7 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
339
368
  const shownActivity = showAllActivity ? activity : activity.slice(0, 2);
340
369
  const activityFeed = (_jsxs("section", { children: [_jsx(SectionHead, { title: t.tabs.activity, action: activity.length > 2 ? (_jsx("button", { type: "button", className: "crisp-recordpage-viewall crisp-recordpage-viewall--inline", onClick: () => setShowAllActivity((s) => !s), children: showAllActivity ? t.showLess : t.viewAll })) : undefined }), _jsx("div", { className: "crisp-recordpage-feed", children: shownActivity.map((a) => (_jsxs("div", { className: "crisp-recordpage-feed-item", children: [_jsx("span", { className: "crisp-recordpage-feed-dot", children: _jsx(ActivityIcon, { size: 13, strokeWidth: 2 }) }), _jsxs("div", { className: "crisp-recordpage-feed-body", children: [a.text, _jsx("div", { className: "crisp-recordpage-feed-time", children: a.time })] })] }, a.id))) })] }));
341
370
  const emailsEmpty = (_jsx(EmptyCard, { icon: _jsx(Inbox, { size: 22, strokeWidth: 1.5 }), children: t.noEmailsWith(name) }));
342
- const panel = (active) => {
371
+ const builtinPanel = (active) => {
343
372
  switch (active) {
344
373
  case "overview":
345
374
  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 })] }));
@@ -355,6 +384,19 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
355
384
  return (_jsx("div", { className: "crisp-recordpage-panel", children: _jsx(EmptyCard, { icon: _jsx(Folder, { size: 22, strokeWidth: 1.5 }), children: t.nothingHereYet }) }));
356
385
  }
357
386
  };
387
+ // #379: `tabs` (consumer-supplied) replaces the CRM set wholesale, rather
388
+ // than merging with it — a mixed set would leave a caller unable to ever
389
+ // drop a built-in tab it doesn't want. `count` maps to Tabs' own `badge`.
390
+ const effectiveTabs = tabsProp
391
+ ? tabsProp.map((tb) => ({
392
+ value: tb.id,
393
+ label: tb.label,
394
+ badge: tb.count,
395
+ }))
396
+ : builtinTabs;
397
+ const effectivePanel = tabsProp
398
+ ? (active) => tabsProp.find((tb) => tb.id === active)?.render()
399
+ : builtinPanel;
358
400
  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", {
359
401
  // biome-ignore lint/a11y/noAutofocus: focusing the just-opened field search is expected
360
402
  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) => {
@@ -368,7 +410,43 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
368
410
  if (!searchOpen)
369
411
  setDetailsOpen(true);
370
412
  }, children: searchOpen ? (_jsx(X, { size: 14, strokeWidth: 1.75 })) : (_jsx(Search, { size: 14, strokeWidth: 1.75 })) })] }), detailsOpen &&
371
- (shownFields.length > 0 ? (_jsxs(_Fragment, { children: [shownFields.map((col) => (_jsxs("div", { className: "crisp-recordpage-field", children: [_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 })] }), _jsx("span", { className: "crisp-recordpage-field-value", children: _jsx(FieldValue, { col: col, row: record, labels: t }) })] }, col.key))), canRevealFields && (_jsx("button", { type: "button", className: "crisp-recordpage-viewall crisp-recordpage-viewall--section", onClick: () => {
413
+ (shownFields.length > 0 ? (_jsxs(_Fragment, { children: [shownFields.map((col) => {
414
+ // #431: a field only actually opens when there's an
415
+ // `onEdit` to commit through -- `editable` with nothing
416
+ // to call would be a dead click.
417
+ const canEdit = Boolean(col.editable && onEdit);
418
+ const isPending = pendingKey === col.key;
419
+ const fieldError = error?.key === col.key ? error.message : null;
420
+ 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 })] }));
421
+ // select/switch are always their own live widget -- no
422
+ // click-to-open, no separate edit/view mode, the same
423
+ // convention DataTable's identical `editor` columns use
424
+ // (#385): choosing a value or flipping a toggle IS the
425
+ // commit gesture.
426
+ if (canEdit && col.editor) {
427
+ const value = record[col.key];
428
+ 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: [col.editor === "select" ? (_jsx(Select, { variant: "ghost", "aria-label": `Edit ${col.label}`, value: value != null ? String(value) : undefined, disabled: isPending, options: col.editOptions ?? [], onValueChange: (v) => commit(record.id, col.key, v) })) : (_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));
429
+ }
430
+ if (canEdit && editingKey === col.key) {
431
+ const value = record[col.key];
432
+ const editor = editableInputType(col.type);
433
+ return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsx("input", { className: "crisp-recordpage-edit",
434
+ // biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
435
+ autoFocus: true, ...editor, disabled: isPending, defaultValue: value == null ? "" : String(value), "aria-label": `Edit ${col.label}`, onKeyDown: (e) => {
436
+ if (isPending)
437
+ return;
438
+ if (e.key === "Enter")
439
+ commit(record.id, col.key, e.currentTarget.value);
440
+ else if (e.key === "Escape")
441
+ closeEditing();
442
+ }, onBlur: (e) => {
443
+ if (isPending)
444
+ return;
445
+ commit(record.id, col.key, e.currentTarget.value);
446
+ } }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-recordpage-edit-pending" })), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
447
+ }
448
+ 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));
449
+ }), canRevealFields && (_jsx("button", { type: "button", className: "crisp-recordpage-viewall crisp-recordpage-viewall--section", onClick: () => {
372
450
  // Revealing all fields also opens the (focused)
373
451
  // field search; collapsing closes it.
374
452
  const next = !showAllValues;
@@ -386,6 +464,6 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
386
464
  const rtl = isRtlElement(e.currentTarget);
387
465
  const grow = e.key === (rtl ? "ArrowLeft" : "ArrowRight");
388
466
  setDetailsW((w) => grow ? Math.min(560, w + 16) : Math.max(300, w - 16));
389
- } }), _jsx("main", { className: "crisp-recordpage-main", children: _jsx(Tabs, { tabs: tabs, defaultValue: "overview", "aria-label": `${name} record`, children: panel }) })] })] }));
467
+ } }), _jsx("main", { className: "crisp-recordpage-main", children: _jsx(Tabs, { tabs: effectiveTabs, defaultValue: tabsProp ? defaultTab : "overview", "aria-label": `${name} record`, children: effectivePanel }) })] })] }));
390
468
  }
391
469
  RecordPage.displayName = "RecordPage";
@@ -76,7 +76,37 @@ const config = defineRecipe({
76
76
  & .crisp-recordpage-field-label svg { flex-shrink: 0; color: ${c.fgMuted}; }
77
77
  & .crisp-recordpage-field-label-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
78
78
  & .crisp-recordpage-field-value { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: ${c.space["1"]}; padding-top: 2px; line-height: 20px; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; }
79
+ & .crisp-recordpage-field-value[data-editable] { cursor: pointer; }
80
+ /* the click-to-edit trigger is a real <button> (keyboard-operable),
81
+ reset back to plain inline text and made a flex-wrap container of its
82
+ own -- FieldValue can render several chips (tags), which still need
83
+ to wrap the way they did before this had a button around them at
84
+ all. It wraps only FieldValue, never the error slot beside it (see
85
+ the component's own comment) -- NOT \`display: contents\`, which
86
+ drops a button's accessible semantics in some engines. */
87
+ & .crisp-recordpage-edit-trigger { display: inline-flex; flex-wrap: wrap; align-items: center; gap: ${c.space["1"]}; margin: 0; padding: 0; border: none; background: none; text-align: start; font: inherit; color: inherit; cursor: pointer; }
79
88
  & .crisp-recordpage-empty { color: ${c.fgMuted}; }
89
+
90
+ /* #431: measured live on app.attio.com's record page Details column --
91
+ clicking a field's value opens a plain, borderless field exactly
92
+ filling the value's own box (no border, no background, no
93
+ box-shadow), same font as the value it replaces (hence \`font:
94
+ inherit\`, not a restated size/weight). A field row's own hover tint
95
+ above already carries the "this is interactive" affordance; the
96
+ pending/error treatment isn't Attio-measured -- it reuses the same
97
+ tokens DataTable's identical states already use (opacity 0.5,
98
+ \`bgDanger\`), per #385/#430. */
99
+ & .crisp-recordpage-edit { display: block; width: 100%; margin: 0; padding: 0; border: none; border-radius: ${c.radius.sm}; background: none; box-shadow: none; outline: none; font: inherit; color: inherit; }
100
+ /* not Attio-measured (its own field shows no focus ring either, caret
101
+ only) -- kept for real keyboard users, the same brand ring every
102
+ other crisp field already shows on focus (DataTable's identical
103
+ .crisp-datatable-edit:focus, e.g.). */
104
+ & .crisp-recordpage-edit:focus { box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; }
105
+ & .crisp-recordpage-edit:disabled { opacity: 0.5; cursor: not-allowed; }
106
+ & .crisp-recordpage-edit-pending { flex-shrink: 0; color: ${c.fgMuted}; }
107
+ & .crisp-recordpage-edit-error { flex-basis: 100%; margin-top: ${c.space["0.5"]}; font-size: ${c.text.sm}; color: ${c.bgDanger}; }
108
+ & .crisp-recordpage-edit-picker { display: flex; align-items: center; gap: ${c.space["1"]}; width: 100%; }
109
+ & .crisp-recordpage-edit-picker .crisp-select-trigger { flex: 1; min-width: 0; }
80
110
  & .crisp-recordpage-link { color: ${c.fgPrimary}; text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
81
111
  & .crisp-recordpage-link:hover { text-decoration: underline; }
82
112
  & .crisp-recordpage-emptystate { padding: ${c.space["2"]} ${c.space["1"]}; font-size: ${c.text.md}; color: ${c.fgMuted}; }
@@ -2,8 +2,11 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { X } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { Avatar } from "../../components/avatar/index.js";
5
+ import { Select } from "../../components/select/index.js";
6
+ import { Spinner } from "../../components/spinner/index.js";
7
+ import { Switch } from "../../components/switch/index.js";
5
8
  import { hasOpenFloatingLayer } from "../../core/index.js";
6
- import { tagTone, } from "../recordstable/index.js";
9
+ import { editableInputType, tagTone, useEditableField, } from "../recordstable/index.js";
7
10
  import { recordpanelRecipe } from "./recordpanel.recipe.js";
8
11
  function FieldValue({ col, row, emptyLabel, }) {
9
12
  const v = row[col.key];
@@ -27,8 +30,9 @@ function FieldValue({ col, row, emptyLabel, }) {
27
30
  * relative` container (e.g. the app main area); it slides in from the right and
28
31
  * overlays the content.
29
32
  */
30
- export function RecordPanel({ record, columns, onClose, className, closeLabel = "Close panel", title, detailsLabel = "Details", emptyLabel = "Empty", footer, ...rest }) {
33
+ export function RecordPanel({ record, columns, onClose, className, closeLabel = "Close panel", title, detailsLabel = "Details", emptyLabel = "Empty", footer, onEdit, ...rest }) {
31
34
  const titleId = React.useId();
35
+ const { editingKey, pendingKey, error, openEditing, closeEditing, commit } = useEditableField(onEdit);
32
36
  // close on Escape while open -- but not when a Menu/Select/Popover/
33
37
  // ContextMenu opened from inside the panel (e.g. a footer "More actions"
34
38
  // menu) is itself open (#361): that Escape belongs to the nested layer,
@@ -53,13 +57,25 @@ export function RecordPanel({ record, columns, onClose, className, closeLabel =
53
57
  if (!record)
54
58
  return;
55
59
  const onKey = (e) => {
56
- if (e.key !== "Escape" || hasOpenFloatingLayer())
60
+ if (e.key !== "Escape")
61
+ return;
62
+ // #431: Escape while a field is open cancels JUST that field, the
63
+ // same way DataTable's own editable cells treat it -- not the whole
64
+ // panel. Checked ahead of `hasOpenFloatingLayer()` (capture phase, so
65
+ // this runs before the field's own bubble-phase onKeyDown would ever
66
+ // get a chance to stopPropagation -- same reasoning as the
67
+ // floating-layer check below).
68
+ if (editingKey) {
69
+ closeEditing();
70
+ return;
71
+ }
72
+ if (hasOpenFloatingLayer())
57
73
  return;
58
74
  onClose();
59
75
  };
60
76
  document.addEventListener("keydown", onKey, true);
61
77
  return () => document.removeEventListener("keydown", onKey, true);
62
- }, [record, onClose]);
78
+ }, [record, onClose, editingKey, closeEditing]);
63
79
  if (!record)
64
80
  return null;
65
81
  const entityCol = columns.find((c) => c.type === "entity");
@@ -68,6 +84,40 @@ export function RecordPanel({ record, columns, onClose, className, closeLabel =
68
84
  : undefined;
69
85
  const fields = columns.filter((c) => c.type !== "entity");
70
86
  const heading = title ?? entity?.name ?? "Record";
71
- return (_jsxs("div", { className: recordpanelRecipe({ className }), ...rest, role: "dialog", "aria-labelledby": titleId, children: [_jsxs("div", { className: "crisp-recordpanel-header", children: [entity?.avatar ? (_jsx("img", { className: "crisp-recordpanel-fav", src: entity.avatar, alt: "" })) : entity ? (_jsx(Avatar, { size: "md", shape: "rounded", tone: "brand", children: entity.name.charAt(0) })) : null, _jsx("span", { id: titleId, className: "crisp-recordpanel-title", children: heading }), _jsx("button", { type: "button", className: "crisp-recordpanel-iconbtn", "aria-label": closeLabel, onClick: onClose, children: _jsx(X, { size: 18, strokeWidth: 1.75 }) })] }), _jsxs("div", { className: "crisp-recordpanel-body", children: [_jsx("div", { className: "crisp-recordpanel-sectionlabel", children: detailsLabel }), fields.map((col) => (_jsxs("div", { className: "crisp-recordpanel-field", children: [_jsxs("span", { className: "crisp-recordpanel-field-label", children: [col.icon ? _jsx(col.icon, { size: 15, strokeWidth: 1.75 }) : null, col.label] }), _jsx("span", { className: "crisp-recordpanel-field-value", children: _jsx(FieldValue, { col: col, row: record, emptyLabel: emptyLabel }) })] }, col.key)))] }), footer != null && (_jsx("div", { className: "crisp-recordpanel-footer", children: footer }))] }));
87
+ return (_jsxs("div", { className: recordpanelRecipe({ className }), ...rest, role: "dialog", "aria-labelledby": titleId, children: [_jsxs("div", { className: "crisp-recordpanel-header", children: [entity?.avatar ? (_jsx("img", { className: "crisp-recordpanel-fav", src: entity.avatar, alt: "" })) : entity ? (_jsx(Avatar, { size: "md", shape: "rounded", tone: "brand", children: entity.name.charAt(0) })) : null, _jsx("span", { id: titleId, className: "crisp-recordpanel-title", children: heading }), _jsx("button", { type: "button", className: "crisp-recordpanel-iconbtn", "aria-label": closeLabel, onClick: onClose, children: _jsx(X, { size: 18, strokeWidth: 1.75 }) })] }), _jsxs("div", { className: "crisp-recordpanel-body", children: [_jsx("div", { className: "crisp-recordpanel-sectionlabel", children: detailsLabel }), fields.map((col) => {
88
+ // #431: a field only actually opens when there's an `onEdit` to
89
+ // commit through -- `editable` with nothing to call would be a
90
+ // dead click.
91
+ const canEdit = Boolean(col.editable && onEdit);
92
+ const isPending = pendingKey === col.key;
93
+ const fieldError = error?.key === col.key ? error.message : null;
94
+ // select/switch are always their own live widget -- no click-to-
95
+ // open, no separate edit/view mode, the same convention
96
+ // DataTable's identical `editor` columns use (#385): choosing a
97
+ // value or flipping a toggle IS the commit gesture.
98
+ if (canEdit && col.editor) {
99
+ const value = record[col.key];
100
+ return (_jsxs("div", { className: "crisp-recordpanel-field", children: [_jsxs("span", { className: "crisp-recordpanel-field-label", children: [col.icon ? _jsx(col.icon, { size: 15, strokeWidth: 1.75 }) : null, col.label] }), _jsxs("span", { className: "crisp-recordpanel-field-value", children: [_jsxs("span", { className: "crisp-recordpanel-edit-picker", children: [col.editor === "select" ? (_jsx(Select, { variant: "ghost", "aria-label": `Edit ${col.label}`, value: value != null ? String(value) : undefined, disabled: isPending, options: col.editOptions ?? [], onValueChange: (v) => commit(record.id, col.key, v) })) : (_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-recordpanel-edit-pending" }))] }), fieldError && (_jsx("span", { className: "crisp-recordpanel-edit-error", children: fieldError }))] })] }, col.key));
101
+ }
102
+ if (canEdit && editingKey === col.key) {
103
+ const value = record[col.key];
104
+ const editor = editableInputType(col.type);
105
+ return (_jsxs("div", { className: "crisp-recordpanel-field", children: [_jsxs("span", { className: "crisp-recordpanel-field-label", children: [col.icon ? _jsx(col.icon, { size: 15, strokeWidth: 1.75 }) : null, col.label] }), _jsxs("span", { className: "crisp-recordpanel-field-value", children: [_jsx("input", { className: "crisp-recordpanel-edit",
106
+ // biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
107
+ autoFocus: true, ...editor, disabled: isPending, defaultValue: value == null ? "" : String(value), "aria-label": `Edit ${col.label}`, onKeyDown: (e) => {
108
+ if (isPending)
109
+ return;
110
+ if (e.key === "Enter")
111
+ commit(record.id, col.key, e.currentTarget.value);
112
+ else if (e.key === "Escape")
113
+ closeEditing();
114
+ }, onBlur: (e) => {
115
+ if (isPending)
116
+ return;
117
+ commit(record.id, col.key, e.currentTarget.value);
118
+ } }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-recordpanel-edit-pending" })), fieldError && (_jsx("span", { className: "crisp-recordpanel-edit-error", children: fieldError }))] })] }, col.key));
119
+ }
120
+ return (_jsxs("div", { className: "crisp-recordpanel-field", children: [_jsxs("span", { className: "crisp-recordpanel-field-label", children: [col.icon ? _jsx(col.icon, { size: 15, strokeWidth: 1.75 }) : null, col.label] }), _jsxs("span", { className: "crisp-recordpanel-field-value", "data-editable": canEdit ? "" : undefined, children: [canEdit ? (_jsx("button", { type: "button", className: "crisp-recordpanel-edit-trigger", onClick: () => openEditing(col.key), children: _jsx(FieldValue, { col: col, row: record, emptyLabel: emptyLabel }) })) : (_jsx(FieldValue, { col: col, row: record, emptyLabel: emptyLabel })), fieldError && (_jsx("span", { className: "crisp-recordpanel-edit-error", children: fieldError }))] })] }, col.key));
121
+ })] }), footer != null && (_jsx("div", { className: "crisp-recordpanel-footer", children: footer }))] }));
72
122
  }
73
123
  RecordPanel.displayName = "RecordPanel";
@@ -31,8 +31,37 @@ const config = defineRecipe({
31
31
  & .crisp-recordpanel-field-label { display: flex; align-items: center; gap: ${c.space["1.5"]}; width: 132px; flex-shrink: 0; padding-top: 3px; color: ${c.fgMuted}; font-size: ${c.text.md}; }
32
32
  & .crisp-recordpanel-field-label svg { flex-shrink: 0; }
33
33
  & .crisp-recordpanel-field-value { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: ${c.space["1"]}; padding-top: 3px; font-size: ${c.text.lg}; color: ${c.fgPrimary}; }
34
+ & .crisp-recordpanel-field-value[data-editable] { cursor: pointer; }
35
+ /* the click-to-edit trigger is a real <button> (keyboard-operable),
36
+ reset back to plain inline text and made a flex-wrap container of its
37
+ own -- FieldValue can render several chips (tags), which still need
38
+ to wrap the way they did before this had a button around them at
39
+ all. It wraps only FieldValue, never the error slot beside it (see
40
+ the component's own comment) -- NOT \`display: contents\`, which
41
+ drops a button's accessible semantics in some engines. */
42
+ & .crisp-recordpanel-edit-trigger { display: inline-flex; flex-wrap: wrap; align-items: center; gap: ${c.space["1"]}; margin: 0; padding: 0; border: none; background: none; text-align: start; font: inherit; color: inherit; cursor: pointer; }
34
43
  & .crisp-recordpanel-empty { color: ${c.fgMuted}; }
35
44
 
45
+ /* #431: measured live on app.attio.com's record side panel -- clicking a
46
+ field's value opens a plain, borderless field exactly filling the
47
+ value's own box (no border, no background, no box-shadow), same font
48
+ as the value it replaces (hence \`font: inherit\`, not a restated
49
+ size/weight). A field row's own hover tint above already carries the
50
+ "this is interactive" affordance; the pending/error treatment isn't
51
+ Attio-measured -- it reuses the same tokens DataTable's identical
52
+ states already use (opacity 0.5, \`bgDanger\`), per #385/#430. */
53
+ & .crisp-recordpanel-edit { display: block; width: 100%; margin: 0; padding: 0; border: none; border-radius: ${c.radius.sm}; background: none; box-shadow: none; outline: none; font: inherit; color: inherit; }
54
+ /* not Attio-measured (its own field shows no focus ring either, caret
55
+ only) -- kept for real keyboard users, the same brand ring every
56
+ other crisp field already shows on focus (DataTable's identical
57
+ .crisp-datatable-edit:focus, e.g.). */
58
+ & .crisp-recordpanel-edit:focus { box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; }
59
+ & .crisp-recordpanel-edit:disabled { opacity: 0.5; cursor: not-allowed; }
60
+ & .crisp-recordpanel-edit-pending { flex-shrink: 0; color: ${c.fgMuted}; }
61
+ & .crisp-recordpanel-edit-error { flex-basis: 100%; margin-top: ${c.space["0.5"]}; font-size: ${c.text.sm}; color: ${c.bgDanger}; }
62
+ & .crisp-recordpanel-edit-picker { display: flex; align-items: center; gap: ${c.space["1"]}; width: 100%; }
63
+ & .crisp-recordpanel-edit-picker .crisp-select-trigger { flex: 1; min-width: 0; }
64
+
36
65
  ${recordTagCss}
37
66
  & .crisp-recordpanel-link { color: ${c.bgBrandSolid}; text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
38
67
  & .crisp-recordpanel-link:hover { text-decoration: underline; }
@@ -0,0 +1,73 @@
1
+ import * as React from "react";
2
+ /**
3
+ * The async-commit state machine behind `RecordPanel`/`RecordPage`'s
4
+ * editable fields (#431) — the same shape `DataTable`'s own `commitEdit`
5
+ * uses (#385/#430): a sync `onEdit` (returns `undefined`) commits and closes
6
+ * immediately; a Promise-returning `onEdit` keeps the field open and pending
7
+ * while it settles, then either closes it (resolve) or closes it AND
8
+ * reverts while showing the rejection's message (reject). The revert needs
9
+ * no extra code here either: neither `RecordPanel` nor `RecordPage` mutates
10
+ * `record` itself, so once `editingKey` clears, the field simply renders
11
+ * `record[key]` again — whatever it still says.
12
+ *
13
+ * One hook shared by both consumers rather than two copies of the same
14
+ * three `useState`s and the same `.then` branching.
15
+ */
16
+ export function useEditableField(onEdit) {
17
+ const [editingKey, setEditingKey] = React.useState(null);
18
+ // Set only while an async `onEdit` is in flight -- a sync `onEdit` never
19
+ // touches this.
20
+ const [pendingKey, setPendingKey] = React.useState(null);
21
+ const [error, setError] = React.useState(null);
22
+ // Opening a field's editor also clears any stale error left over from a
23
+ // PREVIOUS failed commit on it -- otherwise a rejection from three edits
24
+ // ago would still be sitting there while the reader tries again.
25
+ const openEditing = React.useCallback((key) => {
26
+ setError(null);
27
+ setEditingKey(key);
28
+ }, []);
29
+ const closeEditing = React.useCallback(() => setEditingKey(null), []);
30
+ const commit = React.useCallback((id, key, value) => {
31
+ setError(null);
32
+ const result = onEdit?.(id, key, value);
33
+ if (result && typeof result.then === "function") {
34
+ setPendingKey(key);
35
+ result.then(() => {
36
+ setPendingKey(null);
37
+ setEditingKey(null);
38
+ }, (err) => {
39
+ setPendingKey(null);
40
+ setEditingKey(null);
41
+ setError({
42
+ key,
43
+ message: err instanceof Error
44
+ ? err.message
45
+ : typeof err === "string"
46
+ ? err
47
+ : "Could not save",
48
+ });
49
+ });
50
+ return;
51
+ }
52
+ setEditingKey(null);
53
+ }, [onEdit]);
54
+ return { editingKey, pendingKey, error, openEditing, closeEditing, commit };
55
+ }
56
+ /**
57
+ * Which plain editor a column gets when it's `editable` without an
58
+ * `editor: "select" | "switch"` — identical to `DataTable`'s own
59
+ * `editorFor` (#385): number/currency get a numeric field, date a native
60
+ * date field, everything else (text, entity, tags, url, status, reference)
61
+ * a plain text field. Kept as one function so the two never drift apart.
62
+ */
63
+ export function editableInputType(colType) {
64
+ switch (colType) {
65
+ case "number":
66
+ case "currency":
67
+ return { type: "number", inputMode: "decimal" };
68
+ case "date":
69
+ return { type: "date" };
70
+ default:
71
+ return { type: "text", inputMode: "text" };
72
+ }
73
+ }
@@ -1,2 +1,3 @@
1
+ export { editableInputType, useEditableField, } from "./editable-field.js";
1
2
  export { defaultColumns, defaultRows, RecordsTable, tagTone, } from "./recordstable.js";
2
3
  export { recordstableRecipe } from "./recordstable.recipe.js";
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ArrowDown, ArrowDownWideNarrow, ArrowLeft, ArrowRight, ArrowUp, ArrowUpDown, ArrowUpNarrowWide, ArrowUpRight, AtSign, Briefcase, Building2, Calendar, Check, CircleHelp, Copy, CopyPlus, Download, EyeOff, Globe, GripVertical, Hash, Inbox, ListFilter, ListPlus, Mail, MapPin, MoreHorizontal, Pencil, Plus, Star, Tag, Trash2, TrendingUp, Users, Workflow, X, } from "lucide-react";
2
+ import { ArrowDown, ArrowDownWideNarrow, ArrowLeft, ArrowRight, ArrowUp, ArrowUpDown, ArrowUpNarrowWide, ArrowUpRight, AtSign, Briefcase, Building2, Calendar, Check, CircleHelp, CircleUserRound, Copy, CopyPlus, CornerDownLeft, Download, EyeOff, Globe, GripVertical, Hash, Inbox, ListFilter, ListPlus, Mail, MapPin, MoreHorizontal, Pencil, Plus, Star, Tag, Trash2, TrendingUp, Users, Workflow, X, } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import { Avatar } from "../../components/avatar/index.js";
@@ -224,15 +224,17 @@ function EntityCell({ value, pinned, onOpenPage, avatarTone = "brand", labels, }
224
224
  }
225
225
  function CellView({ column, row, pinned, onOpenPage, avatarTone = "brand", labels, }) {
226
226
  const value = row[column.key];
227
- // `render` fully owns the cell's content — no empty-dash fallback, no
228
- // type renderer; it's the only thing drawn here (see the doc comment on
227
+ // `render` fully owns the cell's content — no empty fallback, no type
228
+ // renderer; it's the only thing drawn here (see the doc comment on
229
229
  // `Column.render`).
230
230
  if (column.render)
231
231
  return _jsx(_Fragment, { children: column.render(value, row) });
232
232
  if (value == null ||
233
233
  value === "" ||
234
234
  (Array.isArray(value) && !value.length)) {
235
- return _jsx("span", { className: "crisp-recordstable-muted", children: "\u2014" });
235
+ // Attio's own empty cell is genuinely blank -- no placeholder character
236
+ // at all (#412).
237
+ return null;
236
238
  }
237
239
  // `format` keeps the type renderer's chrome below and only replaces the
238
240
  // text it displays.
@@ -275,11 +277,40 @@ function CellView({ column, row, pinned, onOpenPage, avatarTone = "brand", label
275
277
  function StatusCell({ column, row, onChange, }) {
276
278
  return (_jsx(Select, { variant: "ghost", "aria-label": column.label, placeholder: "\u2014", value: row[column.key] ?? undefined, onValueChange: onChange, options: column.options ?? [] }));
277
279
  }
280
+ // A sentinel that can't collide with a real record id (crisp never generates
281
+ // one, and a consumer's own ids come from their own data store) -- picking
282
+ // this row calls `onCreateReference`, not `onChange` (#449).
283
+ const CREATE_REFERENCE_VALUE = "__crisp_create_reference__";
284
+ /** The 16px avatar Attio's own reference picker draws before every option's
285
+ * label -- a photo when the option carries one, a generic person glyph
286
+ * otherwise (measured live, #449: Attio's own fallback is an icon, not
287
+ * initials -- a different, deliberate choice from the entity cell's own
288
+ * initials fallback elsewhere in this file). */
289
+ function referenceOptionLabel(o) {
290
+ return (_jsxs("span", { className: "crisp-recordstable-refoption", children: [_jsx(Avatar, { size: "sm", className: "crisp-recordstable-refoption-avatar", children: o.avatar ? (_jsx("img", { src: o.avatar, alt: "" })) : (_jsx(CircleUserRound, { size: 12, strokeWidth: 1.75, "aria-hidden": "true" })) }), o.label] }));
291
+ }
278
292
  function ReferenceCell({ column, row, onChange, labels, }) {
279
- return (_jsx(Select, { variant: "ghost", searchable: true, searchPlaceholder: labels.searchRecordsPlaceholder, "aria-label": column.label, placeholder: labels.setRecordPlaceholder, value: row[column.key] ?? undefined, onValueChange: onChange, options: (column.refOptions ?? []).map((o) => ({
280
- value: o.value,
281
- label: o.label,
282
- })) }));
293
+ const [query, setQuery] = React.useState("");
294
+ const options = (column.refOptions ?? []).map((o) => ({
295
+ value: o.value,
296
+ label: referenceOptionLabel(o),
297
+ keywords: o.label,
298
+ }));
299
+ if (column.onCreateReference) {
300
+ options.push({
301
+ value: CREATE_REFERENCE_VALUE,
302
+ label: (_jsxs("span", { className: "crisp-recordstable-refoption crisp-recordstable-refoption--create", children: [_jsx(Plus, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }), `Create ${column.createLabel ?? "record"}`] })),
303
+ // always reachable regardless of the typed query -- it's an action,
304
+ // not a record to filter away
305
+ keywords: query,
306
+ });
307
+ }
308
+ return (_jsx(Select, { variant: "ghost", searchable: true, searchPlaceholder: labels.searchRecordsPlaceholder, onSearchChange: setQuery, "aria-label": column.label, placeholder: labels.setRecordPlaceholder, value: row[column.key] ?? undefined, onValueChange: (v) => {
309
+ if (v === CREATE_REFERENCE_VALUE)
310
+ column.onCreateReference?.(query);
311
+ else
312
+ onChange(v);
313
+ }, options: options }));
283
314
  }
284
315
  function TagsCell({ column, row, options, onChange, labels, }) {
285
316
  const value = row[column.key] ?? [];
@@ -302,12 +333,37 @@ function TagsCell({ column, row, options, onChange, labels, }) {
302
333
  // reachable from this module's public surface.
303
334
  // imported for use here AND re-exported, because a bare `export ... from` does
304
335
  // not bind the names locally
305
- import { formatCellDate, parseCellDate } from "../../core/index.js";
336
+ import { cellDatePrecision, formatCellDate, parseCellDate, } from "../../core/index.js";
306
337
  export { formatCellDate, parseCellDate };
338
+ // "1998" for a year-only raw value, "Sep 1998" for year-month, and the full
339
+ // "Sep 4, 1998" only once the raw value actually named a day (#312) --
340
+ // DatePickerField's own default format always prints full precision, since
341
+ // as a general calendar field it has no raw string to read a precision from.
342
+ function formatByPrecision(d, precision) {
343
+ if (precision === "year")
344
+ return String(d.getFullYear());
345
+ return d.toLocaleDateString(undefined, {
346
+ month: "short",
347
+ day: precision === "month" ? undefined : "numeric",
348
+ year: "numeric",
349
+ });
350
+ }
307
351
  function DateCell({ column, row, onChange, }) {
308
352
  const raw = row[column.key];
309
353
  const value = parseCellDate(raw);
310
- return (_jsx(DatePickerField, { variant: "ghost", "aria-label": column.label, value: value, onChange: (d) => onChange(formatCellDate(d)) }));
354
+ const precision = raw ? cellDatePrecision(raw) : "day";
355
+ return (_jsx(DatePickerField, { variant: "ghost", "aria-label": column.label, value: value,
356
+ // Out of scope for #394 (which added Clear to DatePickerField itself):
357
+ // a cell's OWN clear-to-empty affordance is a different, unmeasured
358
+ // question, and this callback's `string` contract has no "empty" of
359
+ // its own to report yet. Keeps this cell's exact pre-#394 behavior.
360
+ clearable: false,
361
+ // #417: the trigger displays the value at its stored precision
362
+ // (year-only stays year-only) rather than always a full date.
363
+ format: (d) => formatByPrecision(d, precision), onChange: (d) => {
364
+ if (d)
365
+ onChange(formatCellDate(d));
366
+ } }));
311
367
  }
312
368
  /**
313
369
  * The "New <object>" create modal — measured from a create-record dialog:
@@ -335,7 +391,7 @@ function CreateRecordDialog({ open, onOpenChange, columns, entityLabel, onCreate
335
391
  row[col.key] = { name: raw || labels.untitled };
336
392
  }
337
393
  else if (!raw) {
338
- // leave empty fields unset so the cell renders its "—" placeholder
394
+ // leave empty fields unset so the cell renders blank (#412)
339
395
  }
340
396
  else if (col.type === "tags") {
341
397
  row[col.key] = raw
@@ -367,7 +423,7 @@ function CreateRecordDialog({ open, onOpenChange, columns, entityLabel, onCreate
367
423
  return (_jsxs(Dialog, { open: open, onOpenChange: onOpenChange, title: labels.newRecordTitle(entityLabel), icon: EntityIcon ? _jsx(EntityIcon, { size: 16, strokeWidth: 1.75 }) : undefined, width: 720, children: [_jsx(DialogBody, { children: columns.map((col, i) => (_jsxs("div", { className: "crisp-dialog-field", children: [_jsx("label", { className: "crisp-dialog-field-label", htmlFor: `create-${col.key}`, children: col.label }), _jsx(Input, { id: `create-${col.key}`, ref: i === 0 ? firstRef : undefined, value: values[col.key] ?? "", onChange: (e) => set(col.key, e.currentTarget.value), placeholder: labels.setFieldPlaceholder(col.label), onKeyDown: (e) => {
368
424
  if (e.key === "Enter" && (e.metaKey || e.ctrlKey))
369
425
  submit();
370
- } })] }, col.key))) }), _jsxs(DialogFooter, { children: [_jsxs("span", { className: "crisp-dialog-more", children: [labels.createMore, _jsx(Switch, { checked: createMore, onCheckedChange: setCreateMore, "aria-label": labels.createMore })] }), _jsxs("button", { type: "button", className: "crisp-dialog-submit", onClick: submit, children: [labels.createRecord, _jsx("span", { className: "crisp-dialog-submit-key", children: "\u2318\u21B5" })] })] })] }));
426
+ } })] }, col.key))) }), _jsxs(DialogFooter, { children: [_jsxs("span", { className: "crisp-dialog-more", children: [_jsx(Switch, { checked: createMore, onCheckedChange: setCreateMore, "aria-label": labels.createMore }), labels.createMore] }), _jsxs("button", { type: "button", className: "crisp-dialog-submit", onClick: submit, children: [labels.createRecord, _jsxs("span", { className: "crisp-dialog-submit-key", "aria-hidden": "true", children: [_jsx("kbd", { children: "\u2318" }), _jsx("kbd", { children: _jsx(CornerDownLeft, { size: 10, strokeWidth: 2 }) })] })] })] })] }));
371
427
  }
372
428
  /**
373
429
  * A measured-style record grid — fully featured. Toolbar (search / sort status /
@@ -1034,7 +1090,7 @@ export function RecordsTable({ labels, columns: columnsProp = defaultColumns, ro
1034
1090
  setDragCol(col.key);
1035
1091
  e.dataTransfer.effectAllowed = "move";
1036
1092
  }
1037
- : undefined, onDragEnd: () => setDragCol(null), onClick: (e) => openColumnMenu(col.key, e.currentTarget), children: [Icon ? _jsx(Icon, { size: 14, strokeWidth: 1.75 }) : null, _jsx("span", { className: "crisp-recordstable-th-label", children: col.label }), _jsx("span", { className: "crisp-recordstable-sort", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "1.5", "aria-hidden": "true", children: _jsx("path", { d: "m4 6 4 4 4-4" }) }) })] })), openMenu === `col:${col.key}` &&
1093
+ : undefined, onDragEnd: () => setDragCol(null), onClick: (e) => openColumnMenu(col.key, e.currentTarget), children: [Icon ? _jsx(Icon, { size: 16, strokeWidth: 1.75 }) : null, _jsx("span", { className: "crisp-recordstable-th-label", children: col.label }), _jsx("span", { className: "crisp-recordstable-sort", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "1.5", "aria-hidden": "true", children: _jsx("path", { d: "m4 6 4 4 4-4" }) }) })] })), openMenu === `col:${col.key}` &&
1038
1094
  rootRef.current &&
1039
1095
  createPortal(_jsxs("div", { ref: menuRef, className: "crisp-recordstable-menu crisp-recordstable-menu--fixed crisp-recordstable-menu--hdr", role: "menu", "aria-label": `${col.label} column`, style: menuPos ?? undefined, onKeyDown: onMenuKeyDown, children: [_jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => sortBy(col.key, "asc"), children: [_jsx(ArrowUp, { size: 15, strokeWidth: 1.75 }), t.sortAscending] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => sortBy(col.key, "desc"), children: [_jsx(ArrowDown, { size: 15, strokeWidth: 1.75 }), t.sortDescending] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, disabled: i <= 1, onClick: () => moveColumn(col.key, -1), children: [_jsx(ArrowLeft, { "data-rtl-flip": "", size: 15, strokeWidth: 1.75 }), t.moveLeft] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, disabled: i === 0 || i >= cols.length - 1, onClick: () => moveColumn(col.key, 1), children: [_jsx(ArrowRight, { "data-rtl-flip": "", size: 15, strokeWidth: 1.75 }), t.moveRight] }), _jsx("div", { className: "crisp-recordstable-menu-sep" }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => {
1040
1096
  setRenamingCol(col.key);
@@ -71,6 +71,14 @@ ${toolbarChromeBody("recordstable")}
71
71
  & .crisp-recordstable-td--picker .crisp-multiselect-chips .crisp-recordstable-tag { flex-shrink: 0; max-width: none; }
72
72
  & .crisp-recordstable-td--picker .crisp-multiselect-chevron { display: none; }
73
73
  & .crisp-recordstable-td--picker .crisp-multiselect-placeholder { color: ${c.fgMuted}; }
74
+ /* reference picker: a leading 16px avatar per option, plus a trailing
75
+ "Create X" row when the consumer supplies one (#449) -- measured live
76
+ against Attio's own picker (16px circular avatar, 16px plain icon for
77
+ the create row, both sitting in the option's existing 8px gap, no new
78
+ gap value invented). */
79
+ & .crisp-recordstable-refoption { display: flex; align-items: center; gap: ${c.space["2"]}; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
80
+ & .crisp-recordstable-refoption-avatar { flex-shrink: 0; }
81
+ & .crisp-recordstable-refoption--create { color: ${c.fgMuted}; }
74
82
  & .crisp-recordstable-status { display: inline-flex; align-items: center; gap: ${c.space["1.5"]}; min-width: 0; }
75
83
  & .crisp-recordstable-status-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: ${c.radius.full}; }
76
84
  & .crisp-recordstable-cell { display: flex; align-items: center; gap: ${c.space["2"]}; height: 100%; min-width: 0; }
@@ -129,7 +137,7 @@ ${toolbarChromeBody("recordstable")}
129
137
  & .crisp-recordstable-empty { display: flex; align-items: center; justify-content: center; min-height: 320px; padding: ${c.space["8"]} ${c.space["4"]}; }
130
138
 
131
139
  /* summary footer — per-column aggregation, sticky to the bottom */
132
- & .crisp-recordstable-sum { position: sticky; bottom: 0; z-index: 2; height: ${c.size.tableRow}; padding: 0; border-top: 1px solid ${c.borderSubtle}; border-inline-end: 1px solid ${c.borderSubtle}; background: ${c.bgSurface}; }
140
+ & .crisp-recordstable-sum { position: sticky; bottom: 0; z-index: 2; height: ${c.size.tableRow}; padding: 0; border-top: 1px solid ${c.borderSubtle}; border-bottom: 1px solid ${c.borderSubtle}; border-inline-end: 1px solid ${c.borderSubtle}; background: ${c.bgSurface}; }
133
141
  & tfoot .crisp-recordstable-col-check, & tfoot .crisp-recordstable-col-entity { position: sticky; bottom: 0; z-index: 3; }
134
142
  & .crisp-recordstable-sum:last-child { border-inline-end: none; }
135
143
  & .crisp-recordstable-sum-btn { display: flex; align-items: center; justify-content: flex-end; gap: ${c.space["1.5"]}; width: 100%; height: 100%; margin: 0; padding: 0 ${c.space["3"]}; border: none; background: none; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); font-family: inherit; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; cursor: pointer; white-space: nowrap; overflow: hidden; }