@crisp-ui-kit/crisp 0.54.8 → 0.54.9

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 (54) hide show
  1. package/dist/board.css +32 -8
  2. package/dist/board.layered.css +32 -8
  3. package/dist/cjs/components/contextmenu/contextmenu.js +2 -2
  4. package/dist/cjs/components/contextmenu/contextmenu.recipe.js +24 -0
  5. package/dist/cjs/components/datatable/datatable.js +541 -51
  6. package/dist/cjs/components/datatable/datatable.recipe.js +61 -3
  7. package/dist/cjs/components/dialog/dialog.js +65 -1
  8. package/dist/cjs/components/tabletoolbar/tabletoolbar.chrome.js +37 -2
  9. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +148 -15
  10. package/dist/cjs/components/tabletoolbar/tabletoolbar.recipe.js +35 -1
  11. package/dist/cjs/product/board/board.js +62 -28
  12. package/dist/cjs/product/board/board.recipe.js +42 -2
  13. package/dist/cjs/product/comments/comments.js +11 -2
  14. package/dist/cjs/product/comments/comments.recipe.js +14 -4
  15. package/dist/cjs/product/recordstable/recordstable.js +1 -17
  16. package/dist/cjs/product/savedviews/savedviews.js +55 -24
  17. package/dist/cjs/product/savedviews/savedviews.recipe.js +27 -0
  18. package/dist/comments.css +14 -4
  19. package/dist/comments.layered.css +14 -4
  20. package/dist/contextmenu.css +24 -0
  21. package/dist/contextmenu.layered.css +24 -0
  22. package/dist/datatable.css +103 -77
  23. package/dist/datatable.layered.css +103 -77
  24. package/dist/esm/components/contextmenu/contextmenu.js +2 -2
  25. package/dist/esm/components/contextmenu/contextmenu.recipe.js +24 -0
  26. package/dist/esm/components/datatable/datatable.js +541 -52
  27. package/dist/esm/components/datatable/datatable.recipe.js +62 -4
  28. package/dist/esm/components/dialog/dialog.js +65 -1
  29. package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +37 -2
  30. package/dist/esm/components/tabletoolbar/tabletoolbar.js +149 -16
  31. package/dist/esm/components/tabletoolbar/tabletoolbar.recipe.js +37 -3
  32. package/dist/esm/product/board/board.js +63 -29
  33. package/dist/esm/product/board/board.recipe.js +42 -2
  34. package/dist/esm/product/comments/comments.js +12 -3
  35. package/dist/esm/product/comments/comments.recipe.js +14 -4
  36. package/dist/esm/product/recordstable/recordstable.js +2 -18
  37. package/dist/esm/product/savedviews/savedviews.js +57 -26
  38. package/dist/esm/product/savedviews/savedviews.recipe.js +27 -0
  39. package/dist/recordstable.css +33 -31
  40. package/dist/recordstable.layered.css +33 -31
  41. package/dist/savedviews.css +27 -0
  42. package/dist/savedviews.layered.css +27 -0
  43. package/dist/styles.css +266 -124
  44. package/dist/styles.layered.css +266 -124
  45. package/dist/tabletoolbar.css +33 -4
  46. package/dist/tabletoolbar.layered.css +33 -4
  47. package/dist/types/components/contextmenu/contextmenu.d.ts +8 -1
  48. package/dist/types/components/datatable/datatable.d.ts +90 -2
  49. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +72 -2
  50. package/dist/types/product/board/board.d.ts +48 -2
  51. package/dist/types/product/comments/comments.d.ts +6 -1
  52. package/dist/types/product/recordstable/recordstable.d.ts +0 -1
  53. package/dist/types/product/savedviews/savedviews.d.ts +45 -8
  54. package/package.json +1 -1
@@ -34,13 +34,24 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.isLabelTruncated = isLabelTruncated;
37
+ exports.reorderColumns = reorderColumns;
37
38
  exports.DataTable = DataTable;
38
39
  const jsx_runtime_1 = require("react/jsx-runtime");
39
40
  const lucide_react_1 = require("lucide-react");
40
41
  const React = __importStar(require("react"));
41
42
  const core_1 = require("../../core");
43
+ // crisp/#601 supersession: a deliberate exception to "DS atoms stay
44
+ // generic; product components compose them" (AUTHORING.md) -- the row's
45
+ // own comment affordance is `CommentCountBadge` (product/comments, #625),
46
+ // already correctly built and measured for the dot/hover-count states this
47
+ // plain icon path can't express on its own. Reaching into `product/` from
48
+ // `components/` runs backwards from every other import in this file; it's
49
+ // scoped to this one component, not a precedent for the rest of DataTable.
50
+ const comments_1 = require("../../product/comments");
42
51
  const avatar_1 = require("../avatar");
43
52
  const checkbox_1 = require("../checkbox");
53
+ const columncalc_1 = require("../columncalc");
54
+ const contextmenu_1 = require("../contextmenu");
44
55
  const menu_1 = require("../menu");
45
56
  const select_1 = require("../select");
46
57
  const spinner_1 = require("../spinner");
@@ -55,6 +66,35 @@ const datatable_recipe_1 = require("./datatable.recipe");
55
66
  function isLabelTruncated(el) {
56
67
  return el.scrollWidth > el.clientWidth;
57
68
  }
