@crisp-ui-kit/crisp 0.54.2 → 0.54.3

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 (74) hide show
  1. package/dist/chip.css +7 -1
  2. package/dist/chip.d.ts +2 -1
  3. package/dist/chip.layered.css +7 -1
  4. package/dist/cjs/components/chip/chip.js +3 -2
  5. package/dist/cjs/components/chip/chip.recipe.js +37 -1
  6. package/dist/cjs/components/datatable/datatable.js +489 -111
  7. package/dist/cjs/components/emptystate/emptystate.js +3 -2
  8. package/dist/cjs/components/emptystate/emptystate.recipe.js +26 -1
  9. package/dist/cjs/components/personchip/index.js +17 -0
  10. package/dist/cjs/components/personchip/personchip.js +50 -0
  11. package/dist/cjs/components/table/table.js +2 -2
  12. package/dist/cjs/components/tabs/tabs.js +2 -2
  13. package/dist/cjs/components/tabs/tabs.recipe.js +30 -3
  14. package/dist/cjs/components/text/index.js +7 -0
  15. package/dist/cjs/components/text/text.js +55 -0
  16. package/dist/cjs/components/text/text.recipe.js +34 -0
  17. package/dist/cjs/index.js +2 -0
  18. package/dist/cjs/product/headerbar/headerbar.recipe.js +9 -1
  19. package/dist/cjs/product/recordpage/recordpage.recipe.js +10 -1
  20. package/dist/cjs/product/recordstable/recordstable.js +89 -6
  21. package/dist/cjs/product/richtext/richtext.recipe.js +26 -1
  22. package/dist/emptystate.css +15 -1
  23. package/dist/emptystate.d.ts +2 -2
  24. package/dist/emptystate.layered.css +15 -1
  25. package/dist/esm/components/chip/chip.js +3 -2
  26. package/dist/esm/components/chip/chip.recipe.js +37 -1
  27. package/dist/esm/components/datatable/datatable.js +488 -111
  28. package/dist/esm/components/emptystate/emptystate.js +4 -3
  29. package/dist/esm/components/emptystate/emptystate.recipe.js +26 -1
  30. package/dist/esm/components/emptystate/index.js +1 -1
  31. package/dist/esm/components/personchip/index.js +1 -0
  32. package/dist/esm/components/personchip/personchip.js +47 -0
  33. package/dist/esm/components/table/table.js +2 -2
  34. package/dist/esm/components/tabs/tabs.js +2 -2
  35. package/dist/esm/components/tabs/tabs.recipe.js +30 -3
  36. package/dist/esm/components/text/index.js +2 -0
  37. package/dist/esm/components/text/text.js +19 -0
  38. package/dist/esm/components/text/text.recipe.js +31 -0
  39. package/dist/esm/index.js +2 -0
  40. package/dist/esm/product/headerbar/headerbar.recipe.js +9 -1
  41. package/dist/esm/product/recordpage/recordpage.recipe.js +10 -1
  42. package/dist/esm/product/recordstable/recordstable.js +90 -8
  43. package/dist/esm/product/richtext/richtext.recipe.js +26 -1
  44. package/dist/headerbar.css +1 -1
  45. package/dist/headerbar.layered.css +1 -1
  46. package/dist/recordpage.css +13 -2
  47. package/dist/recordpage.layered.css +13 -2
  48. package/dist/richtext.css +11 -1
  49. package/dist/richtext.layered.css +11 -1
  50. package/dist/styles.css +64 -7
  51. package/dist/styles.layered.css +64 -7
  52. package/dist/tabs.css +7 -1
  53. package/dist/tabs.d.ts +2 -2
  54. package/dist/tabs.layered.css +7 -1
  55. package/dist/text.css +9 -0
  56. package/dist/text.d.ts +7 -0
  57. package/dist/text.layered.css +11 -0
  58. package/dist/types/components/chip/chip.d.ts +32 -0
  59. package/dist/types/components/chip/chip.recipe.d.ts +31 -0
  60. package/dist/types/components/datatable/datatable.d.ts +28 -4
  61. package/dist/types/components/emptystate/emptystate.d.ts +29 -4
  62. package/dist/types/components/emptystate/emptystate.recipe.d.ts +2 -0
  63. package/dist/types/components/emptystate/index.d.ts +1 -1
  64. package/dist/types/components/personchip/index.d.ts +1 -0
  65. package/dist/types/components/personchip/personchip.d.ts +37 -0
  66. package/dist/types/components/table/table.d.ts +36 -0
  67. package/dist/types/components/tabs/tabs.d.ts +4 -2
  68. package/dist/types/components/tabs/tabs.recipe.d.ts +18 -3
  69. package/dist/types/components/text/index.d.ts +2 -0
  70. package/dist/types/components/text/text.d.ts +32 -0
  71. package/dist/types/components/text/text.recipe.d.ts +40 -0
  72. package/dist/types/index.d.ts +2 -0
  73. package/dist/types/product/recordstable/recordstable.d.ts +24 -2
  74. package/package.json +11 -1
@@ -9,7 +9,55 @@ import { Select } from "../select/index.js";
9
9
  import { Spinner } from "../spinner/index.js";
10
10
  import { Switch } from "../switch/index.js";
11
11
  import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "../table/index.js";
12
+ import { Tooltip } from "../tooltip/index.js";
12
13
  import { datatableRecipe } from "./datatable.recipe.js";
