@crisp-ui-kit/crisp 0.54.2 → 0.54.4

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 (116) hide show
  1. package/dist/board.css +13 -1
  2. package/dist/board.layered.css +13 -1
  3. package/dist/chip.css +7 -1
  4. package/dist/chip.d.ts +2 -1
  5. package/dist/chip.layered.css +7 -1
  6. package/dist/cjs/components/chip/chip.js +3 -2
  7. package/dist/cjs/components/chip/chip.recipe.js +37 -1
  8. package/dist/cjs/components/datatable/datatable.js +489 -111
  9. package/dist/cjs/components/emptystate/emptystate.js +3 -2
  10. package/dist/cjs/components/emptystate/emptystate.recipe.js +26 -1
  11. package/dist/cjs/components/personchip/index.js +17 -0
  12. package/dist/cjs/components/personchip/personchip.js +50 -0
  13. package/dist/cjs/components/table/table.js +2 -2
  14. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +41 -4
  15. package/dist/cjs/components/tabs/tabs.js +2 -2
  16. package/dist/cjs/components/tabs/tabs.recipe.js +30 -3
  17. package/dist/cjs/components/text/index.js +7 -0
  18. package/dist/cjs/components/text/text.js +55 -0
  19. package/dist/cjs/components/text/text.recipe.js +34 -0
  20. package/dist/cjs/index.js +4 -0
  21. package/dist/cjs/product/board/board.js +60 -2
  22. package/dist/cjs/product/board/board.recipe.js +10 -0
  23. package/dist/cjs/product/comments/comments.js +426 -0
  24. package/dist/cjs/product/comments/comments.recipe.js +126 -0
  25. package/dist/cjs/product/comments/index.js +8 -0
  26. package/dist/cjs/product/headerbar/headerbar.recipe.js +9 -1
  27. package/dist/cjs/product/listpage/listpage.recipe.js +10 -1
  28. package/dist/cjs/product/objectsettings/index.js +16 -0
  29. package/dist/cjs/product/objectsettings/objectsettings.js +365 -0
  30. package/dist/cjs/product/objectsettings/objectsettings.recipe.js +65 -0
  31. package/dist/cjs/product/recordpage/recordpage.recipe.js +10 -1
  32. package/dist/cjs/product/recordstable/recordstable.js +89 -6
  33. package/dist/cjs/product/richtext/richtext.recipe.js +26 -1
  34. package/dist/cjs/product/taskstable/taskstable.recipe.js +10 -1
  35. package/dist/comments.css +80 -0
  36. package/dist/comments.d.ts +7 -0
  37. package/dist/comments.layered.css +82 -0
  38. package/dist/emptystate.css +15 -1
  39. package/dist/emptystate.d.ts +2 -2
  40. package/dist/emptystate.layered.css +15 -1
  41. package/dist/esm/components/chip/chip.js +3 -2
  42. package/dist/esm/components/chip/chip.recipe.js +37 -1
  43. package/dist/esm/components/datatable/datatable.js +488 -111
  44. package/dist/esm/components/emptystate/emptystate.js +4 -3
  45. package/dist/esm/components/emptystate/emptystate.recipe.js +26 -1
  46. package/dist/esm/components/emptystate/index.js +1 -1
  47. package/dist/esm/components/personchip/index.js +1 -0
  48. package/dist/esm/components/personchip/personchip.js +47 -0
  49. package/dist/esm/components/table/table.js +2 -2
  50. package/dist/esm/components/tabletoolbar/tabletoolbar.js +43 -6
  51. package/dist/esm/components/tabs/tabs.js +2 -2
  52. package/dist/esm/components/tabs/tabs.recipe.js +30 -3
  53. package/dist/esm/components/text/index.js +2 -0
  54. package/dist/esm/components/text/text.js +19 -0
  55. package/dist/esm/components/text/text.recipe.js +31 -0
  56. package/dist/esm/index.js +4 -0
  57. package/dist/esm/product/board/board.js +61 -3
  58. package/dist/esm/product/board/board.recipe.js +10 -0
  59. package/dist/esm/product/comments/comments.js +388 -0
  60. package/dist/esm/product/comments/comments.recipe.js +123 -0
  61. package/dist/esm/product/comments/index.js +2 -0
  62. package/dist/esm/product/headerbar/headerbar.recipe.js +9 -1
  63. package/dist/esm/product/listpage/listpage.recipe.js +10 -1
  64. package/dist/esm/product/objectsettings/index.js +2 -0
  65. package/dist/esm/product/objectsettings/objectsettings.js +322 -0
  66. package/dist/esm/product/objectsettings/objectsettings.recipe.js +62 -0
  67. package/dist/esm/product/recordpage/recordpage.recipe.js +10 -1
  68. package/dist/esm/product/recordstable/recordstable.js +90 -8
  69. package/dist/esm/product/richtext/richtext.recipe.js +26 -1
  70. package/dist/esm/product/taskstable/taskstable.recipe.js +10 -1
  71. package/dist/headerbar.css +1 -1
  72. package/dist/headerbar.layered.css +1 -1
  73. package/dist/listpage.css +8 -1
  74. package/dist/listpage.layered.css +8 -1
  75. package/dist/objectsettings.css +49 -0
  76. package/dist/objectsettings.d.ts +7 -0
  77. package/dist/objectsettings.layered.css +51 -0
  78. package/dist/recordpage.css +13 -2
  79. package/dist/recordpage.layered.css +13 -2
  80. package/dist/richtext.css +11 -1
  81. package/dist/richtext.layered.css +11 -1
  82. package/dist/styles.css +224 -10
  83. package/dist/styles.layered.css +224 -10
  84. package/dist/tabs.css +7 -1
  85. package/dist/tabs.d.ts +2 -2
  86. package/dist/tabs.layered.css +7 -1
  87. package/dist/taskstable.css +8 -1
  88. package/dist/taskstable.layered.css +8 -1
  89. package/dist/text.css +9 -0
  90. package/dist/text.d.ts +7 -0
  91. package/dist/text.layered.css +11 -0
  92. package/dist/types/components/chip/chip.d.ts +32 -0
  93. package/dist/types/components/chip/chip.recipe.d.ts +31 -0
  94. package/dist/types/components/datatable/datatable.d.ts +28 -4
  95. package/dist/types/components/emptystate/emptystate.d.ts +29 -4
  96. package/dist/types/components/emptystate/emptystate.recipe.d.ts +2 -0
  97. package/dist/types/components/emptystate/index.d.ts +1 -1
  98. package/dist/types/components/personchip/index.d.ts +1 -0
  99. package/dist/types/components/personchip/personchip.d.ts +37 -0
  100. package/dist/types/components/table/table.d.ts +36 -0
  101. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +8 -2
  102. package/dist/types/components/tabs/tabs.d.ts +4 -2
  103. package/dist/types/components/tabs/tabs.recipe.d.ts +18 -3
  104. package/dist/types/components/text/index.d.ts +2 -0
  105. package/dist/types/components/text/text.d.ts +32 -0
  106. package/dist/types/components/text/text.recipe.d.ts +40 -0
  107. package/dist/types/index.d.ts +4 -0
  108. package/dist/types/product/board/board.d.ts +20 -1
  109. package/dist/types/product/comments/comments.d.ts +192 -0
  110. package/dist/types/product/comments/comments.recipe.d.ts +3 -0
  111. package/dist/types/product/comments/index.d.ts +2 -0
  112. package/dist/types/product/objectsettings/index.d.ts +2 -0
  113. package/dist/types/product/objectsettings/objectsettings.d.ts +261 -0
  114. package/dist/types/product/objectsettings/objectsettings.recipe.d.ts +3 -0
  115. package/dist/types/product/recordstable/recordstable.d.ts +24 -2
  116. package/package.json +21 -1
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.isLabelTruncated = isLabelTruncated;
36
37
  exports.DataTable = DataTable;