69
+ // #731: the pure resequencing behind both the controlled `columnOrder` prop
70
+ // and the drag/Move-left-right mutations that produce one -- exported so the
71
+ // "does this actually reorder, and does it survive a stale/unknown key"
72
+ // logic can be mutation-tested without mounting the table. Every key in
73
+ // `order` that matches a real column is placed first, in `order`'s own
74
+ // sequence; every column NOT named in `order` (a brand-new column a
75
+ // consumer's persisted order hasn't caught up to yet) keeps its relative
76
+ // position at the end, in `cols`' own order -- so a column can never be
77
+ // dropped or duplicated by this, only resequenced. An `order` entry with no
78
+ // matching column (one that was removed, or a stale value from a saved
79
+ // view) is silently skipped rather than throwing.
80
+ function reorderColumns(cols, order) {
81
+ if (!order || order.length === 0)
82
+ return cols;
83
+ const byKey = new Map(cols.map((c) => [c.key, c]));
84
+ const seen = new Set();
85
+ const ordered = [];
86
+ for (const key of order) {
87
+ const c = byKey.get(key);
88
+ if (c && !seen.has(key)) {
89
+ ordered.push(c);
90
+ seen.add(key);
91
+ }
92
+ }
93
+ for (const c of cols)
94
+ if (!seen.has(c.key))
95
+ ordered.push(c);
96
+ return ordered;
97
+ }
58
98
  /**
59
99
  * The header label span: a `Tooltip` (full text, `asChild` so the wrapper
60
100
  * never displaces the ellipsis CSS the span already carries -- see
@@ -260,7 +300,7 @@ function formatCell(type, v, symbol, locale = "en-US") {
260
300
  // silently drop the cell's data. Each branch decides for itself where a
261
301
  // display string belongs -- as an entity's name, a status' label -- while the
262
302
  // raw value keeps driving structure and tone.
263
- function renderTyped(col, value, avatarTone, row, display, locale = "en-US") {
303
+ function renderTyped(col, value, avatarTone, row, display, locale = "en-US", openPreviewLabel = "Open preview", rowHovered = false) {
264
304
  // Attio's own empty cell is genuinely blank -- no placeholder character at
265
305
  // all, for any column type (#412).
266
306
  const empty = null;
@@ -279,7 +319,10 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US") {
279
319
  // at a DIFFERENT record than the row itself (#501).
280
320
  ev.stopPropagation();
281
321
  col.onEntityClick?.(row);
282
- }, children: e.name })) : ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-entity-name", children: e.name }))] }));
322
+ }, children: e.name })) : ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-entity-name", children: e.name })), col.onOpenComments ? ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-entity-actions", children: (0, jsx_runtime_1.jsx)(comments_1.CommentCountBadge, { count: col.commentCount?.(row) ?? 0, hovered: rowHovered, onOpen: () => col.onOpenComments?.(row), onOpenPreview: col.onOpenPreview ? () => col.onOpenPreview?.(row) : undefined, previewLabel: openPreviewLabel }) })) : (col.onOpenPreview && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-entity-actions", children: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-datatable-entity-action", "aria-label": openPreviewLabel, onClick: (ev) => {
323
+ ev.stopPropagation();
324
+ col.onOpenPreview?.(row);
325
+ }, children: (0, jsx_runtime_1.jsx)(lucide_react_1.PanelRight, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }) })))] }));
283
326
  }
284
327
  case "tags": {
285
328
  // asking for a format on a tag column means "render it your way" -- show
@@ -452,7 +495,7 @@ function DataReferenceEditor({ col, value, isPending, onCommit, onCancel, }) {
452
495
  onCommit(v);
453
496
  } }) }));
454
497
  }
455
- function DataTable({ columns: columnsProp, data, selectable = false, selectedIds, onSelectionChange, selectAllLabel: selectAllLabelProp, selectRowLabel = (row) => `Select row ${row.id}`, selectionAnnouncement, columnMenu = false, onColumnsChange, groupBy, onRowsReorder, error, sort: sortProp, defaultSort, onSortChange, filterable = false, searchPlaceholder: searchPlaceholderProp, searchLabel: searchLabelProp, onEdit, stickyFirst = true, stickyLast = false, scrollProps, locale: localeProp, avatarTone = "brand", surface, interactive = false, onRowClick, onAddRow, addRowLabel: addRowLabelProp, empty: emptyProp, showCount = true, countLabel, toolbar, height, className, reorderRowLabel: reorderRowLabelProp, sortAscendingLabel: sortAscendingLabelProp, sortDescendingLabel: sortDescendingLabelProp, moveColumnLeftLabel: moveColumnLeftLabelProp, moveColumnRightLabel: moveColumnRightLabelProp, editColumnLabel: editColumnLabelProp, hideColumnLabel: hideColumnLabelProp, renameColumnLabel: renameColumnLabelProp, viewSettingsLabel: viewSettingsLabelProp, columnsGroupLabel: columnsGroupLabelProp, ...rest }) {
498
+ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds, onSelectionChange, selectAllLabel: selectAllLabelProp, selectRowLabel = (row) => `Select row ${row.id}`, selectionAnnouncement, columnMenu = false, onColumnsChange, columnOrder, onColumnOrderChange, groupBy, onRowsReorder, error, sort: sortProp, defaultSort, onSortChange, filterable = false, searchPlaceholder: searchPlaceholderProp, searchLabel: searchLabelProp, onEdit, onViewEditHistory, stickyFirst = true, stickyLast = false, scrollProps, locale: localeProp, avatarTone = "brand", surface, interactive = false, onRowClick, onAddRow, addRowLabel: addRowLabelProp, empty: emptyProp, showCount = true, countLabel, toolbar, height, className, reorderRowLabel: reorderRowLabelProp, openPreviewLabel: openPreviewLabelProp, sortAscendingLabel: sortAscendingLabelProp, sortDescendingLabel: sortDescendingLabelProp, moveColumnLeftLabel: moveColumnLeftLabelProp, moveColumnRightLabel: moveColumnRightLabelProp, editColumnLabel: editColumnLabelProp, hideColumnLabel: hideColumnLabelProp, renameColumnLabel: renameColumnLabelProp, viewSettingsLabel: viewSettingsLabelProp, columnsGroupLabel: columnsGroupLabelProp, cellMenuLabel: cellMenuLabelProp, copyLabel: copyLabelProp, pasteLabel: pasteLabelProp, viewEditHistoryLabel: viewEditHistoryLabelProp, clearValueLabel: clearValueLabelProp, ...rest }) {
456
499
  // crisp/#471: same resolution order as TableToolbar's own — an explicit
457
500
  // prop wins, then the provider's translation of the built-in English
458
501
  // default, then that default itself with no provider in the tree.
@@ -469,6 +512,7 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
469
512
  // wants shown); only the built-in string default translates.
470
513
  const empty = emptyProp ?? crispMessages["No results."] ?? "No results.";
471
514
  const reorderRowLabel = (0, core_1.useCrispMessage)(reorderRowLabelProp, "Reorder row");
515
+ const openPreviewLabel = (0, core_1.useCrispMessage)(openPreviewLabelProp, "Open preview");
472
516
  const sortAscendingLabel = (0, core_1.useCrispMessage)(sortAscendingLabelProp, "Sort ascending");
473
517
  const sortDescendingLabel = (0, core_1.useCrispMessage)(sortDescendingLabelProp, "Sort descending");
474
518
  const moveColumnLeftLabel = (0, core_1.useCrispMessage)(moveColumnLeftLabelProp, "Move left");
@@ -478,6 +522,11 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
478
522
  const renameColumnLabel = (0, core_1.useCrispMessage)(renameColumnLabelProp, "Rename column");
479
523
  const viewSettingsLabel = (0, core_1.useCrispMessage)(viewSettingsLabelProp, "View settings");
480
524
  const columnsGroupLabel = (0, core_1.useCrispMessage)(columnsGroupLabelProp, "Columns");
525
+ const cellMenuLabel = (0, core_1.useCrispMessage)(cellMenuLabelProp, "Cell options");
526
+ const copyLabel = (0, core_1.useCrispMessage)(copyLabelProp, "Copy");
527
+ const pasteLabel = (0, core_1.useCrispMessage)(pasteLabelProp, "Paste");
528
+ const viewEditHistoryLabel = (0, core_1.useCrispMessage)(viewEditHistoryLabelProp, "View edit history");
529
+ const clearValueLabel = (0, core_1.useCrispMessage)(clearValueLabelProp, "Clear value");
481
530
  // countLabel/selectionAnnouncement stay formatter props, same escape hatch
482
531
  // as TableToolbar's sortTriggerLabel — word order and pluralisation rules
483
532
  // differ too much across languages for a template. Their BUILT-IN default
@@ -506,14 +555,32 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
506
555
  // menu mutates this copy and reports upward via `onColumnsChange`.
507
556
  const [internalCols, setInternalCols] = React.useState(columnsProp);
508
557
  React.useEffect(() => setInternalCols(columnsProp), [columnsProp]);
509
- /** Every column, hidden ones included — what the column manager lists. */
510
- const allColumns = internalCols;
558
+ /** Every column, hidden ones included — what the column manager lists.
559
+ * `columnOrder`, controlled or not, is the one source of truth for ORDER;
560
+ * `internalCols` still carries each column's hidden/renamed state, so
561
+ * resequencing it here never loses either. */
562
+ const allColumns = React.useMemo(() => reorderColumns(internalCols, columnOrder), [internalCols, columnOrder]);
511
563
  /** What the grid actually renders. */