14
+ // #534: whether an element's own content is being cut off by its own box --
15
+ // the same fact `.crisp-datatable-th-label`'s CSS ellipsis is silently
16
+ // applying, exported so the comparison itself (not layout) can be
17
+ // mutation-tested without a real browser.
18
+ export function isLabelTruncated(el) {
19
+ return el.scrollWidth > el.clientWidth;
20
+ }
21
+ /**
22
+ * The header label span: a `Tooltip` (full text, `asChild` so the wrapper
23
+ * never displaces the ellipsis CSS the span already carries -- see
24
+ * `Tooltip`'s own `asChild` doc) permanently closed until the label actually
25
+ * overflows its own box. Re-measures on mount and on resize (column drag,
26
+ * viewport change, header text change) via `ResizeObserver`; SSR-safe the
27
+ * same way `MiddleTruncate` is -- the DOM is only touched inside an effect,
28
+ * so the server (and the first client paint) render the closed tooltip.
29
+ */
30
+ function ThLabel({ header }) {
31
+ const ref = React.useRef(null);
32
+ const [overflowing, setOverflowing] = React.useState(false);
33
+ // 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.
34
+ React.useLayoutEffect(() => {
35
+ const el = ref.current;
36
+ if (!el)
37
+ return;
38
+ const measure = () => setOverflowing(isLabelTruncated(el));
39
+ measure();
40
+ if (typeof ResizeObserver === "undefined")
41
+ return;
42
+ const ro = new ResizeObserver(measure);
43
+ ro.observe(el);
44
+ return () => ro.disconnect();
45
+ }, [header]);
46
+ // `Tooltip` always wraps the SAME span (never swapped in/out of the tree
47
+ // based on `overflowing`) so the span's own DOM node identity survives
48
+ // every toggle -- a real-browser-only bug (happy-dom has no
49
+ // ResizeObserver, so the test suite never exercised this path): swapping
50
+ // between a bare `<span>` and `<Tooltip asChild><span/></Tooltip>` changes
51
+ // the element type at this position, so React unmounts the old span and
52
+ // mounts a new one; the effect above never re-runs (its deps are
53
+ // `[header]`, unchanged), so its ResizeObserver keeps watching the now
54
+ // -detached old node, whose deferred initial callback then reports 0x0 and
55
+ // flips `overflowing` straight back to false -- the tooltip would never
56
+ // actually show. `open={false}` when it fits keeps the Tooltip mounted
57
+ // (asChild costs no extra DOM either way) but pins it permanently closed,
58
+ // so hovering a non-overflowing header still opens nothing.
59
+ return (_jsx(Tooltip, { content: header, asChild: true, open: overflowing ? undefined : false, children: _jsx("span", { ref: ref, className: "crisp-datatable-th-label", children: header }) }));
60
+ }
13
61
  /** A `date` input needs `yyyy-mm-dd`; cells may hold a Date, an ISO string or
14
62
  * a display string. Returns "" when it cannot be parsed, so the field opens
15
63
  * empty rather than showing an invalid value. */
@@ -214,6 +262,51 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US") {
214
262
  }
215
263
  }
216
264
  const CHECK_W = 40;
265
+ // crisp/#577 -- cell-level virtualization on both axes.
266
+ //
267
+ // ROW_HEIGHT/GROUP_ROW_HEIGHT must track table.recipe.ts's `c.size.tableRow`
268
+ // (36px) and datatable.recipe.ts's `.crisp-datatable-group` height, which
269
+ // reads `c.size.menuRow` (32px) -- there is no way to read a recipe's CSS
270
+ // value back out in JS, so these are a second, deliberately-commented copy.
271
+ // A drift between the two would only show up as visual jitter (a spacer's
272
+ // height not quite matching the rows it stands in for), never a crash, which
273
+ // is exactly the kind of silent drift AUTHORING.md Lesson 31 warns about.
274
+ const ROW_HEIGHT = 36;
275
+ const GROUP_ROW_HEIGHT = 32;
276
+ // Overscan is a performance cushion, not a measured value -- Attio's own
277
+ // Companies grid (measured live, #577's own issue body) rendered 24 rows for
278
+ // what a 715px-tall viewport fits at 36px/row (~20), i.e. a handful either
279
+ // side; these are the same shape of cushion, not a parity target.
280
+ const ROW_OVERSCAN = 6;
281
+ const COL_OVERSCAN = 2;
282
+ // The row window a server render (or a client's very first paint, before a
283
+ // real measurement lands) assumes when `height` is a CSS string rather than
284
+ // a plain pixel number -- see the `height` prop doc on why a string can't be
285
+ // turned into an exact row count up front.
286
+ const DEFAULT_VIRTUAL_VIEWPORT_PX = 480;
287
+ /**
288
+ * Cumulative `offsets` has length n+1 (offsets[i] is where item i STARTS,
289
+ * offsets[n] is the total). Returns the index i (clamped to [0, n-1]) whose
290
+ * span contains `target` -- i.e. the largest i with offsets[i] <= target.
291
+ * Shared by the row and column windowing below; a plain linear scan would
292
+ * also be correct but is the wrong complexity for a 10,000-row table
293
+ * recomputed on every scroll event.
294
+ */
295
+ function offsetIndex(offsets, target) {
296
+ const n = offsets.length - 1;
297
+ if (n <= 0)
298
+ return 0;
299
+ let lo = 0;
300
+ let hi = n - 1;
301
+ while (lo < hi) {
302
+ const mid = (lo + hi + 1) >> 1;
303
+ if (offsets[mid] <= target)
304
+ lo = mid;
305
+ else
306
+ hi = mid - 1;
307
+ }
308
+ return lo;
309
+ }
217
310
  // A sentinel that can't collide with a real record id (crisp never generates
218
311
  // one, and a consumer's own ids come from their own data store) -- picking
219
312
  // this row calls `onCreateReference`, not the cell's own commit (#449).
@@ -301,7 +394,7 @@ function DataReferenceEditor({ col, value, isPending, onCommit, onCancel, }) {
301
394
  onCommit(v);
302
395
  } }) }));
303
396
  }
