@crisp-ui-kit/crisp 0.51.0 → 0.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/cjs/components/datatable/datatable.js +98 -24
  2. package/dist/cjs/core/caret-rect.js +73 -0
  3. package/dist/cjs/core/index.js +7 -1
  4. package/dist/cjs/core/mention.js +32 -0
  5. package/dist/cjs/core/use-mention-trigger.js +124 -0
  6. package/dist/cjs/product/recordpanel/recordpanel.js +13 -81
  7. package/dist/cjs/product/recordpanel/recordpanel.recipe.js +53 -44
  8. package/dist/cjs/product/recordstable/editable-field.js +11 -8
  9. package/dist/cjs/product/recordstable/recordstable.tag.js +8 -3
  10. package/dist/cjs/product/richtext/index.js +2 -1
  11. package/dist/cjs/product/richtext/richtext.js +440 -23
  12. package/dist/cjs/product/richtext/richtext.recipe.js +59 -9
  13. package/dist/esm/components/datatable/datatable.js +98 -24
  14. package/dist/esm/core/caret-rect.js +70 -0
  15. package/dist/esm/core/index.js +3 -0
  16. package/dist/esm/core/mention.js +29 -0
  17. package/dist/esm/core/use-mention-trigger.js +88 -0
  18. package/dist/esm/product/recordpanel/index.js +1 -1
  19. package/dist/esm/product/recordpanel/recordpanel.js +15 -83
  20. package/dist/esm/product/recordpanel/recordpanel.recipe.js +54 -45
  21. package/dist/esm/product/recordstable/editable-field.js +11 -8
  22. package/dist/esm/product/recordstable/recordstable.tag.js +8 -3
  23. package/dist/esm/product/richtext/index.js +1 -1
  24. package/dist/esm/product/richtext/richtext.js +439 -22
  25. package/dist/esm/product/richtext/richtext.recipe.js +60 -10
  26. package/dist/recordpanel.css +47 -41
  27. package/dist/recordpanel.layered.css +47 -41
  28. package/dist/richtext.css +31 -5
  29. package/dist/richtext.layered.css +31 -5
  30. package/dist/styles.css +79 -47
  31. package/dist/styles.layered.css +79 -47
  32. package/dist/types/components/datatable/datatable.d.ts +11 -10
  33. package/dist/types/core/caret-rect.d.ts +16 -0
  34. package/dist/types/core/index.d.ts +3 -0
  35. package/dist/types/core/mention.d.ts +27 -0
  36. package/dist/types/core/use-mention-trigger.d.ts +41 -0
  37. package/dist/types/product/recordpanel/index.d.ts +1 -1
  38. package/dist/types/product/recordpanel/recordpanel.d.ts +81 -27
  39. package/dist/types/product/recordstable/editable-field.d.ts +11 -8
  40. package/dist/types/product/recordstable/recordstable.tag.d.ts +8 -3
  41. package/dist/types/product/richtext/index.d.ts +1 -1
  42. package/dist/types/product/richtext/richtext.d.ts +168 -7
  43. package/package.json +1 -1
@@ -218,12 +218,61 @@ const CREATE_REFERENCE_VALUE = "__crisp_create_reference__";
218
218
  function dataReferenceOptionLabel(o) {
219
219
  return (_jsxs("span", { className: "crisp-datatable-refoption", children: [_jsx(Avatar, { size: "sm", className: "crisp-datatable-refoption-avatar", children: o.avatar ? (_jsx("img", { src: o.avatar, alt: "" })) : (_jsx(CircleUserRound, { size: 12, strokeWidth: 1.75, "aria-hidden": "true" })) }), o.label] }));
220
220
  }
