@crisp-ui-kit/crisp 0.50.0 → 0.52.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.
- package/dist/cjs/components/composewindow/composewindow.js +144 -0
- package/dist/cjs/components/composewindow/composewindow.recipe.js +45 -0
- package/dist/cjs/components/composewindow/index.js +8 -0
- package/dist/cjs/components/datatable/datatable.js +224 -43
- package/dist/cjs/components/datatable/datatable.recipe.js +37 -3
- package/dist/cjs/components/datatable/index.js +6 -0
- package/dist/cjs/components/datepickerfield/datepickerfield.js +25 -15
- package/dist/cjs/components/datepickerfield/datepickerfield.recipe.js +25 -8
- package/dist/cjs/components/dialog/dialog.recipe.js +9 -1
- package/dist/cjs/components/menu/menu.js +4 -2
- package/dist/cjs/components/multiselect/multiselect.recipe.js +14 -8
- package/dist/cjs/components/select/select.js +2 -1
- package/dist/cjs/components/table/table.recipe.js +12 -3
- package/dist/cjs/components/tabletoolbar/tabletoolbar.chrome.js +1 -1
- package/dist/cjs/components/tabletoolbar/tabletoolbar.js +18 -7
- package/dist/cjs/core/caret-rect.js +73 -0
- package/dist/cjs/core/cell-date.js +11 -0
- package/dist/cjs/core/index.js +8 -1
- package/dist/cjs/core/mention.js +32 -0
- package/dist/cjs/core/use-mention-trigger.js +124 -0
- package/dist/cjs/index.js +1 -0
- package/dist/cjs/product/recordpage/recordpage.js +88 -10
- package/dist/cjs/product/recordpage/recordpage.recipe.js +30 -0
- package/dist/cjs/product/recordpanel/recordpanel.js +11 -29
- package/dist/cjs/product/recordpanel/recordpanel.recipe.js +55 -17
- package/dist/cjs/product/recordstable/editable-field.js +113 -0
- package/dist/cjs/product/recordstable/index.js +4 -1
- package/dist/cjs/product/recordstable/recordstable.js +67 -11
- package/dist/cjs/product/recordstable/recordstable.recipe.js +9 -1
- package/dist/cjs/product/recordstable/recordstable.tag.js +8 -3
- package/dist/cjs/product/richtext/index.js +2 -1
- package/dist/cjs/product/richtext/richtext.js +440 -23
- package/dist/cjs/product/richtext/richtext.recipe.js +59 -9
- package/dist/cjs/product/settingsshell/settingsshell.recipe.js +13 -4
- package/dist/cjs/product/sidebar/sidebar.js +15 -3
- package/dist/cjs/product/sidebar/sidebar.recipe.js +21 -2
- package/dist/composewindow.css +24 -0
- package/dist/composewindow.d.ts +7 -0
- package/dist/composewindow.layered.css +26 -0
- package/dist/datatable.css +33 -14
- package/dist/datatable.layered.css +33 -14
- package/dist/datepickerfield.css +9 -4
- package/dist/datepickerfield.layered.css +9 -4
- package/dist/dialog.css +2 -1
- package/dist/dialog.layered.css +2 -1
- package/dist/esm/components/composewindow/composewindow.js +107 -0
- package/dist/esm/components/composewindow/composewindow.recipe.js +42 -0
- package/dist/esm/components/composewindow/index.js +2 -0
- package/dist/esm/components/datatable/datatable.js +226 -45
- package/dist/esm/components/datatable/datatable.recipe.js +37 -3
- package/dist/esm/components/datatable/index.js +6 -0
- package/dist/esm/components/datepickerfield/datepickerfield.js +26 -16
- package/dist/esm/components/datepickerfield/datepickerfield.recipe.js +25 -8
- package/dist/esm/components/dialog/dialog.recipe.js +9 -1
- package/dist/esm/components/menu/menu.js +4 -2
- package/dist/esm/components/multiselect/multiselect.recipe.js +14 -8
- package/dist/esm/components/select/select.js +2 -1
- package/dist/esm/components/table/table.recipe.js +12 -3
- package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +1 -1
- package/dist/esm/components/tabletoolbar/tabletoolbar.js +18 -7
- package/dist/esm/core/caret-rect.js +70 -0
- package/dist/esm/core/cell-date.js +10 -0
- package/dist/esm/core/index.js +4 -1
- package/dist/esm/core/mention.js +29 -0
- package/dist/esm/core/use-mention-trigger.js +88 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/product/recordpage/recordpage.js +89 -11
- package/dist/esm/product/recordpage/recordpage.recipe.js +30 -0
- package/dist/esm/product/recordpanel/index.js +1 -1
- package/dist/esm/product/recordpanel/recordpanel.js +13 -31
- package/dist/esm/product/recordpanel/recordpanel.recipe.js +56 -18
- package/dist/esm/product/recordstable/editable-field.js +76 -0
- package/dist/esm/product/recordstable/index.js +1 -0
- package/dist/esm/product/recordstable/recordstable.js +69 -13
- package/dist/esm/product/recordstable/recordstable.recipe.js +9 -1
- package/dist/esm/product/recordstable/recordstable.tag.js +8 -3
- package/dist/esm/product/richtext/index.js +1 -1
- package/dist/esm/product/richtext/richtext.js +439 -22
- package/dist/esm/product/richtext/richtext.recipe.js +60 -10
- package/dist/esm/product/settingsshell/settingsshell.recipe.js +13 -4
- package/dist/esm/product/sidebar/sidebar.js +15 -3
- package/dist/esm/product/sidebar/sidebar.recipe.js +21 -2
- package/dist/multiselect.css +8 -8
- package/dist/multiselect.layered.css +8 -8
- package/dist/recordpage.css +25 -13
- package/dist/recordpage.layered.css +25 -13
- package/dist/recordpanel.css +43 -20
- package/dist/recordpanel.layered.css +43 -20
- package/dist/recordstable.css +9 -2
- package/dist/recordstable.layered.css +9 -2
- package/dist/richtext.css +31 -5
- package/dist/richtext.layered.css +31 -5
- package/dist/settingsshell.css +2 -2
- package/dist/settingsshell.layered.css +2 -2
- package/dist/sidebar.css +7 -2
- package/dist/sidebar.layered.css +7 -2
- package/dist/styles.css +199 -74
- package/dist/styles.layered.css +199 -74
- package/dist/table.css +4 -2
- package/dist/table.layered.css +4 -2
- package/dist/tabletoolbar.css +1 -1
- package/dist/tabletoolbar.layered.css +1 -1
- package/dist/types/components/composewindow/composewindow.d.ts +80 -0
- package/dist/types/components/composewindow/composewindow.recipe.d.ts +3 -0
- package/dist/types/components/composewindow/index.d.ts +2 -0
- package/dist/types/components/datatable/datatable.d.ts +58 -4
- package/dist/types/components/datatable/index.d.ts +1 -1
- package/dist/types/components/datepickerfield/datepickerfield.d.ts +18 -4
- package/dist/types/components/menu/menu.d.ts +10 -1
- package/dist/types/components/select/select.d.ts +6 -1
- package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +29 -5
- package/dist/types/core/caret-rect.d.ts +16 -0
- package/dist/types/core/cell-date.d.ts +17 -0
- package/dist/types/core/index.d.ts +4 -1
- package/dist/types/core/mention.d.ts +27 -0
- package/dist/types/core/use-mention-trigger.d.ts +41 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/product/recordpage/recordpage.d.ts +59 -1
- package/dist/types/product/recordpanel/index.d.ts +1 -1
- package/dist/types/product/recordpanel/recordpanel.d.ts +81 -17
- package/dist/types/product/recordstable/editable-field.d.ts +48 -0
- package/dist/types/product/recordstable/index.d.ts +1 -0
- package/dist/types/product/recordstable/recordstable.d.ts +29 -0
- package/dist/types/product/recordstable/recordstable.tag.d.ts +8 -3
- package/dist/types/product/richtext/index.d.ts +1 -1
- package/dist/types/product/richtext/richtext.d.ts +168 -7
- package/dist/types/product/sidebar/sidebar.d.ts +60 -4
- 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
|
-
|
|
172
|
-
|
|
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
|
|
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) =>
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
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) =>
|
|
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:
|
|
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}; }
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { RecordPanel } from "./recordpanel.js";
|
|
1
|
+
export { RecordPanel, } from "./recordpanel.js";
|
|
2
2
|
export { recordpanelRecipe } from "./recordpanel.recipe.js";
|
|
@@ -1,33 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { X } from "lucide-react";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ArrowUpRight, ChevronDown, ChevronUp, X } from "lucide-react";
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { Avatar } from "../../components/avatar/index.js";
|
|
5
5
|
import { hasOpenFloatingLayer } from "../../core/index.js";
|
|
6
|
-
import { tagTone, } from "../recordstable/index.js";
|
|
7
6
|
import { recordpanelRecipe } from "./recordpanel.recipe.js";
|
|
8
|
-
function FieldValue({ col, row, emptyLabel, }) {
|
|
9
|
-
const v = row[col.key];
|
|
10
|
-
if (v == null || v === "" || (Array.isArray(v) && !v.length)) {
|
|
11
|
-
return _jsx("span", { className: "crisp-recordpanel-empty", children: emptyLabel });
|
|
12
|
-
}
|
|
13
|
-
switch (col.type) {
|
|
14
|
-
case "tags":
|
|
15
|
-
return (_jsx(_Fragment, { children: v.map((t) => (_jsx("span", { className: "crisp-recordstable-tag", "data-tone": tagTone(t), children: t }, t))) }));
|
|
16
|
-
case "number":
|
|
17
|
-
return _jsx(_Fragment, { children: typeof v === "number" ? v.toLocaleString() : v });
|
|
18
|
-
case "url":
|
|
19
|
-
return (_jsx("a", { className: "crisp-recordpanel-link", href: `https://${v}`, target: "_blank", rel: "noreferrer", children: v }));
|
|
20
|
-
default:
|
|
21
|
-
return _jsx(_Fragment, { children: String(v) });
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
7
|
/**
|
|
25
|
-
* Right-side record peek panel —
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
8
|
+
* Right-side record peek panel — Attio's own peek shows a Highlights card
|
|
9
|
+
* area and an Activity feed (measured live: hover a Companies row, click its
|
|
10
|
+
* "Open preview" trigger), never an editable field list (#448 — a "Details"
|
|
11
|
+
* list was this component's own invention before, and #431's editable API
|
|
12
|
+
* had nothing real in this panel to attach to; that API stays on
|
|
13
|
+
* `RecordPage`'s own Details column, where Attio does let you edit in
|
|
14
|
+
* place). Render it inside a `position: relative` container (e.g. the app
|
|
15
|
+
* main area); it slides in from the right and overlays the content.
|
|
29
16
|
*/
|
|
30
|
-
export function RecordPanel({ record,
|
|
17
|
+
export function RecordPanel({ record, onClose, className, closeLabel = "Close panel", onOpenRecord, openRecordLabel = "Open record", onPrevious, previousRecordLabel = "Previous record", onNext, nextRecordLabel = "Next record", title, avatar, avatarTone = "brand", highlightsLabel = "Highlights", highlights, activityLabel = "Activity", viewAllActivityLabel = "View all", onViewAllActivity, activity, footer, ...rest }) {
|
|
31
18
|
const titleId = React.useId();
|
|
32
19
|
// close on Escape while open -- but not when a Menu/Select/Popover/
|
|
33
20
|
// ContextMenu opened from inside the panel (e.g. a footer "More actions"
|
|
@@ -62,12 +49,7 @@ export function RecordPanel({ record, columns, onClose, className, closeLabel =
|
|
|
62
49
|
}, [record, onClose]);
|
|
63
50
|
if (!record)
|
|
64
51
|
return null;
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
? record[entityCol.key]
|
|
68
|
-
: undefined;
|
|
69
|
-
const fields = columns.filter((c) => c.type !== "entity");
|
|
70
|
-
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 }))] }));
|
|
52
|
+
const heading = title ?? record.name ?? "Record";
|
|
53
|
+
return (_jsxs("div", { className: recordpanelRecipe({ className }), ...rest, role: "dialog", "aria-labelledby": titleId, children: [_jsxs("div", { className: "crisp-recordpanel-toolbar", children: [_jsx("button", { type: "button", className: "crisp-recordpanel-iconbtn", "aria-label": closeLabel, onClick: onClose, children: _jsx(X, { size: 16, strokeWidth: 1.75 }) }), (onPrevious || onNext) && (_jsxs("span", { className: "crisp-recordpanel-pager", children: [_jsx("button", { type: "button", className: "crisp-recordpanel-navbtn", "aria-label": previousRecordLabel, disabled: !onPrevious, onClick: onPrevious, children: _jsx(ChevronUp, { size: 16, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpanel-navbtn", "aria-label": nextRecordLabel, disabled: !onNext, onClick: onNext, children: _jsx(ChevronDown, { size: 16, strokeWidth: 1.75 }) })] })), _jsx("span", { className: "crisp-recordpanel-toolbar-spacer" }), onOpenRecord && (_jsx("button", { type: "button", className: "crisp-recordpanel-navbtn", "aria-label": openRecordLabel, onClick: onOpenRecord, children: _jsx(ArrowUpRight, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75 }) }))] }), _jsxs("div", { className: "crisp-recordpanel-header", children: [avatar ? (_jsx("img", { className: "crisp-recordpanel-fav", src: avatar, alt: "" })) : (_jsx(Avatar, { size: "md", shape: "rounded", tone: avatarTone, children: (record.name ?? "?").charAt(0) })), _jsx("span", { id: titleId, className: "crisp-recordpanel-title", children: heading })] }), _jsxs("div", { className: "crisp-recordpanel-body", children: [highlights && highlights.length > 0 && (_jsxs("div", { className: "crisp-recordpanel-section", children: [_jsx("div", { className: "crisp-recordpanel-section-head", children: _jsx("span", { className: "crisp-recordpanel-section-label", children: highlightsLabel }) }), _jsx("div", { className: "crisp-recordpanel-highlights", children: highlights.map((h) => (_jsxs("div", { className: "crisp-recordpanel-highlight", children: [_jsxs("span", { className: "crisp-recordpanel-highlight-label", children: [_jsx("span", { children: h.label }), h.icon ? _jsx(h.icon, { size: 16, strokeWidth: 1.75 }) : null] }), _jsx("span", { className: "crisp-recordpanel-highlight-value", children: h.value })] }, h.key))) })] })), activity != null && (_jsxs("div", { className: "crisp-recordpanel-section", children: [_jsxs("div", { className: "crisp-recordpanel-section-head", children: [_jsx("span", { className: "crisp-recordpanel-section-label", children: activityLabel }), onViewAllActivity && (_jsx("button", { type: "button", className: "crisp-recordpanel-section-action", onClick: onViewAllActivity, children: viewAllActivityLabel }))] }), _jsx("div", { className: "crisp-recordpanel-activity", children: activity })] }))] }), footer != null && (_jsx("div", { className: "crisp-recordpanel-footer", children: footer }))] }));
|
|
72
54
|
}
|
|
73
55
|
RecordPanel.displayName = "RecordPanel";
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { defineRecipe, resolveRecipe } from "../../core/index.js";
|
|
1
|
+
import { defineRecipe, resolveRecipe, rtlFlipCss } from "../../core/index.js";
|
|
2
2
|
import { contract as c, elevation as e } from "../../tokens/index.js";
|
|
3
|
-
import { recordTagCss } from "../recordstable/recordstable.tag.js";
|
|
4
3
|
// A right-side record peek panel — slides in over the main content to show one
|
|
5
|
-
// record's
|
|
6
|
-
// relative container (the app main area); slides in via
|
|
4
|
+
// record's Highlights and Activity, the way Attio's own peek does. Positioned
|
|
5
|
+
// absolutely inside a relative container (the app main area); slides in via
|
|
6
|
+
// @starting-style.
|
|
7
7
|
const config = defineRecipe({
|
|
8
8
|
name: "recordpanel",
|
|
9
9
|
base: `
|
|
@@ -18,24 +18,60 @@ const config = defineRecipe({
|
|
|
18
18
|
@starting-style { transform: translateX(calc(100% + ${c.space["2"]})); }
|
|
19
19
|
& * { box-sizing: border-box; }
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
/* #448: measured live on app.attio.com's peek -- a toolbar row above
|
|
22
|
+
the identity header: Close, then Previous/Next, on the left; "Open
|
|
23
|
+
record" on the right. 49px tall, 8px/10px padding, a hairline border
|
|
24
|
+
beneath (the same borderSubtle every other panel/row edge here
|
|
25
|
+
already uses). Close keeps the existing transparent ghost button;
|
|
26
|
+
Previous/Next/Open record are a distinct field-elevation style
|
|
27
|
+
(white fill, e.field's own ring+shadow, solid icon) -- measured as
|
|
28
|
+
genuinely different chrome from Close, not a reuse. */
|
|
29
|
+
& .crisp-recordpanel-toolbar { display: flex; align-items: center; height: 49px; flex-shrink: 0; padding: ${c.space["2"]} ${c.space["2.5"]}; border-bottom: 1px solid ${c.borderSubtle}; }
|
|
30
|
+
& .crisp-recordpanel-toolbar-spacer { flex: 1; }
|
|
31
|
+
& .crisp-recordpanel-pager { display: flex; align-items: center; gap: ${c.space["1.5"]}; margin-inline-start: ${c.space["3"]}; }
|
|
32
|
+
& .crisp-recordpanel-iconbtn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; flex-shrink: 0; padding: 0; border: none; border-radius: ${c.radius.md}; background: transparent; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); font-family: inherit; cursor: pointer; transition: background-color .12s; }
|
|
33
|
+
& .crisp-recordpanel-iconbtn:hover { background: ${c.bgRaised}; color: ${c.fgPrimary}; }
|
|
34
|
+
& .crisp-recordpanel-navbtn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; flex-shrink: 0; padding: 0; border: none; border-radius: ${c.radius.md}; background: ${c.bgSurface}; box-shadow: ${e.field}; color: ${c.fgPrimary}; font-family: inherit; cursor: pointer; }
|
|
35
|
+
& .crisp-recordpanel-navbtn:hover:not(:disabled) { background: ${c.bgRaised}; }
|
|
36
|
+
& .crisp-recordpanel-navbtn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
37
|
+
|
|
38
|
+
& .crisp-recordpanel-header { display: flex; align-items: center; gap: ${c.space["2"]}; height: 52px; flex-shrink: 0; padding: 0 ${c.space["3"]}; }
|
|
22
39
|
& .crisp-recordpanel-fav { width: 22px; height: ${c.size.chip}; border-radius: ${c.radius.sm}; flex-shrink: 0; object-fit: cover; background: ${c.bgRaised}; }
|
|
23
40
|
& .crisp-recordpanel-title { flex: 1; min-width: 0; font-size: ${c.text.xl}; font-weight: ${c.weight.semibold}; letter-spacing: -0.2px; color: ${c.fgPrimary}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
24
|
-
& .crisp-recordpanel-iconbtn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; flex-shrink: 0; padding: 0; border: none; border-radius: ${c.radius.md}; background: transparent; color: ${c.fgMuted}; font-family: inherit; cursor: pointer; transition: background-color .12s; }
|
|
25
|
-
& .crisp-recordpanel-iconbtn:hover { background: ${c.bgRaised}; color: ${c.fgPrimary}; }
|
|
26
41
|
|
|
27
42
|
& .crisp-recordpanel-body { flex: 1; min-height: 0; overflow-y: auto; padding: ${c.space["2"]} ${c.space["3"]}; }
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
${
|
|
37
|
-
& .crisp-recordpanel-
|
|
38
|
-
|
|
43
|
+
|
|
44
|
+
/* #448: measured live on app.attio.com's peek (hover a Companies row,
|
|
45
|
+
click "Open preview"). Section label reads identically for both
|
|
46
|
+
Highlights and Activity: 14px/500, the same 63%-of-fgPrimary mix
|
|
47
|
+
RecordPage's own field-label already uses (product/recordpage's
|
|
48
|
+
.crisp-recordpage-field-label) -- one more surface landing on the
|
|
49
|
+
same measured "muted section heading" colour. */
|
|
50
|
+
& .crisp-recordpanel-section { padding: ${c.space["2"]} ${c.space["1"]} 0; }
|
|
51
|
+
& .crisp-recordpanel-section-head { display: flex; align-items: center; justify-content: space-between; gap: ${c.space["2"]}; height: 20px; margin-bottom: ${c.space["2"]}; }
|
|
52
|
+
& .crisp-recordpanel-section-label { font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
|
|
53
|
+
/* "View all" -- measured 10px/400, a lighter mix than the section label
|
|
54
|
+
(55%, not 63%) -- its own distinct, honestly-measured value, not a
|
|
55
|
+
reuse of a nearby one that happened to be close. */
|
|
56
|
+
& .crisp-recordpanel-section-action { border: none; background: none; padding: 0; font-family: inherit; font-size: 10px; font-weight: ${c.weight.regular}; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); cursor: pointer; }
|
|
57
|
+
& .crisp-recordpanel-section-action:hover { color: ${c.fgPrimary}; }
|
|
58
|
+
|
|
59
|
+
/* Highlights: a 2-column grid of label+icon+value cards, no card
|
|
60
|
+
chrome at all (no border/background/radius/shadow -- confirmed on
|
|
61
|
+
the live DOM: every highlight's computed style is fully transparent).
|
|
62
|
+
8px row/column gap; two rows measured "233x86" inside 473px content
|
|
63
|
+
width is exactly (473 - 8) / 2. */
|
|
64
|
+
& .crisp-recordpanel-highlights { display: grid; grid-template-columns: 1fr 1fr; gap: ${c.space["2"]}; }
|
|
65
|
+
& .crisp-recordpanel-highlight { display: flex; flex-direction: column; gap: ${c.space["1.5"]}; min-width: 0; }
|
|
66
|
+
& .crisp-recordpanel-highlight-label { display: flex; align-items: center; gap: ${c.space["1"]}; font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
|
|
67
|
+
& .crisp-recordpanel-highlight-label span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
68
|
+
& .crisp-recordpanel-highlight-label svg { flex-shrink: 0; color: ${c.fgMuted}; }
|
|
69
|
+
& .crisp-recordpanel-highlight-value { font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; overflow: hidden; text-overflow: ellipsis; }
|
|
70
|
+
|
|
71
|
+
/* Activity is a consumer-supplied slot (crisp has no activity-feed
|
|
72
|
+
model of its own to render) -- this only wraps it with the measured
|
|
73
|
+
section heading above. */
|
|
74
|
+
& .crisp-recordpanel-activity { font-size: ${c.text.lg}; }
|
|
39
75
|
|
|
40
76
|
/* #329: an optional slot for record actions (e.g. Edit). Reuses the
|
|
41
77
|
header's own measured values rather than inventing new ones -- the
|
|
@@ -44,6 +80,8 @@ const config = defineRecipe({
|
|
|
44
80
|
edge), and the header/body gap (space.2) for its own vertical padding
|
|
45
81
|
and for spacing between whatever it's given. */
|
|
46
82
|
& .crisp-recordpanel-footer { flex-shrink: 0; display: flex; align-items: center; gap: ${c.space["2"]}; padding: ${c.space["2"]} ${c.space["3"]}; border-top: 1px solid ${c.borderSubtle}; }
|
|
83
|
+
|
|
84
|
+
${rtlFlipCss}
|
|
47
85
|
`,
|
|
48
86
|
variants: {},
|
|
49
87
|
defaultVariants: {},
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* The async-commit state machine behind `RecordPage`'s editable Details
|
|
4
|
+
* column (#431) — the same shape `DataTable`'s own `commitEdit` uses
|
|
5
|
+
* (#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: `RecordPage` never mutates `record` itself, so
|
|
10
|
+
* once `editingKey` clears, the field simply renders `record[key]` again —
|
|
11
|
+
* whatever it still says.
|
|
12
|
+
*
|
|
13
|
+
* `RecordPanel` (the peek panel) used to share this hook too, before #448
|
|
14
|
+
* found that Attio's own peek has no editable field at all to attach it to
|
|
15
|
+
* — only `RecordPage`'s Details column does. Kept as its own module rather
|
|
16
|
+
* than folded back into `recordpage.tsx`: a second consumer turning up
|
|
17
|
+
* again is a smaller change than re-extracting it would be.
|
|
18
|
+
*/
|
|
19
|
+
export function useEditableField(onEdit) {
|
|
20
|
+
const [editingKey, setEditingKey] = React.useState(null);
|
|
21
|
+
// Set only while an async `onEdit` is in flight -- a sync `onEdit` never
|
|
22
|
+
// touches this.
|
|
23
|
+
const [pendingKey, setPendingKey] = React.useState(null);
|
|
24
|
+
const [error, setError] = React.useState(null);
|
|
25
|
+
// Opening a field's editor also clears any stale error left over from a
|
|
26
|
+
// PREVIOUS failed commit on it -- otherwise a rejection from three edits
|
|
27
|
+
// ago would still be sitting there while the reader tries again.
|
|
28
|
+
const openEditing = React.useCallback((key) => {
|
|
29
|
+
setError(null);
|
|
30
|
+
setEditingKey(key);
|
|
31
|
+
}, []);
|
|
32
|
+
const closeEditing = React.useCallback(() => setEditingKey(null), []);
|
|
33
|
+
const commit = React.useCallback((id, key, value) => {
|
|
34
|
+
setError(null);
|
|
35
|
+
const result = onEdit?.(id, key, value);
|
|
36
|
+
if (result && typeof result.then === "function") {
|
|
37
|
+
setPendingKey(key);
|
|
38
|
+
result.then(() => {
|
|
39
|
+
setPendingKey(null);
|
|
40
|
+
setEditingKey(null);
|
|
41
|
+
}, (err) => {
|
|
42
|
+
setPendingKey(null);
|
|
43
|
+
setEditingKey(null);
|
|
44
|
+
setError({
|
|
45
|
+
key,
|
|
46
|
+
message: err instanceof Error
|
|
47
|
+
? err.message
|
|
48
|
+
: typeof err === "string"
|
|
49
|
+
? err
|
|
50
|
+
: "Could not save",
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
setEditingKey(null);
|
|
56
|
+
}, [onEdit]);
|
|
57
|
+
return { editingKey, pendingKey, error, openEditing, closeEditing, commit };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Which plain editor a column gets when it's `editable` without an
|
|
61
|
+
* `editor: "select" | "switch"` — identical to `DataTable`'s own
|
|
62
|
+
* `editorFor` (#385): number/currency get a numeric field, date a native
|
|
63
|
+
* date field, everything else (text, entity, tags, url, status, reference)
|
|
64
|
+
* a plain text field. Kept as one function so the two never drift apart.
|
|
65
|
+
*/
|
|
66
|
+
export function editableInputType(colType) {
|
|
67
|
+
switch (colType) {
|
|
68
|
+
case "number":
|
|
69
|
+
case "currency":
|
|
70
|
+
return { type: "number", inputMode: "decimal" };
|
|
71
|
+
case "date":
|
|
72
|
+
return { type: "date" };
|
|
73
|
+
default:
|
|
74
|
+
return { type: "text", inputMode: "text" };
|
|
75
|
+
}
|
|
76
|
+
}
|