37
38
  const jsx_runtime_1 = require("react/jsx-runtime");
38
39
  const lucide_react_1 = require("lucide-react");
@@ -45,7 +46,55 @@ const select_1 = require("../select");
45
46
  const spinner_1 = require("../spinner");
46
47
  const switch_1 = require("../switch");
47
48
  const table_1 = require("../table");
49
+ const tooltip_1 = require("../tooltip");
48
50
  const datatable_recipe_1 = require("./datatable.recipe");
51
+ // #534: whether an element's own content is being cut off by its own box --
52
+ // the same fact `.crisp-datatable-th-label`'s CSS ellipsis is silently
53
+ // applying, exported so the comparison itself (not layout) can be
54
+ // mutation-tested without a real browser.
55
+ function isLabelTruncated(el) {
56
+ return el.scrollWidth > el.clientWidth;
57
+ }
58
+ /**
59
+ * The header label span: a `Tooltip` (full text, `asChild` so the wrapper
60
+ * never displaces the ellipsis CSS the span already carries -- see
61
+ * `Tooltip`'s own `asChild` doc) permanently closed until the label actually
62
+ * overflows its own box. Re-measures on mount and on resize (column drag,
63
+ * viewport change, header text change) via `ResizeObserver`; SSR-safe the
64
+ * same way `MiddleTruncate` is -- the DOM is only touched inside an effect,
65
+ * so the server (and the first client paint) render the closed tooltip.
66
+ */
67
+ function ThLabel({ header }) {
68
+ const ref = React.useRef(null);
69
+ const [overflowing, setOverflowing] = React.useState(false);
70
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `header` isn't read in the body, but a rename (col.header changing with the column's own width unchanged) needs a re-measure a ResizeObserver on `el` won't fire for -- same reasoning as Tabs' own re-measure effect.
71
+ React.useLayoutEffect(() => {
72
+ const el = ref.current;
73
+ if (!el)
74
+ return;
75
+ const measure = () => setOverflowing(isLabelTruncated(el));
76
+ measure();
77
+ if (typeof ResizeObserver === "undefined")
78
+ return;
79
+ const ro = new ResizeObserver(measure);
80
+ ro.observe(el);
81
+ return () => ro.disconnect();
82
+ }, [header]);
83
+ // `Tooltip` always wraps the SAME span (never swapped in/out of the tree
84
+ // based on `overflowing`) so the span's own DOM node identity survives
85
+ // every toggle -- a real-browser-only bug (happy-dom has no
86
+ // ResizeObserver, so the test suite never exercised this path): swapping
87
+ // between a bare `<span>` and `<Tooltip asChild><span/></Tooltip>` changes
88
+ // the element type at this position, so React unmounts the old span and
89
+ // mounts a new one; the effect above never re-runs (its deps are
90
+ // `[header]`, unchanged), so its ResizeObserver keeps watching the now
91
+ // -detached old node, whose deferred initial callback then reports 0x0 and
92
+ // flips `overflowing` straight back to false -- the tooltip would never
93
+ // actually show. `open={false}` when it fits keeps the Tooltip mounted
94
+ // (asChild costs no extra DOM either way) but pins it permanently closed,
95
+ // so hovering a non-overflowing header still opens nothing.
96
+ return ((0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { content: header, asChild: true, open: overflowing ? undefined : false, children: (0, jsx_runtime_1.jsx)("span", { ref: ref, className: "crisp-datatable-th-label", children: header }) }));
97
+ }
49
98
  /** A `date` input needs `yyyy-mm-dd`; cells may hold a Date, an ISO string or
50
99
  * a display string. Returns "" when it cannot be parsed, so the field opens
51
100
  * empty rather than showing an invalid value. */
@@ -250,6 +299,51 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US") {
250
299
  }
251
300
  }
252
301
  const CHECK_W = 40;
302
+ // crisp/#577 -- cell-level virtualization on both axes.
303
+ //
304
+ // ROW_HEIGHT/GROUP_ROW_HEIGHT must track table.recipe.ts's `c.size.tableRow`
305
+ // (36px) and datatable.recipe.ts's `.crisp-datatable-group` height, which
306
+ // reads `c.size.menuRow` (32px) -- there is no way to read a recipe's CSS
307
+ // value back out in JS, so these are a second, deliberately-commented copy.
308
+ // A drift between the two would only show up as visual jitter (a spacer's
309
+ // height not quite matching the rows it stands in for), never a crash, which
310
+ // is exactly the kind of silent drift AUTHORING.md Lesson 31 warns about.
311
+ const ROW_HEIGHT = 36;
312
+ const GROUP_ROW_HEIGHT = 32;
313
+ // Overscan is a performance cushion, not a measured value -- Attio's own
314
+ // Companies grid (measured live, #577's own issue body) rendered 24 rows for
315
+ // what a 715px-tall viewport fits at 36px/row (~20), i.e. a handful either
316
+ // side; these are the same shape of cushion, not a parity target.
317
+ const ROW_OVERSCAN = 6;
318
+ const COL_OVERSCAN = 2;
319
+ // The row window a server render (or a client's very first paint, before a
320
+ // real measurement lands) assumes when `height` is a CSS string rather than
321
+ // a plain pixel number -- see the `height` prop doc on why a string can't be
322
+ // turned into an exact row count up front.
323
+ const DEFAULT_VIRTUAL_VIEWPORT_PX = 480;
324
+ /**
325
+ * Cumulative `offsets` has length n+1 (offsets[i] is where item i STARTS,
326
+ * offsets[n] is the total). Returns the index i (clamped to [0, n-1]) whose
327
+ * span contains `target` -- i.e. the largest i with offsets[i] <= target.
328
+ * Shared by the row and column windowing below; a plain linear scan would
329
+ * also be correct but is the wrong complexity for a 10,000-row table
330
+ * recomputed on every scroll event.
331
+ */
332
+ function offsetIndex(offsets, target) {
333
+ const n = offsets.length - 1;
334
+ if (n <= 0)
335
+ return 0;
336
+ let lo = 0;
337
+ let hi = n - 1;
338
+ while (lo < hi) {
339
+ const mid = (lo + hi + 1) >> 1;
340
+ if (offsets[mid] <= target)
341
+ lo = mid;
342
+ else
343
+ hi = mid - 1;
344
+ }
345
+ return lo;
346
+ }
253
347
  // A sentinel that can't collide with a real record id (crisp never generates