512
- const columns = React.useMemo(() => internalCols.filter((c) => !c.hidden), [internalCols]);
564
+ const columns = React.useMemo(() => allColumns.filter((c) => !c.hidden), [allColumns]);
513
565
  const applyColumns = React.useCallback((next) => {
514
566
  setInternalCols(next);
515
567
  onColumnsChange?.(next);
516
568
  }, [onColumnsChange]);
569
+ // Reordering specifically (a header drag, or the menu's Move left/right) —
570
+ // separate from `applyColumns` above (hide/rename, which never touch
571
+ // order) because this is the one path that also owns `columnOrder`:
572
+ // uncontrolled, it seeds `internalCols`' own order same as any other
573
+ // mutation; controlled, the next render's `allColumns` re-derives order
574
+ // from the (now stale, until the consumer updates it) `columnOrder` prop
575
+ // instead — the same contract `sort`/`selectedIds` already use — so this
576
+ // deliberately does NOT touch `internalCols`' order when controlled.
577
+ const applyOrder = React.useCallback((nextOrder) => {
578
+ const next = reorderColumns(allColumns, nextOrder);
579
+ if (columnOrder === undefined)
580
+ setInternalCols(next);
581
+ onColumnsChange?.(next);
582
+ onColumnOrderChange?.(next.map((c) => c.key));
583
+ }, [allColumns, columnOrder, onColumnsChange, onColumnOrderChange]);
517
584
  const moveColumn = React.useCallback((key, dir) => {
518
585
  const i = allColumns.findIndex((c) => c.key === key);
519
586
  const j = i + dir;
@@ -521,14 +588,44 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
521
588
  return;
522
589
  const next = [...allColumns];
523
590
  [next[i], next[j]] = [next[j], next[i]];
524
- applyColumns(next);
525
- }, [allColumns, applyColumns]);
591
+ applyOrder(next.map((c) => c.key));
592
+ }, [allColumns, applyOrder]);
526
593
  const hideColumn = React.useCallback((key) => applyColumns(allColumns.map((c) => (c.key === key ? { ...c, hidden: true } : c))), [allColumns, applyColumns]);
527
594
  const renameColumn = React.useCallback((key, header) => applyColumns(allColumns.map((c) => (c.key === key ? { ...c, header } : c))), [allColumns, applyColumns]);
528
595
  const [renaming, setRenaming] = React.useState(null);
529
596
  const showColumn = React.useCallback((key) => applyColumns(allColumns.map((c) => (c.key === key ? { ...c, hidden: false } : c))), [allColumns, applyColumns]);
530
597
  const [selCell, setSelCell] = React.useState(null);
598
+ // crisp/#625/#601 supersession: `CommentCountBadge` branches its own
599
+ // render (a plain dot vs. the icon+count row) on a `hovered` boolean it
600
+ // takes as a prop -- unlike the plain #601 icons this replaces, which
601
+ // reveal on pure `tr:hover` CSS, the badge has no hover detection of its
602
+ // own (see its own doc comment) and needs the row's hover state IN JS.
603
+ // A single id, not a Set: only one row is ever hovered at a time, so this
604
+ // is one extra re-render on enter/leave, not per-row state.
605
+ const [hoveredRowId, setHoveredRowId] = React.useState(null);
606
+ // crisp/#645, checklist G-101: a Shift+Click extends a RANGE from the last
607
+ // plain-click anchor to the newly clicked cell (spreadsheet convention --
608
+ // Attio's own trigger is "Click cell A, Shift+Click cell B"). `selAnchor`
609
+ // is the fixed corner; `selCell` (above) keeps doubling as the moving
610
+ // corner/focus cell, so every existing single-cell consumer (editing,
611
+ // arrow-key nav, Enter) is unaffected -- a plain click resets `selAnchor`
612
+ // to null, collapsing back to single-cell mode.
613
+ const [selAnchor, setSelAnchor] = React.useState(null);
531
614
  const gridRef = React.useRef(null);
615
+ // The native-copy-event proxy (crisp/#645): Attio "has no Mousetrap/
616
+ // JS-keydown binding for c/mod+c ... it copies purely via the native
617
+ // browser copy event" -- confirmed live, `document.execCommand('copy')`
618
+ // on the focused element is what reliably fires it, a synthetic keydown
619
+ // does not. A plain focused `<div>` (what `gridRef` is) never receives a
620
+ // native `copy` event at all (no selection, nothing editable) -- verified
621
+ // empirically (Playwright, a focused non-editable div, real Ctrl+C: no
622
+ // `copy` event fires; the same page's `<input>` with selected text does).
623
+ // This hidden, always-selected textarea is what a spreadsheet-style grid
624
+ // needs to give the browser something copyable while the VISIBLE focus
625
+ // styling stays on the grid: it's the same visually-hidden-but-focusable
626
+ // shape as `.crisp-datatable-live` below, just interactive instead of
627
+ // `aria-live`.
628
+ const clipboardProxyRef = React.useRef(null);
532
629
  // #579: the click-to-expand overlay for a truncated cell. Only one is ever
533
630
  // open (a second click-to-expand elsewhere closes the first, matching "one
534
631
  // live session"-style singular UI state already used for `selCell`).
@@ -672,6 +769,25 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
672
769
  const [widths, setWidths] = React.useState({});
673
770
  const [resizingKey, setResizingKey] = React.useState(null);
674
771
  const resizeRef = React.useRef(null);
772
+ // crisp/#731 -- column drag-to-reorder. Pointer events, not native HTML5
773
+ // `draggable`: measured live on Attio's own grid (#731's issue comment) --
774
+ // `aria-roledescription="draggable"` etc. on every draggable header is
775
+ // dnd-kit's own signature, itself pointer-events-based, the same primitive
776
+ // `board.tsx` moved to in #724 (mousedown -> several mousemoves -> mouseup,
777
+ // no `[draggable]` anywhere). Mirrors that fix's own shape: a ref for the
778
+ // in-flight gesture (so a pointermove before the 2px threshold never
779
+ // re-renders), React state for what actually needs to paint.
780
+ const [dragColKey, setDragColKey] = React.useState(null);
781
+ const [dropCol, setDropCol] = React.useState(null);
782
+ const colDragRef = React.useRef(null);
783
+ // Keyed by column key, not index -- #577's own column virtualization means
784
+ // an off-screen column's `TableHead` unmounts entirely (confirmed live:
785
+ // Attio does the same, a column scrolled far enough away disappears from
786
+ // the DOM), so this only ever holds the currently-mounted ones, same as
787
+ // `board.tsx`'s `colRefs`.
788
+ const colRefs = React.useRef(new Map());
789
+ const autoScrollRef = React.useRef(null);
790
+ const COL_DRAG_THRESHOLD = 2; // crisp/#736 review fix: measured ~2px, not 4px
675
791
  const colWidth = (col) => widths[col.key] ?? col.width ?? (col.type === "entity" ? 220 : 160);
676
792
  const minWidth = (showGrip ? GRIP_W : 0) +
677
793
  (selectable ? CHECK_W : 0) +
@@ -920,21 +1036,166 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
920
1036
  return { type: "text", inputMode: "text" };
921
1037
  }
922
1038
  }
