@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
@@ -254,12 +254,61 @@ const CREATE_REFERENCE_VALUE = "__crisp_create_reference__";
254
254
  function dataReferenceOptionLabel(o) {
255
255
  return ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-refoption", children: [(0, jsx_runtime_1.jsx)(avatar_1.Avatar, { size: "sm", className: "crisp-datatable-refoption-avatar", children: o.avatar ? ((0, jsx_runtime_1.jsx)("img", { src: o.avatar, alt: "" })) : ((0, jsx_runtime_1.jsx)(lucide_react_1.CircleUserRound, { size: 12, strokeWidth: 1.75, "aria-hidden": "true" })) }), o.label] }));
256
256
  }
257
+ /** `Select` owns its own closed-by-default `open` state with no prop or
258
+ * imperative way to start it open (see select.tsx) -- the only thing that
259
+ * opens it is a real click on its trigger button. Attio's own picker opens
260
+ * the instant a `select`/`reference` cell enters edit mode (measured live
261
+ * against a GRC test company, 2026-09-24), not after a second click on an
262
+ * already-mounted, still-closed trigger, so drive that click ourselves.
263
+ * Guarded against firing twice (StrictMode double-invokes effects) --
264
+ * `Select`'s own trigger toggles, so a second click would close what the
265
+ * first one opened. */
266
+ function useAutoOpenSelect() {
267
+ const ref = React.useRef(null);
268
+ const opened = React.useRef(false);
269
+ React.useEffect(() => {
270
+ if (opened.current)
271
+ return;
272
+ opened.current = true;
273
+ ref.current
274
+ ?.querySelector(".crisp-select-trigger")
275
+ ?.click();
276
+ }, []);
277
+ return ref;
278
+ }
279
+ /** `select`/`reference` open crisp's own `Select`, which closes its own
280
+ * popover on Escape (see select.tsx's "document-level fallback for Escape")
281
+ * but has no callback telling the CELL to leave editing mode -- and, same
282
+ * problem that fallback exists for, DOM focus doesn't reliably end up
283
+ * inside the newly-opened popover, so a `TableCell`-level `onKeyDown` can't
284
+ * be trusted to see the key at all (#453). Mirror Select's own fix for
285
+ * that: a raw `document` listener, not a focus-dependent React one. */
286
+ function useEscapeExitsEditing(onCancel) {
287
+ React.useEffect(() => {
288
+ const onKey = (e) => {
289
+ if (e.key === "Escape")
290
+ onCancel();
291
+ };
292
+ document.addEventListener("keydown", onKey);
293
+ return () => document.removeEventListener("keydown", onKey);
294
+ }, [onCancel]);
295
+ }
296
+ /** `editor: "select"`'s picker, its own component (not inline in the
297
+ * row-render loop, like `DataReferenceEditor` below) because it needs the
298
+ * open-on-mount and escape-exits-editing effects above (#453). */
299
+ function DataSelectEditor({ col, value, isPending, onCommit, onCancel, }) {
300
+ const ref = useAutoOpenSelect();
301
+ useEscapeExitsEditing(onCancel);
302
+ return ((0, jsx_runtime_1.jsx)("span", { ref: ref, style: { display: "contents" }, children: (0, jsx_runtime_1.jsx)(select_1.Select, { variant: "ghost", "aria-label": `Edit ${col.key}`, value: asText(value) || undefined, disabled: isPending, options: col.options ?? [], onValueChange: onCommit }) }));
303
+ }
257
304
  /** `editor: "reference"`'s picker, its own component (not inline in the
258
305
  * row-render loop) because it needs its own `useState` for the search query
259
306
  * -- one instance per cell, same reasoning `select`/`switch` don't need a
260
307
  * component of their own but this does. */
