@crisp-ui-kit/crisp 0.54.8 → 0.54.10

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 (59) 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 +593 -51
  6. package/dist/cjs/components/datatable/datatable.recipe.js +67 -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 +218 -39
  10. package/dist/cjs/components/tabletoolbar/tabletoolbar.recipe.js +48 -1
  11. package/dist/cjs/components/text/text.recipe.js +17 -7
  12. package/dist/cjs/product/board/board.js +62 -28
  13. package/dist/cjs/product/board/board.recipe.js +42 -2
  14. package/dist/cjs/product/comments/comments.js +11 -2
  15. package/dist/cjs/product/comments/comments.recipe.js +14 -4
  16. package/dist/cjs/product/recordstable/recordstable.js +1 -17
  17. package/dist/cjs/product/savedviews/savedviews.js +55 -24
  18. package/dist/cjs/product/savedviews/savedviews.recipe.js +27 -0
  19. package/dist/comments.css +14 -4
  20. package/dist/comments.layered.css +14 -4
  21. package/dist/contextmenu.css +24 -0
  22. package/dist/contextmenu.layered.css +24 -0
  23. package/dist/datatable.css +106 -77
  24. package/dist/datatable.layered.css +106 -77
  25. package/dist/esm/components/contextmenu/contextmenu.js +2 -2
  26. package/dist/esm/components/contextmenu/contextmenu.recipe.js +24 -0
  27. package/dist/esm/components/datatable/datatable.js +593 -52
  28. package/dist/esm/components/datatable/datatable.recipe.js +68 -4
  29. package/dist/esm/components/dialog/dialog.js +65 -1
  30. package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +37 -2
  31. package/dist/esm/components/tabletoolbar/tabletoolbar.js +219 -40
  32. package/dist/esm/components/tabletoolbar/tabletoolbar.recipe.js +50 -3
  33. package/dist/esm/components/text/text.recipe.js +17 -7
  34. package/dist/esm/product/board/board.js +63 -29
  35. package/dist/esm/product/board/board.recipe.js +42 -2
  36. package/dist/esm/product/comments/comments.js +12 -3
  37. package/dist/esm/product/comments/comments.recipe.js +14 -4
  38. package/dist/esm/product/recordstable/recordstable.js +2 -18
  39. package/dist/esm/product/savedviews/savedviews.js +57 -26
  40. package/dist/esm/product/savedviews/savedviews.recipe.js +27 -0
  41. package/dist/recordstable.css +33 -31
  42. package/dist/recordstable.layered.css +33 -31
  43. package/dist/savedviews.css +27 -0
  44. package/dist/savedviews.layered.css +27 -0
  45. package/dist/styles.css +282 -125
  46. package/dist/styles.layered.css +282 -125
  47. package/dist/tabletoolbar.css +45 -4
  48. package/dist/tabletoolbar.layered.css +45 -4
  49. package/dist/text.css +1 -1
  50. package/dist/text.layered.css +1 -1
  51. package/dist/types/components/contextmenu/contextmenu.d.ts +8 -1
  52. package/dist/types/components/datatable/datatable.d.ts +98 -2
  53. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +72 -2
  54. package/dist/types/components/text/text.recipe.d.ts +1 -1
  55. package/dist/types/product/board/board.d.ts +48 -2
  56. package/dist/types/product/comments/comments.d.ts +6 -1
  57. package/dist/types/product/recordstable/recordstable.d.ts +0 -1
  58. package/dist/types/product/savedviews/savedviews.d.ts +45 -8
  59. package/package.json +1 -1
@@ -1,9 +1,19 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ArrowDown, ArrowUp, Check, ChevronsUpDown, CircleUserRound, GripVertical, Lock, Plus, Search, SlidersHorizontal, } from "lucide-react";
2
+ import { ArrowDown, ArrowUp, Check, ChevronsUpDown, CircleUserRound, ClipboardPaste, Copy, GripVertical, History, Lock, PanelRight, Plus, Search, SlidersHorizontal, X, } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { cellDatePrecision, formatCellDate, isRtlElement, parseCellDate, useCrispMessage, useCrispMessages, } from "../../core/index.js";
5
+ // crisp/#601 supersession: a deliberate exception to "DS atoms stay
6
+ // generic; product components compose them" (AUTHORING.md) -- the row's
7
+ // own comment affordance is `CommentCountBadge` (product/comments, #625),
8
+ // already correctly built and measured for the dot/hover-count states this
9
+ // plain icon path can't express on its own. Reaching into `product/` from
10
+ // `components/` runs backwards from every other import in this file; it's
11
+ // scoped to this one component, not a precedent for the rest of DataTable.
12
+ import { CommentCountBadge } from "../../product/comments/index.js";
5
13
  import { Avatar } from "../avatar/index.js";
6
14
  import { Checkbox } from "../checkbox/index.js";
15
+ import { ColumnCalc } from "../columncalc/index.js";
16
+ import { ContextMenu } from "../contextmenu/index.js";
7
17
  import { Menu, MenuItem } from "../menu/index.js";
8
18
  import { Select } from "../select/index.js";
9
19
  import { Spinner } from "../spinner/index.js";
@@ -18,6 +28,35 @@ import { datatableRecipe } from "./datatable.recipe.js";
18
28
  export function isLabelTruncated(el) {
19
29
  return el.scrollWidth > el.clientWidth;
20
30
  }