1039
+ /** The rectangular range's index bounds (inclusive), or null outside range
1040
+ * mode (`selAnchor` unset -- single-cell selection, the common case).
1041
+ * Indices, not ids/keys, so it always reads against the CURRENT rendered
1042
+ * `rows`/`columns` order -- the same order arrow-key nav already uses. */
1043
+ function getSelRangeIndices() {
1044
+ if (!selAnchor || !selCell)
1045
+ return null;
1046
+ const ar = rows.findIndex((row) => row.id === selAnchor.id);
1047
+ const ac = columns.findIndex((col) => col.key === selAnchor.key);
1048
+ const fr = rows.findIndex((row) => row.id === selCell.id);
1049
+ const fc = columns.findIndex((col) => col.key === selCell.key);
1050
+ if (ar < 0 || ac < 0 || fr < 0 || fc < 0)
1051
+ return null;
1052
+ return {
1053
+ r0: Math.min(ar, fr),
1054
+ r1: Math.max(ar, fr),
1055
+ c0: Math.min(ac, fc),
1056
+ c1: Math.max(ac, fc),
1057
+ };
1058
+ }
1059
+ /**
1060
+ * crisp/#645: Attio copies the RAW, unformatted value (`asText`, not
1061
+ * `formatCell` -- G-070: a formatted "28,946,065" cell copies as raw
1062
+ * "28946065"), writes nothing at all for a `readOnly`/computed cell
1063
+ * (G-100 -- no clipboard write occurs, not an empty string), and a
1064
+ * Shift+Click range copies as TSV: `\t` between columns, `\r\n` between
1065
+ * rows, no header row, raw values (G-101). Returns null for "no clipboard
1066
+ * write" (the single-readOnly-cell case, or nothing selected) rather than
1067
+ * "" -- the caller must not fall back to writing an empty string either.
1068
+ */
1069
+ function buildCopyText() {
1070
+ const range = getSelRangeIndices();
1071
+ if (range) {
1072
+ const { r0, r1, c0, c1 } = range;
1073
+ const lines = [];
1074
+ for (let ri = r0; ri <= r1; ri++) {
1075
+ const row = rows[ri];
1076
+ const cells = [];
1077
+ for (let ci = c0; ci <= c1; ci++) {
1078
+ const col = columns[ci];
1079
+ cells.push(col.readOnly ? "" : asText(cellValue(col, row)));
1080
+ }
1081
+ lines.push(cells.join("\t"));
1082
+ }
1083
+ return lines.join("\r\n");
1084
+ }
1085
+ if (!selCell)
1086
+ return null;
1087
+ const row = rows.find((rw) => rw.id === selCell.id);
1088
+ const col = columns.find((cc) => cc.key === selCell.key);
1089
+ if (!row || !col || col.readOnly)
1090
+ return null;
1091
+ return asText(cellValue(col, row));
1092
+ }
1093
+ /**
1094
+ * crisp/#645: the native `copy`/`paste` events, not a keydown handler --
1095
+ * Attio "has no Mousetrap/JS-keydown binding for c/mod+c ... it copies
1096
+ * purely via the native browser copy event" (confirmed live). These fire
1097
+ * on `clipboardProxyRef`'s hidden textarea (see its own comment), which
1098
+ * `onGridKeyDown` below no longer intercepts Cmd/Ctrl+C for.
1099
+ */
1100
+ function onGridCopy(e) {
1101
+ e.preventDefault();
1102
+ const text = buildCopyText();
1103
+ if (text != null)
1104
+ e.clipboardData.setData("text/plain", text);
1105
+ }
1106
+ /** crisp/#598, G-071: Attio's own context-menu Paste "looked like it would
1107
+ * commit immediately with no safe cancel step" -- matched here: paste
1108
+ * commits straight to `onEdit`, it doesn't open the cell's editor first.
1109
+ * Single-cell only -- a range-fill paste isn't measured (G-071 was
1110
+ * deliberately not exercised against real clipboard content), so it isn't
1111
+ * built rather than guessed. */
1112
+ function onGridPaste(e) {
1113
+ e.preventDefault();
1114
+ if (!selCell || editing)
1115
+ return;
1116
+ const col = columns.find((cc) => cc.key === selCell.key);
1117
+ if (!col?.editable || col.readOnly)
1118
+ return;
1119
+ const text = e.clipboardData.getData("text/plain");
1120
+ onEdit?.(selCell.id, selCell.key, text);
1121
+ }
1122
+ /**
1123
+ * crisp/#598, checklist G-043: Attio's cell context menu is "identical
1124
+ * regardless of field type" — Copy, Paste, View edit history, Clear
1125
+ * value, every time, just some items disabled. No row-level menu exists
1126
+ * at all (G-044 — RecordsTable's own Copy/Duplicate/Pin/Delete row menu
1127
+ * was dropped rather than carried onto DataTable, per the consolidation
1128
+ * plan's own decision: Attio has no row menu to match).
1129
+ */
1130
+ function cellMenuItems(row, col) {
1131
+ return [
1132
+ {
1133
+ label: copyLabel,
1134
+ icon: (0, jsx_runtime_1.jsx)(lucide_react_1.Copy, { size: 16, strokeWidth: 1.75 }),
1135
+ // G-100: Attio has "no copy handler at all" for a computed/
1136
+ // read-only field -- disabled here, not just silently a no-op, so
1137
+ // the menu itself tells the reader why.
1138
+ disabled: !!col.readOnly,
1139
+ onSelect: () => {
1140
+ if (col.readOnly)
1141
+ return;
1142
+ navigator.clipboard
1143
+ ?.writeText(asText(cellValue(col, row)))
1144
+ .catch(() => { });
1145
+ },
1146
+ },
1147
+ {
1148
+ label: pasteLabel,
1149
+ icon: (0, jsx_runtime_1.jsx)(lucide_react_1.ClipboardPaste, { size: 16, strokeWidth: 1.75 }),
1150
+ disabled: !col.editable || !!col.readOnly,
1151
+ onSelect: () => {
1152
+ navigator.clipboard
1153
+ ?.readText()
1154
+ .then((text) => onEdit?.(row.id, col.key, text))
1155
+ .catch(() => { });
1156
+ },
1157
+ },
1158
+ { separator: true },
1159
+ {
1160
+ label: viewEditHistoryLabel,
1161
+ icon: (0, jsx_runtime_1.jsx)(lucide_react_1.History, { size: 16, strokeWidth: 1.75 }),
1162
+ // Attio's own item carries a "Pro" pill (crisp has no billing
1163
+ // concept to gate on, see this prop's own doc comment) -- disabled
1164
+ // only when the consumer gave nothing for it to open, not gated on
1165
+ // a plan crisp doesn't model.
1166
+ disabled: !onViewEditHistory,
1167
+ onSelect: () => onViewEditHistory?.(row.id, col.key),
1168
+ },
1169
+ { separator: true },
1170
+ {
1171
+ label: clearValueLabel,
1172
+ icon: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 16, strokeWidth: 1.75 }),
1173
+ danger: true,
1174
+ disabled: !col.editable,
1175
+ onSelect: () => onEdit?.(row.id, col.key, ""),
1176
+ },
1177
+ ];
1178
+ }
923
1179
  /**
924
1180
  * Spreadsheet keyboard model, ported from RecordsTable: arrows move the
925
1181
  * selected cell, Enter opens its editor (or, on a non-editable cell,
926
- * activates the row via `onRowClick`), Escape clears, and Cmd/Ctrl+C
927
- * copies the cell's RENDERED text (what the user sees, not the raw value).
1182
+ * activates the row via `onRowClick`), Escape clears. Cmd/Ctrl+C is
1183
+ * deliberately NOT handled here (crisp/#645) -- copy is the native `copy`
1184
+ * event on `clipboardProxyRef`, see `onGridCopy` above.
928
1185
  */