304
- 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, className, reorderRowLabel: reorderRowLabelProp, sortAscendingLabel: sortAscendingLabelProp, sortDescendingLabel: sortDescendingLabelProp, moveColumnLeftLabel: moveColumnLeftLabelProp, moveColumnRightLabel: moveColumnRightLabelProp, editColumnLabel: editColumnLabelProp, hideColumnLabel: hideColumnLabelProp, renameColumnLabel: renameColumnLabelProp, viewSettingsLabel: viewSettingsLabelProp, columnsGroupLabel: columnsGroupLabelProp, ...rest }) {
397
+ 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 }) {
305
398
  // crisp/#471: same resolution order as TableToolbar's own — an explicit
306
399
  // prop wins, then the provider's translation of the built-in English
307
400
  // default, then that default itself with no provider in the tree.
@@ -443,21 +536,52 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
443
536
  const [pending, setPending] = React.useState(null);
444
537
  const [editError, setEditError] = React.useState(null);
445
538
  const [scrolledX, setScrolledX] = React.useState(false);
446
- // Mirrors `scrolledX` for the trailing edge: true while there is still
447
- // unrevealed content sliding under the sticky trailing column, i.e. while
448
- // NOT scrolled all the way to the end. Recomputed after every render (see
449
- // the effect below), not just on scroll, because a column resize or a data
450
- // change can move `scrollWidth` without firing a scroll event.
451
- const [scrolledXEnd, setScrolledXEnd] = React.useState(false);
452
539
  const scrollRef = React.useRef(null);
540
+ // crisp/#577 -- the scroll container's own position/size, read on scroll
541
+ // and on mount/resize. `w` (viewport width) is ALWAYS tracked, since column
542
+ // virtualization doesn't need `height` -- the grid already has a
543
+ // determinate horizontal scroll container. `h` only matters once `height`
544
+ // is set; its initial value is the "first window" SSR/first-paint renders
545
+ // (see the `height` prop doc): a plain-number `height` gets an exact
546
+ // viewport up front, a CSS-string one gets `DEFAULT_VIRTUAL_VIEWPORT_PX`
547
+ // until the real size is measured after mount. `w` starts at `Infinity` --
548
+ // "not yet measured" reads as "render every column", the same as today,
549
+ // rather than guessing a width and risking a visible column pop after
550
+ // hydration.
551
+ const [viewport, setViewport] = React.useState(() => ({
552
+ top: 0,
553
+ left: 0,
554
+ h: typeof height === "number"
555
+ ? height
556
+ : height != null
557
+ ? DEFAULT_VIRTUAL_VIEWPORT_PX
558
+ : Number.POSITIVE_INFINITY,
559
+ w: Number.POSITIVE_INFINITY,
560
+ }));
561
+ // Client-only measurement: re-reads the scroll container's real box on
562
+ // mount and whenever it resizes (a window resize, a sidebar toggling, a
563
+ // consumer's own layout change) -- none of which fire a `scroll` event.
564
+ // Never runs during SSR (effects don't run there at all), which is what
565
+ // keeps the "first window" guess above the only thing a server ever
566
+ // renders.
453
567
  React.useEffect(() => {
454
- if (!stickyLast)
455
- return;
456
568
  const el = scrollRef.current;
457
569
  if (!el)
458
570
  return;
459
- setScrolledXEnd(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
460
- });
571
+ const measure = () => {
572
+ setViewport((v) => ({
573
+ ...v,
574
+ h: height != null ? el.clientHeight : v.h,
575
+ w: el.clientWidth,
576
+ }));
577
+ };
578
+ measure();
579
+ if (typeof ResizeObserver === "undefined")
580
+ return;
581
+ const ro = new ResizeObserver(measure);
582
+ ro.observe(el);
583
+ return () => ro.disconnect();
584
+ }, [height]);
461
585
  // column widths (drag-to-resize) + fixed-layout sizing
462
586
  const [widths, setWidths] = React.useState({});
463
587
  const [resizingKey, setResizingKey] = React.useState(null);
@@ -466,6 +590,33 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
466
590
  const minWidth = (showGrip ? GRIP_W : 0) +
467
591
  (selectable ? CHECK_W : 0) +
468
592
  columns.reduce((sum, col) => sum + colWidth(col), 0);
593
+ // Mirrors `scrolledX` for the trailing edge: true while there is still
594
+ // unrevealed content sliding under the sticky trailing column, i.e. while
595
+ // NOT scrolled all the way to the end. Recomputed whenever `minWidth`
596
+ // changes (a column resize or the column set changing), not just on
597
+ // scroll, since either can move `scrollWidth` without firing a scroll
598
+ // event. This used to run after EVERY render (no dependency array at
599
+ // all) -- harmless on its own, but crisp/#577's own `viewport` effects
600
+ // (below) also read layout and call `setViewport` on their own schedule,
601
+ // and the two together resonated: each render's `setScrolledXEnd`
602
+ // triggered another render, which reran this effect, which could
603
+ // observe a still-settling layout and set a DIFFERENT value, which
604
+ // triggered yet another render -- "Maximum update depth exceeded",
605
+ // caught reviewing the virtualized docs example (the first place this
606
+ // repo ever exercises `stickyLast` in a real browser; the existing
607
+ // jsdom tests never hit real layout, so this never surfaced there).
608
+ // `minWidth` is exactly what this comparison's `scrollWidth` side
609
+ // tracks, so it's the one dependency that actually matters.
610
+ const [scrolledXEnd, setScrolledXEnd] = React.useState(false);
611
+ // biome-ignore lint/correctness/useExhaustiveDependencies: minWidth is what scrollWidth tracks, see comment above -- not read directly in the body
612
+ React.useEffect(() => {
613
+ if (!stickyLast)
614
+ return;
615
+ const el = scrollRef.current;
616
+ if (!el)
617
+ return;
618
+ setScrolledXEnd(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
619
+ }, [stickyLast, minWidth]);
469
620
  const colByKey = React.useMemo(() => {
470
621
  const m = new Map();
471
622
  for (const c of columns)
@@ -526,6 +677,110 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
526
677
  rows: groupRows,
527
678
  }));
528
679
  }, [rows, groupBy]);
