@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.
- 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 +38 -4
- 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 +2 -1
- package/dist/cjs/components/tabletoolbar/tabletoolbar.js +39 -8
- package/dist/cjs/core/cell-date.js +11 -0
- package/dist/cjs/core/index.js +2 -1
- 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 +54 -4
- package/dist/cjs/product/recordpanel/recordpanel.recipe.js +29 -0
- package/dist/cjs/product/recordstable/editable-field.js +110 -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/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 +34 -15
- package/dist/datatable.layered.css +34 -15
- 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 +38 -4
- 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 +2 -1
- package/dist/esm/components/tabletoolbar/tabletoolbar.js +38 -8
- package/dist/esm/core/cell-date.js +10 -0
- package/dist/esm/core/index.js +1 -1
- 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/recordpanel.js +55 -5
- package/dist/esm/product/recordpanel/recordpanel.recipe.js +29 -0
- package/dist/esm/product/recordstable/editable-field.js +73 -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/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 +22 -5
- package/dist/recordpanel.layered.css +22 -5
- package/dist/recordstable.css +10 -2
- package/dist/recordstable.layered.css +10 -2
- 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 +150 -55
- package/dist/styles.layered.css +150 -55
- package/dist/table.css +4 -2
- package/dist/table.layered.css +4 -2
- package/dist/tabletoolbar.css +2 -1
- package/dist/tabletoolbar.layered.css +2 -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 +62 -5
- package/dist/types/core/cell-date.d.ts +17 -0
- package/dist/types/core/index.d.ts +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/product/recordpage/recordpage.d.ts +59 -1
- package/dist/types/product/recordpanel/recordpanel.d.ts +11 -1
- package/dist/types/product/recordstable/editable-field.d.ts +45 -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/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}; }
|
|
@@ -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"
|
|
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) =>
|
|
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,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
|
|
228
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
|
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: [
|
|
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:
|
|
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; }
|