929
1186
  function onGridKeyDown(e) {
930
1187
  if (!selCell || editing)
931
1188
  return;
932
- if ((e.metaKey || e.ctrlKey) && (e.key === "c" || e.key === "C")) {
933
- const row = rows.find((rw) => rw.id === selCell.id);
934
- const col = columns.find((cc) => cc.key === selCell.key);
935
- if (row && col)
936
- navigator.clipboard?.writeText(formatCell(col.type, cellValue(col, row), col.symbol, locale));
1189
+ // crisp/#607, G-069: Attio's own Cmd+A selects every row (confirmed
1190
+ // live, `metaKey:true`, all 37/37 rows, header checkbox fully checked)
1191
+ // -- a real SELECT ALL, not `toggleAll`'s flip-if-already-all-selected,
1192
+ // so a second Cmd+A with everything already picked stays a no-op
1193
+ // instead of clearing the selection out from under the user.
1194
+ if ((e.metaKey || e.ctrlKey) && (e.key === "a" || e.key === "A")) {
1195
+ if (!selectable)
1196
+ return;
937
1197
  e.preventDefault();
1198
+ setSelected(new Set(rows.map((rw) => rw.id)));
938
1199
  return;
939
1200
  }
940
1201
  const r = rows.findIndex((row) => row.id === selCell.id);
@@ -954,8 +1215,27 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
954
1215
  const forward = e.key === (rtl ? "ArrowLeft" : "ArrowRight");
955
1216
  nc = forward ? Math.min(columns.length - 1, c + 1) : Math.max(0, c - 1);
956
1217
  }
1218
+ // crisp/#607, G-066/G-068: End jumps to the last row (confirmed live --
1219
+ // it also triggers Attio's own lazy-load, which is a data-loading
1220
+ // concern outside this component; the #577 scroll-into-view effect
1221
+ // below already re-runs on every `selCell` change, on a virtualized
1222
+ // grid too, so jumping straight to the last row is enough to reveal it
1223
+ // the same way arrowing down one row at a time would). PageUp/PageDown
1224
+ // move a viewport's worth of rows -- `scrollRef`'s own rendered height
1225
+ // divided by the uniform row height, at least one row so a viewport
1226
+ // shorter than one row can't get stuck.
1227
+ else if (e.key === "End")
1228
+ nr = rows.length - 1;
1229
+ else if (e.key === "PageDown" || e.key === "PageUp") {
1230
+ const viewportRows = Math.max(1, Math.floor((scrollRef.current?.clientHeight ?? 0) / ROW_HEIGHT));
1231
+ nr =
1232
+ e.key === "PageDown"
1233
+ ? Math.min(rows.length - 1, r + viewportRows)
1234
+ : Math.max(0, r - viewportRows);
1235
+ }
957
1236
  else if (e.key === "Escape") {
958
1237
  setSelCell(null);
1238
+ setSelAnchor(null);
959
1239
  return;
960
1240
  }
961
1241
  else if (e.key === "Enter") {
@@ -977,7 +1257,16 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
977
1257
  else
978
1258
  return;
979
1259
  e.preventDefault();
1260
+ // A plain arrow key moves the single-cell selection and drops any active
1261
+ // range -- Attio's own range trigger is Shift+Click only (G-101), arrow
1262
+ // keys were never measured as a range-extend gesture, so this doesn't
1263
+ // invent one.
1264
+ setSelAnchor(null);
980
1265
  setSelCell({ id: rows[nr].id, key: columns[nc].key });
1266
+ // keeps the clipboard proxy's selection non-collapsed after a move, so
1267
+ // a Cmd+C right after an arrow key still has something native `copy`
1268
+ // can fire on (see `clipboardProxyRef`'s own comment).
1269
+ clipboardProxyRef.current?.select();
981
1270
  }
982
1271
  // Opening a cell's editor also clears any stale error left over from a
983
1272
  // PREVIOUS failed commit on it (#385) -- otherwise a rejection from three
@@ -1089,7 +1378,8 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
1089
1378
  renderItems,
1090
1379
  ]);
1091
1380
  const totalCols = columns.length + (selectable ? 1 : 0) + (showGrip ? 1 : 0);
1092
- const hasSummary = columns.some((c) => c.summary);
1381
+ const hasSummary = columns.some((c) => c.summary || c.calc);
1382
+ const hasCommentColumn = columns.some((c) => c.onOpenComments);
1093
1383
  // sticky attrs for the leading column(s): grip col (index -2) + selection
1094
1384
  // col (-1) + first data column (0). Each offsets by whatever leading
1095
1385
  // columns come before it, so the group stays contiguous when scrolled.
@@ -1186,13 +1476,135 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
1186
1476
  setResizingKey(null);
1187
1477
  e.target.releasePointerCapture(e.pointerId);
1188
1478
  }