31
+ // #731: the pure resequencing behind both the controlled `columnOrder` prop
32
+ // and the drag/Move-left-right mutations that produce one -- exported so the
33
+ // "does this actually reorder, and does it survive a stale/unknown key"
34
+ // logic can be mutation-tested without mounting the table. Every key in
35
+ // `order` that matches a real column is placed first, in `order`'s own
36
+ // sequence; every column NOT named in `order` (a brand-new column a
37
+ // consumer's persisted order hasn't caught up to yet) keeps its relative
38
+ // position at the end, in `cols`' own order -- so a column can never be
39
+ // dropped or duplicated by this, only resequenced. An `order` entry with no
40
+ // matching column (one that was removed, or a stale value from a saved
41
+ // view) is silently skipped rather than throwing.
42
+ export function reorderColumns(cols, order) {
43
+ if (!order || order.length === 0)
44
+ return cols;
45
+ const byKey = new Map(cols.map((c) => [c.key, c]));
46
+ const seen = new Set();
47
+ const ordered = [];
48
+ for (const key of order) {
49
+ const c = byKey.get(key);
50
+ if (c && !seen.has(key)) {
51
+ ordered.push(c);
52
+ seen.add(key);
53
+ }
54
+ }
55
+ for (const c of cols)
56
+ if (!seen.has(c.key))
57
+ ordered.push(c);
58
+ return ordered;
59
+ }
21
60
  /**
22
61
  * The header label span: a `Tooltip` (full text, `asChild` so the wrapper
23
62
  * never displaces the ellipsis CSS the span already carries -- see
@@ -223,7 +262,7 @@ function formatCell(type, v, symbol, locale = "en-US") {
223
262
  // silently drop the cell's data. Each branch decides for itself where a
224
263
  // display string belongs -- as an entity's name, a status' label -- while the
225
264
  // raw value keeps driving structure and tone.
226
- function renderTyped(col, value, avatarTone, row, display, locale = "en-US") {
265
+ function renderTyped(col, value, avatarTone, row, display, locale = "en-US", openPreviewLabel = "Open preview", rowHovered = false) {
227
266
  // Attio's own empty cell is genuinely blank -- no placeholder character at
228
267
  // all, for any column type (#412).
229
268
  const empty = null;
@@ -242,7 +281,10 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US") {
242
281
  // at a DIFFERENT record than the row itself (#501).
243
282
  ev.stopPropagation();
244
283
  col.onEntityClick?.(row);
245
- }, children: e.name })) : (_jsx("span", { className: "crisp-datatable-entity-name", children: e.name }))] }));
284
+ }, children: e.name })) : (_jsx("span", { className: "crisp-datatable-entity-name", children: e.name })), col.onOpenComments ? (_jsx("span", { className: "crisp-datatable-entity-actions", children: _jsx(CommentCountBadge, { count: col.commentCount?.(row) ?? 0, hovered: rowHovered, onOpen: () => col.onOpenComments?.(row), onOpenPreview: col.onOpenPreview ? () => col.onOpenPreview?.(row) : undefined, previewLabel: openPreviewLabel }) })) : (col.onOpenPreview && (_jsx("span", { className: "crisp-datatable-entity-actions", children: _jsx("button", { type: "button", className: "crisp-datatable-entity-action", "aria-label": openPreviewLabel, onClick: (ev) => {
285
+ ev.stopPropagation();
286
+ col.onOpenPreview?.(row);
287
+ }, children: _jsx(PanelRight, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }) })))] }));
246
288
  }
247
289
  case "tags": {
248
290
  // asking for a format on a tag column means "render it your way" -- show
@@ -415,7 +457,7 @@ function DataReferenceEditor({ col, value, isPending, onCommit, onCancel, }) {
415
457
  onCommit(v);
416
458
  } }) }));
417
459
  }
418
- export 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 }) {
460
+ export 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 }) {
419
461
  // crisp/#471: same resolution order as TableToolbar's own — an explicit
420
462
  // prop wins, then the provider's translation of the built-in English
421
463
  // default, then that default itself with no provider in the tree.
@@ -432,6 +474,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
432
474
  // wants shown); only the built-in string default translates.
433
475
  const empty = emptyProp ?? crispMessages["No results."] ?? "No results.";
434
476
  const reorderRowLabel = useCrispMessage(reorderRowLabelProp, "Reorder row");
477
+ const openPreviewLabel = useCrispMessage(openPreviewLabelProp, "Open preview");
435
478
  const sortAscendingLabel = useCrispMessage(sortAscendingLabelProp, "Sort ascending");
436
479
  const sortDescendingLabel = useCrispMessage(sortDescendingLabelProp, "Sort descending");
437
480
  const moveColumnLeftLabel = useCrispMessage(moveColumnLeftLabelProp, "Move left");
@@ -441,6 +484,11 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
441
484
  const renameColumnLabel = useCrispMessage(renameColumnLabelProp, "Rename column");
442
485
  const viewSettingsLabel = useCrispMessage(viewSettingsLabelProp, "View settings");
443
486
  const columnsGroupLabel = useCrispMessage(columnsGroupLabelProp, "Columns");
487
+ const cellMenuLabel = useCrispMessage(cellMenuLabelProp, "Cell options");
488
+ const copyLabel = useCrispMessage(copyLabelProp, "Copy");
489
+ const pasteLabel = useCrispMessage(pasteLabelProp, "Paste");
490
+ const viewEditHistoryLabel = useCrispMessage(viewEditHistoryLabelProp, "View edit history");
491
+ const clearValueLabel = useCrispMessage(clearValueLabelProp, "Clear value");
444
492
  // countLabel/selectionAnnouncement stay formatter props, same escape hatch
445
493
  // as TableToolbar's sortTriggerLabel — word order and pluralisation rules
446
494
  // differ too much across languages for a template. Their BUILT-IN default
@@ -469,14 +517,32 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
469
517
  // menu mutates this copy and reports upward via `onColumnsChange`.
470
518
  const [internalCols, setInternalCols] = React.useState(columnsProp);
471
519
  React.useEffect(() => setInternalCols(columnsProp), [columnsProp]);
472
- /** Every column, hidden ones included — what the column manager lists. */
473
- const allColumns = internalCols;
520
+ /** Every column, hidden ones included — what the column manager lists.
521
+ * `columnOrder`, controlled or not, is the one source of truth for ORDER;
522
+ * `internalCols` still carries each column's hidden/renamed state, so
523
+ * resequencing it here never loses either. */
524
+ const allColumns = React.useMemo(() => reorderColumns(internalCols, columnOrder), [internalCols, columnOrder]);
474
525
  /** What the grid actually renders. */
475
- const columns = React.useMemo(() => internalCols.filter((c) => !c.hidden), [internalCols]);
526
+ const columns = React.useMemo(() => allColumns.filter((c) => !c.hidden), [allColumns]);
476
527
  const applyColumns = React.useCallback((next) => {
477
528
  setInternalCols(next);
478
529
  onColumnsChange?.(next);
479
530
  }, [onColumnsChange]);
531
+ // Reordering specifically (a header drag, or the menu's Move left/right) —
532
+ // separate from `applyColumns` above (hide/rename, which never touch
533
+ // order) because this is the one path that also owns `columnOrder`:
534
+ // uncontrolled, it seeds `internalCols`' own order same as any other
535
+ // mutation; controlled, the next render's `allColumns` re-derives order
536
+ // from the (now stale, until the consumer updates it) `columnOrder` prop
537
+ // instead — the same contract `sort`/`selectedIds` already use — so this
538
+ // deliberately does NOT touch `internalCols`' order when controlled.
539
+ const applyOrder = React.useCallback((nextOrder) => {
540
+ const next = reorderColumns(allColumns, nextOrder);
541
+ if (columnOrder === undefined)
542
+ setInternalCols(next);
543
+ onColumnsChange?.(next);
544
+ onColumnOrderChange?.(next.map((c) => c.key));
545
+ }, [allColumns, columnOrder, onColumnsChange, onColumnOrderChange]);
480
546
  const moveColumn = React.useCallback((key, dir) => {
481
547
  const i = allColumns.findIndex((c) => c.key === key);
482
548
  const j = i + dir;
@@ -484,14 +550,57 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
484
550
  return;
485
551
  const next = [...allColumns];
486
552
  [next[i], next[j]] = [next[j], next[i]];
487
- applyColumns(next);
488
- }, [allColumns, applyColumns]);
553
+ applyOrder(next.map((c) => c.key));
554
+ }, [allColumns, applyOrder]);
489
555
  const hideColumn = React.useCallback((key) => applyColumns(allColumns.map((c) => (c.key === key ? { ...c, hidden: true } : c))), [allColumns, applyColumns]);
490
556
  const renameColumn = React.useCallback((key, header) => applyColumns(allColumns.map((c) => (c.key === key ? { ...c, header } : c))), [allColumns, applyColumns]);
491
557
  const [renaming, setRenaming] = React.useState(null);