261
- function DataReferenceEditor({ col, value, isPending, onCommit, }) {
308
+ function DataReferenceEditor({ col, value, isPending, onCommit, onCancel, }) {
262
309
  const [query, setQuery] = React.useState("");
310
+ const ref = useAutoOpenSelect();
311
+ useEscapeExitsEditing(onCancel);
263
312
  const options = (col.refOptions ?? []).map((o) => ({
264
313
  value: o.value,
265
314
  label: dataReferenceOptionLabel(o),
@@ -274,12 +323,12 @@ function DataReferenceEditor({ col, value, isPending, onCommit, }) {
274
323
  keywords: query,
275
324
  });
276
325
  }
277
- return ((0, jsx_runtime_1.jsx)(select_1.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) => {
278
- if (v === CREATE_REFERENCE_VALUE)
279
- col.onCreateReference?.(query);
280
- else
281
- onCommit(v);
282
- } }));
326
+ return ((0, jsx_runtime_1.jsx)("span", { ref: ref, style: { display: "contents" }, children: (0, jsx_runtime_1.jsx)(select_1.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) => {
327
+ if (v === CREATE_REFERENCE_VALUE)
328
+ col.onCreateReference?.(query);
329
+ else
330
+ onCommit(v);
331
+ } }) }));
283
332
  }
284
333
  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
285
334
  ? `${total} ${total === 1 ? "row" : "rows"}`
@@ -502,10 +551,11 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
502
551
  * throughout, and the browser's own picker already
503
552
  * carries both a calendar and a time control.
504
553
  * text/entity a plain text field