1479
+ // Which mounted column (if any) `clientX` is over, and which side of it --
1480
+ // X only, not the full box: like `board.tsx`'s own `columnAt` (#724), the
1481
+ // header row is a single non-wrapping row, so X alone already identifies a
1482
+ // column unambiguously, and checking Y too only risks the same short-row
1483
+ // miss that #724 found the hard way. The midpoint split is what turns
1484
+ // "over column C" into an insertion point: left half means "before C",
1485
+ // right half means "after C" -- exposed as `dropCol` so the render can draw
1486
+ // the measured blue line on the correct edge.
1487
+ function columnDropAt(clientX) {
1488
+ for (const [key, el] of colRefs.current) {
1489
+ const r = el.getBoundingClientRect();
1490
+ if (clientX >= r.left && clientX <= r.right) {
1491
+ return {
1492
+ key,
1493
+ side: clientX < r.left + r.width / 2 ? "before" : "after",
1494
+ };
1495
+ }
1496
+ }
1497
+ return null;
1498
+ }
1499
+ function stopColAutoScroll() {
1500
+ if (autoScrollRef.current != null) {
1501
+ cancelAnimationFrame(autoScrollRef.current);
1502
+ autoScrollRef.current = null;
1503
+ }
1504
+ }
1505
+ // Edge auto-scroll (measured live, #731's issue comment: dragging a header
1506
+ // toward the viewport edge scrolls the grid to reveal more columns). A
1507
+ // `requestAnimationFrame` loop, not a one-shot nudge per pointermove: the
1508
+ // scroll has to keep going even while the pointer itself sits still near
1509
+ // the edge, which a per-event nudge can't do. Guarded for environments with
1510
+ // no `requestAnimationFrame` (bun test's happy-dom) -- never throws, just a
1511
+ // no-op there, same as the `ResizeObserver` guards elsewhere in this file.
1512
+ function updateColAutoScroll(clientX) {
1513
+ if (typeof requestAnimationFrame === "undefined")
1514
+ return;
1515
+ const el = scrollRef.current;
1516
+ if (!el)
1517
+ return;
1518
+ const r = el.getBoundingClientRect();
1519
+ const EDGE = 40;
1520
+ const dir = clientX < r.left + EDGE ? -1 : clientX > r.right - EDGE ? 1 : 0;
1521
+ stopColAutoScroll();
1522
+ if (dir === 0)
1523
+ return;
1524
+ const step = () => {
1525
+ el.scrollLeft += dir * 12;
1526
+ autoScrollRef.current = requestAnimationFrame(step);
1527
+ };
1528
+ autoScrollRef.current = requestAnimationFrame(step);
1529
+ }
1530
+ function onColHeaderPointerDown(e, key) {
1531
+ if (!e.isPrimary)
1532
+ return;
1533
+ colDragRef.current = {
1534
+ key,
1535
+ pointerId: e.pointerId,
1536
+ startX: e.clientX,
1537
+ startY: e.clientY,
1538
+ active: false,
1539
+ };
1540
+ }
1541
+ function onColHeaderPointerMove(e) {
1542
+ const st = colDragRef.current;
1543
+ if (!st || st.pointerId !== e.pointerId)
1544
+ return;
1545
+ if (!st.active) {
1546
+ if (Math.hypot(e.clientX - st.startX, e.clientY - st.startY) <
1547
+ COL_DRAG_THRESHOLD)
1548
+ return;
1549
+ st.active = true;
1550
+ setDragColKey(st.key);
1551
+ e.currentTarget.setPointerCapture(e.pointerId);
1552
+ }
1553
+ const at = columnDropAt(e.clientX);
1554
+ setDropCol(at && at.key !== st.key ? at : null);
1555
+ updateColAutoScroll(e.clientX);
1556
+ }
1557
+ function endColHeaderDrag(e, commit) {
1558
+ const st = colDragRef.current;
1559
+ if (!st || st.pointerId !== e.pointerId)
1560
+ return;
1561
+ if (commit && st.active && dropCol) {
1562
+ const keys = allColumns.map((c) => c.key);
1563
+ const from = keys.indexOf(st.key);
1564
+ const to = keys.indexOf(dropCol.key);
1565
+ if (from >= 0 && to >= 0) {
1566
+ keys.splice(from, 1);
1567
+ let insertAt = keys.indexOf(dropCol.key);
1568
+ if (dropCol.side === "after")
1569
+ insertAt += 1;
1570
+ keys.splice(insertAt, 0, st.key);
1571
+ applyOrder(keys);
1572
+ }
1573
+ }
1574
+ colDragRef.current = null;
1575
+ setDragColKey(null);
1576
+ setDropCol(null);
1577
+ stopColAutoScroll();
1578
+ }
1579
+ // Escape cancels the drag without committing (measured live, #731's issue
1580
+ // comment: verified twice against the real grid) -- a document listener,
1581
+ // not a React `onKeyDown` on the header, for the same reason
1582
+ // `useEscapeExitsEditing` above uses one: focus during a pointer drag isn't
1583
+ // reliably inside the dragged element.
1584
+ React.useEffect(() => {
1585
+ if (!dragColKey)
1586
+ return;
1587
+ const onKey = (e) => {
1588
+ if (e.key !== "Escape")
1589
+ return;
1590
+ colDragRef.current = null;
1591
+ setDragColKey(null);
1592
+ setDropCol(null);
1593
+ if (autoScrollRef.current != null) {
1594
+ cancelAnimationFrame(autoScrollRef.current);
1595
+ autoScrollRef.current = null;
1596
+ }
1597
+ };
1598
+ document.addEventListener("keydown", onKey);
1599
+ return () => document.removeEventListener("keydown", onKey);
1600
+ }, [dragColKey]);
1189
1601
  function ChevronFor({ colKey }) {
1190
1602
  const dir = sortDirByKey.get(colKey);
1191
1603
  if (dir)
1192
1604
  return dir === "asc" ? ((0, jsx_runtime_1.jsx)(lucide_react_1.ArrowUp, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 })) : ((0, jsx_runtime_1.jsx)(lucide_react_1.ArrowDown, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 }));
1193
1605
  return ((0, jsx_runtime_1.jsx)(lucide_react_1.ChevronsUpDown, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 }));
1194
1606
  }