221
+ /** `Select` owns its own closed-by-default `open` state with no prop or
222
+ * imperative way to start it open (see select.tsx) -- the only thing that
223
+ * opens it is a real click on its trigger button. Attio's own picker opens
224
+ * the instant a `select`/`reference` cell enters edit mode (measured live
225
+ * against a GRC test company, 2026-09-24), not after a second click on an
226
+ * already-mounted, still-closed trigger, so drive that click ourselves.
227
+ * Guarded against firing twice (StrictMode double-invokes effects) --
228
+ * `Select`'s own trigger toggles, so a second click would close what the
229
+ * first one opened. */
230
+ function useAutoOpenSelect() {
231
+ const ref = React.useRef(null);
232
+ const opened = React.useRef(false);
233
+ React.useEffect(() => {
234
+ if (opened.current)
235
+ return;
236
+ opened.current = true;
237
+ ref.current
238
+ ?.querySelector(".crisp-select-trigger")
239
+ ?.click();
240
+ }, []);
241
+ return ref;
242
+ }
243
+ /** `select`/`reference` open crisp's own `Select`, which closes its own
244
+ * popover on Escape (see select.tsx's "document-level fallback for Escape")
245
+ * but has no callback telling the CELL to leave editing mode -- and, same
246
+ * problem that fallback exists for, DOM focus doesn't reliably end up
247
+ * inside the newly-opened popover, so a `TableCell`-level `onKeyDown` can't
248
+ * be trusted to see the key at all (#453). Mirror Select's own fix for
249
+ * that: a raw `document` listener, not a focus-dependent React one. */
250
+ function useEscapeExitsEditing(onCancel) {
251
+ React.useEffect(() => {
252
+ const onKey = (e) => {
253
+ if (e.key === "Escape")
254
+ onCancel();
255
+ };
256
+ document.addEventListener("keydown", onKey);
257
+ return () => document.removeEventListener("keydown", onKey);
258
+ }, [onCancel]);
259
+ }
260
+ /** `editor: "select"`'s picker, its own component (not inline in the
261
+ * row-render loop, like `DataReferenceEditor` below) because it needs the
262
+ * open-on-mount and escape-exits-editing effects above (#453). */
263
+ function DataSelectEditor({ col, value, isPending, onCommit, onCancel, }) {
264
+ const ref = useAutoOpenSelect();
265
+ useEscapeExitsEditing(onCancel);
266
+ return (_jsx("span", { ref: ref, style: { display: "contents" }, children: _jsx(Select, { variant: "ghost", "aria-label": `Edit ${col.key}`, value: asText(value) || undefined, disabled: isPending, options: col.options ?? [], onValueChange: onCommit }) }));
267
+ }
221
268
  /** `editor: "reference"`'s picker, its own component (not inline in the
222
269
  * row-render loop) because it needs its own `useState` for the search query
223
270
  * -- one instance per cell, same reasoning `select`/`switch` don't need a
224
271
  * component of their own but this does. */