680
+ const renderItems = React.useMemo(() => {
681
+ const items = [];
682
+ for (const { group, rows: groupRows } of groupedRows) {
683
+ if (group !== null) {
684
+ items.push({
685
+ kind: "group",
686
+ key: `__group_${group}`,
687
+ label: group,
688
+ count: groupRows.length,
689
+ height: GROUP_ROW_HEIGHT,
690
+ });
691
+ }
692
+ for (const row of groupRows)
693
+ items.push({ kind: "row", key: row.id, row, height: ROW_HEIGHT });
694
+ }
695
+ return items;
696
+ }, [groupedRows]);
697
+ const rowOffsets = React.useMemo(() => {
698
+ const offsets = new Array(renderItems.length + 1);
699
+ offsets[0] = 0;
700
+ for (let i = 0; i < renderItems.length; i++)
701
+ offsets[i + 1] = offsets[i] + renderItems[i].height;
702
+ return offsets;
703
+ }, [renderItems]);
704
+ const totalRowsHeight = rowOffsets[rowOffsets.length - 1] ?? 0;
705
+ // O(1) lookup from a row's id to its position in `renderItems` -- used by
706
+ // the keyboard-nav scroll-into-view effect below, which knows a row by id
707
+ // (from `selCell`), not by its index in the flattened item list.
708
+ const rowIdToItemIndex = React.useMemo(() => {
709
+ const m = new Map();
710
+ renderItems.forEach((it, i) => {
711
+ if (it.kind === "row")
712
+ m.set(it.row.id, i);
713
+ });
714
+ return m;
715
+ }, [renderItems]);
716
+ // `height == null` (the default) makes this a no-op: `viewport.h` starts at
717
+ // `Infinity`, so the visible range below covers every item, both spacers
718
+ // come out to 0px, and neither renders (see the `> 0` guards further down)
719
+ // -- the DOM is byte-identical to before virtualization existed.
720
+ const rowFirstVisible = renderItems.length === 0 ? 0 : offsetIndex(rowOffsets, viewport.top);
721
+ const rowLastVisible = renderItems.length === 0
722
+ ? 0
723
+ : offsetIndex(rowOffsets, Math.max(viewport.top, viewport.top + viewport.h - 1));
724
+ const rowStart = Math.max(0, rowFirstVisible - ROW_OVERSCAN);
725
+ const rowEnd = Math.min(renderItems.length, rowLastVisible + 1 + ROW_OVERSCAN);
726
+ const topSpacerHeight = rowOffsets[rowStart] ?? 0;
727
+ const bottomSpacerHeight = totalRowsHeight - (rowOffsets[rowEnd] ?? 0);
728
+ const visibleItems = renderItems.slice(rowStart, rowEnd);
729
+ // crisp/#577 -- COLUMN virtualization. Always on (no `height` needed): the
730
+ // sticky-pinned columns (the leading `columns[0]` when `stickyFirst`, the
731
+ // trailing column when `stickyLast`) are excluded from the window and
732
+ // always render, exactly like Attio's own checkbox + entity columns while
733
+ // its own grid scrolls under them. Coordinates below are in the SAME
734
+ // horizontal axis `scrollLeft` uses: a sticky column doesn't leave layout
735
+ // flow (only its paint position pins), so its width still occupies real
736
+ // space in that axis.
737
+ const colAlwaysFirst = stickyFirst && columns.length > 0;
738
+ const colAlwaysLastIdx = stickyLast &&
739
+ columns.length > 0 &&
740
+ !(colAlwaysFirst && columns.length === 1)
741
+ ? columns.length - 1
742
+ : -1;
743
+ const colWindowStart = colAlwaysFirst ? 1 : 0;
744
+ const colWindowEnd = colAlwaysLastIdx >= 0 ? colAlwaysLastIdx : columns.length;
745
+ const colLeadingFixedWidth = (showGrip ? GRIP_W : 0) +
746
+ (selectable ? CHECK_W : 0) +
747
+ (colAlwaysFirst ? colWidth(columns[0]) : 0);
748
+ // Cumulative widths of just the WINDOWABLE columns, relative to their own
749
+ // start (not the table's) -- kept separate from the absolute scroll axis
750
+ // so `leftSpacerWidth + rightSpacerWidth + (visible widths)` provably sums
751
+ // to the windowable total no matter how many leading fixed columns there are.
752
+ const colRelOffsets = [0];
753
+ for (let i = colWindowStart; i < colWindowEnd; i++)
754
+ colRelOffsets.push(colRelOffsets[colRelOffsets.length - 1] + colWidth(columns[i]));
755
+ const colWindowableCount = colWindowEnd - colWindowStart;
756
+ const colTargetLeft = Math.max(0, Math.min(colRelOffsets[colWindowableCount] ?? 0, viewport.left - colLeadingFixedWidth));
757
+ const colTargetRight = Math.max(0, Math.min(colRelOffsets[colWindowableCount] ?? 0, viewport.left - colLeadingFixedWidth + viewport.w - 1));
758
+ const colFirstVisibleK = colWindowableCount === 0 ? 0 : offsetIndex(colRelOffsets, colTargetLeft);
759
+ const colLastVisibleK = colWindowableCount === 0 ? 0 : offsetIndex(colRelOffsets, colTargetRight);
760
+ const colStartK = Math.max(0, colFirstVisibleK - COL_OVERSCAN);
761
+ const colEndK = Math.min(colWindowableCount, colLastVisibleK + 1 + COL_OVERSCAN);
762
+ const leftSpacerWidth = colRelOffsets[colStartK] ?? 0;
763
+ const rightSpacerWidth = (colRelOffsets[colWindowableCount] ?? 0) - (colRelOffsets[colEndK] ?? 0);
764
+ const colSlots = [];
765
+ if (colAlwaysFirst)
766
+ colSlots.push({ type: "col", i: 0 });
767
+ if (leftSpacerWidth > 0)
768
+ colSlots.push({ type: "spacer", side: "left", width: leftSpacerWidth });
769
+ for (let i = colWindowStart + colStartK; i < colWindowStart + colEndK; i++)
770
+ colSlots.push({ type: "col", i });
771
+ if (rightSpacerWidth > 0)
772
+ colSlots.push({ type: "spacer", side: "right", width: rightSpacerWidth });
773
+ if (colAlwaysLastIdx >= 0)
774
+ colSlots.push({ type: "col", i: colAlwaysLastIdx });
775
+ // aria-rowcount/-rowindex, aria-colcount/-colindex (#577): a virtualized
776
+ // native `<table>` still exposes its TRUE size and each real cell's TRUE
777
+ // position to assistive tech, even though most rows/columns aren't in the
778
+ // DOM at any moment -- the spacer/placeholder cells are `aria-hidden`
779
+ // instead of numbered. `+1` header row, 1-based indices per the ARIA spec.
780
+ const ariaRowCount = 1 + renderItems.length;
781
+ const ariaColCount = (showGrip ? 1 : 0) + (selectable ? 1 : 0) + columns.length;
782
+ const ariaColIndexBase = (showGrip ? 1 : 0) + (selectable ? 1 : 0);
783
+ const colAriaIndex = (i) => ariaColIndexBase + i + 1;
529
784
  const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.id));
530
785
  const someSelected = selected.size > 0 && !allSelected;
531
786
  function toggleAll() {
@@ -677,6 +932,72 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
677
932
  }
678
933
  setEditing(null);
679
934
  }