1195
- return ((0, jsx_runtime_1.jsxs)("div", { ref: gridRef, className: (0, datatable_recipe_1.datatableRecipe)({ className }), tabIndex: -1, onKeyDown: onGridKeyDown, ...rest, children: [(0, jsx_runtime_1.jsx)("div", { className: "crisp-datatable-live", role: "status", "aria-live": "polite", children: selectionText }), (filterable || showCount || toolbar || columnMenu) && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-datatable-toolbar", children: [columnMenu && ((0, jsx_runtime_1.jsxs)(menu_1.Menu, { align: "start", trigger: (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-datatable-viewsettings", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.SlidersHorizontal, { size: 14, strokeWidth: 1.75 }), viewSettingsLabel] }), children: [(0, jsx_runtime_1.jsx)("div", { className: "crisp-datatable-group-label", children: columnsGroupLabel }), allColumns.map((c) => ((0, jsx_runtime_1.jsxs)(menu_1.MenuItem, { onSelect: () => c.hidden ? showColumn(c.key) : hideColumn(c.key), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Check, { size: 14, strokeWidth: 2, style: { opacity: c.hidden ? 0 : 1 } }), typeof c.header === "string" ? c.header : c.key] }, c.key)))] })), filterable && ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-search", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("input", { type: "search", placeholder: searchPlaceholder, value: query, onChange: (e) => setQuery(e.target.value), "aria-label": searchLabel })] })), showCount && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-count", children: resolvedCountLabel(rows.length, data.length) })), toolbar] })), (0, jsx_runtime_1.jsxs)(table_1.Table, { interactive: interactive, surface: surface,
1607
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: gridRef, className: (0, datatable_recipe_1.datatableRecipe)({ className }), tabIndex: -1, onKeyDown: onGridKeyDown, ...rest, children: [(0, jsx_runtime_1.jsx)("div", { className: "crisp-datatable-live", role: "status", "aria-live": "polite", children: selectionText }), (0, jsx_runtime_1.jsx)("textarea", { ref: clipboardProxyRef, className: "crisp-datatable-clipboard-proxy", readOnly: true, value: " ", "aria-label": cellMenuLabel, tabIndex: -1, onCopy: onGridCopy, onPaste: onGridPaste }), (filterable || showCount || toolbar || columnMenu) && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-datatable-toolbar", children: [columnMenu && ((0, jsx_runtime_1.jsxs)(menu_1.Menu, { align: "start", trigger: (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-datatable-viewsettings", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.SlidersHorizontal, { size: 14, strokeWidth: 1.75 }), viewSettingsLabel] }), children: [(0, jsx_runtime_1.jsx)("div", { className: "crisp-datatable-group-label", children: columnsGroupLabel }), allColumns.map((c) => ((0, jsx_runtime_1.jsxs)(menu_1.MenuItem, { onSelect: () => c.hidden ? showColumn(c.key) : hideColumn(c.key), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Check, { size: 14, strokeWidth: 2, style: { opacity: c.hidden ? 0 : 1 } }), typeof c.header === "string" ? c.header : c.key] }, c.key)))] })), filterable && ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-search", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("input", { type: "search", placeholder: searchPlaceholder, value: query, onChange: (e) => setQuery(e.target.value), "aria-label": searchLabel })] })), showCount && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-count", children: resolvedCountLabel(rows.length, data.length) })), toolbar] })), (0, jsx_runtime_1.jsxs)(table_1.Table, { interactive: interactive, surface: surface,
1196
1608
  // crisp/#581: on the <table> itself (`tableStyle`), not the outer
1197
1609
  // wrapper (`style`) -- see `Table`'s own doc comment on why. This is
1198
1610
  // what lets the grid actually scroll horizontally INSIDE its own
@@ -1263,7 +1675,30 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
1263
1675
  : "descending"
1264
1676
  : "none";
1265
1677
  const inner = ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-th-inner", children: [Icon ? (0, jsx_runtime_1.jsx)(Icon, { size: 16, strokeWidth: 1.75 }) : null, (0, jsx_runtime_1.jsx)(ThLabel, { header: col.header, referencedType: col.referencedType }), col.sortable ? (0, jsx_runtime_1.jsx)(ChevronFor, { colKey: col.key }) : null] }));
1266
- return ((0, jsx_runtime_1.jsxs)(table_1.TableHead, { align: alignFor(col), "aria-sort": ariaSort, "aria-colindex": colAriaIndex(i), ...dataStickyCol(i), children: [renaming === col.key ? ((0, jsx_runtime_1.jsx)("input", {
1678
+ return ((0, jsx_runtime_1.jsxs)(table_1.TableHead, { ref: isPrimary
1679
+ ? undefined
1680
+ : (el) => {
1681
+ if (el)
1682
+ colRefs.current.set(col.key, el);
1683
+ else
1684
+ colRefs.current.delete(col.key);
1685
+ }, align: alignFor(col), "aria-sort": ariaSort, "aria-colindex": colAriaIndex(i), "data-dragging": dragColKey === col.key ? "" : undefined, "data-drop-before": dropCol?.key === col.key && dropCol.side === "before"
1686
+ ? ""
1687
+ : undefined, "data-drop-after": dropCol?.key === col.key && dropCol.side === "after"
1688
+ ? ""
1689
+ : undefined,
1690
+ // #731: column drag-to-reorder lives on the TH itself, not
1691
+ // a dedicated grip -- Attio's own header is draggable by
1692
+ // its whole surface (measured live). A plain click (no
1693
+ // movement past the 2px threshold) never calls
1694
+ // `setPointerCapture`/`preventDefault`, so the sort
1695
+ // button/menu trigger/resize handle nested inside still get
1696
+ // their own click normally; `startResize`'s own
1697
+ // `stopPropagation` (above) keeps a resize drag from ever
1698
+ // reaching this handler in the first place.
1699
+ onPointerDown: isPrimary
1700
+ ? undefined
1701
+ : (e) => onColHeaderPointerDown(e, col.key), onPointerMove: isPrimary ? undefined : onColHeaderPointerMove, onPointerUp: isPrimary ? undefined : (e) => endColHeaderDrag(e, true), onPointerCancel: isPrimary ? undefined : (e) => endColHeaderDrag(e, false), ...dataStickyCol(i), children: [renaming === col.key ? ((0, jsx_runtime_1.jsx)("input", {
1267
1702
  // biome-ignore lint/a11y/noAutofocus: the rename item opens this field
1268
1703
  autoFocus: true, className: "crisp-datatable-th-rename", defaultValue: typeof col.header === "string" ? col.header : "", "aria-label": renameColumnLabel, onBlur: (e) => {
1269
1704
  renameColumn(col.key, e.currentTarget.value);
@@ -1295,7 +1730,9 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
1295
1730
  dragRowId !== row.id &&
1296
1731
  dropRowId === row.id
1297
1732
  ? ""
1298
- : undefined, onClick: onRowClick ? () => onRowClick(row) : undefined, style: onRowClick ? { cursor: "pointer" } : undefined, onDragOver: showGrip && dragRowId
1733
+ : undefined, onClick: onRowClick ? () => onRowClick(row) : undefined, style: onRowClick ? { cursor: "pointer" } : undefined, onMouseEnter: hasCommentColumn
1734
+ ? () => setHoveredRowId(row.id)
1735
+ : undefined, onMouseLeave: hasCommentColumn ? () => setHoveredRowId(null) : undefined, onDragOver: showGrip && dragRowId
1299
1736
  ? (e) => {
1300
1737
  e.preventDefault();
1301
1738
  if (dropRowId !== row.id)
@@ -1379,46 +1816,99 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
1379
1816
  commitEdit(row.id, col.key, e.currentTarget.value);
1380
1817
  } }), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }, col.key));
1381
1818
  }
1819
+ // `itemIndex` is the row's position in the full
1820
+ // `rows` array (getSelRangeIndices' own r0/r1 are
1821
+ // indices into that same array) for the common,
1822
+ // ungrouped case. A `groupBy` view interleaves group-
1823
+ // header items into `visibleItems`, which this
1824
+ // doesn't correct for -- Shift+Click ranging across a
1825
+ // grouped view isn't a measured Attio interaction
1826
+ // (G-101's own trigger is a plain table), so it's a
1827
+ // known gap rather than a guess at behaviour there.
1828
+ const inRange = (() => {
1829
+ const range = getSelRangeIndices();
1830
+ if (!range)
1831
+ return false;
1832
+ return (itemIndex >= range.r0 &&
1833
+ itemIndex <= range.r1 &&
1834
+ i >= range.c0 &&
1835
+ i <= range.c1);
1836
+ })();
1382
1837
  const rendered = col.render
1383
1838
  ? col.render(value, row)
1384
- : renderTyped(col, value, avatarTone, row, col.format?.(value, row), locale);
1839
+ : renderTyped(col, value, avatarTone, row, col.format?.(value, row), locale, openPreviewLabel, hoveredRowId === row.id);
1385
1840
  const isExpanded = expandedCell?.id === row.id &&
1386
1841
  expandedCell?.key === col.key;
1387
- return ((0, jsx_runtime_1.jsxs)(table_1.TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: col.editable
1388
- ? "crisp-datatable-editable"
1389
- : undefined, "data-selected": selCell?.id === row.id && selCell?.key === col.key
1390
- ? ""
1391
- : undefined, "data-cell-expanded": isExpanded ? "" : undefined, onClick: (e) => {
1392
- // the keyboard model's anchor -- a single click
1393
- // only selects. A genuine double-click (or Enter
1394
- // on the selected cell) opens the editor (#411),
1395
- // matching RecordsTable's own onDoubleClick.
1396
- setSelCell({ id: row.id, key: col.key });
1397
- gridRef.current?.focus({ preventScroll: true });
1398
- // #579: a truncated cell also opens a read-only
1399
- // overlay with its full value -- settled live
1400
- // that this is click, not Attio's usual
1401
- // hover-expand (a 1s hover on a truncated
1402
- // Description cell produced nothing). Measured
1403
- // the same way as `ThLabel`'s header tooltip
1404
- // (#534): scrollWidth vs clientWidth on the
1405
- // element the CSS ellipsis actually applies to,
1406
- // which for a data cell is the `td` itself
1407
- // (table.recipe.ts's own `overflow: hidden;
1408
- // text-overflow: ellipsis`), not a nested span.
1409
- setExpandedCell(isLabelTruncated(e.currentTarget)
1410
- ? { id: row.id, key: col.key }
1411
- : null);
1412
- }, onDoubleClick: col.editable
1413
- ? () => openEditing(row.id, col.key)
1414
- : undefined, children: [!isExpanded && rendered, errorHere && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-edit-error", children: errorHere })), isExpanded && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-datatable-cell-overlay", onClick: (e) => e.stopPropagation(), children: rendered }))] }, col.key));
1842
+ const cellContent = ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [!isExpanded && rendered, errorHere && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-edit-error", children: errorHere })), isExpanded && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-datatable-cell-overlay", onClick: (e) => e.stopPropagation(), children: rendered }))] }));
1843
+ return ((0, jsx_runtime_1.jsx)(contextmenu_1.ContextMenu, { asChild: true, variant: "dark", menuLabel: cellMenuLabel, items: cellMenuItems(row, col), children: (0, jsx_runtime_1.jsx)(table_1.TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: col.editable
1844
+ ? "crisp-datatable-editable"
1845
+ : undefined, "data-selected": selCell?.id === row.id && selCell?.key === col.key
1846
+ ? ""
1847
+ : undefined, "data-in-range": inRange ? "" : undefined, "data-cell-expanded": isExpanded ? "" : undefined, onClick: (e) => {
1848
+ // the keyboard model's anchor -- a single click
1849
+ // only selects. A genuine double-click (or Enter
1850
+ // on the selected cell) opens the editor (#411),
1851
+ // matching RecordsTable's own onDoubleClick.
1852
+ // crisp/#645, G-101: Shift+Click extends a
1853
+ // range from the last plain-click anchor
1854
+ // ("Click cell A, Shift+Click cell B") --
1855
+ // `selCell` keeps doubling as the moving
1856
+ // corner, `selAnchor` is the fixed one.
1857
+ if (e.shiftKey && selCell) {
1858
+ setSelAnchor(selAnchor ?? selCell);
1859
+ }
1860
+ else {
1861
+ setSelAnchor(null);
1862
+ }
1863
+ setSelCell({ id: row.id, key: col.key });
1864
+ // Focuses the clipboard proxy, not `gridRef`
1865
+ // itself (crisp/#645) -- it's a DESCENDANT of
1866
+ // `gridRef`, so `onGridKeyDown` still sees
1867
+ // every key via bubbling, but now the browser
1868
+ // also has a focused, selectable text node to
1869
+ // fire a native `copy`/`paste` on. `focus()`
1870
+ // then `select()`, not `select()` alone --
1871
+ // `select()` only guarantees the SELECTION,
1872
+ // focus is a side effect some engines don't
1873
+ // give it for free.
1874
+ clipboardProxyRef.current?.focus();
1875
+ clipboardProxyRef.current?.select();
1876
+ // #579: a truncated cell also opens a read-only
1877
+ // overlay with its full value -- settled live
1878
+ // that this is click, not Attio's usual
1879
+ // hover-expand (a 1s hover on a truncated
1880
+ // Description cell produced nothing). Measured
1881
+ // the same way as `ThLabel`'s header tooltip
1882
+ // (#534): scrollWidth vs clientWidth on the
1883
+ // element the CSS ellipsis actually applies to,
1884
+ // which for a data cell is the `td` itself
1885
+ // (table.recipe.ts's own `overflow: hidden;
1886
+ // text-overflow: ellipsis`), not a nested span.
1887
+ // Skipped on a Shift+Click (crisp/#645) -- that
1888
+ // gesture's own intent is extending the range,
1889
+ // not popping open a read-only overlay on top
1890
+ // of it.
1891
+ setExpandedCell(!e.shiftKey && isLabelTruncated(e.currentTarget)
1892
+ ? { id: row.id, key: col.key }
1893
+ : null);
1894
+ }, onDoubleClick: col.editable
1895
+ ? () => openEditing(row.id, col.key)
1896
+ : undefined, onContextMenu: () => {
1897
+ // Right-clicking targets THAT cell for the
1898
+ // menu regardless of what was previously
1899
+ // selected (crisp/#598) -- collapses any
1900
+ // active range, same as a plain left-click.
1901
+ setSelAnchor(null);
1902
+ setSelCell({ id: row.id, key: col.key });
1903
+ clipboardProxyRef.current?.focus();
1904
+ clipboardProxyRef.current?.select();
1905
+ }, children: cellContent }) }, col.key));
1415
1906
  })] }, row.id));