505
- * `status`, `reference` and `tags` are pickers in RecordsTable, not
506
- * double-click editors, so they are not editable here either -- except
507
- * `editor: "reference"` (#432), which is a picker for the same reason
508
- * `select`/`switch` are: always live, no double-click gate.
554
+ * `editor: "select"`/`"reference"` open their own picker instead of this
555
+ * native-input family, but through the SAME double-click gate (#453) --
556
+ * see the row-render loop's `isEditing` branch for those, this function
557
+ * only covers the native-input-backed types. `editor: "switch"` is the
558
+ * one editor with no double-click gate at all (always live).
509
559
  */
510
560
  function editorFor(col) {
511
561
  switch (col.type) {
@@ -559,11 +609,12 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
559
609
  return;
560
610
  }
561
611
  else if (e.key === "Enter") {
562
- // select/switch columns have no separate editing mode to enter --
563
- // they're already their own live widget (#385); Enter there is a
564
- // no-op here, same as any other non-editable cell that isn't wired to
565
- // onRowClick.
566
- if (columns[c]?.editable && !columns[c]?.editor) {
612
+ // #453: `switch` has no separate editing mode to enter -- it's
613
+ // already its own live widget (#385); Enter there is a no-op, same
614
+ // as any other non-editable cell that isn't wired to onRowClick.
615
+ // `select`/`reference` DO have one now (like every other editor),
616
+ // so Enter opens them too.
617
+ if (columns[c]?.editable && columns[c]?.editor !== "switch") {
567
618
  e.preventDefault();
568
619
  openEditing(selCell.id, selCell.key);
569
620
  }
@@ -794,14 +845,37 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
794
845
  const errorHere = editError?.id === row.id && editError?.key === col.key
795
846
  ? editError.message
796
847
  : null;
797
- // A select/switch editable column is ALWAYS its own
798
- // live widget -- no double-click gate, no separate
799
- // edit/view mode, the same convention RecordsTable's
800
- // status/reference pickers already use: choosing a
801
- // value or flipping a toggle IS the commit gesture
802
- // (#385).
803
- if (col.editable && col.editor) {
804
- return ((0, jsx_runtime_1.jsxs)(table_1.TableCell, { align: alignFor(col), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined, children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-edit-picker", children: [col.editor === "select" ? ((0, jsx_runtime_1.jsx)(select_1.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" ? ((0, jsx_runtime_1.jsx)(DataReferenceEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v) })) : ((0, jsx_runtime_1.jsx)(switch_1.Switch, { "aria-label": `Edit ${col.key}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commitEdit(row.id, col.key, String(checked)) })), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
848
+ // #453: a `switch` editable column IS always its own
849
+ // live widget -- flipping the toggle IS the commit
850
+ // gesture, there's no meaningfully different "rest"
851
+ // display for a boolean. `select`/`reference` are
852
+ // NOT this: at rest they must show the column's own
853
+ // render/format like every other editable type
854
+ // (#412 empty handling included), and only open on
855
+ // double-click (#411) -- this branch previously
856
+ // rendered them unconditionally, replacing every
857
+ // row's own render with a picker trigger, which is
858
+ // the regression #453 reported (also #432/#449,
859
+ // which never had a "rest" state of their own to get
860
+ // right). See the `isEditing` block below for their
861
+ // actual editor.
862
+ if (col.editable && col.editor === "switch") {
863
+ return ((0, jsx_runtime_1.jsxs)(table_1.TableCell, { align: alignFor(col), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined, children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-edit-picker", children: [(0, jsx_runtime_1.jsx)(switch_1.Switch, { "aria-label": `Edit ${col.key}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commitEdit(row.id, col.key, String(checked)) }), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
864
+ }
865
+ if (isEditing &&
866
+ (col.editor === "select" || col.editor === "reference")) {
867
+ return ((0, jsx_runtime_1.jsxs)(table_1.TableCell, { align: alignFor(col), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined,
868
+ // Same as the plain text editor's own cell:
869
+ // opening/using the picker must not also fire
870
+ // the row's onRowClick. Escape is handled by
871
+ // `DataSelectEditor`/`DataReferenceEditor`
872
+ // themselves (a document-level listener, not a
873
+ // `TableCell`-level `onKeyDown`) -- opening the
874
+ // picker moves DOM focus into a floating,
875
+ // portaled popover, so a handler here can't
876
+ // rely on the key event ever bubbling through
877
+ // this element (#453).
878
+ onClick: (e) => e.stopPropagation(), children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-edit-picker", children: [col.editor === "select" ? ((0, jsx_runtime_1.jsx)(DataSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })) : ((0, jsx_runtime_1.jsx)(DataReferenceEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
805
879
  }
806
880
  if (isEditing) {
807
881
  return ((0, jsx_runtime_1.jsxs)(table_1.TableCell, { align: alignFor(col), ...sticky, onClick: (e) => e.stopPropagation(), children: [(0, jsx_runtime_1.jsx)("input", { className: "crisp-datatable-edit",
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.caretRectInField = caretRectInField;
4
+ /**
5
+ * The pixel position of a caret index inside a `<textarea>`/`<input>`, in
6
+ * viewport coordinates — where a mention picker anchors.
7
+ *
8
+ * Neither element exposes caret geometry directly, so this uses the standard
9
+ * mirror-div technique: an off-screen div replicating every computed style
10
+ * that affects text layout (font, padding, border, wrapping), holding the
11
+ * same text up to the caret plus a marker span. The marker's own
12
+ * `getBoundingClientRect()`, translated back onto the real field's box, is
13
+ * the caret's actual rendered position — nothing here guesses at line
14
+ * height or character width itself.
15
+ */
16
+ function caretRectInField(field, index) {
17
+ const div = document.createElement("div");
18
+ const style = window.getComputedStyle(field);
19
+ const mirrored = [
20
+ "boxSizing",
21
+ "width",
22
+ "paddingTop",
23
+ "paddingRight",
24
+ "paddingBottom",
25
+ "paddingLeft",
26
+ "borderTopWidth",
27
+ "borderRightWidth",
28
+ "borderBottomWidth",
29
+ "borderLeftWidth",
30
+ "fontFamily",
31
+ "fontSize",
32
+ "fontWeight",
33
+ "fontStyle",
34
+ "letterSpacing",
35
+ "lineHeight",
36
+ "textTransform",
37
+ "textIndent",
38
+ "textAlign",
39
+ "wordSpacing",
40
+ "tabSize",
41
+ ];
42
+ for (const prop of mirrored)
43
+ div.style.setProperty(prop, style[prop]);
44
+ div.style.position = "absolute";
45
+ div.style.visibility = "hidden";
46
+ div.style.top = "0";
47
+ div.style.left = "-9999px";
48
+ div.style.whiteSpace =
49
+ field instanceof HTMLTextAreaElement ? "pre-wrap" : "pre";
50
+ div.style.wordWrap = "break-word";
51
+ div.style.overflow = "hidden";
52
+ div.style.height = "auto";
53
+ const value = field.value;
54
+ div.textContent = value.slice(0, index);
55
+ const marker = document.createElement("span");
56
+ // a trailing empty span collapses to zero width and reports the wrong x —
57
+ // give it a hair of content so its box sits exactly at the caret
58
+ marker.textContent = value.slice(index, index + 1) || ".";
59
+ div.appendChild(marker);
60
+ document.body.appendChild(div);
61
+ div.scrollTop = field.scrollTop;
62
+ div.scrollLeft = field.scrollLeft;
63
+ const fieldRect = field.getBoundingClientRect();
64
+ const divRect = div.getBoundingClientRect();
65
+ const markerRect = marker.getBoundingClientRect();
66
+ const x = fieldRect.left + (markerRect.left - divRect.left) - div.scrollLeft;
67
+ const y = fieldRect.top +
68
+ (markerRect.top - divRect.top) -
69
+ div.scrollTop +
70
+ markerRect.height;
71
+ document.body.removeChild(div);
72
+ return { x, y };
73
+ }
@@ -14,7 +14,9 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.usePrefersReducedMotion = exports.useLabelAssociation = exports.useFormReset = exports.useFocusAfterRemoval = exports.specToRecipe = exports.defineSpec = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.formValueStyle = exports.registerFloatingLayer = exports.isInsideLaterFloatingLayer = exports.hasOpenFloatingLayer = exports.FloatingPortal = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = exports.parseCellDate = exports.formatCellDate = exports.cellDatePrecision = void 0;
17
+ exports.usePrefersReducedMotion = exports.useMentionTrigger = exports.useLabelAssociation = exports.useFormReset = exports.useFocusAfterRemoval = exports.specToRecipe = exports.defineSpec = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.matchMentionTrigger = exports.formValueStyle = exports.registerFloatingLayer = exports.isInsideLaterFloatingLayer = exports.hasOpenFloatingLayer = exports.FloatingPortal = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = exports.parseCellDate = exports.formatCellDate = exports.cellDatePrecision = exports.caretRectInField = void 0;
18
+ var caret_rect_1 = require("./caret-rect");
19
+ Object.defineProperty(exports, "caretRectInField", { enumerable: true, get: function () { return caret_rect_1.caretRectInField; } });
18
20
  var cell_date_1 = require("./cell-date");
19
21
  Object.defineProperty(exports, "cellDatePrecision", { enumerable: true, get: function () { return cell_date_1.cellDatePrecision; } });
20
22
  Object.defineProperty(exports, "formatCellDate", { enumerable: true, get: function () { return cell_date_1.formatCellDate; } });
@@ -35,6 +37,8 @@ Object.defineProperty(exports, "isInsideLaterFloatingLayer", { enumerable: true,
35
37
  Object.defineProperty(exports, "registerFloatingLayer", { enumerable: true, get: function () { return floating_stack_1.registerFloatingLayer; } });
36
38
  var form_value_1 = require("./form-value");
37
39
  Object.defineProperty(exports, "formValueStyle", { enumerable: true, get: function () { return form_value_1.formValueStyle; } });
40
+ var mention_1 = require("./mention");
41
+ Object.defineProperty(exports, "matchMentionTrigger", { enumerable: true, get: function () { return mention_1.matchMentionTrigger; } });
38
42
  var place_floating_1 = require("./place-floating");
39
43
  Object.defineProperty(exports, "placeFloating", { enumerable: true, get: function () { return place_floating_1.placeFloating; } });
40
44
  var primitive_1 = require("./primitive");
@@ -51,5 +55,7 @@ var use_form_reset_1 = require("./use-form-reset");
51
55
  Object.defineProperty(exports, "useFormReset", { enumerable: true, get: function () { return use_form_reset_1.useFormReset; } });
52
56
  var use_label_association_1 = require("./use-label-association");
53
57
  Object.defineProperty(exports, "useLabelAssociation", { enumerable: true, get: function () { return use_label_association_1.useLabelAssociation; } });
58
+ var use_mention_trigger_1 = require("./use-mention-trigger");
59
+ Object.defineProperty(exports, "useMentionTrigger", { enumerable: true, get: function () { return use_mention_trigger_1.useMentionTrigger; } });
54
60
  var use_prefers_reduced_motion_1 = require("./use-prefers-reduced-motion");
55
61
  Object.defineProperty(exports, "usePrefersReducedMotion", { enumerable: true, get: function () { return use_prefers_reduced_motion_1.usePrefersReducedMotion; } });
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.matchMentionTrigger = matchMentionTrigger;
4
+ /**
5
+ * Is there an active "@query" mention trigger ending at the caret?
6
+ *
7
+ * Matches the rule every "@mention" implementation (GitHub, Slack, Linear)
8
+ * actually uses: the trigger character must start a word — the very first
9
+ * character, or preceded by whitespace — and nothing between it and the
10
+ * caret may contain whitespace. `foo@bar` is an email/handle stuck to a word,
11
+ * not a trigger; `@jo next` has already moved past the mention it typed.
12
+ *
13
+ * Takes plain text rather than a live DOM node so it is one pure function
14
+ * usable against a `<textarea>`'s `value` and a `contentEditable` text
15
+ * node's content alike, and is the one thing here worth unit-testing on its
16
+ * own — everything downstream of it (caret geometry, DOM insertion) needs a
17
+ * real browser to verify.
18
+ */
19
+ function matchMentionTrigger(textBeforeCaret, trigger = "@") {
20
+ if (!trigger)
21
+ return null;
22
+ const start = textBeforeCaret.lastIndexOf(trigger);
23
+ if (start === -1)
24
+ return null;
25
+ const before = start > 0 ? textBeforeCaret[start - 1] : undefined;
26
+ if (before !== undefined && !/\s/.test(before))
27
+ return null;
28
+ const query = textBeforeCaret.slice(start + trigger.length);
29
+ if (/\s/.test(query))
30
+ return null;
31
+ return { query, start };
32
+ }
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.useMentionTrigger = useMentionTrigger;
37
+ const React = __importStar(require("react"));
38
+ const caret_rect_1 = require("./caret-rect");
39
+ const mention_1 = require("./mention");
40
+ /**
41
+ * Watches a `<textarea>`/`<input>` for an "@query" mention trigger and
42
+ * reports where to anchor a consumer-supplied picker (#372).
43
+ *
44
+ * crisp draws no picker of its own here — GitHub/Slack/Linear-style @mention
45
+ * UI varies too much by app (avatars, groups, recent-first ordering, a
46
+ * "create new person" row) to have one measured shape, and this repo's rule
47
+ * is measure-first, never invented polish. What is shared and worth owning
48
+ * centrally is the fiddly, easy-to-get-wrong part: deciding when "@" is
49
+ * actually a trigger rather than part of a word or an email address (see
50
+ * `matchMentionTrigger`), and where its picker anchors on screen (see
51
+ * `caretRectInField`, since neither element exposes caret geometry itself).
52
+ *
53
+ * Does not close on blur: a picker built from this needs to survive the
54
+ * field losing focus to a `mousedown` on one of its own rows, the same
55
+ * problem every floating surface in this repo solves for itself (see
56
+ * `RichTextEditor`'s toolbar `keepSel`, or `Popover`'s own outside-pointer-
57
+ * down listener) — so closing on outside interaction is left to the
58
+ * consumer's picker, which already knows what counts as "outside" for its
59
+ * own markup.
60
+ */
61
+ function useMentionTrigger(ref, { trigger = "@" } = {}) {
62
+ const [state, setState] = React.useState({
63
+ open: false,
64
+ query: "",
65
+ anchor: null,
66
+ });
67
+ const matchRef = React.useRef(null);
68
+ const recompute = React.useCallback(() => {
69
+ const el = ref.current;
70
+ if (!el || el.selectionStart == null) {
71
+ matchRef.current = null;
72
+ setState({ open: false, query: "", anchor: null });
73
+ return;
74
+ }
75
+ const caret = el.selectionStart;
76
+ const match = (0, mention_1.matchMentionTrigger)(el.value.slice(0, caret), trigger);
77
+ matchRef.current = match;
78
+ if (!match) {
79
+ setState({ open: false, query: "", anchor: null });
80
+ return;
81
+ }
82
+ setState({
83
+ open: true,
84
+ query: match.query,
85
+ anchor: (0, caret_rect_1.caretRectInField)(el, caret),
86
+ });
87
+ }, [ref, trigger]);
88
+ React.useEffect(() => {
89
+ const el = ref.current;
90
+ if (!el)
91
+ return;
92
+ // "input" covers typing; "click"/"keyup" cover the caret moving without
93
+ // the text changing (arrow keys, clicking elsewhere on the same line).
94
+ el.addEventListener("input", recompute);
95
+ el.addEventListener("click", recompute);
96
+ el.addEventListener("keyup", recompute);
97
+ return () => {
98
+ el.removeEventListener("input", recompute);
99
+ el.removeEventListener("click", recompute);
100
+ el.removeEventListener("keyup", recompute);
101
+ };
102
+ }, [ref, recompute]);
103
+ const close = React.useCallback(() => {
104
+ matchRef.current = null;
105
+ setState({ open: false, query: "", anchor: null });
106
+ }, []);
107
+ const insert = React.useCallback((text) => {
108
+ const el = ref.current;
109
+ const match = matchRef.current;
110
+ if (!el || !match)
111
+ return;
112
+ const caret = el.selectionStart ?? el.value.length;
113
+ const before = el.value.slice(0, match.start);
114
+ const after = el.value.slice(caret);
115
+ el.value = before + text + after;
116
+ const cursor = before.length + text.length;
117
+ el.focus();
118
+ el.setSelectionRange(cursor, cursor);
119
+ // a controlled consumer listens on "input", the same event typing fires
120
+ el.dispatchEvent(new Event("input", { bubbles: true }));
121
+ close();
122
+ }, [ref, close]);
123
+ return { ...state, close, insert };
124
+ }
@@ -38,37 +38,20 @@ const jsx_runtime_1 = require("react/jsx-runtime");
38
38
  const lucide_react_1 = require("lucide-react");
39
39
  const React = __importStar(require("react"));
40
40
  const avatar_1 = require("../../components/avatar");
41
- const select_1 = require("../../components/select");
42
- const spinner_1 = require("../../components/spinner");
43
- const switch_1 = require("../../components/switch");
44
41
  const core_1 = require("../../core");
45
- const recordstable_1 = require("../recordstable");
46
42
  const recordpanel_recipe_1 = require("./recordpanel.recipe");
47
- function FieldValue({ col, row, emptyLabel, }) {
48
- const v = row[col.key];
49
- if (v == null || v === "" || (Array.isArray(v) && !v.length)) {
50
- return (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-empty", children: emptyLabel });
51
- }
52
- switch (col.type) {
53
- case "tags":
54
- return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: v.map((t) => ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordstable-tag", "data-tone": (0, recordstable_1.tagTone)(t), children: t }, t))) }));
55
- case "number":
56
- return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: typeof v === "number" ? v.toLocaleString() : v });
57
- case "url":
58
- return ((0, jsx_runtime_1.jsx)("a", { className: "crisp-recordpanel-link", href: `https://${v}`, target: "_blank", rel: "noreferrer", children: v }));
59
- default:
60
- return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: String(v) });
61
- }
62
- }
63
43
  /**
64
- * Right-side record peek panel — shows one record's fields, opened from a
65
- * RecordsTable row (via `onOpenRecord`). Render it inside a `position:
66
- * relative` container (e.g. the app main area); it slides in from the right and
67
- * overlays the content.
44
+ * Right-side record peek panel — Attio's own peek shows a Highlights card
45
+ * area and an Activity feed (measured live: hover a Companies row, click its
46
+ * "Open preview" trigger), never an editable field list (#448 — a "Details"
47
+ * list was this component's own invention before, and #431's editable API
48
+ * had nothing real in this panel to attach to; that API stays on
49
+ * `RecordPage`'s own Details column, where Attio does let you edit in
50
+ * place). Render it inside a `position: relative` container (e.g. the app
51
+ * main area); it slides in from the right and overlays the content.
68
52
  */
69
- function RecordPanel({ record, columns, onClose, className, closeLabel = "Close panel", title, detailsLabel = "Details", emptyLabel = "Empty", footer, onEdit, ...rest }) {
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 }) {
70
54
  const titleId = React.useId();
71
- const { editingKey, pendingKey, error, openEditing, closeEditing, commit } = (0, recordstable_1.useEditableField)(onEdit);
72
55
  // close on Escape while open -- but not when a Menu/Select/Popover/
73
56
  // ContextMenu opened from inside the panel (e.g. a footer "More actions"
74
57
  // menu) is itself open (#361): that Escape belongs to the nested layer,
@@ -93,67 +76,16 @@ function RecordPanel({ record, columns, onClose, className, closeLabel = "Close
93
76
  if (!record)
94
77
  return;
95
78
  const onKey = (e) => {
96
- if (e.key !== "Escape")
97
- return;
98
- // #431: Escape while a field is open cancels JUST that field, the
99
- // same way DataTable's own editable cells treat it -- not the whole
100
- // panel. Checked ahead of `hasOpenFloatingLayer()` (capture phase, so
101
- // this runs before the field's own bubble-phase onKeyDown would ever
102
- // get a chance to stopPropagation -- same reasoning as the
103
- // floating-layer check below).
104
- if (editingKey) {
105
- closeEditing();
106
- return;
107
- }
108
- if ((0, core_1.hasOpenFloatingLayer)())
79
+ if (e.key !== "Escape" || (0, core_1.hasOpenFloatingLayer)())
109
80
  return;
110
81
  onClose();
111
82
  };
112
83
  document.addEventListener("keydown", onKey, true);
113
84
  return () => document.removeEventListener("keydown", onKey, true);
114
- }, [record, onClose, editingKey, closeEditing]);
85
+ }, [record, onClose]);
115
86
  if (!record)
116
87
  return null;
117
- const entityCol = columns.find((c) => c.type === "entity");
118
- const entity = entityCol
119
- ? record[entityCol.key]
120
- : undefined;
121
- const fields = columns.filter((c) => c.type !== "entity");
122
- const heading = title ?? entity?.name ?? "Record";
123
- 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-header", children: [entity?.avatar ? ((0, jsx_runtime_1.jsx)("img", { className: "crisp-recordpanel-fav", src: entity.avatar, alt: "" })) : entity ? ((0, jsx_runtime_1.jsx)(avatar_1.Avatar, { size: "md", shape: "rounded", tone: "brand", children: entity.name.charAt(0) })) : null, (0, jsx_runtime_1.jsx)("span", { id: titleId, className: "crisp-recordpanel-title", children: heading }), (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: 18, strokeWidth: 1.75 }) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-body", children: [(0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpanel-sectionlabel", children: detailsLabel }), fields.map((col) => {
124
- // #431: a field only actually opens when there's an `onEdit` to
125
- // commit through -- `editable` with nothing to call would be a
126
- // dead click.
127
- const canEdit = Boolean(col.editable && onEdit);
128
- const isPending = pendingKey === col.key;
129
- const fieldError = error?.key === col.key ? error.message : null;
130
- // select/switch are always their own live widget -- no click-to-
131
- // open, no separate edit/view mode, the same convention
132
- // DataTable's identical `editor` columns use (#385): choosing a
133
- // value or flipping a toggle IS the commit gesture.
134
- if (canEdit && col.editor) {
135
- const value = record[col.key];
136
- return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-field", children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpanel-field-label", children: [col.icon ? (0, jsx_runtime_1.jsx)(col.icon, { size: 15, strokeWidth: 1.75 }) : null, col.label] }), (0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpanel-field-value", children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpanel-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-recordpanel-edit-pending" }))] }), fieldError && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-edit-error", children: fieldError }))] })] }, col.key));
137
- }
138
- if (canEdit && editingKey === col.key) {
139
- const value = record[col.key];
140
- const editor = (0, recordstable_1.editableInputType)(col.type);
141
- return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-field", children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpanel-field-label", children: [col.icon ? (0, jsx_runtime_1.jsx)(col.icon, { size: 15, strokeWidth: 1.75 }) : null, col.label] }), (0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpanel-field-value", children: [(0, jsx_runtime_1.jsx)("input", { className: "crisp-recordpanel-edit",
142
- // biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
143
- autoFocus: true, ...editor, disabled: isPending, defaultValue: value == null ? "" : String(value), "aria-label": `Edit ${col.label}`, onKeyDown: (e) => {
144
- if (isPending)
145
- return;
146
- if (e.key === "Enter")
147
- commit(record.id, col.key, e.currentTarget.value);
148
- else if (e.key === "Escape")
149
- closeEditing();
150
- }, onBlur: (e) => {
151
- if (isPending)
152
- return;
153
- commit(record.id, col.key, e.currentTarget.value);
154
- } }), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-recordpanel-edit-pending" })), fieldError && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-edit-error", children: fieldError }))] })] }, col.key));
155
- }
156
- return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpanel-field", children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpanel-field-label", children: [col.icon ? (0, jsx_runtime_1.jsx)(col.icon, { size: 15, strokeWidth: 1.75 }) : null, col.label] }), (0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpanel-field-value", "data-editable": canEdit ? "" : undefined, children: [canEdit ? ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-recordpanel-edit-trigger", onClick: () => openEditing(col.key), children: (0, jsx_runtime_1.jsx)(FieldValue, { col: col, row: record, emptyLabel: emptyLabel }) })) : ((0, jsx_runtime_1.jsx)(FieldValue, { col: col, row: record, emptyLabel: emptyLabel })), fieldError && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordpanel-edit-error", children: fieldError }))] })] }, col.key));
157
- })] }), footer != null && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-recordpanel-footer", children: footer }))] }));
88
+ 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 }))] }));
158
90
  }
159
91
  RecordPanel.displayName = "RecordPanel";