492
558
  const showColumn = React.useCallback((key) => applyColumns(allColumns.map((c) => (c.key === key ? { ...c, hidden: false } : c))), [allColumns, applyColumns]);
493
559
  const [selCell, setSelCell] = React.useState(null);
560
+ // crisp/#625/#601 supersession: `CommentCountBadge` branches its own
561
+ // render (a plain dot vs. the icon+count row) on a `hovered` boolean it
562
+ // takes as a prop -- unlike the plain #601 icons this replaces, which
563
+ // reveal on pure `tr:hover` CSS, the badge has no hover detection of its
564
+ // own (see its own doc comment) and needs the row's hover state IN JS.
565
+ // A single id, not a Set: only one row is ever hovered at a time, so this
566
+ // is one extra re-render on enter/leave, not per-row state.
567
+ const [hoveredRowId, setHoveredRowId] = React.useState(null);
568
+ // crisp/#645, checklist G-101: a Shift+Click extends a RANGE from the last
569
+ // plain-click anchor to the newly clicked cell (spreadsheet convention --
570
+ // Attio's own trigger is "Click cell A, Shift+Click cell B"). `selAnchor`
571
+ // is the fixed corner; `selCell` (above) keeps doubling as the moving
572
+ // corner/focus cell, so every existing single-cell consumer (editing,
573
+ // arrow-key nav, Enter) is unaffected -- a plain click resets `selAnchor`
574
+ // to null, collapsing back to single-cell mode.
575
+ const [selAnchor, setSelAnchor] = React.useState(null);
576
+ // crisp/#749: Shift+ArrowUp/Down extends the ROW (checkbox) selection --
577
+ // confirmed live on Attio's own Companies grid, a DIFFERENT gesture from
578
+ // `selAnchor` above (that's Shift+CLICK extending a cell-value range;
579
+ // this issue's own text is explicit the two are independent, and arrow
580
+ // keys were never measured as a cell-range trigger, G-101). Same
581
+ // fixed-anchor/moving-cell shape as `selAnchor` -- a spreadsheet's own
582
+ // Shift+Arrow both moves the active cell AND grows/shrinks the selection
583
+ // relative to wherever the drag started, which recomputing the anchor→
584
+ // new-row span from scratch on every press gives for free (arrowing back
585
+ // toward the anchor shrinks the same way arrowing away grows it). Reset
586
+ // to null on any plain (non-shift) navigation or click, same as
587
+ // `selAnchor`'s own reset points.
588
+ const [rowSelectAnchor, setRowSelectAnchor] = React.useState(null);
494
589
  const gridRef = React.useRef(null);
590
+ // The native-copy-event proxy (crisp/#645): Attio "has no Mousetrap/
591
+ // JS-keydown binding for c/mod+c ... it copies purely via the native
592
+ // browser copy event" -- confirmed live, `document.execCommand('copy')`
593
+ // on the focused element is what reliably fires it, a synthetic keydown
594
+ // does not. A plain focused `<div>` (what `gridRef` is) never receives a
595
+ // native `copy` event at all (no selection, nothing editable) -- verified
596
+ // empirically (Playwright, a focused non-editable div, real Ctrl+C: no
597
+ // `copy` event fires; the same page's `<input>` with selected text does).
598
+ // This hidden, always-selected textarea is what a spreadsheet-style grid
599
+ // needs to give the browser something copyable while the VISIBLE focus
600
+ // styling stays on the grid: it's the same visually-hidden-but-focusable
601
+ // shape as `.crisp-datatable-live` below, just interactive instead of
602
+ // `aria-live`.
603
+ const clipboardProxyRef = React.useRef(null);
495
604
  // #579: the click-to-expand overlay for a truncated cell. Only one is ever
496
605
  // open (a second click-to-expand elsewhere closes the first, matching "one
497
606
  // live session"-style singular UI state already used for `selCell`).
@@ -635,6 +744,25 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
635
744
  const [widths, setWidths] = React.useState({});
636
745
  const [resizingKey, setResizingKey] = React.useState(null);
637
746
  const resizeRef = React.useRef(null);
747
+ // crisp/#731 -- column drag-to-reorder. Pointer events, not native HTML5
748
+ // `draggable`: measured live on Attio's own grid (#731's issue comment) --
749
+ // `aria-roledescription="draggable"` etc. on every draggable header is
750
+ // dnd-kit's own signature, itself pointer-events-based, the same primitive
751
+ // `board.tsx` moved to in #724 (mousedown -> several mousemoves -> mouseup,
752
+ // no `[draggable]` anywhere). Mirrors that fix's own shape: a ref for the
753
+ // in-flight gesture (so a pointermove before the 2px threshold never
754
+ // re-renders), React state for what actually needs to paint.
755
+ const [dragColKey, setDragColKey] = React.useState(null);
756
+ const [dropCol, setDropCol] = React.useState(null);
757
+ const colDragRef = React.useRef(null);
758
+ // Keyed by column key, not index -- #577's own column virtualization means
759
+ // an off-screen column's `TableHead` unmounts entirely (confirmed live:
760
+ // Attio does the same, a column scrolled far enough away disappears from
761
+ // the DOM), so this only ever holds the currently-mounted ones, same as
762
+ // `board.tsx`'s `colRefs`.
763
+ const colRefs = React.useRef(new Map());
764
+ const autoScrollRef = React.useRef(null);
765
+ const COL_DRAG_THRESHOLD = 2; // crisp/#736 review fix: measured ~2px, not 4px
638
766
  const colWidth = (col) => widths[col.key] ?? col.width ?? (col.type === "entity" ? 220 : 160);
639
767
  const minWidth = (showGrip ? GRIP_W : 0) +
640
768
  (selectable ? CHECK_W : 0) +
@@ -883,21 +1011,196 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
883
1011
  return { type: "text", inputMode: "text" };
884
1012
  }
885
1013
  }