254
348
  // one, and a consumer's own ids come from their own data store) -- picking
255
349
  // this row calls `onCreateReference`, not the cell's own commit (#449).
@@ -337,7 +431,7 @@ function DataReferenceEditor({ col, value, isPending, onCommit, onCancel, }) {
337
431
  onCommit(v);
338
432
  } }) }));
339
433
  }
340
- 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, className, reorderRowLabel: reorderRowLabelProp, sortAscendingLabel: sortAscendingLabelProp, sortDescendingLabel: sortDescendingLabelProp, moveColumnLeftLabel: moveColumnLeftLabelProp, moveColumnRightLabel: moveColumnRightLabelProp, editColumnLabel: editColumnLabelProp, hideColumnLabel: hideColumnLabelProp, renameColumnLabel: renameColumnLabelProp, viewSettingsLabel: viewSettingsLabelProp, columnsGroupLabel: columnsGroupLabelProp, ...rest }) {
434
+ 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 }) {
341
435
  // crisp/#471: same resolution order as TableToolbar's own — an explicit
342
436
  // prop wins, then the provider's translation of the built-in English
343
437
  // default, then that default itself with no provider in the tree.
@@ -479,21 +573,52 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
479
573
  const [pending, setPending] = React.useState(null);
480
574
  const [editError, setEditError] = React.useState(null);
481
575
  const [scrolledX, setScrolledX] = React.useState(false);
482
- // Mirrors `scrolledX` for the trailing edge: true while there is still
483
- // unrevealed content sliding under the sticky trailing column, i.e. while
484
- // NOT scrolled all the way to the end. Recomputed after every render (see
485
- // the effect below), not just on scroll, because a column resize or a data
486
- // change can move `scrollWidth` without firing a scroll event.
487
- const [scrolledXEnd, setScrolledXEnd] = React.useState(false);
488
576
  const scrollRef = React.useRef(null);
577
+ // crisp/#577 -- the scroll container's own position/size, read on scroll
578
+ // and on mount/resize. `w` (viewport width) is ALWAYS tracked, since column
579
+ // virtualization doesn't need `height` -- the grid already has a
580
+ // determinate horizontal scroll container. `h` only matters once `height`
581
+ // is set; its initial value is the "first window" SSR/first-paint renders
582
+ // (see the `height` prop doc): a plain-number `height` gets an exact
583
+ // viewport up front, a CSS-string one gets `DEFAULT_VIRTUAL_VIEWPORT_PX`
584
+ // until the real size is measured after mount. `w` starts at `Infinity` --
585
+ // "not yet measured" reads as "render every column", the same as today,
586
+ // rather than guessing a width and risking a visible column pop after
587
+ // hydration.
588
+ const [viewport, setViewport] = React.useState(() => ({
589
+ top: 0,
590
+ left: 0,
591
+ h: typeof height === "number"
592
+ ? height
593
+ : height != null
594
+ ? DEFAULT_VIRTUAL_VIEWPORT_PX
595
+ : Number.POSITIVE_INFINITY,
596
+ w: Number.POSITIVE_INFINITY,
597
+ }));
598
+ // Client-only measurement: re-reads the scroll container's real box on
599
+ // mount and whenever it resizes (a window resize, a sidebar toggling, a
600
+ // consumer's own layout change) -- none of which fire a `scroll` event.
601
+ // Never runs during SSR (effects don't run there at all), which is what
602
+ // keeps the "first window" guess above the only thing a server ever
603
+ // renders.
489
604
  React.useEffect(() => {
490
- if (!stickyLast)
491
- return;
492
605
  const el = scrollRef.current;
493
606
  if (!el)
494
607
  return;
495
- setScrolledXEnd(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
496
- });
608
+ const measure = () => {
609
+ setViewport((v) => ({
610
+ ...v,
611
+ h: height != null ? el.clientHeight : v.h,
612
+ w: el.clientWidth,
613
+ }));
614
+ };
615
+ measure();
616
+ if (typeof ResizeObserver === "undefined")
617
+ return;
618
+ const ro = new ResizeObserver(measure);
619
+ ro.observe(el);
620
+ return () => ro.disconnect();
621
+ }, [height]);
497
622
  // column widths (drag-to-resize) + fixed-layout sizing
498
623
  const [widths, setWidths] = React.useState({});
499
624
  const [resizingKey, setResizingKey] = React.useState(null);
@@ -502,6 +627,33 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
502
627
  const minWidth = (showGrip ? GRIP_W : 0) +
503
628
  (selectable ? CHECK_W : 0) +
504
629
  columns.reduce((sum, col) => sum + colWidth(col), 0);
630
+ // Mirrors `scrolledX` for the trailing edge: true while there is still
631
+ // unrevealed content sliding under the sticky trailing column, i.e. while
632
+ // NOT scrolled all the way to the end. Recomputed whenever `minWidth`
633
+ // changes (a column resize or the column set changing), not just on
634
+ // scroll, since either can move `scrollWidth` without firing a scroll
635
+ // event. This used to run after EVERY render (no dependency array at
636
+ // all) -- harmless on its own, but crisp/#577's own `viewport` effects
637
+ // (below) also read layout and call `setViewport` on their own schedule,
638
+ // and the two together resonated: each render's `setScrolledXEnd`
639
+ // triggered another render, which reran this effect, which could
640
+ // observe a still-settling layout and set a DIFFERENT value, which
641
+ // triggered yet another render -- "Maximum update depth exceeded",
642
+ // caught reviewing the virtualized docs example (the first place this
643
+ // repo ever exercises `stickyLast` in a real browser; the existing
644
+ // jsdom tests never hit real layout, so this never surfaced there).
645
+ // `minWidth` is exactly what this comparison's `scrollWidth` side
646
+ // tracks, so it's the one dependency that actually matters.
647
+ const [scrolledXEnd, setScrolledXEnd] = React.useState(false);
648
+ // biome-ignore lint/correctness/useExhaustiveDependencies: minWidth is what scrollWidth tracks, see comment above -- not read directly in the body
649
+ React.useEffect(() => {
650
+ if (!stickyLast)
651
+ return;
652
+ const el = scrollRef.current;
653
+ if (!el)
654
+ return;
655
+ setScrolledXEnd(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
656
+ }, [stickyLast, minWidth]);
505
657
  const colByKey = React.useMemo(() => {
506
658
  const m = new Map();
507
659
  for (const c of columns)
@@ -562,6 +714,110 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
562
714
  rows: groupRows,
563
715
  }));
564
716
  }, [rows, groupBy]);
