@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.
@@ -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
- // select/switch are always their own live widget -- no
459
- // click-to-open, no separate edit/view mode, the same
460
- // convention DataTable's identical `editor` columns use
461
- // (#385): choosing a value or flipping a toggle IS the
462
- // commit gesture.
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.jsxs)("span", { className: "crisp-recordpage-edit-picker", children: [col.editor === "select" ? ((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: (v) => commit(record.id, col.key, v) })) : ((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));
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 })] }))] }), footer != null && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpanel-footer", children: footer }))] }));
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
- // 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) {
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: [_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));
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 })] }))] }), footer != null && (_jsx("div", { className: "crisp-recordpanel-footer", children: footer }))] }));
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