1014
+ /** The rectangular range's index bounds (inclusive), or null outside range
1015
+ * mode (`selAnchor` unset -- single-cell selection, the common case).
1016
+ * Indices, not ids/keys, so it always reads against the CURRENT rendered
1017
+ * `rows`/`columns` order -- the same order arrow-key nav already uses. */
1018
+ function getSelRangeIndices() {
1019
+ if (!selAnchor || !selCell)
1020
+ return null;
1021
+ const ar = rows.findIndex((row) => row.id === selAnchor.id);
1022
+ const ac = columns.findIndex((col) => col.key === selAnchor.key);
1023
+ const fr = rows.findIndex((row) => row.id === selCell.id);
1024
+ const fc = columns.findIndex((col) => col.key === selCell.key);
1025
+ if (ar < 0 || ac < 0 || fr < 0 || fc < 0)
1026
+ return null;
1027
+ return {
1028
+ r0: Math.min(ar, fr),
1029
+ r1: Math.max(ar, fr),
1030
+ c0: Math.min(ac, fc),
1031
+ c1: Math.max(ac, fc),
1032
+ };
1033
+ }
1034
+ /**
1035
+ * crisp/#645: Attio copies the RAW, unformatted value (`asText`, not
1036
+ * `formatCell` -- G-070: a formatted "28,946,065" cell copies as raw
1037
+ * "28946065"), writes nothing at all for a `readOnly`/computed cell
1038
+ * (G-100 -- no clipboard write occurs, not an empty string), and a
1039
+ * Shift+Click range copies as TSV: `\t` between columns, `\r\n` between
1040
+ * rows, no header row, raw values (G-101). Returns null for "no clipboard
1041
+ * write" (the single-readOnly-cell case, or nothing selected) rather than
1042
+ * "" -- the caller must not fall back to writing an empty string either.
1043
+ */
1044
+ function buildCopyText() {
1045
+ const range = getSelRangeIndices();
1046
+ if (range) {
1047
+ const { r0, r1, c0, c1 } = range;
1048
+ const lines = [];
1049
+ for (let ri = r0; ri <= r1; ri++) {
1050
+ const row = rows[ri];
1051
+ const cells = [];
1052
+ for (let ci = c0; ci <= c1; ci++) {
1053
+ const col = columns[ci];
1054
+ cells.push(col.readOnly ? "" : asText(cellValue(col, row)));
1055
+ }
1056
+ lines.push(cells.join("\t"));
1057
+ }
1058
+ return lines.join("\r\n");
1059
+ }
1060
+ if (!selCell)
1061
+ return null;
1062
+ const row = rows.find((rw) => rw.id === selCell.id);
1063
+ const col = columns.find((cc) => cc.key === selCell.key);
1064
+ if (!row || !col || col.readOnly)
1065
+ return null;
1066
+ return asText(cellValue(col, row));
1067
+ }
1068
+ /**
1069
+ * crisp/#645: the native `copy`/`paste` events, not a keydown handler --
1070
+ * Attio "has no Mousetrap/JS-keydown binding for c/mod+c ... it copies
1071
+ * purely via the native browser copy event" (confirmed live). These fire
1072
+ * on `clipboardProxyRef`'s hidden textarea (see its own comment), which
1073
+ * `onGridKeyDown` below no longer intercepts Cmd/Ctrl+C for.
1074
+ */
1075
+ function onGridCopy(e) {
1076
+ e.preventDefault();
1077
+ const text = buildCopyText();
1078
+ if (text != null)
1079
+ e.clipboardData.setData("text/plain", text);
1080
+ }
1081
+ /** crisp/#598, G-071: Attio's own context-menu Paste "looked like it would
1082
+ * commit immediately with no safe cancel step" -- matched here: paste
1083
+ * commits straight to `onEdit`, it doesn't open the cell's editor first.
1084
+ * Single-cell only -- a range-fill paste isn't measured (G-071 was
1085
+ * deliberately not exercised against real clipboard content), so it isn't
1086
+ * built rather than guessed. */
1087
+ function onGridPaste(e) {
1088
+ e.preventDefault();
1089
+ if (!selCell || editing)
1090
+ return;
1091
+ const col = columns.find((cc) => cc.key === selCell.key);
1092
+ if (!col?.editable || col.readOnly)
1093
+ return;
1094
+ const text = e.clipboardData.getData("text/plain");
1095
+ onEdit?.(selCell.id, selCell.key, text);
1096
+ }
1097
+ /**
1098
+ * crisp/#598, checklist G-043: Attio's cell context menu is "identical
1099
+ * regardless of field type" — Copy, Paste, View edit history, Clear
1100
+ * value, every time, just some items disabled. No row-level menu exists
1101
+ * at all (G-044 — RecordsTable's own Copy/Duplicate/Pin/Delete row menu
1102
+ * was dropped rather than carried onto DataTable, per the consolidation
1103
+ * plan's own decision: Attio has no row menu to match).
1104
+ */
1105
+ function cellMenuItems(row, col) {
1106
+ return [
1107
+ {
1108
+ label: copyLabel,
1109
+ icon: _jsx(Copy, { size: 16, strokeWidth: 1.75 }),
1110
+ // G-100: Attio has "no copy handler at all" for a computed/
1111
+ // read-only field -- disabled here, not just silently a no-op, so
1112
+ // the menu itself tells the reader why.
1113
+ disabled: !!col.readOnly,
1114
+ onSelect: () => {
1115
+ if (col.readOnly)
1116
+ return;
1117
+ navigator.clipboard
1118
+ ?.writeText(asText(cellValue(col, row)))
1119
+ .catch(() => { });
1120
+ },
1121
+ },
1122
+ {
1123
+ label: pasteLabel,
1124
+ icon: _jsx(ClipboardPaste, { size: 16, strokeWidth: 1.75 }),
1125
+ disabled: !col.editable || !!col.readOnly,
1126
+ onSelect: () => {
1127
+ navigator.clipboard
1128
+ ?.readText()
1129
+ .then((text) => onEdit?.(row.id, col.key, text))
1130
+ .catch(() => { });
1131
+ },
1132
+ },
1133
+ { separator: true },
1134
+ {
1135
+ label: viewEditHistoryLabel,
1136
+ icon: _jsx(History, { size: 16, strokeWidth: 1.75 }),
1137
+ // Attio's own item carries a "Pro" pill (crisp has no billing
1138
+ // concept to gate on, see this prop's own doc comment) -- disabled
1139
+ // only when the consumer gave nothing for it to open, not gated on
1140
+ // a plan crisp doesn't model.
1141
+ disabled: !onViewEditHistory,
1142
+ onSelect: () => onViewEditHistory?.(row.id, col.key),
1143
+ },
1144
+ { separator: true },
1145
+ {
1146
+ label: clearValueLabel,
1147
+ icon: _jsx(X, { size: 16, strokeWidth: 1.75 }),
1148
+ danger: true,
1149
+ disabled: !col.editable,
1150
+ onSelect: () => onEdit?.(row.id, col.key, ""),
1151
+ },
1152
+ ];
1153
+ }
886
1154
  /**
887
1155
  * Spreadsheet keyboard model, ported from RecordsTable: arrows move the
888
1156
  * selected cell, Enter opens its editor (or, on a non-editable cell,
889
- * activates the row via `onRowClick`), Escape clears, and Cmd/Ctrl+C
890
- * copies the cell's RENDERED text (what the user sees, not the raw value).
1157
+ * activates the row via `onRowClick`), Escape clears. Cmd/Ctrl+C is
1158
+ * deliberately NOT handled here (crisp/#645) -- copy is the native `copy`
1159
+ * event on `clipboardProxyRef`, see `onGridCopy` above.
891
1160
  */