935
+ // crisp/#577 -- keyboard nav to an off-screen (virtualized-out) cell
936
+ // scrolls it into the rendered window, on both axes. Column scroll-into-
937
+ // view runs regardless of `height` -- the horizontal scroll container (and
938
+ // its own virtualization) exists either way; row scroll-into-view only
939
+ // makes sense once `height` gives the grid an internal vertical scroll
940
+ // position to move. `widths` is read only indirectly, through `colWidth`'s
941
+ // closure over it -- biome can't see that, but a column resize while a
942
+ // cell is selected genuinely needs to re-run this to keep it in view, so
943
+ // the dependency is real even though the lint rule can't prove it.
944
+ // biome-ignore lint/correctness/useExhaustiveDependencies: widths is read via colWidth's closure, see comment above
945
+ React.useEffect(() => {
946
+ if (!selCell)
947
+ return;
948
+ const el = scrollRef.current;
949
+ if (!el)
950
+ return;
951
+ // Sets `viewport` directly from the SAME value just assigned to the
952
+ // real DOM property, rather than waiting for a follow-up `scroll` event
953
+ // to round-trip through the `onScroll` handler above -- a real browser
954
+ // does fire one when `scrollTop`/`scrollLeft` is set programmatically,
955
+ // but relying on that would make this effect's own visible result (the
956
+ // newly-selected cell actually rendering) depend on a second render
957
+ // triggered from outside React's own update, which is both slower and
958
+ // one more thing that has to be true for this to work.
959
+ if (height != null) {
960
+ const itemIndex = rowIdToItemIndex.get(selCell.id);
961
+ if (itemIndex != null) {
962
+ const itemTop = rowOffsets[itemIndex];
963
+ const itemBottom = itemTop + renderItems[itemIndex].height;
964
+ if (itemTop < el.scrollTop) {
965
+ el.scrollTop = itemTop;
966
+ setViewport((v) => ({ ...v, top: itemTop }));
967
+ }
968
+ else if (itemBottom > el.scrollTop + el.clientHeight) {
969
+ const newTop = itemBottom - el.clientHeight;
970
+ el.scrollTop = newTop;
971
+ setViewport((v) => ({ ...v, top: newTop }));
972
+ }
973
+ }
974
+ }
975
+ const ci = columns.findIndex((c) => c.key === selCell.key);
976
+ if (ci >= 0) {
977
+ let left = (showGrip ? GRIP_W : 0) + (selectable ? CHECK_W : 0);
978
+ for (let i = 0; i < ci; i++)
979
+ left += colWidth(columns[i]);
980
+ const right = left + colWidth(columns[ci]);
981
+ if (left < el.scrollLeft) {
982
+ el.scrollLeft = left;
983
+ setViewport((v) => ({ ...v, left }));
984
+ }
985
+ else if (right > el.scrollLeft + el.clientWidth) {
986
+ const newLeft = right - el.clientWidth;
987
+ el.scrollLeft = newLeft;
988
+ setViewport((v) => ({ ...v, left: newLeft }));
989
+ }
990
+ }
991
+ }, [
992
+ selCell,
993
+ height,
994
+ columns,
995
+ widths,
996
+ selectable,
997
+ rowIdToItemIndex,
998
+ rowOffsets,
999
+ renderItems,
1000
+ ]);
680
1001
  const totalCols = columns.length + (selectable ? 1 : 0) + (showGrip ? 1 : 0);
681
1002
  const hasSummary = columns.some((c) => c.summary);
682
1003
  // sticky attrs for the leading column(s): grip col (index -2) + selection
@@ -781,19 +1102,51 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
781
1102
  return dir === "asc" ? (_jsx(ArrowUp, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 })) : (_jsx(ArrowDown, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 }));
782
1103
  return (_jsx(ChevronsUpDown, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 }));
783
1104
  }
