@crisp-ui-kit/crisp 0.52.1 → 0.52.3
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/product/recordpage/recordpage.js +86 -7
- package/dist/cjs/product/recordpage/recordpage.recipe.js +7 -0
- package/dist/cjs/product/recordpanel/recordpanel.js +65 -2
- package/dist/cjs/product/recordpanel/recordpanel.recipe.js +37 -0
- package/dist/esm/product/recordpage/recordpage.js +86 -7
- package/dist/esm/product/recordpage/recordpage.recipe.js +7 -0
- package/dist/esm/product/recordpanel/recordpanel.js +66 -3
- package/dist/esm/product/recordpanel/recordpanel.recipe.js +37 -0
- package/dist/recordpage.css +18 -1
- package/dist/recordpage.layered.css +18 -1
- package/dist/recordpanel.css +35 -14
- package/dist/recordpanel.layered.css +35 -14
- package/dist/styles.css +53 -15
- package/dist/styles.layered.css +53 -15
- package/dist/types/product/recordpanel/recordpanel.d.ts +49 -1
- package/package.json +1 -1
|
@@ -125,6 +125,53 @@ exports.recordPageLabels = {
|
|
|
125
125
|
recordCreated: (name) => ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("b", { children: name }), " record was created"] })),
|
|
126
126
|
noEmailsWith: (name) => (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: ["No emails logged with ", name, " yet."] }),
|
|
127
127
|
};
|
|
128
|
+
/** `Select` owns its own closed-by-default `open` state with no prop or
|
|
129
|
+
* imperative way to start it open -- the only thing that opens it is a real
|
|
130
|
+
* click on its trigger button. Mounting one on click (rather than rendering
|
|
131
|
+
* it already open) leaves the reader with a second click to make before
|
|
132
|
+
* they can pick anything, so drive that click ourselves the moment it
|
|
133
|
+
* mounts. Mirrors `DataTable`'s own `useAutoOpenSelect` (#453) -- kept as
|
|
134
|
+
* its own copy here rather than a shared import, the same "duplicated per
|
|
135
|
+
* product surface" choice already made for `EditableOption`/
|
|
136
|
+
* `editableInputType` above. Guarded against firing twice (StrictMode
|
|
137
|
+
* double-invokes effects): `Select`'s own trigger toggles, so a second
|
|
138
|
+
* click would close what the first one opened. */
|
|
139
|
+
function useAutoOpenSelect() {
|
|
140
|
+
const ref = React.useRef(null);
|
|
141
|
+
const opened = React.useRef(false);
|
|
142
|
+
React.useEffect(() => {
|
|
143
|
+
if (opened.current)
|
|
144
|
+
return;
|
|
145
|
+
opened.current = true;
|
|
146
|
+
ref.current
|
|
147
|
+
?.querySelector(".crisp-select-trigger")
|
|
148
|
+
?.click();
|
|
149
|
+
}, []);
|
|
150
|
+
return ref;
|
|
151
|
+
}
|
|
152
|
+
/** `Select` closes its own popover on Escape but has no callback telling
|
|
153
|
+
* the FIELD to leave editing mode -- and DOM focus doesn't reliably end up
|
|
154
|
+
* inside the newly-opened popover either, so a focus-dependent React
|
|
155
|
+
* `onKeyDown` can't be trusted to see the key at all. Mirrors `Select`'s
|
|
156
|
+
* own "document-level fallback for Escape" (see select.tsx) and
|
|
157
|
+
* `DataTable`'s identical fix for the same problem (#453). */
|
|
158
|
+
function useEscapeExitsEditing(onCancel) {
|
|
159
|
+
React.useEffect(() => {
|
|
160
|
+
const onKey = (e) => {
|
|
161
|
+
if (e.key === "Escape")
|
|
162
|
+
onCancel();
|
|
163
|
+
};
|
|
164
|
+
document.addEventListener("keydown", onKey);
|
|
165
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
166
|
+
}, [onCancel]);
|
|
167
|
+
}
|
|
168
|
+
/** `editor: "select"`'s picker, its own component (not inline in the field
|
|
169
|
+
* loop) because it needs the two hooks above (#457). */
|
|
170
|
+
function RecordPageSelectEditor({ col, value, isPending, onCommit, onCancel, }) {
|
|
171
|
+
const ref = useAutoOpenSelect();
|
|
172
|
+
useEscapeExitsEditing(onCancel);
|
|
173
|
+
return ((0, jsx_runtime_1.jsxs)("span", { ref: ref, className: "crisp-recordpage-edit-picker", children: [(0, jsx_runtime_1.jsx)(select_1.Select, { variant: "ghost", "aria-label": `Edit ${col.label}`, value: value != null ? String(value) : undefined, disabled: isPending, options: col.editOptions ?? [], onValueChange: onCommit }), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-recordpage-edit-pending" }))] }));
|
|
174
|
+
}
|
|
128
175
|
function TagsValue({ values }) {
|
|
129
176
|
const [expanded, setExpanded] = React.useState(false);
|
|
130
177
|
const overflow = values.length - 1;
|
|
@@ -135,6 +182,21 @@ function FieldValue({ col, row, labels, }) {
|
|
|
135
182
|
const v = row[col.key];
|
|
136
183
|
if (valueEmpty(v))
|
|
137
184
|
return (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-empty", children: labels.setAValue });
|
|
185
|
+
// `editor: "select"` at rest (#457) -- checked ahead of `col.type` since
|
|
186
|
+
// the editor is orthogonal to it. Same dot+label shape as RecordsTable's
|
|
187
|
+
// own measured status chip, but this component's own classes
|
|
188
|
+
// (`crisp-recordpage-status[-dot]`): RecordsTable's own pair is scoped
|
|
189
|
+
// under `.crisp-recordstable`'s recipe root, so reusing its classnames
|
|
190
|
+
// verbatim here would resolve to nothing. Shown when the matched option
|
|
191
|
+
// carries a `color`, same as every other status-shaped value in this
|
|
192
|
+
// codebase; a colourless option (or no match) falls back to plain text.
|
|
193
|
+
if (col.editor === "select") {
|
|
194
|
+
const opt = col.editOptions?.find((o) => o.value === v);
|
|
195
|
+
const text = opt?.label ?? String(v);
|
|
196
|
+
if (!opt?.color)
|
|
197
|
+
return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: text });
|
|
198
|
+
return ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-status", children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-status-dot", style: { background: opt.color } }), text] }));
|
|
199
|
+
}
|
|
138
200
|
switch (col.type) {
|
|
139
201
|
case "tags":
|
|
140
202
|
return (0, jsx_runtime_1.jsx)(TagsValue, { values: v });
|
|
@@ -455,14 +517,31 @@ function RecordPage({ record, columns, collection = "Records", labels, onBack, o
|
|
|
455
517
|
const isPending = pendingKey === col.key;
|
|
456
518
|
const fieldError = error?.key === col.key ? error.message : null;
|
|
457
519
|
const label = ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-field-label", children: [col.icon ? ((0, jsx_runtime_1.jsx)(col.icon, { size: 15, strokeWidth: 1.75 })) : null, (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-field-label-text", children: col.label })] }));
|
|
458
|
-
//
|
|
459
|
-
// click-to-open, no separate edit/view mode
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
if (canEdit && col.editor) {
|
|
520
|
+
// `switch` is always its own live widget -- no
|
|
521
|
+
// click-to-open, no separate edit/view mode: flipping the
|
|
522
|
+
// toggle IS the commit gesture, and there's no
|
|
523
|
+
// meaningfully different "rest" display for a boolean
|
|
524
|
+
// (same exception `DataTable`'s own `editor` makes, #453).
|
|
525
|
+
if (canEdit && col.editor === "switch") {
|
|
526
|
+
const value = record[col.key];
|
|
527
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-field", children: [label, (0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-field-value", children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-edit-picker", children: [(0, jsx_runtime_1.jsx)(switch_1.Switch, { "aria-label": `Edit ${col.label}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commit(record.id, col.key, String(checked)) }), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-recordpage-edit-pending" }))] }), fieldError && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
|
|
528
|
+
}
|
|
529
|
+
// `select` opens crisp's own `Select` on a SINGLE click
|
|
530
|
+
// (#457, corrects #431/#385's original always-live
|
|
531
|
+
// copy of the same regression #453 fixed on `DataTable`)
|
|
532
|
+
// -- like every other editable type here, at rest it
|
|
533
|
+
// shows its value (see `FieldValue`'s own `col.editor
|
|
534
|
+
// === "select"` case) via the generic click-to-open
|
|
535
|
+
// branch below, and only mounts `Select` once
|
|
536
|
+
// `editingKey` matches. `DataTable`'s own double-click
|
|
537
|
+
// gate doesn't apply: `RecordPage`'s plain text/date
|
|
538
|
+
// fields already open on a single click (measured live
|
|
539
|
+
// for #431), so `select` matches that, not `DataTable`.
|
|
540
|
+
if (canEdit &&
|
|
541
|
+
editingKey === col.key &&
|
|
542
|
+
col.editor === "select") {
|
|
464
543
|
const value = record[col.key];
|
|
465
|
-
return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-field", children: [label, (0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-field-value", children: [(0, jsx_runtime_1.
|
|
544
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-field", children: [label, (0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-field-value", children: [(0, jsx_runtime_1.jsx)(RecordPageSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commit(record.id, col.key, v), onCancel: closeEditing }), fieldError && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
|
|
466
545
|
}
|
|
467
546
|
if (canEdit && editingKey === col.key) {
|
|
468
547
|
const value = record[col.key];
|
|
@@ -108,6 +108,13 @@ const config = (0, core_1.defineRecipe)({
|
|
|
108
108
|
& .crisp-recordpage-edit:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
109
109
|
& .crisp-recordpage-edit-pending { flex-shrink: 0; color: ${tokens_1.contract.fgMuted}; }
|
|
110
110
|
& .crisp-recordpage-edit-error { flex-basis: 100%; margin-top: ${tokens_1.contract.space["0.5"]}; font-size: ${tokens_1.contract.text.sm}; color: ${tokens_1.contract.bgDanger}; }
|
|
111
|
+
/* #457: an \`editor: "select"\` field's at-rest chip -- same shape as
|
|
112
|
+
RecordsTable's own \`.crisp-recordstable-status[-dot]\` (already
|
|
113
|
+
measured against Attio there), but its own classes: that pair is
|
|
114
|
+
scoped under \`.crisp-recordstable\`'s own recipe root, so reusing the
|
|
115
|
+
classnames verbatim here would resolve to nothing. */
|
|
116
|
+
& .crisp-recordpage-status { display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]}; min-width: 0; }
|
|
117
|
+
& .crisp-recordpage-status-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: ${tokens_1.contract.radius.full}; }
|
|
111
118
|
& .crisp-recordpage-edit-picker { display: flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; width: 100%; }
|
|
112
119
|
& .crisp-recordpage-edit-picker .crisp-select-trigger { flex: 1; min-width: 0; }
|
|
113
120
|
& .crisp-recordpage-link { color: ${tokens_1.contract.fgPrimary}; text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
|
|
@@ -40,6 +40,8 @@ const React = __importStar(require("react"));
|
|
|
40
40
|
const avatar_1 = require("../../components/avatar");
|
|
41
41
|
const core_1 = require("../../core");
|
|
42
42
|
const recordpanel_recipe_1 = require("./recordpanel.recipe");
|
|
43
|
+
const RESIZE_MIN_WIDTH = 360;
|
|
44
|
+
const RESIZE_MAX_WIDTH = 720;
|
|
43
45
|
/**
|
|
44
46
|
* Right-side record peek panel — Attio's own peek shows a Highlights card
|
|
45
47
|
* area and an Activity feed (measured live: hover a Companies row, click its
|
|
@@ -50,8 +52,65 @@ const recordpanel_recipe_1 = require("./recordpanel.recipe");
|
|
|
50
52
|
* place). Render it inside a `position: relative` container (e.g. the app
|
|
51
53
|
* main area); it slides in from the right and overlays the content.
|
|
52
54
|
*/
|
|
53
|
-
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 }) {
|
|
55
|
+
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, counterLabel, resizable = false, width: controlledWidth, defaultWidth = 420, onWidthChange, minWidth = RESIZE_MIN_WIDTH, maxWidth = RESIZE_MAX_WIDTH, activityLabel = "Activity", viewAllActivityLabel = "View all", onViewAllActivity, activity, sections, footer, ...rest }) {
|
|
54
56
|
const titleId = React.useId();
|
|
57
|
+
// Controlled/uncontrolled width (#413), the same split every other
|
|
58
|
+
// stateful crisp prop uses (see Accordion's own value/defaultValue). Only
|
|
59
|
+
// read when `resizable` — an immovable panel has no reason to carry width
|
|
60
|
+
// state at all, controlled or not.
|
|
61
|
+
const [internalWidth, setInternalWidth] = React.useState(defaultWidth);
|
|
62
|
+
const width = controlledWidth ?? internalWidth;
|
|
63
|
+
const setWidth = (next) => {
|
|
64
|
+
const clamped = Math.min(maxWidth, Math.max(minWidth, next));
|
|
65
|
+
if (controlledWidth === undefined)
|
|
66
|
+
setInternalWidth(clamped);
|
|
67
|
+
onWidthChange?.(clamped);
|
|
68
|
+
};
|
|
69
|
+
// Dragging reads/writes a ref, not state, mid-drag -- a `pointermove`
|
|
70
|
+
// firing dozens of times a second has no business going through a React
|
|
71
|
+
// re-render for each one; only the final clamped value needs to land in
|
|
72
|
+
// state (or a controlled consumer's own), via `setWidth` above. Mirrors
|
|
73
|
+
// DataTable's own column-resize ref shape (`resizeRef`) for the same
|
|
74
|
+
// reason, `dir` included.
|
|
75
|
+
const resizeRef = React.useRef(null);
|
|
76
|
+
const onResizeStart = (e) => {
|
|
77
|
+
if (e.button !== 0)
|
|
78
|
+
return;
|
|
79
|
+
// The panel sits at `inset-inline-end`, so its resize handle (at the
|
|
80
|
+
// panel's own inline-start edge) is physically on the LEFT in LTR and
|
|
81
|
+
// the RIGHT in RTL. Dragging it away from the panel's pinned edge must
|
|
82
|
+
// grow the panel either way -- physically LEFT in LTR (dir -1, so a
|
|
83
|
+
// negative `clientX` delta adds width), physically RIGHT in RTL (dir
|
|
84
|
+
// +1) -- the mirror image of DataTable's own column-resize grip, which
|
|
85
|
+
// is anchored at inset-inline-end instead (#214's own comment there).
|
|
86
|
+
const dir = (0, core_1.isRtlElement)(e.currentTarget) ? 1 : -1;
|
|
87
|
+
resizeRef.current = { x: e.clientX, width, dir };
|
|
88
|
+
e.target.setPointerCapture(e.pointerId);
|
|
89
|
+
};
|
|
90
|
+
const onResizeMove = (e) => {
|
|
91
|
+
const r = resizeRef.current;
|
|
92
|
+
if (!r)
|
|
93
|
+
return;
|
|
94
|
+
setWidth(r.width + r.dir * (e.clientX - r.x));
|
|
95
|
+
};
|
|
96
|
+
const onResizeEnd = (e) => {
|
|
97
|
+
resizeRef.current = null;
|
|
98
|
+
e.target.releasePointerCapture(e.pointerId);
|
|
99
|
+
};
|
|
100
|
+
// Which sections (#413) are expanded -- uncontrolled and independent
|
|
101
|
+
// (Attio's own Emails/Notes/Tasks open without closing one another, the
|
|
102
|
+
// same as Highlights cards never affecting each other): a plain `Set`,
|
|
103
|
+
// not modelled on Accordion's own single/multiple split, since a
|
|
104
|
+
// consumer here has no reason to want only one open at a time.
|
|
105
|
+
const [openSections, setOpenSections] = React.useState(() => new Set());
|
|
106
|
+
const toggleSection = (key) => setOpenSections((prev) => {
|
|
107
|
+
const next = new Set(prev);
|
|
108
|
+
if (next.has(key))
|
|
109
|
+
next.delete(key);
|
|
110
|
+
else
|
|
111
|
+
next.add(key);
|
|
112
|
+
return next;
|
|
113
|
+
});
|
|
55
114
|
// close on Escape while open -- but not when a Menu/Select/Popover/
|
|
56
115
|
// ContextMenu opened from inside the panel (e.g. a footer "More actions"
|
|
57
116
|
// menu) is itself open (#361): that Escape belongs to the nested layer,
|
|
@@ -86,6 +145,10 @@ function RecordPanel({ record, onClose, className, closeLabel = "Close panel", o
|
|
|
86
145
|
if (!record)
|
|
87
146
|
return null;
|
|
88
147
|
const heading = title ?? record.name ?? "Record";
|
|
89
|
-
return ((0, jsx_runtime_1.jsxs)("div", { className: (0, recordpanel_recipe_1.recordpanelRecipe)({ className }), ...rest, role: "dialog", "aria-labelledby": titleId, children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-toolbar", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpanel-iconbtn", "aria-label": closeLabel, onClick: onClose, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 16, strokeWidth: 1.75 }) }), (onPrevious || onNext) && ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpanel-pager", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpanel-navbtn", "aria-label": previousRecordLabel, disabled: !onPrevious, onClick: onPrevious, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronUp, { size: 16, strokeWidth: 1.75 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpanel-navbtn", "aria-label": nextRecordLabel, disabled: !onNext, onClick: onNext, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { size: 16, strokeWidth: 1.75 }) })] })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-toolbar-spacer" }), onOpenRecord && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpanel-navbtn", "aria-label": openRecordLabel, onClick: onOpenRecord, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ArrowUpRight, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75 }) }))] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-header", children: [avatar ? ((0, jsx_runtime_1.jsx)("img", { className: "crisp-recordpanel-fav", src: avatar, alt: "" })) : ((0, jsx_runtime_1.jsx)(avatar_1.Avatar, { size: "md", shape: "rounded", tone: avatarTone, children: (record.name ?? "?").charAt(0) })), (0, jsx_runtime_1.jsx)("span", { id: titleId, className: "crisp-recordpanel-title", children: heading })] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-body", children: [highlights && highlights.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-section", children: [(0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpanel-section-head", children: (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-section-label", children: highlightsLabel }) }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpanel-highlights", children: highlights.map((h) => ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-highlight", children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpanel-highlight-label", children: [(0, jsx_runtime_1.jsx)("span", { children: h.label }), h.icon ? (0, jsx_runtime_1.jsx)(h.icon, { size: 16, strokeWidth: 1.75 }) : null] }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-highlight-value", children: h.value })] }, h.key))) })] })), activity != null && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-section", children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-section-head", children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-section-label", children: activityLabel }), onViewAllActivity && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpanel-section-action", onClick: onViewAllActivity, children: viewAllActivityLabel }))] }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpanel-activity", children: activity })] }))
|
|
148
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: (0, recordpanel_recipe_1.recordpanelRecipe)({ className }), style: resizable ? { width, maxWidth: "calc(92% - 8px)" } : undefined, ...rest, role: "dialog", "aria-labelledby": titleId, children: [resizable && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpanel-resize-handle", onPointerDown: onResizeStart, onPointerMove: onResizeMove, onPointerUp: onResizeEnd })), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-toolbar", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpanel-iconbtn", "aria-label": closeLabel, onClick: onClose, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 16, strokeWidth: 1.75 }) }), (onPrevious || onNext) && ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpanel-pager", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpanel-navbtn", "aria-label": previousRecordLabel, disabled: !onPrevious, onClick: onPrevious, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronUp, { size: 16, strokeWidth: 1.75 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpanel-navbtn", "aria-label": nextRecordLabel, disabled: !onNext, onClick: onNext, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { size: 16, strokeWidth: 1.75 }) })] })), counterLabel != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-counter", children: counterLabel })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-toolbar-spacer" }), onOpenRecord && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpanel-navbtn", "aria-label": openRecordLabel, onClick: onOpenRecord, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ArrowUpRight, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75 }) }))] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-header", children: [avatar ? ((0, jsx_runtime_1.jsx)("img", { className: "crisp-recordpanel-fav", src: avatar, alt: "" })) : ((0, jsx_runtime_1.jsx)(avatar_1.Avatar, { size: "md", shape: "rounded", tone: avatarTone, children: (record.name ?? "?").charAt(0) })), (0, jsx_runtime_1.jsx)("span", { id: titleId, className: "crisp-recordpanel-title", children: heading })] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-body", children: [highlights && highlights.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-section", children: [(0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpanel-section-head", children: (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-section-label", children: highlightsLabel }) }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpanel-highlights", children: highlights.map((h) => ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-highlight", children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpanel-highlight-label", children: [(0, jsx_runtime_1.jsx)("span", { children: h.label }), h.icon ? (0, jsx_runtime_1.jsx)(h.icon, { size: 16, strokeWidth: 1.75 }) : null] }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-highlight-value", children: h.value })] }, h.key))) })] })), activity != null && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-section", children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-section-head", children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-section-label", children: activityLabel }), onViewAllActivity && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpanel-section-action", onClick: onViewAllActivity, children: viewAllActivityLabel }))] }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpanel-activity", children: activity })] })), sections && sections.length > 0 && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpanel-sections", children: sections.map((s) => {
|
|
149
|
+
const isOpen = openSections.has(s.key);
|
|
150
|
+
const panelId = `${titleId}-section-${s.key}`;
|
|
151
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-section-row", children: [s.content ? ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordpanel-section-trigger", "aria-expanded": isOpen, "aria-controls": panelId, onClick: () => toggleSection(s.key), children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-section-row-label", children: s.label }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-section-row-count", children: s.count }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "crisp-recordpanel-section-chevron", size: 14, strokeWidth: 1.75, "data-open": isOpen ? "" : undefined, "aria-hidden": "true" })] })) : ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-section-trigger crisp-recordpanel-section-trigger-inert", children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-section-row-label", children: s.label }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-section-row-count", children: s.count })] })), s.content && isOpen && ((0, jsx_runtime_1.jsx)("div", { id: panelId, className: "crisp-recordpanel-section-content", children: s.content }))] }, s.key));
|
|
152
|
+
}) }))] }), footer != null && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpanel-footer", children: footer }))] }));
|
|
90
153
|
}
|
|
91
154
|
RecordPanel.displayName = "RecordPanel";
|
|
@@ -32,12 +32,27 @@ const config = (0, core_1.defineRecipe)({
|
|
|
32
32
|
& .crisp-recordpanel-toolbar { display: flex; align-items: center; height: 49px; flex-shrink: 0; padding: ${tokens_1.contract.space["2"]} ${tokens_1.contract.space["2.5"]}; border-bottom: 1px solid ${tokens_1.contract.borderSubtle}; }
|
|
33
33
|
& .crisp-recordpanel-toolbar-spacer { flex: 1; }
|
|
34
34
|
& .crisp-recordpanel-pager { display: flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]}; margin-inline-start: ${tokens_1.contract.space["3"]}; }
|
|
35
|
+
/* #413: "2 of 37 in All Companies" -- measured live, same toolbar row,
|
|
36
|
+
right after Prev/Next. 12px/500, the same 63%-of-fgPrimary mix every
|
|
37
|
+
other muted heading in this panel already uses. */
|
|
38
|
+
& .crisp-recordpanel-counter { margin-inline-start: ${tokens_1.contract.space["3"]}; font-size: 12px; font-weight: ${tokens_1.contract.weight.medium}; color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 63%, transparent); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
35
39
|
& .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: ${tokens_1.contract.radius.md}; background: transparent; color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 55%, transparent); font-family: inherit; cursor: pointer; transition: background-color .12s; }
|
|
36
40
|
& .crisp-recordpanel-iconbtn:hover { background: ${tokens_1.contract.bgRaised}; color: ${tokens_1.contract.fgPrimary}; }
|
|
37
41
|
& .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: ${tokens_1.contract.radius.md}; background: ${tokens_1.contract.bgSurface}; box-shadow: ${tokens_1.elevation.field}; color: ${tokens_1.contract.fgPrimary}; font-family: inherit; cursor: pointer; }
|
|
38
42
|
& .crisp-recordpanel-navbtn:hover:not(:disabled) { background: ${tokens_1.contract.bgRaised}; }
|
|
39
43
|
& .crisp-recordpanel-navbtn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
40
44
|
|
|
45
|
+
/* #413: measured live -- a 10px strip at the panel's own left edge,
|
|
46
|
+
cursor: col-resize (the issue's own repro calls it "ew-resize";
|
|
47
|
+
the actual computed value read off the live DOM is col-resize, the
|
|
48
|
+
same cursor family under a different keyword). Kept fully inside the
|
|
49
|
+
panel's own box, not extending past inset-inline-start: 0, so the
|
|
50
|
+
panel's own overflow: hidden (there for its rounded corners) never
|
|
51
|
+
clips it. touch-action: none stops a touchscreen's own pan gesture
|
|
52
|
+
from fighting the drag. */
|
|
53
|
+
& .crisp-recordpanel-resize-handle { position: absolute; top: 0; bottom: 0; inset-inline-start: 0; width: 10px; cursor: col-resize; z-index: 1; touch-action: none; }
|
|
54
|
+
& .crisp-recordpanel-resize-handle:hover, & .crisp-recordpanel-resize-handle:active { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 6%, transparent); }
|
|
55
|
+
|
|
41
56
|
& .crisp-recordpanel-header { display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; height: 52px; flex-shrink: 0; padding: 0 ${tokens_1.contract.space["3"]}; }
|
|
42
57
|
& .crisp-recordpanel-fav { width: 22px; height: ${tokens_1.contract.size.chip}; border-radius: ${tokens_1.contract.radius.sm}; flex-shrink: 0; object-fit: cover; background: ${tokens_1.contract.bgRaised}; }
|
|
43
58
|
& .crisp-recordpanel-title { flex: 1; min-width: 0; font-size: ${tokens_1.contract.text.xl}; font-weight: ${tokens_1.contract.weight.semibold}; letter-spacing: -0.2px; color: ${tokens_1.contract.fgPrimary}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
@@ -76,6 +91,28 @@ const config = (0, core_1.defineRecipe)({
|
|
|
76
91
|
section heading above. */
|
|
77
92
|
& .crisp-recordpanel-activity { font-size: ${tokens_1.contract.text.lg}; }
|
|
78
93
|
|
|
94
|
+
/* #413: collapsed Emails/Notes/Tasks below Activity -- measured live: a
|
|
95
|
+
473px-wide row (the panel's own content width, 497 minus its 12px/8px
|
|
96
|
+
side insets), 20px tall, 14px/500 solid fgPrimary label (NOT the 63%
|
|
97
|
+
mix Highlights/Activity headings use -- this is body text, not a
|
|
98
|
+
muted heading), a trailing count. padding "0 8px 0 12px" measured on
|
|
99
|
+
the row itself; reproduced here as the trigger's own padding, since
|
|
100
|
+
the trigger IS the row (a plain div, not a button, when there's no
|
|
101
|
+
content to expand into -- matches the empty ("Emails 0") row
|
|
102
|
+
measured live, which has no expand affordance at all). */
|
|
103
|
+
& .crisp-recordpanel-sections { display: flex; flex-direction: column; padding-top: ${tokens_1.contract.space["2"]}; }
|
|
104
|
+
& .crisp-recordpanel-section-trigger { display: flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; width: 100%; height: 20px; padding: 0 ${tokens_1.contract.space["2"]} 0 ${tokens_1.contract.space["3"]}; border: none; background: none; font-family: inherit; text-align: start; cursor: pointer; }
|
|
105
|
+
& .crisp-recordpanel-section-trigger-inert { cursor: default; }
|
|
106
|
+
& .crisp-recordpanel-section-row-label { font-size: ${tokens_1.contract.text.lg}; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgPrimary}; }
|
|
107
|
+
/* The count wasn't captured with its own background/pill chrome in the
|
|
108
|
+
live measurement (only its text: 14px, a "tertiary" internal variant
|
|
109
|
+
name that doesn't map to anything crisp defines) -- muted text next
|
|
110
|
+
to the label, not an invented badge shape. */
|
|
111
|
+
& .crisp-recordpanel-section-row-count { font-size: ${tokens_1.contract.text.lg}; color: ${tokens_1.contract.fgMuted}; }
|
|
112
|
+
& .crisp-recordpanel-section-chevron { margin-inline-start: auto; flex-shrink: 0; color: ${tokens_1.contract.fgMuted}; transition: transform .15s ease; }
|
|
113
|
+
& .crisp-recordpanel-section-chevron[data-open] { transform: rotate(180deg); }
|
|
114
|
+
& .crisp-recordpanel-section-content { padding: ${tokens_1.contract.space["2"]} ${tokens_1.contract.space["2"]} ${tokens_1.contract.space["2"]} ${tokens_1.contract.space["3"]}; }
|
|
115
|
+
|
|
79
116
|
/* #329: an optional slot for record actions (e.g. Edit). Reuses the
|
|
80
117
|
header's own measured values rather than inventing new ones -- the
|
|
81
118
|
same horizontal inset (space.3), the same hairline border token
|
|
@@ -88,6 +88,53 @@ export const recordPageLabels = {
|
|
|
88
88
|
recordCreated: (name) => (_jsxs(_Fragment, { children: [_jsx("b", { children: name }), " record was created"] })),
|
|
89
89
|
noEmailsWith: (name) => _jsxs(_Fragment, { children: ["No emails logged with ", name, " yet."] }),
|
|
90
90
|
};
|
|
91
|
+
/** `Select` owns its own closed-by-default `open` state with no prop or
|
|
92
|
+
* imperative way to start it open -- the only thing that opens it is a real
|
|
93
|
+
* click on its trigger button. Mounting one on click (rather than rendering
|
|
94
|
+
* it already open) leaves the reader with a second click to make before
|
|
95
|
+
* they can pick anything, so drive that click ourselves the moment it
|
|
96
|
+
* mounts. Mirrors `DataTable`'s own `useAutoOpenSelect` (#453) -- kept as
|
|
97
|
+
* its own copy here rather than a shared import, the same "duplicated per
|
|
98
|
+
* product surface" choice already made for `EditableOption`/
|
|
99
|
+
* `editableInputType` above. Guarded against firing twice (StrictMode
|
|
100
|
+
* double-invokes effects): `Select`'s own trigger toggles, so a second
|
|
101
|
+
* click would close what the first one opened. */
|
|
102
|
+
function useAutoOpenSelect() {
|
|
103
|
+
const ref = React.useRef(null);
|
|
104
|
+
const opened = React.useRef(false);
|
|
105
|
+
React.useEffect(() => {
|
|
106
|
+
if (opened.current)
|
|
107
|
+
return;
|
|
108
|
+
opened.current = true;
|
|
109
|
+
ref.current
|
|
110
|
+
?.querySelector(".crisp-select-trigger")
|
|
111
|
+
?.click();
|
|
112
|
+
}, []);
|
|
113
|
+
return ref;
|
|
114
|
+
}
|
|
115
|
+
/** `Select` closes its own popover on Escape but has no callback telling
|
|
116
|
+
* the FIELD to leave editing mode -- and DOM focus doesn't reliably end up
|
|
117
|
+
* inside the newly-opened popover either, so a focus-dependent React
|
|
118
|
+
* `onKeyDown` can't be trusted to see the key at all. Mirrors `Select`'s
|
|
119
|
+
* own "document-level fallback for Escape" (see select.tsx) and
|
|
120
|
+
* `DataTable`'s identical fix for the same problem (#453). */
|
|
121
|
+
function useEscapeExitsEditing(onCancel) {
|
|
122
|
+
React.useEffect(() => {
|
|
123
|
+
const onKey = (e) => {
|
|
124
|
+
if (e.key === "Escape")
|
|
125
|
+
onCancel();
|
|
126
|
+
};
|
|
127
|
+
document.addEventListener("keydown", onKey);
|
|
128
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
129
|
+
}, [onCancel]);
|
|
130
|
+
}
|
|
131
|
+
/** `editor: "select"`'s picker, its own component (not inline in the field
|
|
132
|
+
* loop) because it needs the two hooks above (#457). */
|
|
133
|
+
function RecordPageSelectEditor({ col, value, isPending, onCommit, onCancel, }) {
|
|
134
|
+
const ref = useAutoOpenSelect();
|
|
135
|
+
useEscapeExitsEditing(onCancel);
|
|
136
|
+
return (_jsxs("span", { ref: ref, className: "crisp-recordpage-edit-picker", children: [_jsx(Select, { variant: "ghost", "aria-label": `Edit ${col.label}`, value: value != null ? String(value) : undefined, disabled: isPending, options: col.editOptions ?? [], onValueChange: onCommit }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-recordpage-edit-pending" }))] }));
|
|
137
|
+
}
|
|
91
138
|
function TagsValue({ values }) {
|
|
92
139
|
const [expanded, setExpanded] = React.useState(false);
|
|
93
140
|
const overflow = values.length - 1;
|
|
@@ -98,6 +145,21 @@ function FieldValue({ col, row, labels, }) {
|
|
|
98
145
|
const v = row[col.key];
|
|
99
146
|
if (valueEmpty(v))
|
|
100
147
|
return _jsx("span", { className: "crisp-recordpage-empty", children: labels.setAValue });
|
|
148
|
+
// `editor: "select"` at rest (#457) -- checked ahead of `col.type` since
|
|
149
|
+
// the editor is orthogonal to it. Same dot+label shape as RecordsTable's
|
|
150
|
+
// own measured status chip, but this component's own classes
|
|
151
|
+
// (`crisp-recordpage-status[-dot]`): RecordsTable's own pair is scoped
|
|
152
|
+
// under `.crisp-recordstable`'s recipe root, so reusing its classnames
|
|
153
|
+
// verbatim here would resolve to nothing. Shown when the matched option
|
|
154
|
+
// carries a `color`, same as every other status-shaped value in this
|
|
155
|
+
// codebase; a colourless option (or no match) falls back to plain text.
|
|
156
|
+
if (col.editor === "select") {
|
|
157
|
+
const opt = col.editOptions?.find((o) => o.value === v);
|
|
158
|
+
const text = opt?.label ?? String(v);
|
|
159
|
+
if (!opt?.color)
|
|
160
|
+
return _jsx(_Fragment, { children: text });
|
|
161
|
+
return (_jsxs("span", { className: "crisp-recordpage-status", children: [_jsx("span", { className: "crisp-recordpage-status-dot", style: { background: opt.color } }), text] }));
|
|
162
|
+
}
|
|
101
163
|
switch (col.type) {
|
|
102
164
|
case "tags":
|
|
103
165
|
return _jsx(TagsValue, { values: v });
|
|
@@ -418,14 +480,31 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
|
|
|
418
480
|
const isPending = pendingKey === col.key;
|
|
419
481
|
const fieldError = error?.key === col.key ? error.message : null;
|
|
420
482
|
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
|
-
//
|
|
422
|
-
// click-to-open, no separate edit/view mode
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
if (canEdit && col.editor) {
|
|
483
|
+
// `switch` is always its own live widget -- no
|
|
484
|
+
// click-to-open, no separate edit/view mode: flipping the
|
|
485
|
+
// toggle IS the commit gesture, and there's no
|
|
486
|
+
// meaningfully different "rest" display for a boolean
|
|
487
|
+
// (same exception `DataTable`'s own `editor` makes, #453).
|
|
488
|
+
if (canEdit && col.editor === "switch") {
|
|
489
|
+
const value = record[col.key];
|
|
490
|
+
return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsxs("span", { className: "crisp-recordpage-edit-picker", children: [_jsx(Switch, { "aria-label": `Edit ${col.label}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commit(record.id, col.key, String(checked)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-recordpage-edit-pending" }))] }), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
|
|
491
|
+
}
|
|
492
|
+
// `select` opens crisp's own `Select` on a SINGLE click
|
|
493
|
+
// (#457, corrects #431/#385's original always-live
|
|
494
|
+
// copy of the same regression #453 fixed on `DataTable`)
|
|
495
|
+
// -- like every other editable type here, at rest it
|
|
496
|
+
// shows its value (see `FieldValue`'s own `col.editor
|
|
497
|
+
// === "select"` case) via the generic click-to-open
|
|
498
|
+
// branch below, and only mounts `Select` once
|
|
499
|
+
// `editingKey` matches. `DataTable`'s own double-click
|
|
500
|
+
// gate doesn't apply: `RecordPage`'s plain text/date
|
|
501
|
+
// fields already open on a single click (measured live
|
|
502
|
+
// for #431), so `select` matches that, not `DataTable`.
|
|
503
|
+
if (canEdit &&
|
|
504
|
+
editingKey === col.key &&
|
|
505
|
+
col.editor === "select") {
|
|
427
506
|
const value = record[col.key];
|
|
428
|
-
return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [
|
|
507
|
+
return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsx(RecordPageSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commit(record.id, col.key, v), onCancel: closeEditing }), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
|
|
429
508
|
}
|
|
430
509
|
if (canEdit && editingKey === col.key) {
|
|
431
510
|
const value = record[col.key];
|
|
@@ -105,6 +105,13 @@ const config = defineRecipe({
|
|
|
105
105
|
& .crisp-recordpage-edit:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
106
106
|
& .crisp-recordpage-edit-pending { flex-shrink: 0; color: ${c.fgMuted}; }
|
|
107
107
|
& .crisp-recordpage-edit-error { flex-basis: 100%; margin-top: ${c.space["0.5"]}; font-size: ${c.text.sm}; color: ${c.bgDanger}; }
|
|
108
|
+
/* #457: an \`editor: "select"\` field's at-rest chip -- same shape as
|
|
109
|
+
RecordsTable's own \`.crisp-recordstable-status[-dot]\` (already
|
|
110
|
+
measured against Attio there), but its own classes: that pair is
|
|
111
|
+
scoped under \`.crisp-recordstable\`'s own recipe root, so reusing the
|
|
112
|
+
classnames verbatim here would resolve to nothing. */
|
|
113
|
+
& .crisp-recordpage-status { display: inline-flex; align-items: center; gap: ${c.space["1.5"]}; min-width: 0; }
|
|
114
|
+
& .crisp-recordpage-status-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: ${c.radius.full}; }
|
|
108
115
|
& .crisp-recordpage-edit-picker { display: flex; align-items: center; gap: ${c.space["1"]}; width: 100%; }
|
|
109
116
|
& .crisp-recordpage-edit-picker .crisp-select-trigger { flex: 1; min-width: 0; }
|
|
110
117
|
& .crisp-recordpage-link { color: ${c.fgPrimary}; text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
|
|
@@ -2,8 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
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
|
-
import { hasOpenFloatingLayer } from "../../core/index.js";
|
|
5
|
+
import { hasOpenFloatingLayer, isRtlElement } from "../../core/index.js";
|
|
6
6
|
import { recordpanelRecipe } from "./recordpanel.recipe.js";
|
|
7
|
+
const RESIZE_MIN_WIDTH = 360;
|
|
8
|
+
const RESIZE_MAX_WIDTH = 720;
|
|
7
9
|
/**
|
|
8
10
|
* Right-side record peek panel — Attio's own peek shows a Highlights card
|
|
9
11
|
* area and an Activity feed (measured live: hover a Companies row, click its
|
|
@@ -14,8 +16,65 @@ import { recordpanelRecipe } from "./recordpanel.recipe.js";
|
|
|
14
16
|
* place). Render it inside a `position: relative` container (e.g. the app
|
|
15
17
|
* main area); it slides in from the right and overlays the content.
|
|
16
18
|
*/
|
|
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 }) {
|
|
19
|
+
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, counterLabel, resizable = false, width: controlledWidth, defaultWidth = 420, onWidthChange, minWidth = RESIZE_MIN_WIDTH, maxWidth = RESIZE_MAX_WIDTH, activityLabel = "Activity", viewAllActivityLabel = "View all", onViewAllActivity, activity, sections, footer, ...rest }) {
|
|
18
20
|
const titleId = React.useId();
|
|
21
|
+
// Controlled/uncontrolled width (#413), the same split every other
|
|
22
|
+
// stateful crisp prop uses (see Accordion's own value/defaultValue). Only
|
|
23
|
+
// read when `resizable` — an immovable panel has no reason to carry width
|
|
24
|
+
// state at all, controlled or not.
|
|
25
|
+
const [internalWidth, setInternalWidth] = React.useState(defaultWidth);
|
|
26
|
+
const width = controlledWidth ?? internalWidth;
|
|
27
|
+
const setWidth = (next) => {
|
|
28
|
+
const clamped = Math.min(maxWidth, Math.max(minWidth, next));
|
|
29
|
+
if (controlledWidth === undefined)
|
|
30
|
+
setInternalWidth(clamped);
|
|
31
|
+
onWidthChange?.(clamped);
|
|
32
|
+
};
|
|
33
|
+
// Dragging reads/writes a ref, not state, mid-drag -- a `pointermove`
|
|
34
|
+
// firing dozens of times a second has no business going through a React
|
|
35
|
+
// re-render for each one; only the final clamped value needs to land in
|
|
36
|
+
// state (or a controlled consumer's own), via `setWidth` above. Mirrors
|
|
37
|
+
// DataTable's own column-resize ref shape (`resizeRef`) for the same
|
|
38
|
+
// reason, `dir` included.
|
|
39
|
+
const resizeRef = React.useRef(null);
|
|
40
|
+
const onResizeStart = (e) => {
|
|
41
|
+
if (e.button !== 0)
|
|
42
|
+
return;
|
|
43
|
+
// The panel sits at `inset-inline-end`, so its resize handle (at the
|
|
44
|
+
// panel's own inline-start edge) is physically on the LEFT in LTR and
|
|
45
|
+
// the RIGHT in RTL. Dragging it away from the panel's pinned edge must
|
|
46
|
+
// grow the panel either way -- physically LEFT in LTR (dir -1, so a
|
|
47
|
+
// negative `clientX` delta adds width), physically RIGHT in RTL (dir
|
|
48
|
+
// +1) -- the mirror image of DataTable's own column-resize grip, which
|
|
49
|
+
// is anchored at inset-inline-end instead (#214's own comment there).
|
|
50
|
+
const dir = isRtlElement(e.currentTarget) ? 1 : -1;
|
|
51
|
+
resizeRef.current = { x: e.clientX, width, dir };
|
|
52
|
+
e.target.setPointerCapture(e.pointerId);
|
|
53
|
+
};
|
|
54
|
+
const onResizeMove = (e) => {
|
|
55
|
+
const r = resizeRef.current;
|
|
56
|
+
if (!r)
|
|
57
|
+
return;
|
|
58
|
+
setWidth(r.width + r.dir * (e.clientX - r.x));
|
|
59
|
+
};
|
|
60
|
+
const onResizeEnd = (e) => {
|
|
61
|
+
resizeRef.current = null;
|
|
62
|
+
e.target.releasePointerCapture(e.pointerId);
|
|
63
|
+
};
|
|
64
|
+
// Which sections (#413) are expanded -- uncontrolled and independent
|
|
65
|
+
// (Attio's own Emails/Notes/Tasks open without closing one another, the
|
|
66
|
+
// same as Highlights cards never affecting each other): a plain `Set`,
|
|
67
|
+
// not modelled on Accordion's own single/multiple split, since a
|
|
68
|
+
// consumer here has no reason to want only one open at a time.
|
|
69
|
+
const [openSections, setOpenSections] = React.useState(() => new Set());
|
|
70
|
+
const toggleSection = (key) => setOpenSections((prev) => {
|
|
71
|
+
const next = new Set(prev);
|
|
72
|
+
if (next.has(key))
|
|
73
|
+
next.delete(key);
|
|
74
|
+
else
|
|
75
|
+
next.add(key);
|
|
76
|
+
return next;
|
|
77
|
+
});
|
|
19
78
|
// close on Escape while open -- but not when a Menu/Select/Popover/
|
|
20
79
|
// ContextMenu opened from inside the panel (e.g. a footer "More actions"
|
|
21
80
|
// menu) is itself open (#361): that Escape belongs to the nested layer,
|
|
@@ -50,6 +109,10 @@ export function RecordPanel({ record, onClose, className, closeLabel = "Close pa
|
|
|
50
109
|
if (!record)
|
|
51
110
|
return null;
|
|
52
111
|
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 })] }))
|
|
112
|
+
return (_jsxs("div", { className: recordpanelRecipe({ className }), style: resizable ? { width, maxWidth: "calc(92% - 8px)" } : undefined, ...rest, role: "dialog", "aria-labelledby": titleId, children: [resizable && (_jsx("div", { className: "crisp-recordpanel-resize-handle", onPointerDown: onResizeStart, onPointerMove: onResizeMove, onPointerUp: onResizeEnd })), _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 }) })] })), counterLabel != null && (_jsx("span", { className: "crisp-recordpanel-counter", children: counterLabel })), _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 })] })), sections && sections.length > 0 && (_jsx("div", { className: "crisp-recordpanel-sections", children: sections.map((s) => {
|
|
113
|
+
const isOpen = openSections.has(s.key);
|
|
114
|
+
const panelId = `${titleId}-section-${s.key}`;
|
|
115
|
+
return (_jsxs("div", { className: "crisp-recordpanel-section-row", children: [s.content ? (_jsxs("button", { type: "button", className: "crisp-recordpanel-section-trigger", "aria-expanded": isOpen, "aria-controls": panelId, onClick: () => toggleSection(s.key), children: [_jsx("span", { className: "crisp-recordpanel-section-row-label", children: s.label }), _jsx("span", { className: "crisp-recordpanel-section-row-count", children: s.count }), _jsx(ChevronDown, { className: "crisp-recordpanel-section-chevron", size: 14, strokeWidth: 1.75, "data-open": isOpen ? "" : undefined, "aria-hidden": "true" })] })) : (_jsxs("div", { className: "crisp-recordpanel-section-trigger crisp-recordpanel-section-trigger-inert", children: [_jsx("span", { className: "crisp-recordpanel-section-row-label", children: s.label }), _jsx("span", { className: "crisp-recordpanel-section-row-count", children: s.count })] })), s.content && isOpen && (_jsx("div", { id: panelId, className: "crisp-recordpanel-section-content", children: s.content }))] }, s.key));
|
|
116
|
+
}) }))] }), footer != null && (_jsx("div", { className: "crisp-recordpanel-footer", children: footer }))] }));
|
|
54
117
|
}
|
|
55
118
|
RecordPanel.displayName = "RecordPanel";
|
|
@@ -29,12 +29,27 @@ const config = defineRecipe({
|
|
|
29
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
30
|
& .crisp-recordpanel-toolbar-spacer { flex: 1; }
|
|
31
31
|
& .crisp-recordpanel-pager { display: flex; align-items: center; gap: ${c.space["1.5"]}; margin-inline-start: ${c.space["3"]}; }
|
|
32
|
+
/* #413: "2 of 37 in All Companies" -- measured live, same toolbar row,
|
|
33
|
+
right after Prev/Next. 12px/500, the same 63%-of-fgPrimary mix every
|
|
34
|
+
other muted heading in this panel already uses. */
|
|
35
|
+
& .crisp-recordpanel-counter { margin-inline-start: ${c.space["3"]}; font-size: 12px; font-weight: ${c.weight.medium}; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
32
36
|
& .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
37
|
& .crisp-recordpanel-iconbtn:hover { background: ${c.bgRaised}; color: ${c.fgPrimary}; }
|
|
34
38
|
& .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
39
|
& .crisp-recordpanel-navbtn:hover:not(:disabled) { background: ${c.bgRaised}; }
|
|
36
40
|
& .crisp-recordpanel-navbtn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
37
41
|
|
|
42
|
+
/* #413: measured live -- a 10px strip at the panel's own left edge,
|
|
43
|
+
cursor: col-resize (the issue's own repro calls it "ew-resize";
|
|
44
|
+
the actual computed value read off the live DOM is col-resize, the
|
|
45
|
+
same cursor family under a different keyword). Kept fully inside the
|
|
46
|
+
panel's own box, not extending past inset-inline-start: 0, so the
|
|
47
|
+
panel's own overflow: hidden (there for its rounded corners) never
|
|
48
|
+
clips it. touch-action: none stops a touchscreen's own pan gesture
|
|
49
|
+
from fighting the drag. */
|
|
50
|
+
& .crisp-recordpanel-resize-handle { position: absolute; top: 0; bottom: 0; inset-inline-start: 0; width: 10px; cursor: col-resize; z-index: 1; touch-action: none; }
|
|
51
|
+
& .crisp-recordpanel-resize-handle:hover, & .crisp-recordpanel-resize-handle:active { background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); }
|
|
52
|
+
|
|
38
53
|
& .crisp-recordpanel-header { display: flex; align-items: center; gap: ${c.space["2"]}; height: 52px; flex-shrink: 0; padding: 0 ${c.space["3"]}; }
|
|
39
54
|
& .crisp-recordpanel-fav { width: 22px; height: ${c.size.chip}; border-radius: ${c.radius.sm}; flex-shrink: 0; object-fit: cover; background: ${c.bgRaised}; }
|
|
40
55
|
& .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; }
|
|
@@ -73,6 +88,28 @@ const config = defineRecipe({
|
|
|
73
88
|
section heading above. */
|
|
74
89
|
& .crisp-recordpanel-activity { font-size: ${c.text.lg}; }
|
|
75
90
|
|
|
91
|
+
/* #413: collapsed Emails/Notes/Tasks below Activity -- measured live: a
|
|
92
|
+
473px-wide row (the panel's own content width, 497 minus its 12px/8px
|
|
93
|
+
side insets), 20px tall, 14px/500 solid fgPrimary label (NOT the 63%
|
|
94
|
+
mix Highlights/Activity headings use -- this is body text, not a
|
|
95
|
+
muted heading), a trailing count. padding "0 8px 0 12px" measured on
|
|
96
|
+
the row itself; reproduced here as the trigger's own padding, since
|
|
97
|
+
the trigger IS the row (a plain div, not a button, when there's no
|
|
98
|
+
content to expand into -- matches the empty ("Emails 0") row
|
|
99
|
+
measured live, which has no expand affordance at all). */
|
|
100
|
+
& .crisp-recordpanel-sections { display: flex; flex-direction: column; padding-top: ${c.space["2"]}; }
|
|
101
|
+
& .crisp-recordpanel-section-trigger { display: flex; align-items: center; gap: ${c.space["1"]}; width: 100%; height: 20px; padding: 0 ${c.space["2"]} 0 ${c.space["3"]}; border: none; background: none; font-family: inherit; text-align: start; cursor: pointer; }
|
|
102
|
+
& .crisp-recordpanel-section-trigger-inert { cursor: default; }
|
|
103
|
+
& .crisp-recordpanel-section-row-label { font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; }
|
|
104
|
+
/* The count wasn't captured with its own background/pill chrome in the
|
|
105
|
+
live measurement (only its text: 14px, a "tertiary" internal variant
|
|
106
|
+
name that doesn't map to anything crisp defines) -- muted text next
|
|
107
|
+
to the label, not an invented badge shape. */
|
|
108
|
+
& .crisp-recordpanel-section-row-count { font-size: ${c.text.lg}; color: ${c.fgMuted}; }
|
|
109
|
+
& .crisp-recordpanel-section-chevron { margin-inline-start: auto; flex-shrink: 0; color: ${c.fgMuted}; transition: transform .15s ease; }
|
|
110
|
+
& .crisp-recordpanel-section-chevron[data-open] { transform: rotate(180deg); }
|
|
111
|
+
& .crisp-recordpanel-section-content { padding: ${c.space["2"]} ${c.space["2"]} ${c.space["2"]} ${c.space["3"]}; }
|
|
112
|
+
|
|
76
113
|
/* #329: an optional slot for record actions (e.g. Edit). Reuses the
|
|
77
114
|
header's own measured values rather than inventing new ones -- the
|
|
78
115
|
same horizontal inset (space.3), the same hairline border token
|