892
1161
  function onGridKeyDown(e) {
893
1162
  if (!selCell || editing)
894
1163
  return;
895
- if ((e.metaKey || e.ctrlKey) && (e.key === "c" || e.key === "C")) {
896
- const row = rows.find((rw) => rw.id === selCell.id);
897
- const col = columns.find((cc) => cc.key === selCell.key);
898
- if (row && col)
899
- navigator.clipboard?.writeText(formatCell(col.type, cellValue(col, row), col.symbol, locale));
1164
+ // crisp/#607, G-069: Attio's own Cmd+A selects every row (confirmed
1165
+ // live, `metaKey:true`, all 37/37 rows, header checkbox fully checked)
1166
+ // -- a real SELECT ALL, not `toggleAll`'s flip-if-already-all-selected,
1167
+ // so a second Cmd+A with everything already picked stays a no-op
1168
+ // instead of clearing the selection out from under the user.
1169
+ if ((e.metaKey || e.ctrlKey) && (e.key === "a" || e.key === "A")) {
1170
+ if (!selectable)
1171
+ return;
1172
+ e.preventDefault();
1173
+ setSelected(new Set(rows.map((rw) => rw.id)));
1174
+ return;
1175
+ }
1176
+ // crisp/#749: Shift+ArrowUp/Down extends the row-checkbox selection --
1177
+ // confirmed live, distinct from Shift+Click's cell-range gesture above
1178
+ // (that one is `selAnchor`; this is `rowSelectAnchor`, and the two
1179
+ // don't interact). Recomputes the whole anchor→destination span on
1180
+ // every press rather than accumulating, so arrowing back toward the
1181
+ // anchor shrinks the selection the same way arrowing away grows it --
1182
+ // the "shrink-from-the-anchor" behavior a spreadsheet's own Shift+Arrow
1183
+ // gives, which a plain add-on-each-press approach can't express.
1184
+ if (selectable &&
1185
+ e.shiftKey &&
1186
+ (e.key === "ArrowDown" || e.key === "ArrowUp")) {
1187
+ const r0 = rows.findIndex((row) => row.id === selCell.id);
1188
+ if (r0 < 0)
1189
+ return;
1190
+ const nr0 = e.key === "ArrowDown"
1191
+ ? Math.min(rows.length - 1, r0 + 1)
1192
+ : Math.max(0, r0 - 1);
900
1193
  e.preventDefault();
1194
+ const anchorId = rowSelectAnchor ?? selCell.id;
1195
+ const anchorIdx = rows.findIndex((row) => row.id === anchorId);
1196
+ if (anchorIdx < 0)
1197
+ return;
1198
+ setRowSelectAnchor(anchorId);
1199
+ const lo = Math.min(anchorIdx, nr0);
1200
+ const hi = Math.max(anchorIdx, nr0);
1201
+ setSelected(new Set(rows.slice(lo, hi + 1).map((row) => row.id)));
1202
+ setSelCell({ id: rows[nr0].id, key: selCell.key });
1203
+ clipboardProxyRef.current?.select();
901
1204
  return;
902
1205
  }
903
1206
  const r = rows.findIndex((row) => row.id === selCell.id);
@@ -917,8 +1220,28 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
917
1220
  const forward = e.key === (rtl ? "ArrowLeft" : "ArrowRight");
918
1221
  nc = forward ? Math.min(columns.length - 1, c + 1) : Math.max(0, c - 1);
919
1222
  }
1223
+ // crisp/#607, G-066/G-068: End jumps to the last row (confirmed live --
1224
+ // it also triggers Attio's own lazy-load, which is a data-loading
1225
+ // concern outside this component; the #577 scroll-into-view effect
1226
+ // below already re-runs on every `selCell` change, on a virtualized
1227
+ // grid too, so jumping straight to the last row is enough to reveal it
1228
+ // the same way arrowing down one row at a time would). PageUp/PageDown
1229
+ // move a viewport's worth of rows -- `scrollRef`'s own rendered height
1230
+ // divided by the uniform row height, at least one row so a viewport
1231
+ // shorter than one row can't get stuck.
1232
+ else if (e.key === "End")
1233
+ nr = rows.length - 1;
1234
+ else if (e.key === "PageDown" || e.key === "PageUp") {
1235
+ const viewportRows = Math.max(1, Math.floor((scrollRef.current?.clientHeight ?? 0) / ROW_HEIGHT));
1236
+ nr =
1237
+ e.key === "PageDown"
1238
+ ? Math.min(rows.length - 1, r + viewportRows)
1239
+ : Math.max(0, r - viewportRows);
1240
+ }
920
1241
  else if (e.key === "Escape") {
921
1242
  setSelCell(null);
1243
+ setSelAnchor(null);
1244
+ setRowSelectAnchor(null);
922
1245
  return;
923
1246
  }
924
1247
  else if (e.key === "Enter") {
@@ -940,7 +1263,18 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
940
1263
  else
941
1264
  return;
942
1265
  e.preventDefault();
1266
+ // A plain arrow key moves the single-cell selection and drops any active
1267
+ // range -- Attio's own range trigger is Shift+Click only (G-101), arrow
1268
+ // keys were never measured as a range-extend gesture, so this doesn't
1269
+ // invent one. Also drops an in-progress Shift+Arrow row-selection drag
1270
+ // (crisp/#749) for the same reason -- a plain arrow ends that gesture.
1271
+ setSelAnchor(null);
1272
+ setRowSelectAnchor(null);
943
1273
  setSelCell({ id: rows[nr].id, key: columns[nc].key });
1274
+ // keeps the clipboard proxy's selection non-collapsed after a move, so
1275
+ // a Cmd+C right after an arrow key still has something native `copy`
1276
+ // can fire on (see `clipboardProxyRef`'s own comment).
1277
+ clipboardProxyRef.current?.select();
944
1278
  }
945
1279
  // Opening a cell's editor also clears any stale error left over from a
946
1280
  // PREVIOUS failed commit on it (#385) -- otherwise a rejection from three
@@ -1052,7 +1386,8 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1052
1386
  renderItems,
1053
1387
  ]);
1054
1388
  const totalCols = columns.length + (selectable ? 1 : 0) + (showGrip ? 1 : 0);
1055
- const hasSummary = columns.some((c) => c.summary);
1389
+ const hasSummary = columns.some((c) => c.summary || c.calc);
1390
+ const hasCommentColumn = columns.some((c) => c.onOpenComments);
1056
1391
  // sticky attrs for the leading column(s): grip col (index -2) + selection
1057
1392
  // col (-1) + first data column (0). Each offsets by whatever leading
1058
1393
  // columns come before it, so the group stays contiguous when scrolled.
@@ -1149,13 +1484,135 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1149
1484
  setResizingKey(null);
1150
1485
  e.target.releasePointerCapture(e.pointerId);
1151
1486
  }