225
- function DataReferenceEditor({ col, value, isPending, onCommit, }) {
272
+ function DataReferenceEditor({ col, value, isPending, onCommit, onCancel, }) {
226
273
  const [query, setQuery] = React.useState("");
274
+ const ref = useAutoOpenSelect();
275
+ useEscapeExitsEditing(onCancel);
227
276
  const options = (col.refOptions ?? []).map((o) => ({
228
277
  value: o.value,
229
278
  label: dataReferenceOptionLabel(o),
@@ -238,12 +287,12 @@ function DataReferenceEditor({ col, value, isPending, onCommit, }) {
238
287
  keywords: query,
239
288
  });
240
289
  }
241
- return (_jsx(Select, { variant: "ghost", searchable: true, searchPlaceholder: col.refSearchPlaceholder, onSearchChange: setQuery, "aria-label": `Edit ${col.key}`, value: asText(value) || undefined, disabled: isPending, options: options, onValueChange: (v) => {
242
- if (v === CREATE_REFERENCE_VALUE)
243
- col.onCreateReference?.(query);
244
- else
245
- onCommit(v);
246
- } }));
290
+ return (_jsx("span", { ref: ref, style: { display: "contents" }, children: _jsx(Select, { variant: "ghost", searchable: true, searchPlaceholder: col.refSearchPlaceholder, onSearchChange: setQuery, "aria-label": `Edit ${col.key}`, value: asText(value) || undefined, disabled: isPending, options: options, onValueChange: (v) => {
291
+ if (v === CREATE_REFERENCE_VALUE)
292
+ col.onCreateReference?.(query);
293
+ else
294
+ onCommit(v);
295
+ } }) }));
247
296
  }
248
297
  export function DataTable({ columns: columnsProp, data, selectable = false, selectedIds, onSelectionChange, selectAllLabel = "Select all rows", selectRowLabel = (row) => `Select row ${row.id}`, selectionAnnouncement = (count) => count === 0 ? "Selection cleared" : `${count} selected`, columnMenu = false, onColumnsChange, groupBy, onRowsReorder, error, sort: sortProp, defaultSort, onSortChange, filterable = false, searchPlaceholder = "Search…", searchLabel = "Search table", onEdit, stickyFirst = true, stickyLast = false, locale: localeProp, avatarTone = "brand", surface, interactive = false, onRowClick, onAddRow, addRowLabel = "New record", empty = "No results.", showCount = true, countLabel = (shown, total) => shown === total
249
298
  ? `${total} ${total === 1 ? "row" : "rows"}`
@@ -466,10 +515,11 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
466
515
  * throughout, and the browser's own picker already
467
516
  * carries both a calendar and a time control.
468
517
  * text/entity a plain text field
469
- * `status`, `reference` and `tags` are pickers in RecordsTable, not
470
- * double-click editors, so they are not editable here either -- except
471
- * `editor: "reference"` (#432), which is a picker for the same reason
472
- * `select`/`switch` are: always live, no double-click gate.
518
+ * `editor: "select"`/`"reference"` open their own picker instead of this
519
+ * native-input family, but through the SAME double-click gate (#453) --
520
+ * see the row-render loop's `isEditing` branch for those, this function
521
+ * only covers the native-input-backed types. `editor: "switch"` is the
522
+ * one editor with no double-click gate at all (always live).
473
523
  */
474
524
  function editorFor(col) {
475
525
  switch (col.type) {
@@ -523,11 +573,12 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
523
573
  return;
524
574
  }
525
575
  else if (e.key === "Enter") {
526
- // select/switch columns have no separate editing mode to enter --
527
- // they're already their own live widget (#385); Enter there is a
528
- // no-op here, same as any other non-editable cell that isn't wired to
529
- // onRowClick.
530
- if (columns[c]?.editable && !columns[c]?.editor) {
576
+ // #453: `switch` has no separate editing mode to enter -- it's
577
+ // already its own live widget (#385); Enter there is a no-op, same
578
+ // as any other non-editable cell that isn't wired to onRowClick.
579
+ // `select`/`reference` DO have one now (like every other editor),
580
+ // so Enter opens them too.
581
+ if (columns[c]?.editable && columns[c]?.editor !== "switch") {
531
582
  e.preventDefault();
532
583
  openEditing(selCell.id, selCell.key);
533
584
  }
@@ -758,14 +809,37 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
758
809
  const errorHere = editError?.id === row.id && editError?.key === col.key
759
810
  ? editError.message
760
811
  : null;
761
- // A select/switch editable column is ALWAYS its own
762
- // live widget -- no double-click gate, no separate
763
- // edit/view mode, the same convention RecordsTable's
764
- // status/reference pickers already use: choosing a
765
- // value or flipping a toggle IS the commit gesture
766
- // (#385).
767
- if (col.editable && col.editor) {
768
- return (_jsxs(TableCell, { align: alignFor(col), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined, children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [col.editor === "select" ? (_jsx(Select, { variant: "ghost", "aria-label": `Edit ${col.key}`, value: asText(value) || undefined, disabled: isPending, options: col.options ?? [], onValueChange: (v) => commitEdit(row.id, col.key, v) })) : col.editor === "reference" ? (_jsx(DataReferenceEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v) })) : (_jsx(Switch, { "aria-label": `Edit ${col.key}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commitEdit(row.id, col.key, String(checked)) })), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
812
+ // #453: a `switch` editable column IS always its own
813
+ // live widget -- flipping the toggle IS the commit
814
+ // gesture, there's no meaningfully different "rest"
815
+ // display for a boolean. `select`/`reference` are
816
+ // NOT this: at rest they must show the column's own
817
+ // render/format like every other editable type
818
+ // (#412 empty handling included), and only open on
819
+ // double-click (#411) -- this branch previously
820
+ // rendered them unconditionally, replacing every
821
+ // row's own render with a picker trigger, which is
822
+ // the regression #453 reported (also #432/#449,
823
+ // which never had a "rest" state of their own to get
824
+ // right). See the `isEditing` block below for their
825
+ // actual editor.
826
+ if (col.editable && col.editor === "switch") {
827
+ return (_jsxs(TableCell, { align: alignFor(col), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined, children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [_jsx(Switch, { "aria-label": `Edit ${col.key}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commitEdit(row.id, col.key, String(checked)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
828
+ }
829
+ if (isEditing &&
830
+ (col.editor === "select" || col.editor === "reference")) {
831
+ return (_jsxs(TableCell, { align: alignFor(col), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined,
832
+ // Same as the plain text editor's own cell:
833
+ // opening/using the picker must not also fire
834
+ // the row's onRowClick. Escape is handled by
835
+ // `DataSelectEditor`/`DataReferenceEditor`
836
+ // themselves (a document-level listener, not a
837
+ // `TableCell`-level `onKeyDown`) -- opening the
838
+ // picker moves DOM focus into a floating,
839
+ // portaled popover, so a handler here can't
840
+ // rely on the key event ever bubbling through
841
+ // this element (#453).
842
+ onClick: (e) => e.stopPropagation(), children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [col.editor === "select" ? (_jsx(DataSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })) : (_jsx(DataReferenceEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
769
843
  }
770
844
  if (isEditing) {
771
845
  return (_jsxs(TableCell, { align: alignFor(col), ...sticky, onClick: (e) => e.stopPropagation(), children: [_jsx("input", { className: "crisp-datatable-edit",
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The pixel position of a caret index inside a `<textarea>`/`<input>`, in
3
+ * viewport coordinates — where a mention picker anchors.
4
+ *
5
+ * Neither element exposes caret geometry directly, so this uses the standard
6
+ * mirror-div technique: an off-screen div replicating every computed style
7
+ * that affects text layout (font, padding, border, wrapping), holding the
8
+ * same text up to the caret plus a marker span. The marker's own
9
+ * `getBoundingClientRect()`, translated back onto the real field's box, is
10
+ * the caret's actual rendered position — nothing here guesses at line
11
+ * height or character width itself.
12
+ */
13
+ export function caretRectInField(field, index) {
14
+ const div = document.createElement("div");
15
+ const style = window.getComputedStyle(field);
16
+ const mirrored = [
17
+ "boxSizing",
18
+ "width",
19
+ "paddingTop",
20
+ "paddingRight",
21
+ "paddingBottom",
22
+ "paddingLeft",
23
+ "borderTopWidth",
24
+ "borderRightWidth",
25
+ "borderBottomWidth",
26
+ "borderLeftWidth",
27
+ "fontFamily",
28
+ "fontSize",
29
+ "fontWeight",
30
+ "fontStyle",
31
+ "letterSpacing",
32
+ "lineHeight",
33
+ "textTransform",
34
+ "textIndent",
35
+ "textAlign",
36
+ "wordSpacing",
37
+ "tabSize",
38
+ ];
39
+ for (const prop of mirrored)
40
+ div.style.setProperty(prop, style[prop]);
41
+ div.style.position = "absolute";
42
+ div.style.visibility = "hidden";
43
+ div.style.top = "0";
44
+ div.style.left = "-9999px";
45
+ div.style.whiteSpace =
46
+ field instanceof HTMLTextAreaElement ? "pre-wrap" : "pre";
47
+ div.style.wordWrap = "break-word";
48
+ div.style.overflow = "hidden";
49
+ div.style.height = "auto";
50
+ const value = field.value;
51
+ div.textContent = value.slice(0, index);
52
+ const marker = document.createElement("span");
53
+ // a trailing empty span collapses to zero width and reports the wrong x —
54
+ // give it a hair of content so its box sits exactly at the caret
55
+ marker.textContent = value.slice(index, index + 1) || ".";
56
+ div.appendChild(marker);
57
+ document.body.appendChild(div);
58
+ div.scrollTop = field.scrollTop;
59
+ div.scrollLeft = field.scrollLeft;
60
+ const fieldRect = field.getBoundingClientRect();
61
+ const divRect = div.getBoundingClientRect();
62
+ const markerRect = marker.getBoundingClientRect();
63
+ const x = fieldRect.left + (markerRect.left - divRect.left) - div.scrollLeft;
64
+ const y = fieldRect.top +
65
+ (markerRect.top - divRect.top) -
66
+ div.scrollTop +
67
+ markerRect.height;
68
+ document.body.removeChild(div);
69
+ return { x, y };
70
+ }
@@ -1,3 +1,4 @@
1
+ export { caretRectInField } from "./caret-rect.js";
1
2
  export { cellDatePrecision, formatCellDate, parseCellDate, } from "./cell-date.js";
2
3
  export { cx } from "./cx.js";
3
4
  export { defineRecipe } from "./define.js";
@@ -6,6 +7,7 @@ export { isRtlElement } from "./direction.js";
6
7
  export { FloatingPortal } from "./floating-portal.js";
7
8
  export { hasOpenFloatingLayer, isInsideLaterFloatingLayer, registerFloatingLayer, } from "./floating-stack.js";
8
9
  export { formValueStyle } from "./form-value.js";
10
+ export { matchMentionTrigger, } from "./mention.js";
9
11
  export { placeFloating, } from "./place-floating.js";
10
12
  export { Primitive } from "./primitive.js";
11
13
  export { resolveRecipe } from "./resolve.js";
@@ -14,4 +16,5 @@ export { defineSpec, specToRecipe } from "./spec.js";
14
16
  export { useFocusAfterRemoval } from "./use-focus-after-removal.js";
15
17
  export { useFormReset } from "./use-form-reset.js";
16
18
  export { useLabelAssociation } from "./use-label-association.js";
19
+ export { useMentionTrigger, } from "./use-mention-trigger.js";
17
20
  export { usePrefersReducedMotion } from "./use-prefers-reduced-motion.js";
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Is there an active "@query" mention trigger ending at the caret?
3
+ *
4
+ * Matches the rule every "@mention" implementation (GitHub, Slack, Linear)
5
+ * actually uses: the trigger character must start a word — the very first
6
+ * character, or preceded by whitespace — and nothing between it and the
7
+ * caret may contain whitespace. `foo@bar` is an email/handle stuck to a word,
8
+ * not a trigger; `@jo next` has already moved past the mention it typed.
9
+ *
10
+ * Takes plain text rather than a live DOM node so it is one pure function
11
+ * usable against a `<textarea>`'s `value` and a `contentEditable` text
12
+ * node's content alike, and is the one thing here worth unit-testing on its
13
+ * own — everything downstream of it (caret geometry, DOM insertion) needs a
14
+ * real browser to verify.
15
+ */
16
+ export function matchMentionTrigger(textBeforeCaret, trigger = "@") {
17
+ if (!trigger)
18
+ return null;
19
+ const start = textBeforeCaret.lastIndexOf(trigger);
20
+ if (start === -1)
21
+ return null;
22
+ const before = start > 0 ? textBeforeCaret[start - 1] : undefined;
23
+ if (before !== undefined && !/\s/.test(before))
24
+ return null;
25
+ const query = textBeforeCaret.slice(start + trigger.length);
26
+ if (/\s/.test(query))
27
+ return null;
28
+ return { query, start };
29
+ }
@@ -0,0 +1,88 @@
1
+ import * as React from "react";
2
+ import { caretRectInField } from "./caret-rect.js";
3
+ import { matchMentionTrigger, } from "./mention.js";
4
+ /**
5
+ * Watches a `<textarea>`/`<input>` for an "@query" mention trigger and
6
+ * reports where to anchor a consumer-supplied picker (#372).
7
+ *
8
+ * crisp draws no picker of its own here — GitHub/Slack/Linear-style @mention
9
+ * UI varies too much by app (avatars, groups, recent-first ordering, a
10
+ * "create new person" row) to have one measured shape, and this repo's rule
11
+ * is measure-first, never invented polish. What is shared and worth owning
12
+ * centrally is the fiddly, easy-to-get-wrong part: deciding when "@" is
13
+ * actually a trigger rather than part of a word or an email address (see
14
+ * `matchMentionTrigger`), and where its picker anchors on screen (see
15
+ * `caretRectInField`, since neither element exposes caret geometry itself).
16
+ *
17
+ * Does not close on blur: a picker built from this needs to survive the
18
+ * field losing focus to a `mousedown` on one of its own rows, the same
19
+ * problem every floating surface in this repo solves for itself (see
20
+ * `RichTextEditor`'s toolbar `keepSel`, or `Popover`'s own outside-pointer-
21
+ * down listener) — so closing on outside interaction is left to the
22
+ * consumer's picker, which already knows what counts as "outside" for its
23
+ * own markup.
24
+ */
25
+ export function useMentionTrigger(ref, { trigger = "@" } = {}) {
26
+ const [state, setState] = React.useState({
27
+ open: false,
28
+ query: "",
29
+ anchor: null,
30
+ });
31
+ const matchRef = React.useRef(null);
32
+ const recompute = React.useCallback(() => {
33
+ const el = ref.current;
34
+ if (!el || el.selectionStart == null) {
35
+ matchRef.current = null;
36
+ setState({ open: false, query: "", anchor: null });
37
+ return;
38
+ }
39
+ const caret = el.selectionStart;
40
+ const match = matchMentionTrigger(el.value.slice(0, caret), trigger);
41
+ matchRef.current = match;
42
+ if (!match) {
43
+ setState({ open: false, query: "", anchor: null });
44
+ return;
45
+ }
46
+ setState({
47
+ open: true,
48
+ query: match.query,
49
+ anchor: caretRectInField(el, caret),
50
+ });
51
+ }, [ref, trigger]);
52
+ React.useEffect(() => {
53
+ const el = ref.current;
54
+ if (!el)
55
+ return;
56
+ // "input" covers typing; "click"/"keyup" cover the caret moving without
57
+ // the text changing (arrow keys, clicking elsewhere on the same line).
58
+ el.addEventListener("input", recompute);
59
+ el.addEventListener("click", recompute);
60
+ el.addEventListener("keyup", recompute);
61
+ return () => {
62
+ el.removeEventListener("input", recompute);
63
+ el.removeEventListener("click", recompute);
64
+ el.removeEventListener("keyup", recompute);
65
+ };
66
+ }, [ref, recompute]);
67
+ const close = React.useCallback(() => {
68
+ matchRef.current = null;
69
+ setState({ open: false, query: "", anchor: null });
70
+ }, []);
71
+ const insert = React.useCallback((text) => {
72
+ const el = ref.current;
73
+ const match = matchRef.current;
74
+ if (!el || !match)
75
+ return;
76
+ const caret = el.selectionStart ?? el.value.length;
77
+ const before = el.value.slice(0, match.start);
78
+ const after = el.value.slice(caret);
79
+ el.value = before + text + after;
80
+ const cursor = before.length + text.length;
81
+ el.focus();
82
+ el.setSelectionRange(cursor, cursor);
83
+ // a controlled consumer listens on "input", the same event typing fires
84
+ el.dispatchEvent(new Event("input", { bubbles: true }));
85
+ close();
86
+ }, [ref, close]);
87
+ return { ...state, close, insert };
88
+ }
@@ -1,2 +1,2 @@
1
- export { RecordPanel } from "./recordpanel.js";
1
+ export { RecordPanel, } from "./recordpanel.js";
2
2
  export { recordpanelRecipe } from "./recordpanel.recipe.js";
@@ -1,38 +1,21 @@
1
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { X } from "lucide-react";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ArrowUpRight, ChevronDown, ChevronUp, X } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { Avatar } from "../../components/avatar/index.js";
5
- import { Select } from "../../components/select/index.js";
6
- import { Spinner } from "../../components/spinner/index.js";
7
- import { Switch } from "../../components/switch/index.js";
8
5
  import { hasOpenFloatingLayer } from "../../core/index.js";
9
- import { editableInputType, tagTone, useEditableField, } from "../recordstable/index.js";
10
6
  import { recordpanelRecipe } from "./recordpanel.recipe.js";
11
- function FieldValue({ col, row, emptyLabel, }) {
12
- const v = row[col.key];
13
- if (v == null || v === "" || (Array.isArray(v) && !v.length)) {
14
- return _jsx("span", { className: "crisp-recordpanel-empty", children: emptyLabel });
15
- }
16
- switch (col.type) {
17
- case "tags":
18
- return (_jsx(_Fragment, { children: v.map((t) => (_jsx("span", { className: "crisp-recordstable-tag", "data-tone": tagTone(t), children: t }, t))) }));
19
- case "number":
20
- return _jsx(_Fragment, { children: typeof v === "number" ? v.toLocaleString() : v });
21
- case "url":
22
- return (_jsx("a", { className: "crisp-recordpanel-link", href: `https://${v}`, target: "_blank", rel: "noreferrer", children: v }));
23
- default:
24
- return _jsx(_Fragment, { children: String(v) });
25
- }
26
- }
27
7
  /**
28
- * Right-side record peek panel — shows one record's fields, opened from a
29
- * RecordsTable row (via `onOpenRecord`). Render it inside a `position:
30
- * relative` container (e.g. the app main area); it slides in from the right and
31
- * overlays the content.
8
+ * Right-side record peek panel — Attio's own peek shows a Highlights card
9
+ * area and an Activity feed (measured live: hover a Companies row, click its
10
+ * "Open preview" trigger), never an editable field list (#448 — a "Details"
11
+ * list was this component's own invention before, and #431's editable API
12
+ * had nothing real in this panel to attach to; that API stays on
13
+ * `RecordPage`'s own Details column, where Attio does let you edit in
14
+ * place). Render it inside a `position: relative` container (e.g. the app
15
+ * main area); it slides in from the right and overlays the content.
32
16
  */
33
- export function RecordPanel({ record, columns, onClose, className, closeLabel = "Close panel", title, detailsLabel = "Details", emptyLabel = "Empty", footer, onEdit, ...rest }) {
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 }) {
34
18
  const titleId = React.useId();
35
- const { editingKey, pendingKey, error, openEditing, closeEditing, commit } = useEditableField(onEdit);
36
19
  // close on Escape while open -- but not when a Menu/Select/Popover/
37
20
  // ContextMenu opened from inside the panel (e.g. a footer "More actions"
38
21
  // menu) is itself open (#361): that Escape belongs to the nested layer,
@@ -57,67 +40,16 @@ export function RecordPanel({ record, columns, onClose, className, closeLabel =
57
40
  if (!record)
58
41
  return;
59
42
  const onKey = (e) => {
60
- if (e.key !== "Escape")
61
- return;
62
- // #431: Escape while a field is open cancels JUST that field, the
63
- // same way DataTable's own editable cells treat it -- not the whole
64
- // panel. Checked ahead of `hasOpenFloatingLayer()` (capture phase, so
65
- // this runs before the field's own bubble-phase onKeyDown would ever
66
- // get a chance to stopPropagation -- same reasoning as the
67
- // floating-layer check below).
68
- if (editingKey) {
69
- closeEditing();
70
- return;
71
- }
72
- if (hasOpenFloatingLayer())
43
+ if (e.key !== "Escape" || hasOpenFloatingLayer())
73
44
  return;
74
45
  onClose();
75
46
  };
76
47
  document.addEventListener("keydown", onKey, true);
77
48
  return () => document.removeEventListener("keydown", onKey, true);
78
- }, [record, onClose, editingKey, closeEditing]);
49
+ }, [record, onClose]);
79
50
  if (!record)
80
51
  return null;
81
- const entityCol = columns.find((c) => c.type === "entity");
82
- const entity = entityCol
83
- ? record[entityCol.key]
84
- : undefined;
85
- const fields = columns.filter((c) => c.type !== "entity");
86
- const heading = title ?? entity?.name ?? "Record";
87
- return (_jsxs("div", { className: recordpanelRecipe({ className }), ...rest, role: "dialog", "aria-labelledby": titleId, children: [_jsxs("div", { className: "crisp-recordpanel-header", children: [entity?.avatar ? (_jsx("img", { className: "crisp-recordpanel-fav", src: entity.avatar, alt: "" })) : entity ? (_jsx(Avatar, { size: "md", shape: "rounded", tone: "brand", children: entity.name.charAt(0) })) : null, _jsx("span", { id: titleId, className: "crisp-recordpanel-title", children: heading }), _jsx("button", { type: "button", className: "crisp-recordpanel-iconbtn", "aria-label": closeLabel, onClick: onClose, children: _jsx(X, { size: 18, strokeWidth: 1.75 }) })] }), _jsxs("div", { className: "crisp-recordpanel-body", children: [_jsx("div", { className: "crisp-recordpanel-sectionlabel", children: detailsLabel }), fields.map((col) => {
88
- // #431: a field only actually opens when there's an `onEdit` to
89
- // commit through -- `editable` with nothing to call would be a
90
- // dead click.
91
- const canEdit = Boolean(col.editable && onEdit);
92
- const isPending = pendingKey === col.key;
93
- const fieldError = error?.key === col.key ? error.message : null;
94
- // select/switch are always their own live widget -- no click-to-
95
- // open, no separate edit/view mode, the same convention
96
- // DataTable's identical `editor` columns use (#385): choosing a
97
- // value or flipping a toggle IS the commit gesture.
98
- if (canEdit && col.editor) {
99
- const value = record[col.key];
100
- return (_jsxs("div", { className: "crisp-recordpanel-field", children: [_jsxs("span", { className: "crisp-recordpanel-field-label", children: [col.icon ? _jsx(col.icon, { size: 15, strokeWidth: 1.75 }) : null, col.label] }), _jsxs("span", { className: "crisp-recordpanel-field-value", children: [_jsxs("span", { className: "crisp-recordpanel-edit-picker", children: [col.editor === "select" ? (_jsx(Select, { variant: "ghost", "aria-label": `Edit ${col.label}`, value: value != null ? String(value) : undefined, disabled: isPending, options: col.editOptions ?? [], onValueChange: (v) => commit(record.id, col.key, v) })) : (_jsx(Switch, { "aria-label": `Edit ${col.label}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commit(record.id, col.key, String(checked)) })), isPending && (_jsx(Spinner, { size: 14, className: "crisp-recordpanel-edit-pending" }))] }), fieldError && (_jsx("span", { className: "crisp-recordpanel-edit-error", children: fieldError }))] })] }, col.key));
101
- }
102
- if (canEdit && editingKey === col.key) {
103
- const value = record[col.key];
104
- const editor = editableInputType(col.type);
105
- return (_jsxs("div", { className: "crisp-recordpanel-field", children: [_jsxs("span", { className: "crisp-recordpanel-field-label", children: [col.icon ? _jsx(col.icon, { size: 15, strokeWidth: 1.75 }) : null, col.label] }), _jsxs("span", { className: "crisp-recordpanel-field-value", children: [_jsx("input", { className: "crisp-recordpanel-edit",
106
- // biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
107
- autoFocus: true, ...editor, disabled: isPending, defaultValue: value == null ? "" : String(value), "aria-label": `Edit ${col.label}`, onKeyDown: (e) => {
108
- if (isPending)
109
- return;
110
- if (e.key === "Enter")
111
- commit(record.id, col.key, e.currentTarget.value);
112
- else if (e.key === "Escape")
113
- closeEditing();
114
- }, onBlur: (e) => {
115
- if (isPending)
116
- return;
117
- commit(record.id, col.key, e.currentTarget.value);
118
- } }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-recordpanel-edit-pending" })), fieldError && (_jsx("span", { className: "crisp-recordpanel-edit-error", children: fieldError }))] })] }, col.key));
119
- }
120
- return (_jsxs("div", { className: "crisp-recordpanel-field", children: [_jsxs("span", { className: "crisp-recordpanel-field-label", children: [col.icon ? _jsx(col.icon, { size: 15, strokeWidth: 1.75 }) : null, col.label] }), _jsxs("span", { className: "crisp-recordpanel-field-value", "data-editable": canEdit ? "" : undefined, children: [canEdit ? (_jsx("button", { type: "button", className: "crisp-recordpanel-edit-trigger", onClick: () => openEditing(col.key), children: _jsx(FieldValue, { col: col, row: record, emptyLabel: emptyLabel }) })) : (_jsx(FieldValue, { col: col, row: record, emptyLabel: emptyLabel })), fieldError && (_jsx("span", { className: "crisp-recordpanel-edit-error", children: fieldError }))] })] }, col.key));
121
- })] }), footer != null && (_jsx("div", { className: "crisp-recordpanel-footer", children: footer }))] }));
52
+ const heading = title ?? record.name ?? "Record";
53
+ return (_jsxs("div", { className: recordpanelRecipe({ className }), ...rest, role: "dialog", "aria-labelledby": titleId, children: [_jsxs("div", { className: "crisp-recordpanel-toolbar", children: [_jsx("button", { type: "button", className: "crisp-recordpanel-iconbtn", "aria-label": closeLabel, onClick: onClose, children: _jsx(X, { size: 16, strokeWidth: 1.75 }) }), (onPrevious || onNext) && (_jsxs("span", { className: "crisp-recordpanel-pager", children: [_jsx("button", { type: "button", className: "crisp-recordpanel-navbtn", "aria-label": previousRecordLabel, disabled: !onPrevious, onClick: onPrevious, children: _jsx(ChevronUp, { size: 16, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpanel-navbtn", "aria-label": nextRecordLabel, disabled: !onNext, onClick: onNext, children: _jsx(ChevronDown, { size: 16, strokeWidth: 1.75 }) })] })), _jsx("span", { className: "crisp-recordpanel-toolbar-spacer" }), onOpenRecord && (_jsx("button", { type: "button", className: "crisp-recordpanel-navbtn", "aria-label": openRecordLabel, onClick: onOpenRecord, children: _jsx(ArrowUpRight, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75 }) }))] }), _jsxs("div", { className: "crisp-recordpanel-header", children: [avatar ? (_jsx("img", { className: "crisp-recordpanel-fav", src: avatar, alt: "" })) : (_jsx(Avatar, { size: "md", shape: "rounded", tone: avatarTone, children: (record.name ?? "?").charAt(0) })), _jsx("span", { id: titleId, className: "crisp-recordpanel-title", children: heading })] }), _jsxs("div", { className: "crisp-recordpanel-body", children: [highlights && highlights.length > 0 && (_jsxs("div", { className: "crisp-recordpanel-section", children: [_jsx("div", { className: "crisp-recordpanel-section-head", children: _jsx("span", { className: "crisp-recordpanel-section-label", children: highlightsLabel }) }), _jsx("div", { className: "crisp-recordpanel-highlights", children: highlights.map((h) => (_jsxs("div", { className: "crisp-recordpanel-highlight", children: [_jsxs("span", { className: "crisp-recordpanel-highlight-label", children: [_jsx("span", { children: h.label }), h.icon ? _jsx(h.icon, { size: 16, strokeWidth: 1.75 }) : null] }), _jsx("span", { className: "crisp-recordpanel-highlight-value", children: h.value })] }, h.key))) })] })), activity != null && (_jsxs("div", { className: "crisp-recordpanel-section", children: [_jsxs("div", { className: "crisp-recordpanel-section-head", children: [_jsx("span", { className: "crisp-recordpanel-section-label", children: activityLabel }), onViewAllActivity && (_jsx("button", { type: "button", className: "crisp-recordpanel-section-action", onClick: onViewAllActivity, children: viewAllActivityLabel }))] }), _jsx("div", { className: "crisp-recordpanel-activity", children: activity })] }))] }), footer != null && (_jsx("div", { className: "crisp-recordpanel-footer", children: footer }))] }));
122
54
  }
123
55
  RecordPanel.displayName = "RecordPanel";