1416
1907
  }), bottomSpacerHeight > 0 && ((0, jsx_runtime_1.jsx)("tr", { "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("td", { colSpan: totalCols, style: {
1417
1908
  height: bottomSpacerHeight,
1418
1909
  padding: 0,
1419
1910
  border: "none",
1420
- } }) }))] })) }), (hasSummary || onAddRow) && ((0, jsx_runtime_1.jsxs)(table_1.TableFooter, { children: [hasSummary && ((0, jsx_runtime_1.jsxs)(table_1.TableRow, { children: [showGrip && (0, jsx_runtime_1.jsx)(table_1.TableCell, { ...stickyCol(-2) }), selectable && (0, jsx_runtime_1.jsx)(table_1.TableCell, { ...stickyCol(-1) }), colSlots.map((slot) => slot.type === "spacer" ? ((0, jsx_runtime_1.jsx)("td", { role: "presentation", "aria-hidden": "true", style: { padding: 0, border: "none" } }, `__spacer_${slot.side}`)) : ((0, jsx_runtime_1.jsx)(table_1.TableCell, { align: alignFor(columns[slot.i]), "aria-colindex": colAriaIndex(slot.i), ...dataStickyCol(slot.i), children: columns[slot.i].summary
1421
- ? columns[slot.i].summary?.(rows)
1422
- : null }, columns[slot.i].key)))] })), onAddRow && ((0, jsx_runtime_1.jsx)(table_1.TableRow, { children: (0, jsx_runtime_1.jsx)(table_1.TableCell, { className: "crisp-datatable-addcell", colSpan: totalCols, children: (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-datatable-addrow", onClick: onAddRow, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), addRowLabel] }) }) }))] }))] })] }));
1911
+ } }) }))] })) }), (hasSummary || onAddRow) && ((0, jsx_runtime_1.jsxs)(table_1.TableFooter, { children: [hasSummary && ((0, jsx_runtime_1.jsxs)(table_1.TableRow, { children: [showGrip && (0, jsx_runtime_1.jsx)(table_1.TableCell, { ...stickyCol(-2) }), selectable && (0, jsx_runtime_1.jsx)(table_1.TableCell, { ...stickyCol(-1) }), colSlots.map((slot) => slot.type === "spacer" ? ((0, jsx_runtime_1.jsx)("td", { role: "presentation", "aria-hidden": "true", style: { padding: 0, border: "none" } }, `__spacer_${slot.side}`)) : ((0, jsx_runtime_1.jsx)(table_1.TableCell, { align: alignFor(columns[slot.i]), "aria-colindex": colAriaIndex(slot.i), ...dataStickyCol(slot.i), children: columns[slot.i].calc ? ((0, jsx_runtime_1.jsx)(columncalc_1.ColumnCalc, { rows: rows, value: (row) => cellValue(columns[slot.i], row), numeric: columns[slot.i].type === "number" ||
1912
+ columns[slot.i].type === "currency", defaultOp: columns[slot.i].calc?.defaultOp, op: columns[slot.i].calc?.op, onOpChange: columns[slot.i].calc?.onOpChange, align: alignFor(columns[slot.i]) })) : columns[slot.i].summary ? (columns[slot.i].summary?.(rows)) : null }, columns[slot.i].key)))] })), onAddRow && ((0, jsx_runtime_1.jsx)(table_1.TableRow, { children: (0, jsx_runtime_1.jsx)(table_1.TableCell, { className: "crisp-datatable-addcell", colSpan: totalCols, children: (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-datatable-addrow", onClick: onAddRow, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), addRowLabel] }) }) }))] }))] })] }));
1423
1913
  }
1424
1914
  DataTable.displayName = "DataTable";