1487
+ // Which mounted column (if any) `clientX` is over, and which side of it --
1488
+ // X only, not the full box: like `board.tsx`'s own `columnAt` (#724), the
1489
+ // header row is a single non-wrapping row, so X alone already identifies a
1490
+ // column unambiguously, and checking Y too only risks the same short-row
1491
+ // miss that #724 found the hard way. The midpoint split is what turns
1492
+ // "over column C" into an insertion point: left half means "before C",
1493
+ // right half means "after C" -- exposed as `dropCol` so the render can draw
1494
+ // the measured blue line on the correct edge.
1495
+ function columnDropAt(clientX) {
1496
+ for (const [key, el] of colRefs.current) {
1497
+ const r = el.getBoundingClientRect();
1498
+ if (clientX >= r.left && clientX <= r.right) {
1499
+ return {
1500
+ key,
1501
+ side: clientX < r.left + r.width / 2 ? "before" : "after",
1502
+ };
1503
+ }
1504
+ }
1505
+ return null;
1506
+ }
1507
+ function stopColAutoScroll() {
1508
+ if (autoScrollRef.current != null) {
1509
+ cancelAnimationFrame(autoScrollRef.current);
1510
+ autoScrollRef.current = null;
1511
+ }
1512
+ }
1513
+ // Edge auto-scroll (measured live, #731's issue comment: dragging a header
1514
+ // toward the viewport edge scrolls the grid to reveal more columns). A
1515
+ // `requestAnimationFrame` loop, not a one-shot nudge per pointermove: the
1516
+ // scroll has to keep going even while the pointer itself sits still near
1517
+ // the edge, which a per-event nudge can't do. Guarded for environments with
1518
+ // no `requestAnimationFrame` (bun test's happy-dom) -- never throws, just a
1519
+ // no-op there, same as the `ResizeObserver` guards elsewhere in this file.
1520
+ function updateColAutoScroll(clientX) {
1521
+ if (typeof requestAnimationFrame === "undefined")
1522
+ return;
1523
+ const el = scrollRef.current;
1524
+ if (!el)
1525
+ return;
1526
+ const r = el.getBoundingClientRect();
1527
+ const EDGE = 40;
1528
+ const dir = clientX < r.left + EDGE ? -1 : clientX > r.right - EDGE ? 1 : 0;
1529
+ stopColAutoScroll();
1530
+ if (dir === 0)
1531
+ return;
1532
+ const step = () => {
1533
+ el.scrollLeft += dir * 12;
1534
+ autoScrollRef.current = requestAnimationFrame(step);
1535
+ };
1536
+ autoScrollRef.current = requestAnimationFrame(step);
1537
+ }
1538
+ function onColHeaderPointerDown(e, key) {
1539
+ if (!e.isPrimary)
1540
+ return;
1541
+ colDragRef.current = {
1542
+ key,
1543
+ pointerId: e.pointerId,
1544
+ startX: e.clientX,
1545
+ startY: e.clientY,
1546
+ active: false,
1547
+ };
1548
+ }
1549
+ function onColHeaderPointerMove(e) {
1550
+ const st = colDragRef.current;
1551
+ if (!st || st.pointerId !== e.pointerId)
1552
+ return;
1553
+ if (!st.active) {
1554
+ if (Math.hypot(e.clientX - st.startX, e.clientY - st.startY) <
1555
+ COL_DRAG_THRESHOLD)
1556
+ return;
1557
+ st.active = true;
1558
+ setDragColKey(st.key);
1559
+ e.currentTarget.setPointerCapture(e.pointerId);
1560
+ }
1561
+ const at = columnDropAt(e.clientX);
1562
+ setDropCol(at && at.key !== st.key ? at : null);
1563
+ updateColAutoScroll(e.clientX);
1564
+ }
1565
+ function endColHeaderDrag(e, commit) {
1566
+ const st = colDragRef.current;
1567
+ if (!st || st.pointerId !== e.pointerId)
1568
+ return;
1569
+ if (commit && st.active && dropCol) {
1570
+ const keys = allColumns.map((c) => c.key);
1571
+ const from = keys.indexOf(st.key);
1572
+ const to = keys.indexOf(dropCol.key);
1573
+ if (from >= 0 && to >= 0) {
1574
+ keys.splice(from, 1);
1575
+ let insertAt = keys.indexOf(dropCol.key);
1576
+ if (dropCol.side === "after")
1577
+ insertAt += 1;
1578
+ keys.splice(insertAt, 0, st.key);
1579
+ applyOrder(keys);
1580
+ }
1581
+ }
1582
+ colDragRef.current = null;
1583
+ setDragColKey(null);
1584
+ setDropCol(null);
1585
+ stopColAutoScroll();
1586
+ }
1587
+ // Escape cancels the drag without committing (measured live, #731's issue
1588
+ // comment: verified twice against the real grid) -- a document listener,
1589
+ // not a React `onKeyDown` on the header, for the same reason
1590
+ // `useEscapeExitsEditing` above uses one: focus during a pointer drag isn't
1591
+ // reliably inside the dragged element.
1592
+ React.useEffect(() => {
1593
+ if (!dragColKey)
1594
+ return;
1595
+ const onKey = (e) => {
1596
+ if (e.key !== "Escape")
1597
+ return;
1598
+ colDragRef.current = null;
1599
+ setDragColKey(null);
1600
+ setDropCol(null);
1601
+ if (autoScrollRef.current != null) {
1602
+ cancelAnimationFrame(autoScrollRef.current);
1603
+ autoScrollRef.current = null;
1604
+ }
1605
+ };
1606
+ document.addEventListener("keydown", onKey);
1607
+ return () => document.removeEventListener("keydown", onKey);
1608
+ }, [dragColKey]);
1152
1609
  function ChevronFor({ colKey }) {
1153
1610
  const dir = sortDirByKey.get(colKey);
1154
1611
  if (dir)
1155
1612
  return dir === "asc" ? (_jsx(ArrowUp, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 })) : (_jsx(ArrowDown, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 }));
1156
1613
  return (_jsx(ChevronsUpDown, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 }));
1157
1614
  }