717
+ const renderItems = React.useMemo(() => {
718
+ const items = [];
719
+ for (const { group, rows: groupRows } of groupedRows) {
720
+ if (group !== null) {
721
+ items.push({
722
+ kind: "group",
723
+ key: `__group_${group}`,
724
+ label: group,
725
+ count: groupRows.length,
726
+ height: GROUP_ROW_HEIGHT,
727
+ });
728
+ }
729
+ for (const row of groupRows)
730
+ items.push({ kind: "row", key: row.id, row, height: ROW_HEIGHT });
731
+ }
732
+ return items;
733
+ }, [groupedRows]);
734
+ const rowOffsets = React.useMemo(() => {
735
+ const offsets = new Array(renderItems.length + 1);
736
+ offsets[0] = 0;
737
+ for (let i = 0; i < renderItems.length; i++)
738
+ offsets[i + 1] = offsets[i] + renderItems[i].height;
739
+ return offsets;
740
+ }, [renderItems]);
741
+ const totalRowsHeight = rowOffsets[rowOffsets.length - 1] ?? 0;
742
+ // O(1) lookup from a row's id to its position in `renderItems` -- used by
743
+ // the keyboard-nav scroll-into-view effect below, which knows a row by id
744
+ // (from `selCell`), not by its index in the flattened item list.
745
+ const rowIdToItemIndex = React.useMemo(() => {
746
+ const m = new Map();
747
+ renderItems.forEach((it, i) => {
748
+ if (it.kind === "row")
749
+ m.set(it.row.id, i);
750
+ });
751
+ return m;
752
+ }, [renderItems]);
753
+ // `height == null` (the default) makes this a no-op: `viewport.h` starts at
754
+ // `Infinity`, so the visible range below covers every item, both spacers
755
+ // come out to 0px, and neither renders (see the `> 0` guards further down)
756
+ // -- the DOM is byte-identical to before virtualization existed.
757
+ const rowFirstVisible = renderItems.length === 0 ? 0 : offsetIndex(rowOffsets, viewport.top);
758
+ const rowLastVisible = renderItems.length === 0
759
+ ? 0
760
+ : offsetIndex(rowOffsets, Math.max(viewport.top, viewport.top + viewport.h - 1));
761
+ const rowStart = Math.max(0, rowFirstVisible - ROW_OVERSCAN);
762
+ const rowEnd = Math.min(renderItems.length, rowLastVisible + 1 + ROW_OVERSCAN);
763
+ const topSpacerHeight = rowOffsets[rowStart] ?? 0;
764
+ const bottomSpacerHeight = totalRowsHeight - (rowOffsets[rowEnd] ?? 0);
765
+ const visibleItems = renderItems.slice(rowStart, rowEnd);
766
+ // crisp/#577 -- COLUMN virtualization. Always on (no `height` needed): the
767
+ // sticky-pinned columns (the leading `columns[0]` when `stickyFirst`, the
768
+ // trailing column when `stickyLast`) are excluded from the window and
769
+ // always render, exactly like Attio's own checkbox + entity columns while
770
+ // its own grid scrolls under them. Coordinates below are in the SAME
771
+ // horizontal axis `scrollLeft` uses: a sticky column doesn't leave layout
772
+ // flow (only its paint position pins), so its width still occupies real
773
+ // space in that axis.
774
+ const colAlwaysFirst = stickyFirst && columns.length > 0;
775
+ const colAlwaysLastIdx = stickyLast &&
776
+ columns.length > 0 &&
777
+ !(colAlwaysFirst && columns.length === 1)
778
+ ? columns.length - 1
779
+ : -1;
780
+ const colWindowStart = colAlwaysFirst ? 1 : 0;
781
+ const colWindowEnd = colAlwaysLastIdx >= 0 ? colAlwaysLastIdx : columns.length;
782
+ const colLeadingFixedWidth = (showGrip ? GRIP_W : 0) +
783
+ (selectable ? CHECK_W : 0) +
784
+ (colAlwaysFirst ? colWidth(columns[0]) : 0);
785
+ // Cumulative widths of just the WINDOWABLE columns, relative to their own
786
+ // start (not the table's) -- kept separate from the absolute scroll axis
787
+ // so `leftSpacerWidth + rightSpacerWidth + (visible widths)` provably sums
788
+ // to the windowable total no matter how many leading fixed columns there are.
789
+ const colRelOffsets = [0];
790
+ for (let i = colWindowStart; i < colWindowEnd; i++)
791
+ colRelOffsets.push(colRelOffsets[colRelOffsets.length - 1] + colWidth(columns[i]));
792
+ const colWindowableCount = colWindowEnd - colWindowStart;
793
+ const colTargetLeft = Math.max(0, Math.min(colRelOffsets[colWindowableCount] ?? 0, viewport.left - colLeadingFixedWidth));
794
+ const colTargetRight = Math.max(0, Math.min(colRelOffsets[colWindowableCount] ?? 0, viewport.left - colLeadingFixedWidth + viewport.w - 1));
795
+ const colFirstVisibleK = colWindowableCount === 0 ? 0 : offsetIndex(colRelOffsets, colTargetLeft);
796
+ const colLastVisibleK = colWindowableCount === 0 ? 0 : offsetIndex(colRelOffsets, colTargetRight);
797
+ const colStartK = Math.max(0, colFirstVisibleK - COL_OVERSCAN);
798
+ const colEndK = Math.min(colWindowableCount, colLastVisibleK + 1 + COL_OVERSCAN);
799
+ const leftSpacerWidth = colRelOffsets[colStartK] ?? 0;
800
+ const rightSpacerWidth = (colRelOffsets[colWindowableCount] ?? 0) - (colRelOffsets[colEndK] ?? 0);
801
+ const colSlots = [];
802
+ if (colAlwaysFirst)
803
+ colSlots.push({ type: "col", i: 0 });
804
+ if (leftSpacerWidth > 0)
805
+ colSlots.push({ type: "spacer", side: "left", width: leftSpacerWidth });
806
+ for (let i = colWindowStart + colStartK; i < colWindowStart + colEndK; i++)
807
+ colSlots.push({ type: "col", i });
808
+ if (rightSpacerWidth > 0)
809
+ colSlots.push({ type: "spacer", side: "right", width: rightSpacerWidth });
810
+ if (colAlwaysLastIdx >= 0)
811
+ colSlots.push({ type: "col", i: colAlwaysLastIdx });
812
+ // aria-rowcount/-rowindex, aria-colcount/-colindex (#577): a virtualized
813
+ // native `<table>` still exposes its TRUE size and each real cell's TRUE
814
+ // position to assistive tech, even though most rows/columns aren't in the
815
+ // DOM at any moment -- the spacer/placeholder cells are `aria-hidden`
816
+ // instead of numbered. `+1` header row, 1-based indices per the ARIA spec.
817
+ const ariaRowCount = 1 + renderItems.length;
818
+ const ariaColCount = (showGrip ? 1 : 0) + (selectable ? 1 : 0) + columns.length;
819
+ const ariaColIndexBase = (showGrip ? 1 : 0) + (selectable ? 1 : 0);
820
+ const colAriaIndex = (i) => ariaColIndexBase + i + 1;
565
821
  const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.id));
566
822
  const someSelected = selected.size > 0 && !allSelected;
567
823
  function toggleAll() {
@@ -713,6 +969,72 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
713
969
  }
714
970
  setEditing(null);
715
971
  }