784
- 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, style: { minWidth }, scrollProps: {
1105
+ 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,
1106
+ // crisp/#581: on the <table> itself (`tableStyle`), not the outer
1107
+ // wrapper (`style`) -- see `Table`'s own doc comment on why. This is
1108
+ // what lets the grid actually scroll horizontally INSIDE its own
1109
+ // box instead of widening its consumer's page.
1110
+ tableStyle: { minWidth }, "aria-rowcount": ariaRowCount, "aria-colcount": ariaColCount, scrollProps: {
785
1111
  ...scrollProps,
786
1112
  ref: scrollRef,
1113
+ // crisp/#577: `height` opts into an internally-scrolling viewport
1114
+ // (and so into row virtualization, see the prop's own doc) by
1115
+ // bounding + capping overflow here -- merged onto, not replacing,
1116
+ // whatever style a consumer already passed for #554's sticky
1117
+ // header/footer bounding.
1118
+ style: {
1119
+ ...scrollProps?.style,
1120
+ ...(height != null
1121
+ ? { overflowY: "auto", height, maxHeight: height }
1122
+ : {}),
1123
+ },
787
1124
  onScroll: (e) => {
788
1125
  const el = e.currentTarget;
789
1126
  setScrolledX(el.scrollLeft > 0);
790
1127
  if (stickyLast) {
791
1128
  setScrolledXEnd(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
792
1129
  }
1130
+ // Re-reads clientHeight/clientWidth here too, not just
1131
+ // scrollTop/scrollLeft -- a defensive fallback for whenever
1132
+ // ResizeObserver isn't available (or hasn't fired yet): any
1133
+ // scroll re-syncs the viewport size along with its position,
1134
+ // rather than trusting a possibly-stale mount-time measurement.
1135
+ setViewport((v) => ({
1136
+ top: el.scrollTop,
1137
+ left: el.scrollLeft,
1138
+ h: height != null ? el.clientHeight : v.h,
1139
+ w: el.clientWidth,
1140
+ }));
793
1141
  },
794
1142
  ...(scrolledX ? { "data-scrolled-x": "" } : {}),
795
1143
  ...(scrolledXEnd ? { "data-scrolled-x-end": "" } : {}),
796
- }, children: [_jsxs("colgroup", { children: [showGrip && _jsx("col", { style: { width: GRIP_W } }), selectable && _jsx("col", { style: { width: CHECK_W } }), columns.map((col) => (_jsx("col", { style: { width: colWidth(col) } }, col.key)))] }), _jsx(TableHeader, { children: _jsxs(TableRow, { children: [showGrip && (_jsx(TableHead, { className: "crisp-datatable-col-grip", ...stickyCol(-2) })), selectable && (_jsx(TableHead, { className: "crisp-datatable-col-check", ...stickyCol(-1), children: _jsx("span", { className: "crisp-datatable-check", children: _jsx(Checkbox, { "aria-label": selectAllLabel, checked: allSelected, indeterminate: someSelected, onChange: toggleAll }) }) })), columns.map((col, i) => {
1144
+ }, children: [_jsxs("colgroup", { children: [showGrip && _jsx("col", { style: { width: GRIP_W } }), selectable && _jsx("col", { style: { width: CHECK_W } }), colSlots.map((slot) => slot.type === "spacer" ? (_jsx("col", { style: { width: slot.width } }, `__spacer_${slot.side}`)) : (_jsx("col", { style: { width: colWidth(columns[slot.i]) } }, columns[slot.i].key)))] }), _jsx(TableHeader, { children: _jsxs(TableRow, { "aria-rowindex": 1, children: [showGrip && (_jsx(TableHead, { className: "crisp-datatable-col-grip", "aria-colindex": showGrip ? 1 : undefined, ...stickyCol(-2) })), selectable && (_jsx(TableHead, { className: "crisp-datatable-col-check", "aria-colindex": showGrip ? 2 : 1, ...stickyCol(-1), children: _jsx("span", { className: "crisp-datatable-check", children: _jsx(Checkbox, { "aria-label": selectAllLabel, checked: allSelected, indeterminate: someSelected, onChange: toggleAll }) }) })), colSlots.map((slot) => {
1145
+ if (slot.type === "spacer") {
1146
+ return (_jsx("th", { role: "presentation", "aria-hidden": "true", style: { padding: 0, border: "none" } }, `__spacer_${slot.side}`));
1147
+ }
1148
+ const col = columns[slot.i];
1149
+ const i = slot.i;
797
1150
  const Icon = col.icon;
798
1151
  // The sort direction was painted (`data-sorted`) and never
799
1152
  // announced: a screen reader heard the same header before and
@@ -809,8 +1162,8 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
809
1162
  ? "ascending"
810
1163
  : "descending"
811
1164
  : "none";
812
- const inner = (_jsxs("span", { className: "crisp-datatable-th-inner", children: [Icon ? _jsx(Icon, { size: 16, strokeWidth: 1.75 }) : null, _jsx("span", { className: "crisp-datatable-th-label", children: col.header }), col.sortable ? _jsx(ChevronFor, { colKey: col.key }) : null] }));
813
- return (_jsxs(TableHead, { align: alignFor(col), "aria-sort": ariaSort, ...dataStickyCol(i), children: [renaming === col.key ? (_jsx("input", {
1165
+ const inner = (_jsxs("span", { className: "crisp-datatable-th-inner", children: [Icon ? _jsx(Icon, { size: 16, strokeWidth: 1.75 }) : null, _jsx(ThLabel, { header: col.header }), col.sortable ? _jsx(ChevronFor, { colKey: col.key }) : null] }));
1166
+ return (_jsxs(TableHead, { align: alignFor(col), "aria-sort": ariaSort, "aria-colindex": colAriaIndex(i), ...dataStickyCol(i), children: [renaming === col.key ? (_jsx("input", {
814
1167
  // biome-ignore lint/a11y/noAutofocus: the rename item opens this field
815
1168
  autoFocus: true, className: "crisp-datatable-th-rename", defaultValue: typeof col.header === "string" ? col.header : "", "aria-label": renameColumnLabel, onBlur: (e) => {
816
1169
  renameColumn(col.key, e.currentTarget.value);
@@ -825,104 +1178,128 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
825
1178
  } })) : columnMenu ? (_jsxs(Menu, { align: "start", trigger: _jsx("button", { type: "button", className: "crisp-datatable-sort", "data-sorted": sortDirByKey.get(col.key), "aria-label": `${typeof col.header === "string"
826
1179
  ? col.header
827
1180
  : col.key} ${crispMessages["column options"] ?? "column options"}`, children: inner }), children: [_jsx(MenuItem, { onSelect: () => setSort({ key: col.key, dir: "asc" }), children: sortAscendingLabel }), _jsx(MenuItem, { onSelect: () => setSort({ key: col.key, dir: "desc" }), children: sortDescendingLabel }), col.managed === false ? null : (_jsxs(_Fragment, { children: [_jsx(MenuItem, { onSelect: () => moveColumn(col.key, -1), children: moveColumnLeftLabel }), _jsx(MenuItem, { onSelect: () => moveColumn(col.key, 1), children: moveColumnRightLabel }), _jsx(MenuItem, { onSelect: () => setRenaming(col.key), children: editColumnLabel }), _jsx(MenuItem, { onSelect: () => hideColumn(col.key), children: hideColumnLabel })] }))] })) : col.sortable ? (_jsx("button", { type: "button", className: "crisp-datatable-sort", "data-sorted": sortDirByKey.get(col.key), onClick: () => cycleSort(col.key), children: inner })) : (inner), col.resizable !== false && (_jsx("span", { className: "crisp-datatable-resize", "data-resizing": resizingKey === col.key ? "" : undefined, onPointerDown: (e) => startResize(col, e), onPointerMove: moveResize, onPointerUp: endResize }))] }, col.key));
828
- })] }) }), _jsx(TableBody, { children: error ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: totalCols, children: _jsx("div", { className: "crisp-datatable-empty", children: error }) }) })) : rows.length === 0 ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: totalCols, children: _jsx("div", { className: "crisp-datatable-empty", children: empty }) }) })) : (groupedRows.map(({ group, rows: groupRows }) => (_jsxs(React.Fragment, { children: [group !== null && (_jsx(TableRow, { children: _jsxs(TableCell, { colSpan: totalCols, className: "crisp-datatable-group", children: [_jsx("span", { className: "crisp-datatable-group-label", children: group }), _jsx("span", { className: "crisp-datatable-group-count", children: groupRows.length })] }) })), groupRows.map((row) => (_jsxs(TableRow, { "data-selected": selected.has(row.id) ? "" : undefined, "data-drop-target": showGrip &&
829
- dragRowId &&
830
- dragRowId !== row.id &&
831
- dropRowId === row.id
832
- ? ""
833
- : undefined, onClick: onRowClick ? () => onRowClick(row) : undefined, style: onRowClick ? { cursor: "pointer" } : undefined, onDragOver: showGrip && dragRowId
834
- ? (e) => {
835
- e.preventDefault();
836
- if (dropRowId !== row.id)
837
- setDropRowId(row.id);
838
- }
839
- : undefined, onDrop: showGrip && dragRowId
840
- ? (e) => {
841
- e.preventDefault();
842
- reorderRows(dragRowId, row.id);
843
- }
844
- : undefined, children: [showGrip && (_jsx(TableCell, { className: "crisp-datatable-col-grip", ...stickyCol(-2), onClick: (e) => e.stopPropagation(), children: _jsx("button", { type: "button", className: "crisp-datatable-grip", "aria-label": reorderRowLabel, draggable: true, onDragStart: (e) => {
845
- setDragRowId(row.id);
846
- e.dataTransfer.effectAllowed = "move";
847
- }, onDragEnd: () => {
848
- setDragRowId(null);
849
- setDropRowId(null);
850
- }, children: _jsx(GripVertical, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }) })), selectable && (_jsx(TableCell, { className: "crisp-datatable-col-check", ...stickyCol(-1), onClick: (e) => e.stopPropagation(), children: _jsx("span", { className: "crisp-datatable-check", children: _jsx(Checkbox, { "aria-label": selectRowLabel(row), checked: selected.has(row.id), onChange: () => toggleRow(row.id) }) }) })), columns.map((col, i) => {
851
- const isEditing = editing?.id === row.id && editing?.key === col.key;
852
- const value = cellValue(col, row);
853
- const sticky = dataStickyCol(i);
854
- const isPending = pending?.id === row.id && pending?.key === col.key;
855
- const errorHere = editError?.id === row.id && editError?.key === col.key
856
- ? editError.message
857
- : null;
858
- // #453: a `switch` editable column IS always its own
859
- // live widget -- flipping the toggle IS the commit
860
- // gesture, there's no meaningfully different "rest"
861
- // display for a boolean. `select`/`reference` are
862
- // NOT this: at rest they must show the column's own
863
- // render/format like every other editable type
864
- // (#412 empty handling included), and only open on
865
- // double-click (#411) -- this branch previously
866
- // rendered them unconditionally, replacing every
867
- // row's own render with a picker trigger, which is
868
- // the regression #453 reported (also #432/#449,
869
- // which never had a "rest" state of their own to get
870
- // right). See the `isEditing` block below for their
871
- // actual editor.
872
- if (col.editable && col.editor === "switch") {
873
- return (_jsxs(TableCell, { align: alignFor(col), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined, children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [_jsx(Switch, { "aria-label": `Edit ${col.key}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commitEdit(row.id, col.key, String(checked)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
1181
+ })] }) }), _jsx(TableBody, { children: error ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: totalCols, children: _jsx("div", { className: "crisp-datatable-empty", children: error }) }) })) : rows.length === 0 ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: totalCols, children: _jsx("div", { className: "crisp-datatable-empty", children: empty }) }) })) : (_jsxs(_Fragment, { children: [topSpacerHeight > 0 && (_jsx("tr", { "aria-hidden": "true", children: _jsx("td", { colSpan: totalCols, style: {
1182
+ height: topSpacerHeight,
1183
+ padding: 0,
1184
+ border: "none",
1185
+ } }) })), visibleItems.map((item, localIndex) => {
1186
+ const itemIndex = rowStart + localIndex;
1187
+ // header row is aria-rowindex 1, so the first body item is 2
1188
+ const ariaRowIndex = itemIndex + 2;
1189
+ if (item.kind === "group") {
1190
+ return (_jsx(TableRow, { "aria-rowindex": ariaRowIndex, children: _jsxs(TableCell, { colSpan: totalCols, className: "crisp-datatable-group", children: [_jsx("span", { className: "crisp-datatable-group-label", children: item.label }), _jsx("span", { className: "crisp-datatable-group-count", children: item.count })] }) }, item.key));
1191
+ }
1192
+ const row = item.row;
1193
+ return (_jsxs(TableRow, { "aria-rowindex": ariaRowIndex, "data-selected": selected.has(row.id) ? "" : undefined, "data-drop-target": showGrip &&
1194
+ dragRowId &&
1195
+ dragRowId !== row.id &&
1196
+ dropRowId === row.id
1197
+ ? ""
1198
+ : undefined, onClick: onRowClick ? () => onRowClick(row) : undefined, style: onRowClick ? { cursor: "pointer" } : undefined, onDragOver: showGrip && dragRowId
1199
+ ? (e) => {
1200
+ e.preventDefault();
1201
+ if (dropRowId !== row.id)
1202
+ setDropRowId(row.id);
874
1203
  }
875
- if (isEditing &&
876
- (col.editor === "select" || col.editor === "reference")) {
877
- return (_jsxs(TableCell, { align: alignFor(col), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined,
878
- // Same as the plain text editor's own cell:
879
- // opening/using the picker must not also fire
880
- // the row's onRowClick. Escape is handled by
881
- // `DataSelectEditor`/`DataReferenceEditor`
882
- // themselves (a document-level listener, not a
883
- // `TableCell`-level `onKeyDown`) -- opening the
884
- // picker moves DOM focus into a floating,
885
- // portaled popover, so a handler here can't
886
- // rely on the key event ever bubbling through
887
- // this element (#453).
888
- onClick: (e) => e.stopPropagation(), children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [col.editor === "select" ? (_jsx(DataSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })) : (_jsx(DataReferenceEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
1204
+ : undefined, onDrop: showGrip && dragRowId
1205
+ ? (e) => {
1206
+ e.preventDefault();
1207
+ reorderRows(dragRowId, row.id);
889
1208
  }
890
- if (isEditing) {
891
- return (_jsxs(TableCell, { align: alignFor(col), ...sticky, onClick: (e) => e.stopPropagation(), children: [_jsx("input", { className: "crisp-datatable-edit",
892
- // biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
893
- autoFocus: true, ...editorFor(col), "data-editor": editorFor(col).type, disabled: isPending, defaultValue: col.type === "date"
894
- ? toDateInputValue(value)
895
- : col.type === "datetime"
896
- ? toDateTimeInputValue(value)
897
- : asText(value), "aria-label": `Edit ${col.key}`, onKeyDown: (e) => {
898
- if (isPending)
899
- return;
900
- if (e.key === "Enter")
1209
+ : undefined, children: [showGrip && (_jsx(TableCell, { className: "crisp-datatable-col-grip", "aria-colindex": showGrip ? 1 : undefined, ...stickyCol(-2), onClick: (e) => e.stopPropagation(), children: _jsx("button", { type: "button", className: "crisp-datatable-grip", "aria-label": reorderRowLabel, draggable: true, onDragStart: (e) => {
1210
+ setDragRowId(row.id);
1211
+ e.dataTransfer.effectAllowed = "move";
1212
+ }, onDragEnd: () => {
1213
+ setDragRowId(null);
1214
+ setDropRowId(null);
1215
+ }, children: _jsx(GripVertical, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }) })), selectable && (_jsx(TableCell, { className: "crisp-datatable-col-check", "aria-colindex": showGrip ? 2 : 1, ...stickyCol(-1), onClick: (e) => e.stopPropagation(), children: _jsx("span", { className: "crisp-datatable-check", children: _jsx(Checkbox, { "aria-label": selectRowLabel(row), checked: selected.has(row.id), onChange: () => toggleRow(row.id) }) }) })), colSlots.map((slot) => {
1216
+ if (slot.type === "spacer") {
1217
+ return (_jsx("td", { role: "presentation", "aria-hidden": "true", style: { padding: 0, border: "none" } }, `__spacer_${slot.side}`));
1218
+ }
1219
+ const col = columns[slot.i];
1220
+ const i = slot.i;
1221
+ const isEditing = editing?.id === row.id && editing?.key === col.key;
1222
+ const value = cellValue(col, row);
1223
+ const sticky = dataStickyCol(i);
1224
+ const isPending = pending?.id === row.id && pending?.key === col.key;
1225
+ const errorHere = editError?.id === row.id && editError?.key === col.key
1226
+ ? editError.message
1227
+ : null;
1228
+ // #453: a `switch` editable column IS always its own
1229
+ // live widget -- flipping the toggle IS the commit
1230
+ // gesture, there's no meaningfully different "rest"
1231
+ // display for a boolean. `select`/`reference` are
1232
+ // NOT this: at rest they must show the column's own
1233
+ // render/format like every other editable type
1234
+ // (#412 empty handling included), and only open on
1235
+ // double-click (#411) -- this branch previously
1236
+ // rendered them unconditionally, replacing every
1237
+ // row's own render with a picker trigger, which is
1238
+ // the regression #453 reported (also #432/#449,
1239
+ // which never had a "rest" state of their own to get
1240
+ // right). See the `isEditing` block below for their
1241
+ // actual editor.
1242
+ if (col.editable && col.editor === "switch") {
1243
+ return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined, children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [_jsx(Switch, { "aria-label": `Edit ${col.key}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commitEdit(row.id, col.key, String(checked)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
1244
+ }
1245
+ if (isEditing &&
1246
+ (col.editor === "select" || col.editor === "reference")) {
1247
+ return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined,
1248
+ // Same as the plain text editor's own cell:
1249
+ // opening/using the picker must not also fire
1250
+ // the row's onRowClick. Escape is handled by
1251
+ // `DataSelectEditor`/`DataReferenceEditor`
1252
+ // themselves (a document-level listener, not a
1253
+ // `TableCell`-level `onKeyDown`) -- opening the
1254
+ // picker moves DOM focus into a floating,
1255
+ // portaled popover, so a handler here can't
1256
+ // rely on the key event ever bubbling through
1257
+ // this element (#453).
1258
+ onClick: (e) => e.stopPropagation(), children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [col.editor === "select" ? (_jsx(DataSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })) : (_jsx(DataReferenceEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
1259
+ }
1260
+ if (isEditing) {
1261
+ return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, onClick: (e) => e.stopPropagation(), children: [_jsx("input", { className: "crisp-datatable-edit",
1262
+ // biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
1263
+ autoFocus: true, ...editorFor(col), "data-editor": editorFor(col).type, disabled: isPending, defaultValue: col.type === "date"
1264
+ ? toDateInputValue(value)
1265
+ : col.type === "datetime"
1266
+ ? toDateTimeInputValue(value)
1267
+ : asText(value), "aria-label": `Edit ${col.key}`, onKeyDown: (e) => {
1268
+ if (isPending)
1269
+ return;
1270
+ if (e.key === "Enter")
1271
+ commitEdit(row.id, col.key, e.currentTarget.value);
1272
+ else if (e.key === "Escape")
1273
+ setEditing(null);
1274
+ }, onBlur: (e) => {
1275
+ if (isPending)
1276
+ return;
901
1277
  commitEdit(row.id, col.key, e.currentTarget.value);
902
- else if (e.key === "Escape")
903
- setEditing(null);
904
- }, onBlur: (e) => {
905
- if (isPending)
906
- return;
907
- commitEdit(row.id, col.key, e.currentTarget.value);
908
- } }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }, col.key));
909
- }
910
- return (_jsxs(TableCell, { align: alignFor(col), ...sticky, className: col.editable
911
- ? "crisp-datatable-editable"
912
- : undefined, "data-selected": selCell?.id === row.id && selCell?.key === col.key
913
- ? ""
914
- : undefined, onClick: () => {
915
- // the keyboard model's anchor -- a single click
916
- // only selects. A genuine double-click (or Enter
917
- // on the selected cell) opens the editor (#411),
918
- // matching RecordsTable's own onDoubleClick.
919
- setSelCell({ id: row.id, key: col.key });
920
- gridRef.current?.focus({ preventScroll: true });
921
- }, onDoubleClick: col.editable
922
- ? () => openEditing(row.id, col.key)
923
- : undefined, children: [col.render
924
- ? col.render(value, row)
925
- : renderTyped(col, value, avatarTone, row, col.format?.(value, row), locale), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
926
- })] }, row.id)))] }, group ?? "__ungrouped")))) }), (hasSummary || onAddRow) && (_jsxs(TableFooter, { children: [hasSummary && (_jsxs(TableRow, { children: [showGrip && _jsx(TableCell, { ...stickyCol(-2) }), selectable && _jsx(TableCell, { ...stickyCol(-1) }), columns.map((col, i) => (_jsx(TableCell, { align: alignFor(col), ...dataStickyCol(i), children: col.summary ? col.summary(rows) : null }, col.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] }) }) }))] }))] })] }));
1278
+ } }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }, col.key));
1279
+ }
1280
+ return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: col.editable
1281
+ ? "crisp-datatable-editable"
1282
+ : undefined, "data-selected": selCell?.id === row.id && selCell?.key === col.key
1283
+ ? ""
1284
+ : undefined, onClick: () => {
1285
+ // the keyboard model's anchor -- a single click
1286
+ // only selects. A genuine double-click (or Enter
1287
+ // on the selected cell) opens the editor (#411),
1288
+ // matching RecordsTable's own onDoubleClick.
1289
+ setSelCell({ id: row.id, key: col.key });
1290
+ gridRef.current?.focus({ preventScroll: true });
1291
+ }, onDoubleClick: col.editable
1292
+ ? () => openEditing(row.id, col.key)
1293
+ : undefined, children: [col.render
1294
+ ? col.render(value, row)
1295
+ : renderTyped(col, value, avatarTone, row, col.format?.(value, row), locale), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
1296
+ })] }, row.id));
1297
+ }), bottomSpacerHeight > 0 && (_jsx("tr", { "aria-hidden": "true", children: _jsx("td", { colSpan: totalCols, style: {
1298
+ height: bottomSpacerHeight,
1299
+ padding: 0,
1300
+ border: "none",
1301
+ } }) }))] })) }), (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
1302
+ ? columns[slot.i].summary?.(rows)
1303
+ : 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] }) }) }))] }))] })] }));
927
1304
  }
928
1305
  DataTable.displayName = "DataTable";