1158
- return (_jsxs("div", { ref: gridRef, className: datatableRecipe({ className }), tabIndex: -1, onKeyDown: onGridKeyDown, ...rest, children: [_jsx("div", { className: "crisp-datatable-live", role: "status", "aria-live": "polite", children: selectionText }), (filterable || showCount || toolbar || columnMenu) && (_jsxs("div", { className: "crisp-datatable-toolbar", children: [columnMenu && (_jsxs(Menu, { align: "start", trigger: _jsxs("button", { type: "button", className: "crisp-datatable-viewsettings", children: [_jsx(SlidersHorizontal, { size: 14, strokeWidth: 1.75 }), viewSettingsLabel] }), children: [_jsx("div", { className: "crisp-datatable-group-label", children: columnsGroupLabel }), allColumns.map((c) => (_jsxs(MenuItem, { onSelect: () => c.hidden ? showColumn(c.key) : hideColumn(c.key), children: [_jsx(Check, { size: 14, strokeWidth: 2, style: { opacity: c.hidden ? 0 : 1 } }), typeof c.header === "string" ? c.header : c.key] }, c.key)))] })), filterable && (_jsxs("span", { className: "crisp-datatable-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), _jsx("input", { type: "search", placeholder: searchPlaceholder, value: query, onChange: (e) => setQuery(e.target.value), "aria-label": searchLabel })] })), showCount && (_jsx("span", { className: "crisp-datatable-count", children: resolvedCountLabel(rows.length, data.length) })), toolbar] })), _jsxs(Table, { interactive: interactive, surface: surface,
1615
+ return (_jsxs("div", { ref: gridRef, className: datatableRecipe({ className }), tabIndex: -1, onKeyDown: onGridKeyDown, ...rest, children: [_jsx("div", { className: "crisp-datatable-live", role: "status", "aria-live": "polite", children: selectionText }), _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) && (_jsxs("div", { className: "crisp-datatable-toolbar", children: [columnMenu && (_jsxs(Menu, { align: "start", trigger: _jsxs("button", { type: "button", className: "crisp-datatable-viewsettings", children: [_jsx(SlidersHorizontal, { size: 14, strokeWidth: 1.75 }), viewSettingsLabel] }), children: [_jsx("div", { className: "crisp-datatable-group-label", children: columnsGroupLabel }), allColumns.map((c) => (_jsxs(MenuItem, { onSelect: () => c.hidden ? showColumn(c.key) : hideColumn(c.key), children: [_jsx(Check, { size: 14, strokeWidth: 2, style: { opacity: c.hidden ? 0 : 1 } }), typeof c.header === "string" ? c.header : c.key] }, c.key)))] })), filterable && (_jsxs("span", { className: "crisp-datatable-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), _jsx("input", { type: "search", placeholder: searchPlaceholder, value: query, onChange: (e) => setQuery(e.target.value), "aria-label": searchLabel })] })), showCount && (_jsx("span", { className: "crisp-datatable-count", children: resolvedCountLabel(rows.length, data.length) })), toolbar] })), _jsxs(Table, { interactive: interactive, surface: surface,
1159
1616
  // crisp/#581: on the <table> itself (`tableStyle`), not the outer
1160
1617
  // wrapper (`style`) -- see `Table`'s own doc comment on why. This is
1161
1618
  // what lets the grid actually scroll horizontally INSIDE its own
@@ -1226,7 +1683,30 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1226
1683
  : "descending"
1227
1684
  : "none";
1228
1685
  const inner = (_jsxs("span", { className: "crisp-datatable-th-inner", children: [Icon ? _jsx(Icon, { size: 16, strokeWidth: 1.75 }) : null, _jsx(ThLabel, { header: col.header, referencedType: col.referencedType }), col.sortable ? _jsx(ChevronFor, { colKey: col.key }) : null] }));
1229
- return (_jsxs(TableHead, { align: alignFor(col), "aria-sort": ariaSort, "aria-colindex": colAriaIndex(i), ...dataStickyCol(i), children: [renaming === col.key ? (_jsx("input", {
1686
+ return (_jsxs(TableHead, { ref: isPrimary
1687
+ ? undefined
1688
+ : (el) => {
1689
+ if (el)
1690
+ colRefs.current.set(col.key, el);
1691
+ else
1692
+ colRefs.current.delete(col.key);
1693
+ }, 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"
1694
+ ? ""
1695
+ : undefined, "data-drop-after": dropCol?.key === col.key && dropCol.side === "after"
1696
+ ? ""
1697
+ : undefined,
1698
+ // #731: column drag-to-reorder lives on the TH itself, not
1699
+ // a dedicated grip -- Attio's own header is draggable by
1700
+ // its whole surface (measured live). A plain click (no
1701
+ // movement past the 2px threshold) never calls
1702
+ // `setPointerCapture`/`preventDefault`, so the sort
1703
+ // button/menu trigger/resize handle nested inside still get
1704
+ // their own click normally; `startResize`'s own
1705
+ // `stopPropagation` (above) keeps a resize drag from ever
1706
+ // reaching this handler in the first place.
1707
+ onPointerDown: isPrimary
1708
+ ? undefined
1709
+ : (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 ? (_jsx("input", {
1230
1710
  // biome-ignore lint/a11y/noAutofocus: the rename item opens this field
1231
1711
  autoFocus: true, className: "crisp-datatable-th-rename", defaultValue: typeof col.header === "string" ? col.header : "", "aria-label": renameColumnLabel, onBlur: (e) => {
1232
1712
  renameColumn(col.key, e.currentTarget.value);
@@ -1258,7 +1738,9 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1258
1738
  dragRowId !== row.id &&
1259
1739
  dropRowId === row.id
1260
1740
  ? ""
1261
- : undefined, onClick: onRowClick ? () => onRowClick(row) : undefined, style: onRowClick ? { cursor: "pointer" } : undefined, onDragOver: showGrip && dragRowId
1741
+ : undefined, onClick: onRowClick ? () => onRowClick(row) : undefined, style: onRowClick ? { cursor: "pointer" } : undefined, onMouseEnter: hasCommentColumn
1742
+ ? () => setHoveredRowId(row.id)
1743
+ : undefined, onMouseLeave: hasCommentColumn ? () => setHoveredRowId(null) : undefined, onDragOver: showGrip && dragRowId
1262
1744
  ? (e) => {
1263
1745
  e.preventDefault();
1264
1746
  if (dropRowId !== row.id)
@@ -1342,46 +1824,105 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1342
1824
  commitEdit(row.id, col.key, e.currentTarget.value);
1343
1825
  } }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }, col.key));
1344
1826
  }
1827
+ // `itemIndex` is the row's position in the full
1828
+ // `rows` array (getSelRangeIndices' own r0/r1 are
1829
+ // indices into that same array) for the common,
1830
+ // ungrouped case. A `groupBy` view interleaves group-
1831
+ // header items into `visibleItems`, which this
1832
+ // doesn't correct for -- Shift+Click ranging across a
1833
+ // grouped view isn't a measured Attio interaction
1834
+ // (G-101's own trigger is a plain table), so it's a
1835
+ // known gap rather than a guess at behaviour there.
1836
+ const inRange = (() => {
1837
+ const range = getSelRangeIndices();
1838
+ if (!range)
1839
+ return false;
1840
+ return (itemIndex >= range.r0 &&
1841
+ itemIndex <= range.r1 &&
1842
+ i >= range.c0 &&
1843
+ i <= range.c1);
1844
+ })();
1345
1845
  const rendered = col.render
1346
1846
  ? col.render(value, row)
1347
- : renderTyped(col, value, avatarTone, row, col.format?.(value, row), locale);
1847
+ : renderTyped(col, value, avatarTone, row, col.format?.(value, row), locale, openPreviewLabel, hoveredRowId === row.id);
1348
1848
  const isExpanded = expandedCell?.id === row.id &&
1349
1849
  expandedCell?.key === col.key;
1350
- return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: col.editable
1351
- ? "crisp-datatable-editable"
1352
- : undefined, "data-selected": selCell?.id === row.id && selCell?.key === col.key
1353
- ? ""
1354
- : undefined, "data-cell-expanded": isExpanded ? "" : undefined, onClick: (e) => {
1355
- // the keyboard model's anchor -- a single click
1356
- // only selects. A genuine double-click (or Enter
1357
- // on the selected cell) opens the editor (#411),
1358
- // matching RecordsTable's own onDoubleClick.
1359
- setSelCell({ id: row.id, key: col.key });
1360
- gridRef.current?.focus({ preventScroll: true });
1361
- // #579: a truncated cell also opens a read-only
1362
- // overlay with its full value -- settled live
1363
- // that this is click, not Attio's usual
1364
- // hover-expand (a 1s hover on a truncated
1365
- // Description cell produced nothing). Measured
1366
- // the same way as `ThLabel`'s header tooltip
1367
- // (#534): scrollWidth vs clientWidth on the
1368
- // element the CSS ellipsis actually applies to,
1369
- // which for a data cell is the `td` itself
1370
- // (table.recipe.ts's own `overflow: hidden;
1371
- // text-overflow: ellipsis`), not a nested span.
1372
- setExpandedCell(isLabelTruncated(e.currentTarget)
1373
- ? { id: row.id, key: col.key }
1374
- : null);
1375
- }, onDoubleClick: col.editable
1376
- ? () => openEditing(row.id, col.key)
1377
- : undefined, children: [!isExpanded && rendered, errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere })), isExpanded && (_jsx("div", { className: "crisp-datatable-cell-overlay", onClick: (e) => e.stopPropagation(), children: rendered }))] }, col.key));
1850
+ const cellContent = (_jsxs(_Fragment, { children: [!isExpanded && rendered, errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere })), isExpanded && (_jsx("div", { className: "crisp-datatable-cell-overlay", onClick: (e) => e.stopPropagation(), children: rendered }))] }));
1851
+ return (_jsx(ContextMenu, { asChild: true, variant: "dark", menuLabel: cellMenuLabel, items: cellMenuItems(row, col), children: _jsx(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: col.editable
1852
+ ? "crisp-datatable-editable"
1853
+ : undefined, "data-selected": selCell?.id === row.id && selCell?.key === col.key
1854
+ ? ""
1855
+ : undefined, "data-in-range": inRange ? "" : undefined, "data-cell-expanded": isExpanded ? "" : undefined, "data-enriched": col.enriched ? "" : undefined, onClick: (e) => {
1856
+ // the keyboard model's anchor -- a single click
1857
+ // only selects. A genuine double-click (or Enter
1858
+ // on the selected cell) opens the editor (#411),
1859
+ // matching RecordsTable's own onDoubleClick.
1860
+ // crisp/#645, G-101: Shift+Click extends a
1861
+ // range from the last plain-click anchor
1862
+ // ("Click cell A, Shift+Click cell B") --
1863
+ // `selCell` keeps doubling as the moving
1864
+ // corner, `selAnchor` is the fixed one.
1865
+ if (e.shiftKey && selCell) {
1866
+ setSelAnchor(selAnchor ?? selCell);
1867
+ }
1868
+ else {
1869
+ setSelAnchor(null);
1870
+ // crisp/#749: a plain click also ends an
1871
+ // in-progress Shift+Arrow row-selection drag
1872
+ // -- Shift+Click itself doesn't touch it,
1873
+ // the two gestures are independent.
1874
+ setRowSelectAnchor(null);
1875
+ }
1876
+ setSelCell({ id: row.id, key: col.key });
1877
+ // Focuses the clipboard proxy, not `gridRef`
1878
+ // itself (crisp/#645) -- it's a DESCENDANT of
1879
+ // `gridRef`, so `onGridKeyDown` still sees
1880
+ // every key via bubbling, but now the browser
1881
+ // also has a focused, selectable text node to
1882
+ // fire a native `copy`/`paste` on. `focus()`
1883
+ // then `select()`, not `select()` alone --
1884
+ // `select()` only guarantees the SELECTION,
1885
+ // focus is a side effect some engines don't
1886
+ // give it for free.
1887
+ clipboardProxyRef.current?.focus();
1888
+ clipboardProxyRef.current?.select();
1889
+ // #579: a truncated cell also opens a read-only
1890
+ // overlay with its full value -- settled live
1891
+ // that this is click, not Attio's usual
1892
+ // hover-expand (a 1s hover on a truncated
1893
+ // Description cell produced nothing). Measured
1894
+ // the same way as `ThLabel`'s header tooltip
1895
+ // (#534): scrollWidth vs clientWidth on the
1896
+ // element the CSS ellipsis actually applies to,
1897
+ // which for a data cell is the `td` itself
1898
+ // (table.recipe.ts's own `overflow: hidden;
1899
+ // text-overflow: ellipsis`), not a nested span.
1900
+ // Skipped on a Shift+Click (crisp/#645) -- that
1901
+ // gesture's own intent is extending the range,
1902
+ // not popping open a read-only overlay on top
1903
+ // of it.
1904
+ setExpandedCell(!e.shiftKey && isLabelTruncated(e.currentTarget)
1905
+ ? { id: row.id, key: col.key }
1906
+ : null);
1907
+ }, onDoubleClick: col.editable
1908
+ ? () => openEditing(row.id, col.key)
1909
+ : undefined, onContextMenu: () => {
1910
+ // Right-clicking targets THAT cell for the
1911
+ // menu regardless of what was previously
1912
+ // selected (crisp/#598) -- collapses any
1913
+ // active range, same as a plain left-click.
1914
+ setSelAnchor(null);
1915
+ setRowSelectAnchor(null);
1916
+ setSelCell({ id: row.id, key: col.key });
1917
+ clipboardProxyRef.current?.focus();
1918
+ clipboardProxyRef.current?.select();
1919
+ }, children: cellContent }) }, col.key));
1378
1920
  })] }, row.id));
1379
1921
  }), bottomSpacerHeight > 0 && (_jsx("tr", { "aria-hidden": "true", children: _jsx("td", { colSpan: totalCols, style: {
1380
1922
  height: bottomSpacerHeight,
1381
1923
  padding: 0,
1382
1924
  border: "none",
1383
- } }) }))] })) }), (hasSummary || onAddRow) && (_jsxs(TableFooter, { children: [hasSummary && (_jsxs(TableRow, { children: [showGrip && _jsx(TableCell, { ...stickyCol(-2) }), selectable && _jsx(TableCell, { ...stickyCol(-1) }), colSlots.map((slot) => slot.type === "spacer" ? (_jsx("td", { role: "presentation", "aria-hidden": "true", style: { padding: 0, border: "none" } }, `__spacer_${slot.side}`)) : (_jsx(TableCell, { align: alignFor(columns[slot.i]), "aria-colindex": colAriaIndex(slot.i), ...dataStickyCol(slot.i), children: columns[slot.i].summary
1384
- ? columns[slot.i].summary?.(rows)
1385
- : null }, columns[slot.i].key)))] })), onAddRow && (_jsx(TableRow, { children: _jsx(TableCell, { className: "crisp-datatable-addcell", colSpan: totalCols, children: _jsxs("button", { type: "button", className: "crisp-datatable-addrow", onClick: onAddRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), addRowLabel] }) }) }))] }))] })] }));
1925
+ } }) }))] })) }), (hasSummary || onAddRow) && (_jsxs(TableFooter, { children: [hasSummary && (_jsxs(TableRow, { children: [showGrip && _jsx(TableCell, { ...stickyCol(-2) }), selectable && _jsx(TableCell, { ...stickyCol(-1) }), colSlots.map((slot) => slot.type === "spacer" ? (_jsx("td", { role: "presentation", "aria-hidden": "true", style: { padding: 0, border: "none" } }, `__spacer_${slot.side}`)) : (_jsx(TableCell, { align: alignFor(columns[slot.i]), "aria-colindex": colAriaIndex(slot.i), ...dataStickyCol(slot.i), children: columns[slot.i].calc ? (_jsx(ColumnCalc, { rows: rows, value: (row) => cellValue(columns[slot.i], row), numeric: columns[slot.i].type === "number" ||
1926
+ 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 && (_jsx(TableRow, { children: _jsx(TableCell, { className: "crisp-datatable-addcell", colSpan: totalCols, children: _jsxs("button", { type: "button", className: "crisp-datatable-addrow", onClick: onAddRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), addRowLabel] }) }) }))] }))] })] }));
1386
1927
  }
1387
1928
  DataTable.displayName = "DataTable";