972
+ // crisp/#577 -- keyboard nav to an off-screen (virtualized-out) cell
973
+ // scrolls it into the rendered window, on both axes. Column scroll-into-
974
+ // view runs regardless of `height` -- the horizontal scroll container (and
975
+ // its own virtualization) exists either way; row scroll-into-view only
976
+ // makes sense once `height` gives the grid an internal vertical scroll
977
+ // position to move. `widths` is read only indirectly, through `colWidth`'s
978
+ // closure over it -- biome can't see that, but a column resize while a
979
+ // cell is selected genuinely needs to re-run this to keep it in view, so
980
+ // the dependency is real even though the lint rule can't prove it.
981
+ // biome-ignore lint/correctness/useExhaustiveDependencies: widths is read via colWidth's closure, see comment above
982
+ React.useEffect(() => {
983
+ if (!selCell)
984
+ return;
985
+ const el = scrollRef.current;
986
+ if (!el)
987
+ return;
988
+ // Sets `viewport` directly from the SAME value just assigned to the
989
+ // real DOM property, rather than waiting for a follow-up `scroll` event
990
+ // to round-trip through the `onScroll` handler above -- a real browser
991
+ // does fire one when `scrollTop`/`scrollLeft` is set programmatically,
992
+ // but relying on that would make this effect's own visible result (the
993
+ // newly-selected cell actually rendering) depend on a second render
994
+ // triggered from outside React's own update, which is both slower and
995
+ // one more thing that has to be true for this to work.
996
+ if (height != null) {
997
+ const itemIndex = rowIdToItemIndex.get(selCell.id);
998
+ if (itemIndex != null) {
999
+ const itemTop = rowOffsets[itemIndex];
1000
+ const itemBottom = itemTop + renderItems[itemIndex].height;
1001
+ if (itemTop < el.scrollTop) {
1002
+ el.scrollTop = itemTop;
1003
+ setViewport((v) => ({ ...v, top: itemTop }));
1004
+ }
1005
+ else if (itemBottom > el.scrollTop + el.clientHeight) {
1006
+ const newTop = itemBottom - el.clientHeight;
1007
+ el.scrollTop = newTop;
1008
+ setViewport((v) => ({ ...v, top: newTop }));
1009
+ }
1010
+ }
1011
+ }
1012
+ const ci = columns.findIndex((c) => c.key === selCell.key);
1013
+ if (ci >= 0) {
1014
+ let left = (showGrip ? GRIP_W : 0) + (selectable ? CHECK_W : 0);
1015
+ for (let i = 0; i < ci; i++)
1016
+ left += colWidth(columns[i]);
1017
+ const right = left + colWidth(columns[ci]);
1018
+ if (left < el.scrollLeft) {
1019
+ el.scrollLeft = left;
1020
+ setViewport((v) => ({ ...v, left }));
1021
+ }
1022
+ else if (right > el.scrollLeft + el.clientWidth) {
1023
+ const newLeft = right - el.clientWidth;
1024
+ el.scrollLeft = newLeft;
1025
+ setViewport((v) => ({ ...v, left: newLeft }));
1026
+ }
1027
+ }
1028
+ }, [
1029
+ selCell,
1030
+ height,
1031
+ columns,
1032
+ widths,
1033
+ selectable,
1034
+ rowIdToItemIndex,
1035
+ rowOffsets,
1036
+ renderItems,
1037
+ ]);
716
1038
  const totalCols = columns.length + (selectable ? 1 : 0) + (showGrip ? 1 : 0);
717
1039
  const hasSummary = columns.some((c) => c.summary);
718
1040
  // sticky attrs for the leading column(s): grip col (index -2) + selection
@@ -817,19 +1139,51 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
817
1139
  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 }));
818
1140
  return ((0, jsx_runtime_1.jsx)(lucide_react_1.ChevronsUpDown, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 }));
819
1141
  }
820
- 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, style: { minWidth }, scrollProps: {
1142
+ 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,
1143
+ // crisp/#581: on the <table> itself (`tableStyle`), not the outer
1144
+ // wrapper (`style`) -- see `Table`'s own doc comment on why. This is
1145
+ // what lets the grid actually scroll horizontally INSIDE its own
1146
+ // box instead of widening its consumer's page.
1147
+ tableStyle: { minWidth }, "aria-rowcount": ariaRowCount, "aria-colcount": ariaColCount, scrollProps: {
821
1148
  ...scrollProps,
822
1149
  ref: scrollRef,
1150
+ // crisp/#577: `height` opts into an internally-scrolling viewport
1151
+ // (and so into row virtualization, see the prop's own doc) by
1152
+ // bounding + capping overflow here -- merged onto, not replacing,
1153
+ // whatever style a consumer already passed for #554's sticky
1154
+ // header/footer bounding.
1155
+ style: {
1156
+ ...scrollProps?.style,
1157
+ ...(height != null
1158
+ ? { overflowY: "auto", height, maxHeight: height }
1159
+ : {}),
1160
+ },
823
1161
  onScroll: (e) => {
824
1162
  const el = e.currentTarget;
825
1163
  setScrolledX(el.scrollLeft > 0);
826
1164
  if (stickyLast) {
827
1165
  setScrolledXEnd(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
828
1166
  }
1167
+ // Re-reads clientHeight/clientWidth here too, not just
1168
+ // scrollTop/scrollLeft -- a defensive fallback for whenever
1169
+ // ResizeObserver isn't available (or hasn't fired yet): any
1170
+ // scroll re-syncs the viewport size along with its position,
1171
+ // rather than trusting a possibly-stale mount-time measurement.
1172
+ setViewport((v) => ({
1173
+ top: el.scrollTop,
1174
+ left: el.scrollLeft,
1175
+ h: height != null ? el.clientHeight : v.h,
1176
+ w: el.clientWidth,
1177
+ }));
829
1178
  },
830
1179
  ...(scrolledX ? { "data-scrolled-x": "" } : {}),
831
1180
  ...(scrolledXEnd ? { "data-scrolled-x-end": "" } : {}),
832
- }, children: [(0, jsx_runtime_1.jsxs)("colgroup", { children: [showGrip && (0, jsx_runtime_1.jsx)("col", { style: { width: GRIP_W } }), selectable && (0, jsx_runtime_1.jsx)("col", { style: { width: CHECK_W } }), columns.map((col) => ((0, jsx_runtime_1.jsx)("col", { style: { width: colWidth(col) } }, col.key)))] }), (0, jsx_runtime_1.jsx)(table_1.TableHeader, { children: (0, jsx_runtime_1.jsxs)(table_1.TableRow, { children: [showGrip && ((0, jsx_runtime_1.jsx)(table_1.TableHead, { className: "crisp-datatable-col-grip", ...stickyCol(-2) })), selectable && ((0, jsx_runtime_1.jsx)(table_1.TableHead, { className: "crisp-datatable-col-check", ...stickyCol(-1), children: (0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-check", children: (0, jsx_runtime_1.jsx)(checkbox_1.Checkbox, { "aria-label": selectAllLabel, checked: allSelected, indeterminate: someSelected, onChange: toggleAll }) }) })), columns.map((col, i) => {
1181
+ }, children: [(0, jsx_runtime_1.jsxs)("colgroup", { children: [showGrip && (0, jsx_runtime_1.jsx)("col", { style: { width: GRIP_W } }), selectable && (0, jsx_runtime_1.jsx)("col", { style: { width: CHECK_W } }), colSlots.map((slot) => slot.type === "spacer" ? ((0, jsx_runtime_1.jsx)("col", { style: { width: slot.width } }, `__spacer_${slot.side}`)) : ((0, jsx_runtime_1.jsx)("col", { style: { width: colWidth(columns[slot.i]) } }, columns[slot.i].key)))] }), (0, jsx_runtime_1.jsx)(table_1.TableHeader, { children: (0, jsx_runtime_1.jsxs)(table_1.TableRow, { "aria-rowindex": 1, children: [showGrip && ((0, jsx_runtime_1.jsx)(table_1.TableHead, { className: "crisp-datatable-col-grip", "aria-colindex": showGrip ? 1 : undefined, ...stickyCol(-2) })), selectable && ((0, jsx_runtime_1.jsx)(table_1.TableHead, { className: "crisp-datatable-col-check", "aria-colindex": showGrip ? 2 : 1, ...stickyCol(-1), children: (0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-check", children: (0, jsx_runtime_1.jsx)(checkbox_1.Checkbox, { "aria-label": selectAllLabel, checked: allSelected, indeterminate: someSelected, onChange: toggleAll }) }) })), colSlots.map((slot) => {
1182
+ if (slot.type === "spacer") {
1183
+ return ((0, jsx_runtime_1.jsx)("th", { role: "presentation", "aria-hidden": "true", style: { padding: 0, border: "none" } }, `__spacer_${slot.side}`));
1184
+ }
1185
+ const col = columns[slot.i];
1186
+ const i = slot.i;
833
1187
  const Icon = col.icon;
834
1188
  // The sort direction was painted (`data-sorted`) and never
835
1189
  // announced: a screen reader heard the same header before and
@@ -845,8 +1199,8 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
845
1199
  ? "ascending"
846
1200
  : "descending"
847
1201
  : "none";
848
- 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)("span", { className: "crisp-datatable-th-label", children: col.header }), col.sortable ? (0, jsx_runtime_1.jsx)(ChevronFor, { colKey: col.key }) : null] }));
849
- return ((0, jsx_runtime_1.jsxs)(table_1.TableHead, { align: alignFor(col), "aria-sort": ariaSort, ...dataStickyCol(i), children: [renaming === col.key ? ((0, jsx_runtime_1.jsx)("input", {
1202
+ 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 }), col.sortable ? (0, jsx_runtime_1.jsx)(ChevronFor, { colKey: col.key }) : null] }));
1203
+ 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", {
850
1204
  // biome-ignore lint/a11y/noAutofocus: the rename item opens this field
851
1205
  autoFocus: true, className: "crisp-datatable-th-rename", defaultValue: typeof col.header === "string" ? col.header : "", "aria-label": renameColumnLabel, onBlur: (e) => {
852
1206
  renameColumn(col.key, e.currentTarget.value);
@@ -861,104 +1215,128 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
861
1215
  } })) : columnMenu ? ((0, jsx_runtime_1.jsxs)(menu_1.Menu, { align: "start", trigger: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-datatable-sort", "data-sorted": sortDirByKey.get(col.key), "aria-label": `${typeof col.header === "string"
862
1216
  ? col.header
863
1217
  : col.key} ${crispMessages["column options"] ?? "column options"}`, children: inner }), children: [(0, jsx_runtime_1.jsx)(menu_1.MenuItem, { onSelect: () => setSort({ key: col.key, dir: "asc" }), children: sortAscendingLabel }), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { onSelect: () => setSort({ key: col.key, dir: "desc" }), children: sortDescendingLabel }), col.managed === false ? null : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(menu_1.MenuItem, { onSelect: () => moveColumn(col.key, -1), children: moveColumnLeftLabel }), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { onSelect: () => moveColumn(col.key, 1), children: moveColumnRightLabel }), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { onSelect: () => setRenaming(col.key), children: editColumnLabel }), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { onSelect: () => hideColumn(col.key), children: hideColumnLabel })] }))] })) : col.sortable ? ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-datatable-sort", "data-sorted": sortDirByKey.get(col.key), onClick: () => cycleSort(col.key), children: inner })) : (inner), col.resizable !== false && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-resize", "data-resizing": resizingKey === col.key ? "" : undefined, onPointerDown: (e) => startResize(col, e), onPointerMove: moveResize, onPointerUp: endResize }))] }, col.key));
864
- })] }) }), (0, jsx_runtime_1.jsx)(table_1.TableBody, { children: error ? ((0, jsx_runtime_1.jsx)(table_1.TableRow, { children: (0, jsx_runtime_1.jsx)(table_1.TableCell, { colSpan: totalCols, children: (0, jsx_runtime_1.jsx)("div", { className: "crisp-datatable-empty", children: error }) }) })) : rows.length === 0 ? ((0, jsx_runtime_1.jsx)(table_1.TableRow, { children: (0, jsx_runtime_1.jsx)(table_1.TableCell, { colSpan: totalCols, children: (0, jsx_runtime_1.jsx)("div", { className: "crisp-datatable-empty", children: empty }) }) })) : (groupedRows.map(({ group, rows: groupRows }) => ((0, jsx_runtime_1.jsxs)(React.Fragment, { children: [group !== null && ((0, jsx_runtime_1.jsx)(table_1.TableRow, { children: (0, jsx_runtime_1.jsxs)(table_1.TableCell, { colSpan: totalCols, className: "crisp-datatable-group", children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-group-label", children: group }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-group-count", children: groupRows.length })] }) })), groupRows.map((row) => ((0, jsx_runtime_1.jsxs)(table_1.TableRow, { "data-selected": selected.has(row.id) ? "" : undefined, "data-drop-target": showGrip &&
865
- dragRowId &&
866
- dragRowId !== row.id &&
867
- dropRowId === row.id
868
- ? ""
869
- : undefined, onClick: onRowClick ? () => onRowClick(row) : undefined, style: onRowClick ? { cursor: "pointer" } : undefined, onDragOver: showGrip && dragRowId
870
- ? (e) => {
871
- e.preventDefault();
872
- if (dropRowId !== row.id)
873
- setDropRowId(row.id);
874
- }
875
- : undefined, onDrop: showGrip && dragRowId
876
- ? (e) => {
877
- e.preventDefault();
878
- reorderRows(dragRowId, row.id);
879
- }
880
- : undefined, children: [showGrip && ((0, jsx_runtime_1.jsx)(table_1.TableCell, { className: "crisp-datatable-col-grip", ...stickyCol(-2), onClick: (e) => e.stopPropagation(), children: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-datatable-grip", "aria-label": reorderRowLabel, draggable: true, onDragStart: (e) => {
881
- setDragRowId(row.id);
882
- e.dataTransfer.effectAllowed = "move";
883
- }, onDragEnd: () => {
884
- setDragRowId(null);
885
- setDropRowId(null);
886
- }, children: (0, jsx_runtime_1.jsx)(lucide_react_1.GripVertical, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }) })), selectable && ((0, jsx_runtime_1.jsx)(table_1.TableCell, { className: "crisp-datatable-col-check", ...stickyCol(-1), onClick: (e) => e.stopPropagation(), children: (0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-check", children: (0, jsx_runtime_1.jsx)(checkbox_1.Checkbox, { "aria-label": selectRowLabel(row), checked: selected.has(row.id), onChange: () => toggleRow(row.id) }) }) })), columns.map((col, i) => {
887
- const isEditing = editing?.id === row.id && editing?.key === col.key;
888
- const value = cellValue(col, row);
889
- const sticky = dataStickyCol(i);
890
- const isPending = pending?.id === row.id && pending?.key === col.key;
891
- const errorHere = editError?.id === row.id && editError?.key === col.key
892
- ? editError.message
893
- : null;
894
- // #453: a `switch` editable column IS always its own
895
- // live widget -- flipping the toggle IS the commit
896
- // gesture, there's no meaningfully different "rest"
897
- // display for a boolean. `select`/`reference` are
898
- // NOT this: at rest they must show the column's own
899
- // render/format like every other editable type
900
- // (#412 empty handling included), and only open on
901
- // double-click (#411) -- this branch previously
902
- // rendered them unconditionally, replacing every
903
- // row's own render with a picker trigger, which is
904
- // the regression #453 reported (also #432/#449,
905
- // which never had a "rest" state of their own to get
906
- // right). See the `isEditing` block below for their
907
- // actual editor.
908
- if (col.editable && col.editor === "switch") {
909
- return ((0, jsx_runtime_1.jsxs)(table_1.TableCell, { align: alignFor(col), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined, children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-edit-picker", children: [(0, jsx_runtime_1.jsx)(switch_1.Switch, { "aria-label": `Edit ${col.key}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commitEdit(row.id, col.key, String(checked)) }), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
1218
+ })] }) }), (0, jsx_runtime_1.jsx)(table_1.TableBody, { children: error ? ((0, jsx_runtime_1.jsx)(table_1.TableRow, { children: (0, jsx_runtime_1.jsx)(table_1.TableCell, { colSpan: totalCols, children: (0, jsx_runtime_1.jsx)("div", { className: "crisp-datatable-empty", children: error }) }) })) : rows.length === 0 ? ((0, jsx_runtime_1.jsx)(table_1.TableRow, { children: (0, jsx_runtime_1.jsx)(table_1.TableCell, { colSpan: totalCols, children: (0, jsx_runtime_1.jsx)("div", { className: "crisp-datatable-empty", children: empty }) }) })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [topSpacerHeight > 0 && ((0, jsx_runtime_1.jsx)("tr", { "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("td", { colSpan: totalCols, style: {
1219
+ height: topSpacerHeight,
1220
+ padding: 0,
1221
+ border: "none",
1222
+ } }) })), visibleItems.map((item, localIndex) => {
1223
+ const itemIndex = rowStart + localIndex;
1224
+ // header row is aria-rowindex 1, so the first body item is 2
1225
+ const ariaRowIndex = itemIndex + 2;
1226
+ if (item.kind === "group") {
1227
+ return ((0, jsx_runtime_1.jsx)(table_1.TableRow, { "aria-rowindex": ariaRowIndex, children: (0, jsx_runtime_1.jsxs)(table_1.TableCell, { colSpan: totalCols, className: "crisp-datatable-group", children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-group-label", children: item.label }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-group-count", children: item.count })] }) }, item.key));
1228
+ }
1229
+ const row = item.row;
1230
+ return ((0, jsx_runtime_1.jsxs)(table_1.TableRow, { "aria-rowindex": ariaRowIndex, "data-selected": selected.has(row.id) ? "" : undefined, "data-drop-target": showGrip &&
1231
+ dragRowId &&
1232
+ dragRowId !== row.id &&
1233
+ dropRowId === row.id
1234
+ ? ""
1235
+ : undefined, onClick: onRowClick ? () => onRowClick(row) : undefined, style: onRowClick ? { cursor: "pointer" } : undefined, onDragOver: showGrip && dragRowId
1236
+ ? (e) => {
1237
+ e.preventDefault();
1238
+ if (dropRowId !== row.id)
1239
+ setDropRowId(row.id);
910
1240
  }
911
- if (isEditing &&
912
- (col.editor === "select" || col.editor === "reference")) {
913
- return ((0, jsx_runtime_1.jsxs)(table_1.TableCell, { align: alignFor(col), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined,
914
- // Same as the plain text editor's own cell:
915
- // opening/using the picker must not also fire
916
- // the row's onRowClick. Escape is handled by
917
- // `DataSelectEditor`/`DataReferenceEditor`
918
- // themselves (a document-level listener, not a
919
- // `TableCell`-level `onKeyDown`) -- opening the
920
- // picker moves DOM focus into a floating,
921
- // portaled popover, so a handler here can't
922
- // rely on the key event ever bubbling through
923
- // this element (#453).
924
- onClick: (e) => e.stopPropagation(), children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-edit-picker", children: [col.editor === "select" ? ((0, jsx_runtime_1.jsx)(DataSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })) : ((0, jsx_runtime_1.jsx)(DataReferenceEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
1241
+ : undefined, onDrop: showGrip && dragRowId
1242
+ ? (e) => {
1243
+ e.preventDefault();
1244
+ reorderRows(dragRowId, row.id);
925
1245
  }
926
- if (isEditing) {
927
- return ((0, jsx_runtime_1.jsxs)(table_1.TableCell, { align: alignFor(col), ...sticky, onClick: (e) => e.stopPropagation(), children: [(0, jsx_runtime_1.jsx)("input", { className: "crisp-datatable-edit",
928
- // biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
929
- autoFocus: true, ...editorFor(col), "data-editor": editorFor(col).type, disabled: isPending, defaultValue: col.type === "date"
930
- ? toDateInputValue(value)
931
- : col.type === "datetime"
932
- ? toDateTimeInputValue(value)
933
- : asText(value), "aria-label": `Edit ${col.key}`, onKeyDown: (e) => {
934
- if (isPending)
935
- return;
936
- if (e.key === "Enter")
1246
+ : undefined, children: [showGrip && ((0, jsx_runtime_1.jsx)(table_1.TableCell, { className: "crisp-datatable-col-grip", "aria-colindex": showGrip ? 1 : undefined, ...stickyCol(-2), onClick: (e) => e.stopPropagation(), children: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-datatable-grip", "aria-label": reorderRowLabel, draggable: true, onDragStart: (e) => {
1247
+ setDragRowId(row.id);
1248
+ e.dataTransfer.effectAllowed = "move";
1249
+ }, onDragEnd: () => {
1250
+ setDragRowId(null);
1251
+ setDropRowId(null);
1252
+ }, children: (0, jsx_runtime_1.jsx)(lucide_react_1.GripVertical, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }) })), selectable && ((0, jsx_runtime_1.jsx)(table_1.TableCell, { className: "crisp-datatable-col-check", "aria-colindex": showGrip ? 2 : 1, ...stickyCol(-1), onClick: (e) => e.stopPropagation(), children: (0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-check", children: (0, jsx_runtime_1.jsx)(checkbox_1.Checkbox, { "aria-label": selectRowLabel(row), checked: selected.has(row.id), onChange: () => toggleRow(row.id) }) }) })), colSlots.map((slot) => {
1253
+ if (slot.type === "spacer") {
1254
+ return ((0, jsx_runtime_1.jsx)("td", { role: "presentation", "aria-hidden": "true", style: { padding: 0, border: "none" } }, `__spacer_${slot.side}`));
1255
+ }
1256
+ const col = columns[slot.i];
1257
+ const i = slot.i;
1258
+ const isEditing = editing?.id === row.id && editing?.key === col.key;
1259
+ const value = cellValue(col, row);
1260
+ const sticky = dataStickyCol(i);
1261
+ const isPending = pending?.id === row.id && pending?.key === col.key;
1262
+ const errorHere = editError?.id === row.id && editError?.key === col.key
1263
+ ? editError.message
1264
+ : null;
1265
+ // #453: a `switch` editable column IS always its own
1266
+ // live widget -- flipping the toggle IS the commit
1267
+ // gesture, there's no meaningfully different "rest"
1268
+ // display for a boolean. `select`/`reference` are
1269
+ // NOT this: at rest they must show the column's own
1270
+ // render/format like every other editable type
1271
+ // (#412 empty handling included), and only open on
1272
+ // double-click (#411) -- this branch previously
1273
+ // rendered them unconditionally, replacing every
1274
+ // row's own render with a picker trigger, which is
1275
+ // the regression #453 reported (also #432/#449,
1276
+ // which never had a "rest" state of their own to get
1277
+ // right). See the `isEditing` block below for their
1278
+ // actual editor.
1279
+ if (col.editable && col.editor === "switch") {
1280
+ return ((0, jsx_runtime_1.jsxs)(table_1.TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined, children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-edit-picker", children: [(0, jsx_runtime_1.jsx)(switch_1.Switch, { "aria-label": `Edit ${col.key}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commitEdit(row.id, col.key, String(checked)) }), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
1281
+ }
1282
+ if (isEditing &&
1283
+ (col.editor === "select" || col.editor === "reference")) {
1284
+ return ((0, jsx_runtime_1.jsxs)(table_1.TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined,
1285
+ // Same as the plain text editor's own cell:
1286
+ // opening/using the picker must not also fire
1287
+ // the row's onRowClick. Escape is handled by
1288
+ // `DataSelectEditor`/`DataReferenceEditor`
1289
+ // themselves (a document-level listener, not a
1290
+ // `TableCell`-level `onKeyDown`) -- opening the
1291
+ // picker moves DOM focus into a floating,
1292
+ // portaled popover, so a handler here can't
1293
+ // rely on the key event ever bubbling through
1294
+ // this element (#453).
1295
+ onClick: (e) => e.stopPropagation(), children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-edit-picker", children: [col.editor === "select" ? ((0, jsx_runtime_1.jsx)(DataSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })) : ((0, jsx_runtime_1.jsx)(DataReferenceEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
1296
+ }
1297
+ if (isEditing) {
1298
+ return ((0, jsx_runtime_1.jsxs)(table_1.TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, onClick: (e) => e.stopPropagation(), children: [(0, jsx_runtime_1.jsx)("input", { className: "crisp-datatable-edit",
1299
+ // biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
1300
+ autoFocus: true, ...editorFor(col), "data-editor": editorFor(col).type, disabled: isPending, defaultValue: col.type === "date"
1301
+ ? toDateInputValue(value)
1302
+ : col.type === "datetime"
1303
+ ? toDateTimeInputValue(value)
1304
+ : asText(value), "aria-label": `Edit ${col.key}`, onKeyDown: (e) => {
1305
+ if (isPending)
1306
+ return;
1307
+ if (e.key === "Enter")
1308
+ commitEdit(row.id, col.key, e.currentTarget.value);
1309
+ else if (e.key === "Escape")
1310
+ setEditing(null);
1311
+ }, onBlur: (e) => {
1312
+ if (isPending)
1313
+ return;
937
1314
  commitEdit(row.id, col.key, e.currentTarget.value);
938
- else if (e.key === "Escape")
939
- setEditing(null);
940
- }, onBlur: (e) => {
941
- if (isPending)
942
- return;
943
- commitEdit(row.id, col.key, e.currentTarget.value);
944
- } }), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }, col.key));
945
- }
946
- return ((0, jsx_runtime_1.jsxs)(table_1.TableCell, { align: alignFor(col), ...sticky, className: col.editable
947
- ? "crisp-datatable-editable"
948
- : undefined, "data-selected": selCell?.id === row.id && selCell?.key === col.key
949
- ? ""
950
- : undefined, onClick: () => {
951
- // the keyboard model's anchor -- a single click
952
- // only selects. A genuine double-click (or Enter
953
- // on the selected cell) opens the editor (#411),
954
- // matching RecordsTable's own onDoubleClick.
955
- setSelCell({ id: row.id, key: col.key });
956
- gridRef.current?.focus({ preventScroll: true });
957
- }, onDoubleClick: col.editable
958
- ? () => openEditing(row.id, col.key)
959
- : undefined, children: [col.render
960
- ? col.render(value, row)
961
- : renderTyped(col, value, avatarTone, row, col.format?.(value, row), locale), errorHere && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
962
- })] }, row.id)))] }, group ?? "__ungrouped")))) }), (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) }), columns.map((col, i) => ((0, jsx_runtime_1.jsx)(table_1.TableCell, { align: alignFor(col), ...dataStickyCol(i), children: col.summary ? col.summary(rows) : null }, col.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] }) }) }))] }))] })] }));
1315
+ } }), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }, col.key));
1316
+ }
1317
+ return ((0, jsx_runtime_1.jsxs)(table_1.TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: col.editable
1318
+ ? "crisp-datatable-editable"
1319
+ : undefined, "data-selected": selCell?.id === row.id && selCell?.key === col.key
1320
+ ? ""
1321
+ : undefined, onClick: () => {
1322
+ // the keyboard model's anchor -- a single click
1323
+ // only selects. A genuine double-click (or Enter
1324
+ // on the selected cell) opens the editor (#411),
1325
+ // matching RecordsTable's own onDoubleClick.
1326
+ setSelCell({ id: row.id, key: col.key });
1327
+ gridRef.current?.focus({ preventScroll: true });
1328
+ }, onDoubleClick: col.editable
1329
+ ? () => openEditing(row.id, col.key)
1330
+ : undefined, children: [col.render
1331
+ ? col.render(value, row)
1332
+ : renderTyped(col, value, avatarTone, row, col.format?.(value, row), locale), errorHere && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
1333
+ })] }, row.id));
1334
+ }), bottomSpacerHeight > 0 && ((0, jsx_runtime_1.jsx)("tr", { "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("td", { colSpan: totalCols, style: {
1335
+ height: bottomSpacerHeight,
1336
+ padding: 0,
1337
+ border: "none",
1338
+ } }) }))] })) }), (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
1339
+ ? columns[slot.i].summary?.(rows)
1340
+ : 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] }) }) }))] }))] })] }));
963
1341
  }
964
1342
  DataTable.displayName = "DataTable";