@crispy-seed/data-table 0.11.2 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1179,6 +1179,51 @@ function isStringArray(value) {
1179
1179
  /** Removes the view with this `id`. */ function removeDataTableView(views, id) {
1180
1180
  return views.filter((view)=>view.id !== id);
1181
1181
  }
1182
+ // -- row virtualization ---------------------------------------------------------------
1183
+ // `virtualized` renders only the rows inside (and a little around) the scroll
1184
+ // container's viewport, with two spacer rows standing in for the rest so the scroll
1185
+ // height -- and therefore the scrollbar -- still matches the full data set. Rows are a
1186
+ // fixed height, so which rows are visible is plain arithmetic on `scrollTop`; no
1187
+ // per-row measurement, no ResizeObserver per row.
1188
+ /** The recipe's own row height (`--seed-dimension-x9` = 36px, `row.height`). */ const DEFAULT_VIRTUAL_ROW_HEIGHT = 36;
1189
+ /** Rows rendered beyond each edge of the viewport, so a fast scroll never outruns the
1190
+ * render and flashes an empty gap. */ const DEFAULT_VIRTUAL_OVERSCAN = 8;
1191
+ /** Distance from the bottom (px) at which `onLoadMore` fires in virtualized mode --
1192
+ * the same 160px look-ahead the IntersectionObserver's rootMargin gives a non-
1193
+ * virtualized table. */ const LOAD_MORE_THRESHOLD = 160;
1194
+ /**
1195
+ * Which slice `[start, end)` of `count` uniform-height rows to render for a viewport,
1196
+ * plus the heights of the two spacers that keep the total scroll height at
1197
+ * `count * rowHeight` (`topSpacer + rendered rows + bottomSpacer`). Pure -- the hook
1198
+ * measures the DOM, this decides.
1199
+ */ function computeVirtualWindow(input) {
1200
+ const { count, rowHeight, viewportHeight, overscan } = input;
1201
+ if (count <= 0 || rowHeight <= 0) return {
1202
+ start: 0,
1203
+ end: 0,
1204
+ topSpacer: 0,
1205
+ bottomSpacer: 0
1206
+ };
1207
+ const scrollTop = Math.max(0, input.scrollTop);
1208
+ const firstVisible = Math.floor(scrollTop / rowHeight);
1209
+ const lastVisibleExclusive = Math.ceil((scrollTop + Math.max(0, viewportHeight)) / rowHeight);
1210
+ const start = Math.min(count, Math.max(0, firstVisible - overscan));
1211
+ const end = Math.min(count, Math.max(start, lastVisibleExclusive + overscan));
1212
+ return {
1213
+ start,
1214
+ end,
1215
+ topSpacer: start * rowHeight,
1216
+ bottomSpacer: (count - end) * rowHeight
1217
+ };
1218
+ }
1219
+ /**
1220
+ * Scroll-position replacement for the sentinel row's IntersectionObserver: a
1221
+ * virtualized table can't rely on a sentinel (it would be windowed out along with the
1222
+ * rows), so "close enough to the bottom" is read straight off the scroll container.
1223
+ * The 1px slop matches `useDataTableScroll`'s own sub-pixel tolerance.
1224
+ */ function isNearBottom(scrollTop, clientHeight, scrollHeight, threshold = LOAD_MORE_THRESHOLD) {
1225
+ return scrollTop + clientHeight >= scrollHeight - threshold - 1;
1226
+ }
1182
1227
  const EMPTY_STRINGS = [];
1183
1228
  const EMPTY_RECORD = {};
1184
1229
  /**
@@ -1295,13 +1340,15 @@ const EMPTY_RECORD = {};
1295
1340
  })), [
1296
1341
  setColumnWidths
1297
1342
  ]);
1298
- const moveColumn = React.useCallback((key, direction)=>setRawOrder((current)=>moveColumnKey(current ?? columnKeys, key, direction)), [
1343
+ const moveColumn = React.useCallback((key, direction)=>setRawOrder((current)=>moveColumnKey(reconcileColumnOrder(current ?? columnKeys, columnKeys, actionsColumnKey), key, direction)), [
1299
1344
  setRawOrder,
1300
- columnKeys
1345
+ columnKeys,
1346
+ actionsColumnKey
1301
1347
  ]);
1302
- const reorderColumn = React.useCallback((dragKey, dropKey)=>setRawOrder((current)=>reorderColumnKey(current ?? columnKeys, dragKey, dropKey)), [
1348
+ const reorderColumn = React.useCallback((dragKey, dropKey)=>setRawOrder((current)=>reorderColumnKey(reconcileColumnOrder(current ?? columnKeys, columnKeys, actionsColumnKey), dragKey, dropKey)), [
1303
1349
  setRawOrder,
1304
- columnKeys
1350
+ columnKeys,
1351
+ actionsColumnKey
1305
1352
  ]);
1306
1353
  // -- drag-to-reorder ---------------------------------------------------------------
1307
1354
  // Live state only (which column is currently being dragged) -- the styled layer's
@@ -1469,11 +1516,11 @@ function sameCell(a, id, key) {
1469
1516
  key
1470
1517
  });
1471
1518
  result.then(()=>{
1472
- setPendingCell(null);
1473
- setEditingCell(null);
1519
+ setPendingCell((c)=>sameCell(c, id, key) ? null : c);
1520
+ setEditingCell((c)=>sameCell(c, id, key) ? null : c);
1474
1521
  }, (err)=>{
1475
- setPendingCell(null);
1476
- setEditingCell(null);
1522
+ setPendingCell((c)=>sameCell(c, id, key) ? null : c);
1523
+ setEditingCell((c)=>sameCell(c, id, key) ? null : c);
1477
1524
  setEditError({
1478
1525
  id,
1479
1526
  key,
@@ -1510,7 +1557,7 @@ const ROOT_MARGIN = "160px";
1510
1557
  * wires this to useDataTableScroll's own scroll container) so a BOUNDED table
1511
1558
  * triggers off its own internal scroll, not the browsing viewport.
1512
1559
  */ function useDataTableInfiniteScroll(props) {
1513
- const { onLoadMore, hasMore = true, loadingMore: loadingMoreProp, scrollRootRef } = props;
1560
+ const { onLoadMore, hasMore = true, loadingMore: loadingMoreProp, scrollRootRef, virtualized = false } = props;
1514
1561
  const [internalLoadingMore, setInternalLoadingMore] = React.useState(false);
1515
1562
  const loadingMore = loadingMoreProp ?? internalLoadingMore;
1516
1563
  // Mirrors the latest props into a ref the observer callback reads -- the callback
@@ -1550,6 +1597,7 @@ const ROOT_MARGIN = "160px";
1550
1597
  const [sentinelNode, setSentinelNode] = React.useState(null);
1551
1598
  const sentinelRef = React.useCallback((node)=>setSentinelNode(node), []);
1552
1599
  React.useEffect(()=>{
1600
+ if (virtualized) return;
1553
1601
  if (!sentinelNode || !onLoadMore || typeof IntersectionObserver === "undefined") return;
1554
1602
  // Effects run after every ref callback of the SAME commit (including
1555
1603
  // `scrollRootRef`'s own, set by useDataTableScroll's `scrollAreaRef`) regardless
@@ -1570,6 +1618,23 @@ const ROOT_MARGIN = "160px";
1570
1618
  sentinelNode,
1571
1619
  onLoadMore,
1572
1620
  scrollRootRef,
1621
+ triggerLoadMore,
1622
+ virtualized
1623
+ ]);
1624
+ // Virtualized mode's trigger: near the bottom of the scroll container (same gate --
1625
+ // `hasMore`, not already loading -- via `triggerLoadMore`). Called on every scroll
1626
+ // and again after the row count / loading state changes (a page that lands while
1627
+ // the user is still at the bottom, or too short to overflow, must chain the next
1628
+ // load rather than wait for a scroll that can never come).
1629
+ const checkScrollPosition = React.useCallback(()=>{
1630
+ const element = scrollRootRef?.current;
1631
+ if (!virtualized || !element) return;
1632
+ if (isNearBottom(element.scrollTop, element.clientHeight, element.scrollHeight)) {
1633
+ triggerLoadMore();
1634
+ }
1635
+ }, [
1636
+ virtualized,
1637
+ scrollRootRef,
1573
1638
  triggerLoadMore
1574
1639
  ]);
1575
1640
  return {
@@ -1583,7 +1648,9 @@ const ROOT_MARGIN = "160px";
1583
1648
  /** Fires `onLoadMore` directly, subject to the same `hasMore`/`loadingMore` gate
1584
1649
  * the observer itself uses -- exposed for a consumer that wants a manual retry
1585
1650
  * affordance (e.g. after a failed load) rather than only the observer's own
1586
- * scroll trigger. */ triggerLoadMore
1651
+ * scroll trigger. */ triggerLoadMore,
1652
+ /** Virtualized mode's scroll-position check (no-op otherwise); `useDataTable`
1653
+ * calls it from the scroll container's `onScroll`. */ checkScrollPosition
1587
1654
  };
1588
1655
  }
1589
1656
 
@@ -1595,19 +1662,53 @@ const ROOT_MARGIN = "160px";
1595
1662
  * real ref to a real scrollable element, re-measured on every scroll (and whenever the
1596
1663
  * styled layer calls `remeasure`, e.g. after a column resize/reorder changes the
1597
1664
  * scrollable width without the element itself ever firing a `scroll` event).
1598
- */ function useDataTableScroll() {
1665
+ */ function useDataTableScroll(props = {}) {
1666
+ const { virtualized = false, rowHeight = DEFAULT_VIRTUAL_ROW_HEIGHT } = props;
1667
+ const overscan = props.overscan ?? DEFAULT_VIRTUAL_OVERSCAN;
1599
1668
  const elementRef = React.useRef(null);
1600
1669
  const [scrolledStart, setScrolledStart] = React.useState(false);
1601
1670
  const [scrolledEnd, setScrolledEnd] = React.useState(false);
1671
+ const [viewport, setViewport] = React.useState({
1672
+ scrollTop: 0,
1673
+ viewportHeight: 0
1674
+ });
1602
1675
  const remeasure = React.useCallback(()=>{
1603
1676
  const element = elementRef.current;
1604
1677
  if (!element) return;
1678
+ if (virtualized) {
1679
+ // Row 0 starts below the sticky header, so measure from there. Snapping to whole
1680
+ // rows keeps re-renders to one per row scrolled (not one per scroll event); the
1681
+ // extra `rowHeight` of viewport makes up for the rows the snap rounded away.
1682
+ const headerHeight = element.querySelector("thead")?.offsetHeight ?? 0;
1683
+ const scrollTop = Math.max(0, element.scrollTop - headerHeight);
1684
+ const snapped = Math.floor(scrollTop / rowHeight) * rowHeight;
1685
+ const viewportHeight = element.clientHeight + rowHeight;
1686
+ setViewport((prev)=>prev.scrollTop === snapped && prev.viewportHeight === viewportHeight ? prev : {
1687
+ scrollTop: snapped,
1688
+ viewportHeight
1689
+ });
1690
+ }
1605
1691
  setScrolledStart(element.scrollLeft > 0);
1606
1692
  // A 1px slop: sub-pixel layout can leave `scrollLeft + clientWidth` a fraction
1607
1693
  // short of `scrollWidth` even when fully scrolled, which would otherwise leave
1608
1694
  // the trailing shadow stuck on forever.
1609
1695
  setScrolledEnd(element.scrollLeft + element.clientWidth < element.scrollWidth - 1);
1610
- }, []);
1696
+ }, [
1697
+ virtualized,
1698
+ rowHeight
1699
+ ]);
1700
+ // The container can resize without scrolling (window resize, a bounded parent
1701
+ // changing height) -- the viewport height feeds the window, so re-measure then too.
1702
+ React.useEffect(()=>{
1703
+ const element = elementRef.current;
1704
+ if (!virtualized || !element || typeof ResizeObserver === "undefined") return;
1705
+ const observer = new ResizeObserver(()=>remeasure());
1706
+ observer.observe(element);
1707
+ return ()=>observer.disconnect();
1708
+ }, [
1709
+ virtualized,
1710
+ remeasure
1711
+ ]);
1611
1712
  const scrollAreaRef = React.useCallback((node)=>{
1612
1713
  elementRef.current = node;
1613
1714
  if (node) remeasure();
@@ -1623,6 +1724,12 @@ const ROOT_MARGIN = "160px";
1623
1724
  scrolledEnd,
1624
1725
  onScroll,
1625
1726
  remeasure,
1727
+ /** Row-virtualization state; `enabled: false` (the default) means render every row. */ virtual: {
1728
+ enabled: virtualized,
1729
+ rowHeight,
1730
+ overscan,
1731
+ ...viewport
1732
+ },
1626
1733
  // The raw ref object itself (not just the callback ref above) -- useDataTable.ts
1627
1734
  // hands this to useDataTableInfiniteScroll as the IntersectionObserver `root` for
1628
1735
  // its sentinel row, so a scrolled/bounded table (DATATABLE-ATTIO-SPEC.md's
@@ -2021,7 +2128,7 @@ function useDataTable(props) {
2021
2128
  const toolbarFilter = useDataTableToolbarFilter(props);
2022
2129
  const columns = useDataTableColumns(props);
2023
2130
  const columnCalc = useDataTableColumnCalc(props);
2024
- const scroll = useDataTableScroll();
2131
+ const scroll = useDataTableScroll(props);
2025
2132
  const edit = useDataTableEdit(props);
2026
2133
  // Feature C (#12) -- which single cell (if any) is showing its read-only "show
2027
2134
  // full content" overlay. Independent of `edit` above -- see
@@ -2035,6 +2142,28 @@ function useDataTable(props) {
2035
2142
  ...props,
2036
2143
  scrollRootRef: scroll.elementRef
2037
2144
  });
2145
+ // Virtualized mode swaps the sentinel's IntersectionObserver for a scroll-position
2146
+ // check: on every scroll (below), and again whenever the row count or loading state
2147
+ // changes -- new rows can leave the user still at the bottom.
2148
+ const { checkScrollPosition } = infiniteScroll;
2149
+ const rowCount = props.rowIds?.length;
2150
+ // biome-ignore lint/correctness/useExhaustiveDependencies: rowCount/loadingMore/hasMore are the triggers, not values read in the effect
2151
+ React.useEffect(()=>{
2152
+ checkScrollPosition();
2153
+ }, [
2154
+ checkScrollPosition,
2155
+ rowCount,
2156
+ infiniteScroll.loadingMore,
2157
+ infiniteScroll.hasMore
2158
+ ]);
2159
+ const { onScroll: measureScroll } = scroll;
2160
+ const onScroll = React.useCallback(()=>{
2161
+ measureScroll();
2162
+ checkScrollPosition();
2163
+ }, [
2164
+ measureScroll,
2165
+ checkScrollPosition
2166
+ ]);
2038
2167
  // Unifies `sort` (the header's own single click-to-sort column, above) with
2039
2168
  // `toolbarSort.toolbarSorts` (the toolbar Sort popover's ordered list, above) into
2040
2169
  // ONE state instead of two -- before this, a header click only ever moved `sort`,
@@ -2146,6 +2275,7 @@ function useDataTable(props) {
2146
2275
  ...edit,
2147
2276
  ...peek,
2148
2277
  ...infiniteScroll,
2278
+ onScroll,
2149
2279
  rootProps: domUtils.elementProps({
2150
2280
  role: "table"
2151
2281
  })
@@ -2188,7 +2318,7 @@ function useDataTableRowContext({ strict = true } = {}) {
2188
2318
  return context;
2189
2319
  }
2190
2320
  const DataTableRoot = /*#__PURE__*/ React.forwardRef((props, ref)=>{
2191
- const { sort, defaultSort, onSortChange, rowIds, selectable, selectedIds, defaultSelectedIds, onSelectionChange, query, defaultQuery, onQueryChange, toolbarSorts, defaultToolbarSorts, onToolbarSortsChange, toolbarFilters, defaultToolbarFilters, onToolbarFiltersChange, columnKeys, actionsColumnKey, columnOrder, defaultColumnOrder, onColumnOrderChange, hiddenColumnKeys, defaultHiddenColumnKeys, onHiddenColumnKeysChange, columnWidths, defaultColumnWidths, onColumnWidthsChange, columnLabels, defaultColumnLabels, onColumnLabelsChange, pinnedColumnKeys, defaultPinnedColumnKeys, onPinnedColumnKeysChange, columnCalcOps, defaultColumnCalcOps, onColumnCalcOpsChange, onEdit, onLoadMore, hasMore, loadingMore, ...otherProps } = props;
2321
+ const { sort, defaultSort, onSortChange, rowIds, selectable, selectedIds, defaultSelectedIds, onSelectionChange, query, defaultQuery, onQueryChange, toolbarSorts, defaultToolbarSorts, onToolbarSortsChange, toolbarFilters, defaultToolbarFilters, onToolbarFiltersChange, columnKeys, actionsColumnKey, columnOrder, defaultColumnOrder, onColumnOrderChange, hiddenColumnKeys, defaultHiddenColumnKeys, onHiddenColumnKeysChange, columnWidths, defaultColumnWidths, onColumnWidthsChange, columnLabels, defaultColumnLabels, onColumnLabelsChange, pinnedColumnKeys, defaultPinnedColumnKeys, onPinnedColumnKeysChange, columnCalcOps, defaultColumnCalcOps, onColumnCalcOpsChange, onEdit, onLoadMore, hasMore, loadingMore, virtualized, rowHeight, overscan, ...otherProps } = props;
2192
2322
  const api = useDataTable({
2193
2323
  sort,
2194
2324
  defaultSort,
@@ -2230,7 +2360,10 @@ const DataTableRoot = /*#__PURE__*/ React.forwardRef((props, ref)=>{
2230
2360
  onEdit,
2231
2361
  onLoadMore,
2232
2362
  hasMore,
2233
- loadingMore
2363
+ loadingMore,
2364
+ virtualized,
2365
+ rowHeight,
2366
+ overscan
2234
2367
  });
2235
2368
  return /*#__PURE__*/ jsxRuntime.jsx(DataTableProvider, {
2236
2369
  value: api,
@@ -2313,9 +2446,20 @@ DataTableCell$1.displayName = "DataTableCell";
2313
2446
 
2314
2447
  const EMPTY_VIEWS = [];
2315
2448
  function readStoredViews(storageKey) {
2316
- if (!storageKey || typeof window === "undefined") return EMPTY_VIEWS;
2449
+ if (!storageKey || typeof window === "undefined") return null;
2317
2450
  try {
2318
- return deserializeDataTableViews(window.localStorage.getItem(storageKey));
2451
+ const raw = window.localStorage.getItem(storageKey);
2452
+ if (raw === null) return null;
2453
+ // Only a well-formed array counts as "stored" -- `[]` is a real answer (the user
2454
+ // deleted every view), whereas garbage/non-array JSON is treated as absent so the
2455
+ // defaults still seed.
2456
+ let parsed;
2457
+ try {
2458
+ parsed = JSON.parse(raw);
2459
+ } catch {
2460
+ return null;
2461
+ }
2462
+ return Array.isArray(parsed) ? deserializeDataTableViews(raw) : null;
2319
2463
  } catch {
2320
2464
  // A private-browsing tab / blocked storage throws on access, not just on quota --
2321
2465
  // this hook degrades to in-memory-only rather than crash the table over it.
@@ -2353,7 +2497,7 @@ function readStoredViews(storageKey) {
2353
2497
  // Nothing in storage yet (first run, or no `storageKey` at all) -- seed from
2354
2498
  // `defaultViews` instead of an empty list, matching every other hook's own
2355
2499
  // `defaultProp` semantics.
2356
- initialViewsRef.current = stored.length > 0 ? stored : defaultViews;
2500
+ initialViewsRef.current = stored !== null ? stored : defaultViews;
2357
2501
  }
2358
2502
  }
2359
2503
  const [views = EMPTY_VIEWS, setViews] = useControllableState$1({
@@ -4349,6 +4493,10 @@ const { ClassNamesProvider, withContext: withContext$1, useClassNames } = create
4349
4493
  });
4350
4494
  }
4351
4495
 
4496
+ /** Attribute on the floating-ui-portaled option list of the `select`/`reference`/`actor`
4497
+ * editors. The portal lands in `document.body`, outside the cell and its expand overlay,
4498
+ * so `body-cells.tsx`'s outside-click detector checks for this marker to avoid reading an
4499
+ * option click as "outside" and cancelling the edit before it commits. */ const EDITOR_PORTAL_ATTR = "data-datatable-editor-portal";
4352
4500
  /** The raw value a live editor reads, distinct from `DataTableColumn.value` (the
4353
4501
  * accessor sort/search/filter/export share): `editValue` returns whatever TYPE the
4354
4502
  * editor itself expects -- a real `boolean` for `editor: "switch"`/`type:
@@ -4497,6 +4645,7 @@ function SelectEditor({ column, value, pending, onCommit, onCancel }) {
4497
4645
  ]
4498
4646
  }),
4499
4647
  /*#__PURE__*/ jsxRuntime.jsx(SelectPositioner, {
4648
+ [EDITOR_PORTAL_ATTR]: "",
4500
4649
  children: /*#__PURE__*/ jsxRuntime.jsxs(SelectContent, {
4501
4650
  children: [
4502
4651
  searchable && /*#__PURE__*/ jsxRuntime.jsx(SelectSearchField, {
@@ -4572,6 +4721,7 @@ function ReferenceEditor({ column, value, pending, onCommit, onCancel }) {
4572
4721
  ]
4573
4722
  }),
4574
4723
  /*#__PURE__*/ jsxRuntime.jsx(SelectPositioner, {
4724
+ [EDITOR_PORTAL_ATTR]: "",
4575
4725
  children: /*#__PURE__*/ jsxRuntime.jsxs(SelectContent, {
4576
4726
  children: [
4577
4727
  /*#__PURE__*/ jsxRuntime.jsx(SelectSearchField, {
@@ -5187,8 +5337,14 @@ function ColumnBodyCells({ columns, row, classNames, locale, selectable, stickyF
5187
5337
  function isInside(target) {
5188
5338
  return (cellRef.current?.contains(target) ?? false) || (popoverRef.current?.contains(target) ?? false) || (expandOverlayRef.current?.contains(target) ?? false);
5189
5339
  }
5340
+ // The select/reference/actor editor's option list is portaled to `document.body` by
5341
+ // floating-ui, outside both `cellRef` and `expandOverlayRef`; `editors.tsx` marks its
5342
+ // positioner with `EDITOR_PORTAL_ATTR`, so a click on an option is not an outside click.
5343
+ function isInsideEditorPortal(target) {
5344
+ return target instanceof Element && target.closest(`[${EDITOR_PORTAL_ATTR}]`) != null;
5345
+ }
5190
5346
  function handlePointerDown(event) {
5191
- if (!isInside(event.target)) {
5347
+ if (!isInside(event.target) && !isInsideEditorPortal(event.target)) {
5192
5348
  setOverlayOpen(false);
5193
5349
  setPopoverStyle(null);
5194
5350
  closePeek();
@@ -11062,7 +11218,13 @@ const toDateOrNull = (v)=>{
11062
11218
  return f.op === "before" ? d < cd : d > cd;
11063
11219
  }
11064
11220
  const t = asText(raw).toLowerCase();
11065
- if (f.op === "isanyof") return (f.values ?? []).some((v)=>v.toLowerCase() === t);
11221
+ if (f.op === "isanyof") {
11222
+ const wanted = (f.values ?? []).map((v)=>v.toLowerCase());
11223
+ const cells = Array.isArray(raw) ? raw.map((x)=>asText(x).toLowerCase()) : [
11224
+ t
11225
+ ];
11226
+ return cells.some((c)=>wanted.includes(c));
11227
+ }
11066
11228
  const v = f.value.trim().toLowerCase();
11067
11229
  if (f.op === "contains") return t.includes(v);
11068
11230
  if (f.op === "doesnotcontain") return !t.includes(v);
@@ -11710,7 +11872,7 @@ function RowCount({ shown, total, countLabel }) {
11710
11872
  }
11711
11873
 
11712
11874
  function DataTableInner(props, ref) {
11713
- const { columns, data, surface, className, locale = "en-US", sort, defaultSort, onSortChange, sortIcon, selectable, selectedIds, defaultSelectedIds, onSelectionChange, selectAllLabel = "Select all rows", getRowSelectionLabel = ()=>"Select row", selectionCheckedIcon, selectionIndeterminateIcon, bulkActions, clearSelectionLabel, query, defaultQuery, onQueryChange, searchPlaceholder = "Search…", searchLabel = "Search table", toolbarSorts, defaultToolbarSorts, onToolbarSortsChange, toolbarFilters, defaultToolbarFilters, onToolbarFiltersChange, showCount = true, countLabel, toolbarActions, columnMenu = false, reorderable = true, resizable = true, columnOrder, defaultColumnOrder, onColumnOrderChange, hiddenColumnKeys, defaultHiddenColumnKeys, onHiddenColumnKeysChange, columnWidths, defaultColumnWidths, onColumnWidthsChange, columnLabels, defaultColumnLabels, onColumnLabelsChange, pinnedColumnKeys, defaultPinnedColumnKeys, onPinnedColumnKeysChange, onColumnsChange, columnCalcOps, defaultColumnCalcOps, onColumnCalcOpsChange, stickyFirst = false, stickyLast = false, onEdit, cellContextMenu = true, onViewEditHistory, cellContextMenuItems, interactive = false, onRowClick, onAddRow, addRowLabel = "New record", loading = false, loadingLabel = "Loading...", error, empty = "No results.", onLoadMore, hasMore, loadingMore, scrollProps, ...restProps } = props;
11875
+ const { columns, data, surface, className, locale = "en-US", sort, defaultSort, onSortChange, sortIcon, selectable, selectedIds, defaultSelectedIds, onSelectionChange, selectAllLabel = "Select all rows", getRowSelectionLabel = ()=>"Select row", selectionCheckedIcon, selectionIndeterminateIcon, bulkActions, clearSelectionLabel, query, defaultQuery, onQueryChange, searchPlaceholder = "Search…", searchLabel = "Search table", toolbarSorts, defaultToolbarSorts, onToolbarSortsChange, toolbarFilters, defaultToolbarFilters, onToolbarFiltersChange, showCount = true, countLabel, toolbarActions, columnMenu = false, reorderable = true, resizable = true, columnOrder, defaultColumnOrder, onColumnOrderChange, hiddenColumnKeys, defaultHiddenColumnKeys, onHiddenColumnKeysChange, columnWidths, defaultColumnWidths, onColumnWidthsChange, columnLabels, defaultColumnLabels, onColumnLabelsChange, pinnedColumnKeys, defaultPinnedColumnKeys, onPinnedColumnKeysChange, onColumnsChange, columnCalcOps, defaultColumnCalcOps, onColumnCalcOpsChange, stickyFirst = false, stickyLast = false, onEdit, cellContextMenu = true, onViewEditHistory, cellContextMenuItems, interactive = false, onRowClick, onAddRow, addRowLabel = "New record", loading = false, loadingLabel = "Loading...", error, empty = "No results.", onLoadMore, hasMore, loadingMore, scrollProps, virtualized = false, rowHeight, overscan, ...restProps } = props;
11714
11876
  const classNames = dataTable({
11715
11877
  surface,
11716
11878
  interactive
@@ -11883,6 +12045,9 @@ function DataTableInner(props, ref) {
11883
12045
  onLoadMore: onLoadMore,
11884
12046
  hasMore: hasMore,
11885
12047
  loadingMore: loadingMore,
12048
+ virtualized: virtualized,
12049
+ rowHeight: rowHeight,
12050
+ overscan: overscan,
11886
12051
  ...restProps,
11887
12052
  children: [
11888
12053
  onColumnsChange && /*#__PURE__*/ jsxRuntime.jsx(ColumnsChangeEffect, {
@@ -11915,7 +12080,9 @@ function DataTableInner(props, ref) {
11915
12080
  }),
11916
12081
  /*#__PURE__*/ jsxRuntime.jsx(DataTableScrollBody, {
11917
12082
  scrollProps: scrollProps,
12083
+ virtualized: virtualized,
11918
12084
  children: /*#__PURE__*/ jsxRuntime.jsxs(DataTableTable, {
12085
+ "aria-rowcount": virtualized ? sortedData.length + 1 : undefined,
11919
12086
  children: [
11920
12087
  /*#__PURE__*/ jsxRuntime.jsx(DataTableColGroup, {
11921
12088
  columns: columns,
@@ -11971,39 +12138,47 @@ function DataTableInner(props, ref) {
11971
12138
  })
11972
12139
  }) : /*#__PURE__*/ jsxRuntime.jsxs(jsxRuntime.Fragment, {
11973
12140
  children: [
11974
- sortedData.map((row)=>/*#__PURE__*/ jsxRuntime.jsxs(DataTableRow, {
11975
- rowId: row.id,
11976
- "data-clickable": onRowClick ? "" : undefined,
11977
- onClick: onRowClick ? ()=>onRowClick(row) : undefined,
11978
- children: [
11979
- selectable && /*#__PURE__*/ jsxRuntime.jsx(SelectionCell, {
11980
- // The row itself carries `onClick={onRowClick}` above, and a
11981
- // native click on this cell's checkbox bubbles up through it
11982
- // (React attaches DataTableRow's handler on the <tr>, which
11983
- // sits between the checkbox and anything a consumer could stop
11984
- // it from at -- so this has to live here, not in the consumer).
11985
- // Without this, checking a row's box also fires `onRowClick`.
11986
- onClick: (event)=>event.stopPropagation(),
11987
- stickyFirst: stickyFirst,
11988
- children: /*#__PURE__*/ jsxRuntime.jsx(RowCheckbox, {
11989
- label: getRowSelectionLabel(row),
11990
- checkedIcon: selectionCheckedIcon ?? DEFAULT_CHECKED_ICON
11991
- })
11992
- }),
11993
- /*#__PURE__*/ jsxRuntime.jsx(ColumnBodyCells, {
11994
- columns: columns,
11995
- row: row,
11996
- classNames: classNames,
11997
- locale: locale,
11998
- selectable: !!selectable,
11999
- stickyFirst: stickyFirst,
12000
- stickyLast: resolvedStickyLast,
12001
- cellContextMenu: cellContextMenu,
12002
- onViewEditHistory: onViewEditHistory,
12003
- cellContextMenuItems: cellContextMenuItems
12004
- })
12005
- ]
12006
- }, row.id)),
12141
+ /*#__PURE__*/ jsxRuntime.jsx(WindowedRows, {
12142
+ rows: sortedData,
12143
+ virtualized: virtualized,
12144
+ colSpan: totalCols,
12145
+ children: (windowRows, firstIndex)=>/*#__PURE__*/ jsxRuntime.jsx(jsxRuntime.Fragment, {
12146
+ children: windowRows.map((row, offset)=>/*#__PURE__*/ jsxRuntime.jsxs(DataTableRow, {
12147
+ rowId: row.id,
12148
+ "aria-rowindex": virtualized ? firstIndex + offset + 2 : undefined,
12149
+ "data-clickable": onRowClick ? "" : undefined,
12150
+ onClick: onRowClick ? ()=>onRowClick(row) : undefined,
12151
+ children: [
12152
+ selectable && /*#__PURE__*/ jsxRuntime.jsx(SelectionCell, {
12153
+ // The row itself carries `onClick={onRowClick}` above, and a
12154
+ // native click on this cell's checkbox bubbles up through it
12155
+ // (React attaches DataTableRow's handler on the <tr>, which
12156
+ // sits between the checkbox and anything a consumer could stop
12157
+ // it from at -- so this has to live here, not in the consumer).
12158
+ // Without this, checking a row's box also fires `onRowClick`.
12159
+ onClick: (event)=>event.stopPropagation(),
12160
+ stickyFirst: stickyFirst,
12161
+ children: /*#__PURE__*/ jsxRuntime.jsx(RowCheckbox, {
12162
+ label: getRowSelectionLabel(row),
12163
+ checkedIcon: selectionCheckedIcon ?? DEFAULT_CHECKED_ICON
12164
+ })
12165
+ }),
12166
+ /*#__PURE__*/ jsxRuntime.jsx(ColumnBodyCells, {
12167
+ columns: columns,
12168
+ row: row,
12169
+ classNames: classNames,
12170
+ locale: locale,
12171
+ selectable: !!selectable,
12172
+ stickyFirst: stickyFirst,
12173
+ stickyLast: resolvedStickyLast,
12174
+ cellContextMenu: cellContextMenu,
12175
+ onViewEditHistory: onViewEditHistory,
12176
+ cellContextMenuItems: cellContextMenuItems
12177
+ })
12178
+ ]
12179
+ }, row.id))
12180
+ })
12181
+ }),
12007
12182
  onLoadMore && /*#__PURE__*/ jsxRuntime.jsx(InfiniteScrollRow, {
12008
12183
  colSpan: totalCols,
12009
12184
  classNames: classNames,
@@ -12104,7 +12279,69 @@ const DataTableTable = withContext$1(DataTableTable$1, "table");
12104
12279
  * out of sync between the three. A column with no explicit width gets a bare `<col>`
12105
12280
  * (no `style`): `table-layout: fixed`'s own algorithm gives every such column an equal
12106
12281
  * share of whatever space is left, matching the old flex grid's `flex: 1 1 0%`
12107
- * default with no extra code needed here. */ function DataTableColGroup({ columns, selectable, stickyFirst }) {
12282
+ * default with no extra code needed here. */ /**
12283
+ * Row virtualization's render slice. Lives inside `DataTableRoot` (it reads the
12284
+ * windowing state off context, which `DataTableInner` above can't) and hands its
12285
+ * children the visible `[start, end)` slice of `rows` plus that slice's first index,
12286
+ * flanked by a spacer row each side that stands in for everything cut off -- so the
12287
+ * scroll height stays `rows.length * rowHeight` and the scrollbar matches the full
12288
+ * data set. `virtualized` off: every row, no spacers, the pre-0.12 output exactly.
12289
+ */ function WindowedRows({ rows, virtualized, colSpan, children }) {
12290
+ const { virtual } = useDataTableContext();
12291
+ const windowed = React__namespace.useMemo(()=>virtualized ? computeVirtualWindow({
12292
+ count: rows.length,
12293
+ rowHeight: virtual.rowHeight,
12294
+ scrollTop: virtual.scrollTop,
12295
+ viewportHeight: virtual.viewportHeight,
12296
+ overscan: virtual.overscan
12297
+ }) : null, [
12298
+ virtualized,
12299
+ rows.length,
12300
+ virtual.rowHeight,
12301
+ virtual.scrollTop,
12302
+ virtual.viewportHeight,
12303
+ virtual.overscan
12304
+ ]);
12305
+ if (!windowed) return /*#__PURE__*/ jsxRuntime.jsx(jsxRuntime.Fragment, {
12306
+ children: children(rows, 0)
12307
+ });
12308
+ return /*#__PURE__*/ jsxRuntime.jsxs(jsxRuntime.Fragment, {
12309
+ children: [
12310
+ windowed.topSpacer > 0 && /*#__PURE__*/ jsxRuntime.jsx(VirtualSpacerRow, {
12311
+ height: windowed.topSpacer,
12312
+ colSpan: colSpan
12313
+ }),
12314
+ children(rows.slice(windowed.start, windowed.end), windowed.start),
12315
+ windowed.bottomSpacer > 0 && /*#__PURE__*/ jsxRuntime.jsx(VirtualSpacerRow, {
12316
+ height: windowed.bottomSpacer,
12317
+ colSpan: colSpan
12318
+ })
12319
+ ]
12320
+ });
12321
+ }
12322
+ /** A plain `<tr><td colSpan>` of a given height -- deliberately NOT a `DataTableRow`/
12323
+ * `DataTableCell` (no hover tint, no cell chrome, no border, no 36px min height).
12324
+ * `colSpan` covers every column, so the `<colgroup>` still drives widths and sticky
12325
+ * columns are unaffected. */ function VirtualSpacerRow({ height, colSpan }) {
12326
+ return /*#__PURE__*/ jsxRuntime.jsx("tr", {
12327
+ "data-virtual-spacer": "",
12328
+ style: {
12329
+ height
12330
+ },
12331
+ children: /*#__PURE__*/ jsxRuntime.jsx("td", {
12332
+ "aria-hidden": "true",
12333
+ colSpan: colSpan,
12334
+ style: {
12335
+ height,
12336
+ padding: 0,
12337
+ border: 0,
12338
+ display: "table-cell",
12339
+ lineHeight: 0
12340
+ }
12341
+ })
12342
+ });
12343
+ }
12344
+ function DataTableColGroup({ columns, selectable, stickyFirst }) {
12108
12345
  // MUST use the same pinned-first ordering every OTHER row (header/body/footer)
12109
12346
  // renders in (`useEffectiveColumns`'s own doc comment) -- a `<colgroup>`'s `<col>`s
12110
12347
  // are matched positionally to each row's cells, so if a pinned column visually moves
@@ -12179,7 +12416,7 @@ const DataTableSelectionCell = withSelectionCell(DataTableCell$1, "cell");
12179
12416
  // to clip), so it never introduces a second vertical scrollbar next to whatever
12180
12417
  // ambient container the whole DataTable already sits in.
12181
12418
  const DataTableScrollBodyBase = withContext$1(Primitive.div, "scrollArea");
12182
- function DataTableScrollBody({ children, scrollProps }) {
12419
+ function DataTableScrollBody({ children, virtualized, scrollProps }) {
12183
12420
  const { scrollAreaRef, scrolledStart, scrolledEnd, onScroll } = useDataTableContext();
12184
12421
  const { className: scrollClassName, style: scrollStyle, ...otherScrollProps } = scrollProps ?? {};
12185
12422
  return /*#__PURE__*/ jsxRuntime.jsx(DataTableScrollBodyBase, {
@@ -12187,7 +12424,13 @@ function DataTableScrollBody({ children, scrollProps }) {
12187
12424
  ref: scrollAreaRef,
12188
12425
  onScroll: onScroll,
12189
12426
  className: scrollClassName,
12190
- style: scrollStyle,
12427
+ // Virtualized: the browser's scroll anchoring would try to hold the visible rows
12428
+ // steady as the spacer heights change under it, fighting the scroll position
12429
+ // this windowing itself derives from -- turn it off.
12430
+ style: virtualized ? {
12431
+ ...scrollStyle,
12432
+ overflowAnchor: "none"
12433
+ } : scrollStyle,
12191
12434
  "data-scrolled-start": domUtils.dataAttr(scrolledStart),
12192
12435
  "data-scrolled-end": domUtils.dataAttr(scrolledEnd),
12193
12436
  children: children
package/dist/index.d.ts CHANGED
@@ -534,6 +534,20 @@ interface DataTableProps<TRow extends {
534
534
  * 됩니다 -- 새 prop 없이 기존 `style` 전달만으로 두 모드 다 지원됩니다.
535
535
  */
536
536
  scrollProps?: Omit<react.ComponentPropsWithoutRef<"div">, "ref" | "onScroll">;
537
+ /**
538
+ * 행 가상화(windowing): 스크롤 컨테이너 뷰포트 근처의 행만 렌더링하고 나머지는 위/아래
539
+ * spacer 행으로 대체해 전체 스크롤 높이를 유지합니다. 수천 행 이상에서 사용하세요.
540
+ * 기본값(`false`)이면 지금까지처럼 모든 행을 렌더링합니다. 켜면 모든 행이 `rowHeight`로
541
+ * 동일한 높이여야 하고, 스크롤이 생기도록 높이 제한(`style={{ maxHeight }}` 등)이
542
+ * 필요합니다. 무한 스크롤(`onLoadMore`)은 sentinel 대신 스크롤 위치로 트리거됩니다.
543
+ * @default false
544
+ */
545
+ virtualized?: boolean;
546
+ /** `virtualized`일 때 한 행의 높이(px)입니다. 레시피의 행 높이(36px)와 일치해야 합니다.
547
+ * @default 36 */
548
+ rowHeight?: number;
549
+ /** `virtualized`일 때 뷰포트 위/아래로 추가 렌더링할 행 수입니다. @default 8 */
550
+ overscan?: number;
537
551
  }
538
552
  /**
539
553
  * @see https://seed-design.io/react/components/data-table
package/dist/index.js CHANGED
@@ -1160,6 +1160,51 @@ function isStringArray(value) {
1160
1160
  /** Removes the view with this `id`. */ function removeDataTableView(views, id) {
1161
1161
  return views.filter((view)=>view.id !== id);
1162
1162
  }
1163
+ // -- row virtualization ---------------------------------------------------------------
1164
+ // `virtualized` renders only the rows inside (and a little around) the scroll
1165
+ // container's viewport, with two spacer rows standing in for the rest so the scroll
1166
+ // height -- and therefore the scrollbar -- still matches the full data set. Rows are a
1167
+ // fixed height, so which rows are visible is plain arithmetic on `scrollTop`; no
1168
+ // per-row measurement, no ResizeObserver per row.
1169
+ /** The recipe's own row height (`--seed-dimension-x9` = 36px, `row.height`). */ const DEFAULT_VIRTUAL_ROW_HEIGHT = 36;
1170
+ /** Rows rendered beyond each edge of the viewport, so a fast scroll never outruns the
1171
+ * render and flashes an empty gap. */ const DEFAULT_VIRTUAL_OVERSCAN = 8;
1172
+ /** Distance from the bottom (px) at which `onLoadMore` fires in virtualized mode --
1173
+ * the same 160px look-ahead the IntersectionObserver's rootMargin gives a non-
1174
+ * virtualized table. */ const LOAD_MORE_THRESHOLD = 160;
1175
+ /**
1176
+ * Which slice `[start, end)` of `count` uniform-height rows to render for a viewport,
1177
+ * plus the heights of the two spacers that keep the total scroll height at
1178
+ * `count * rowHeight` (`topSpacer + rendered rows + bottomSpacer`). Pure -- the hook
1179
+ * measures the DOM, this decides.
1180
+ */ function computeVirtualWindow(input) {
1181
+ const { count, rowHeight, viewportHeight, overscan } = input;
1182
+ if (count <= 0 || rowHeight <= 0) return {
1183
+ start: 0,
1184
+ end: 0,
1185
+ topSpacer: 0,
1186
+ bottomSpacer: 0
1187
+ };
1188
+ const scrollTop = Math.max(0, input.scrollTop);
1189
+ const firstVisible = Math.floor(scrollTop / rowHeight);
1190
+ const lastVisibleExclusive = Math.ceil((scrollTop + Math.max(0, viewportHeight)) / rowHeight);
1191
+ const start = Math.min(count, Math.max(0, firstVisible - overscan));
1192
+ const end = Math.min(count, Math.max(start, lastVisibleExclusive + overscan));
1193
+ return {
1194
+ start,
1195
+ end,
1196
+ topSpacer: start * rowHeight,
1197
+ bottomSpacer: (count - end) * rowHeight
1198
+ };
1199
+ }
1200
+ /**
1201
+ * Scroll-position replacement for the sentinel row's IntersectionObserver: a
1202
+ * virtualized table can't rely on a sentinel (it would be windowed out along with the
1203
+ * rows), so "close enough to the bottom" is read straight off the scroll container.
1204
+ * The 1px slop matches `useDataTableScroll`'s own sub-pixel tolerance.
1205
+ */ function isNearBottom(scrollTop, clientHeight, scrollHeight, threshold = LOAD_MORE_THRESHOLD) {
1206
+ return scrollTop + clientHeight >= scrollHeight - threshold - 1;
1207
+ }
1163
1208
  const EMPTY_STRINGS = [];
1164
1209
  const EMPTY_RECORD = {};
1165
1210
  /**
@@ -1276,13 +1321,15 @@ const EMPTY_RECORD = {};
1276
1321
  })), [
1277
1322
  setColumnWidths
1278
1323
  ]);
1279
- const moveColumn = useCallback((key, direction)=>setRawOrder((current)=>moveColumnKey(current ?? columnKeys, key, direction)), [
1324
+ const moveColumn = useCallback((key, direction)=>setRawOrder((current)=>moveColumnKey(reconcileColumnOrder(current ?? columnKeys, columnKeys, actionsColumnKey), key, direction)), [
1280
1325
  setRawOrder,
1281
- columnKeys
1326
+ columnKeys,
1327
+ actionsColumnKey
1282
1328
  ]);
1283
- const reorderColumn = useCallback((dragKey, dropKey)=>setRawOrder((current)=>reorderColumnKey(current ?? columnKeys, dragKey, dropKey)), [
1329
+ const reorderColumn = useCallback((dragKey, dropKey)=>setRawOrder((current)=>reorderColumnKey(reconcileColumnOrder(current ?? columnKeys, columnKeys, actionsColumnKey), dragKey, dropKey)), [
1284
1330
  setRawOrder,
1285
- columnKeys
1331
+ columnKeys,
1332
+ actionsColumnKey
1286
1333
  ]);
1287
1334
  // -- drag-to-reorder ---------------------------------------------------------------
1288
1335
  // Live state only (which column is currently being dragged) -- the styled layer's
@@ -1450,11 +1497,11 @@ function sameCell(a, id, key) {
1450
1497
  key
1451
1498
  });
1452
1499
  result.then(()=>{
1453
- setPendingCell(null);
1454
- setEditingCell(null);
1500
+ setPendingCell((c)=>sameCell(c, id, key) ? null : c);
1501
+ setEditingCell((c)=>sameCell(c, id, key) ? null : c);
1455
1502
  }, (err)=>{
1456
- setPendingCell(null);
1457
- setEditingCell(null);
1503
+ setPendingCell((c)=>sameCell(c, id, key) ? null : c);
1504
+ setEditingCell((c)=>sameCell(c, id, key) ? null : c);
1458
1505
  setEditError({
1459
1506
  id,
1460
1507
  key,
@@ -1491,7 +1538,7 @@ const ROOT_MARGIN = "160px";
1491
1538
  * wires this to useDataTableScroll's own scroll container) so a BOUNDED table
1492
1539
  * triggers off its own internal scroll, not the browsing viewport.
1493
1540
  */ function useDataTableInfiniteScroll(props) {
1494
- const { onLoadMore, hasMore = true, loadingMore: loadingMoreProp, scrollRootRef } = props;
1541
+ const { onLoadMore, hasMore = true, loadingMore: loadingMoreProp, scrollRootRef, virtualized = false } = props;
1495
1542
  const [internalLoadingMore, setInternalLoadingMore] = useState(false);
1496
1543
  const loadingMore = loadingMoreProp ?? internalLoadingMore;
1497
1544
  // Mirrors the latest props into a ref the observer callback reads -- the callback
@@ -1531,6 +1578,7 @@ const ROOT_MARGIN = "160px";
1531
1578
  const [sentinelNode, setSentinelNode] = useState(null);
1532
1579
  const sentinelRef = useCallback((node)=>setSentinelNode(node), []);
1533
1580
  useEffect(()=>{
1581
+ if (virtualized) return;
1534
1582
  if (!sentinelNode || !onLoadMore || typeof IntersectionObserver === "undefined") return;
1535
1583
  // Effects run after every ref callback of the SAME commit (including
1536
1584
  // `scrollRootRef`'s own, set by useDataTableScroll's `scrollAreaRef`) regardless
@@ -1551,6 +1599,23 @@ const ROOT_MARGIN = "160px";
1551
1599
  sentinelNode,
1552
1600
  onLoadMore,
1553
1601
  scrollRootRef,
1602
+ triggerLoadMore,
1603
+ virtualized
1604
+ ]);
1605
+ // Virtualized mode's trigger: near the bottom of the scroll container (same gate --
1606
+ // `hasMore`, not already loading -- via `triggerLoadMore`). Called on every scroll
1607
+ // and again after the row count / loading state changes (a page that lands while
1608
+ // the user is still at the bottom, or too short to overflow, must chain the next
1609
+ // load rather than wait for a scroll that can never come).
1610
+ const checkScrollPosition = useCallback(()=>{
1611
+ const element = scrollRootRef?.current;
1612
+ if (!virtualized || !element) return;
1613
+ if (isNearBottom(element.scrollTop, element.clientHeight, element.scrollHeight)) {
1614
+ triggerLoadMore();
1615
+ }
1616
+ }, [
1617
+ virtualized,
1618
+ scrollRootRef,
1554
1619
  triggerLoadMore
1555
1620
  ]);
1556
1621
  return {
@@ -1564,7 +1629,9 @@ const ROOT_MARGIN = "160px";
1564
1629
  /** Fires `onLoadMore` directly, subject to the same `hasMore`/`loadingMore` gate
1565
1630
  * the observer itself uses -- exposed for a consumer that wants a manual retry
1566
1631
  * affordance (e.g. after a failed load) rather than only the observer's own
1567
- * scroll trigger. */ triggerLoadMore
1632
+ * scroll trigger. */ triggerLoadMore,
1633
+ /** Virtualized mode's scroll-position check (no-op otherwise); `useDataTable`
1634
+ * calls it from the scroll container's `onScroll`. */ checkScrollPosition
1568
1635
  };
1569
1636
  }
1570
1637
 
@@ -1576,19 +1643,53 @@ const ROOT_MARGIN = "160px";
1576
1643
  * real ref to a real scrollable element, re-measured on every scroll (and whenever the
1577
1644
  * styled layer calls `remeasure`, e.g. after a column resize/reorder changes the
1578
1645
  * scrollable width without the element itself ever firing a `scroll` event).
1579
- */ function useDataTableScroll() {
1646
+ */ function useDataTableScroll(props = {}) {
1647
+ const { virtualized = false, rowHeight = DEFAULT_VIRTUAL_ROW_HEIGHT } = props;
1648
+ const overscan = props.overscan ?? DEFAULT_VIRTUAL_OVERSCAN;
1580
1649
  const elementRef = useRef(null);
1581
1650
  const [scrolledStart, setScrolledStart] = useState(false);
1582
1651
  const [scrolledEnd, setScrolledEnd] = useState(false);
1652
+ const [viewport, setViewport] = useState({
1653
+ scrollTop: 0,
1654
+ viewportHeight: 0
1655
+ });
1583
1656
  const remeasure = useCallback(()=>{
1584
1657
  const element = elementRef.current;
1585
1658
  if (!element) return;
1659
+ if (virtualized) {
1660
+ // Row 0 starts below the sticky header, so measure from there. Snapping to whole
1661
+ // rows keeps re-renders to one per row scrolled (not one per scroll event); the
1662
+ // extra `rowHeight` of viewport makes up for the rows the snap rounded away.
1663
+ const headerHeight = element.querySelector("thead")?.offsetHeight ?? 0;
1664
+ const scrollTop = Math.max(0, element.scrollTop - headerHeight);
1665
+ const snapped = Math.floor(scrollTop / rowHeight) * rowHeight;
1666
+ const viewportHeight = element.clientHeight + rowHeight;
1667
+ setViewport((prev)=>prev.scrollTop === snapped && prev.viewportHeight === viewportHeight ? prev : {
1668
+ scrollTop: snapped,
1669
+ viewportHeight
1670
+ });
1671
+ }
1586
1672
  setScrolledStart(element.scrollLeft > 0);
1587
1673
  // A 1px slop: sub-pixel layout can leave `scrollLeft + clientWidth` a fraction
1588
1674
  // short of `scrollWidth` even when fully scrolled, which would otherwise leave
1589
1675
  // the trailing shadow stuck on forever.
1590
1676
  setScrolledEnd(element.scrollLeft + element.clientWidth < element.scrollWidth - 1);
1591
- }, []);
1677
+ }, [
1678
+ virtualized,
1679
+ rowHeight
1680
+ ]);
1681
+ // The container can resize without scrolling (window resize, a bounded parent
1682
+ // changing height) -- the viewport height feeds the window, so re-measure then too.
1683
+ useEffect(()=>{
1684
+ const element = elementRef.current;
1685
+ if (!virtualized || !element || typeof ResizeObserver === "undefined") return;
1686
+ const observer = new ResizeObserver(()=>remeasure());
1687
+ observer.observe(element);
1688
+ return ()=>observer.disconnect();
1689
+ }, [
1690
+ virtualized,
1691
+ remeasure
1692
+ ]);
1592
1693
  const scrollAreaRef = useCallback((node)=>{
1593
1694
  elementRef.current = node;
1594
1695
  if (node) remeasure();
@@ -1604,6 +1705,12 @@ const ROOT_MARGIN = "160px";
1604
1705
  scrolledEnd,
1605
1706
  onScroll,
1606
1707
  remeasure,
1708
+ /** Row-virtualization state; `enabled: false` (the default) means render every row. */ virtual: {
1709
+ enabled: virtualized,
1710
+ rowHeight,
1711
+ overscan,
1712
+ ...viewport
1713
+ },
1607
1714
  // The raw ref object itself (not just the callback ref above) -- useDataTable.ts
1608
1715
  // hands this to useDataTableInfiniteScroll as the IntersectionObserver `root` for
1609
1716
  // its sentinel row, so a scrolled/bounded table (DATATABLE-ATTIO-SPEC.md's
@@ -2002,7 +2109,7 @@ function useDataTable(props) {
2002
2109
  const toolbarFilter = useDataTableToolbarFilter(props);
2003
2110
  const columns = useDataTableColumns(props);
2004
2111
  const columnCalc = useDataTableColumnCalc(props);
2005
- const scroll = useDataTableScroll();
2112
+ const scroll = useDataTableScroll(props);
2006
2113
  const edit = useDataTableEdit(props);
2007
2114
  // Feature C (#12) -- which single cell (if any) is showing its read-only "show
2008
2115
  // full content" overlay. Independent of `edit` above -- see
@@ -2016,6 +2123,28 @@ function useDataTable(props) {
2016
2123
  ...props,
2017
2124
  scrollRootRef: scroll.elementRef
2018
2125
  });
2126
+ // Virtualized mode swaps the sentinel's IntersectionObserver for a scroll-position
2127
+ // check: on every scroll (below), and again whenever the row count or loading state
2128
+ // changes -- new rows can leave the user still at the bottom.
2129
+ const { checkScrollPosition } = infiniteScroll;
2130
+ const rowCount = props.rowIds?.length;
2131
+ // biome-ignore lint/correctness/useExhaustiveDependencies: rowCount/loadingMore/hasMore are the triggers, not values read in the effect
2132
+ useEffect(()=>{
2133
+ checkScrollPosition();
2134
+ }, [
2135
+ checkScrollPosition,
2136
+ rowCount,
2137
+ infiniteScroll.loadingMore,
2138
+ infiniteScroll.hasMore
2139
+ ]);
2140
+ const { onScroll: measureScroll } = scroll;
2141
+ const onScroll = useCallback(()=>{
2142
+ measureScroll();
2143
+ checkScrollPosition();
2144
+ }, [
2145
+ measureScroll,
2146
+ checkScrollPosition
2147
+ ]);
2019
2148
  // Unifies `sort` (the header's own single click-to-sort column, above) with
2020
2149
  // `toolbarSort.toolbarSorts` (the toolbar Sort popover's ordered list, above) into
2021
2150
  // ONE state instead of two -- before this, a header click only ever moved `sort`,
@@ -2127,6 +2256,7 @@ function useDataTable(props) {
2127
2256
  ...edit,
2128
2257
  ...peek,
2129
2258
  ...infiniteScroll,
2259
+ onScroll,
2130
2260
  rootProps: elementProps({
2131
2261
  role: "table"
2132
2262
  })
@@ -2169,7 +2299,7 @@ function useDataTableRowContext({ strict = true } = {}) {
2169
2299
  return context;
2170
2300
  }
2171
2301
  const DataTableRoot = /*#__PURE__*/ forwardRef((props, ref)=>{
2172
- const { sort, defaultSort, onSortChange, rowIds, selectable, selectedIds, defaultSelectedIds, onSelectionChange, query, defaultQuery, onQueryChange, toolbarSorts, defaultToolbarSorts, onToolbarSortsChange, toolbarFilters, defaultToolbarFilters, onToolbarFiltersChange, columnKeys, actionsColumnKey, columnOrder, defaultColumnOrder, onColumnOrderChange, hiddenColumnKeys, defaultHiddenColumnKeys, onHiddenColumnKeysChange, columnWidths, defaultColumnWidths, onColumnWidthsChange, columnLabels, defaultColumnLabels, onColumnLabelsChange, pinnedColumnKeys, defaultPinnedColumnKeys, onPinnedColumnKeysChange, columnCalcOps, defaultColumnCalcOps, onColumnCalcOpsChange, onEdit, onLoadMore, hasMore, loadingMore, ...otherProps } = props;
2302
+ const { sort, defaultSort, onSortChange, rowIds, selectable, selectedIds, defaultSelectedIds, onSelectionChange, query, defaultQuery, onQueryChange, toolbarSorts, defaultToolbarSorts, onToolbarSortsChange, toolbarFilters, defaultToolbarFilters, onToolbarFiltersChange, columnKeys, actionsColumnKey, columnOrder, defaultColumnOrder, onColumnOrderChange, hiddenColumnKeys, defaultHiddenColumnKeys, onHiddenColumnKeysChange, columnWidths, defaultColumnWidths, onColumnWidthsChange, columnLabels, defaultColumnLabels, onColumnLabelsChange, pinnedColumnKeys, defaultPinnedColumnKeys, onPinnedColumnKeysChange, columnCalcOps, defaultColumnCalcOps, onColumnCalcOpsChange, onEdit, onLoadMore, hasMore, loadingMore, virtualized, rowHeight, overscan, ...otherProps } = props;
2173
2303
  const api = useDataTable({
2174
2304
  sort,
2175
2305
  defaultSort,
@@ -2211,7 +2341,10 @@ const DataTableRoot = /*#__PURE__*/ forwardRef((props, ref)=>{
2211
2341
  onEdit,
2212
2342
  onLoadMore,
2213
2343
  hasMore,
2214
- loadingMore
2344
+ loadingMore,
2345
+ virtualized,
2346
+ rowHeight,
2347
+ overscan
2215
2348
  });
2216
2349
  return /*#__PURE__*/ jsx(DataTableProvider, {
2217
2350
  value: api,
@@ -2294,9 +2427,20 @@ DataTableCell$1.displayName = "DataTableCell";
2294
2427
 
2295
2428
  const EMPTY_VIEWS = [];
2296
2429
  function readStoredViews(storageKey) {
2297
- if (!storageKey || typeof window === "undefined") return EMPTY_VIEWS;
2430
+ if (!storageKey || typeof window === "undefined") return null;
2298
2431
  try {
2299
- return deserializeDataTableViews(window.localStorage.getItem(storageKey));
2432
+ const raw = window.localStorage.getItem(storageKey);
2433
+ if (raw === null) return null;
2434
+ // Only a well-formed array counts as "stored" -- `[]` is a real answer (the user
2435
+ // deleted every view), whereas garbage/non-array JSON is treated as absent so the
2436
+ // defaults still seed.
2437
+ let parsed;
2438
+ try {
2439
+ parsed = JSON.parse(raw);
2440
+ } catch {
2441
+ return null;
2442
+ }
2443
+ return Array.isArray(parsed) ? deserializeDataTableViews(raw) : null;
2300
2444
  } catch {
2301
2445
  // A private-browsing tab / blocked storage throws on access, not just on quota --
2302
2446
  // this hook degrades to in-memory-only rather than crash the table over it.
@@ -2334,7 +2478,7 @@ function readStoredViews(storageKey) {
2334
2478
  // Nothing in storage yet (first run, or no `storageKey` at all) -- seed from
2335
2479
  // `defaultViews` instead of an empty list, matching every other hook's own
2336
2480
  // `defaultProp` semantics.
2337
- initialViewsRef.current = stored.length > 0 ? stored : defaultViews;
2481
+ initialViewsRef.current = stored !== null ? stored : defaultViews;
2338
2482
  }
2339
2483
  }
2340
2484
  const [views = EMPTY_VIEWS, setViews] = useControllableState$1({
@@ -4330,6 +4474,10 @@ const { ClassNamesProvider, withContext: withContext$1, useClassNames } = create
4330
4474
  });
4331
4475
  }
4332
4476
 
4477
+ /** Attribute on the floating-ui-portaled option list of the `select`/`reference`/`actor`
4478
+ * editors. The portal lands in `document.body`, outside the cell and its expand overlay,
4479
+ * so `body-cells.tsx`'s outside-click detector checks for this marker to avoid reading an
4480
+ * option click as "outside" and cancelling the edit before it commits. */ const EDITOR_PORTAL_ATTR = "data-datatable-editor-portal";
4333
4481
  /** The raw value a live editor reads, distinct from `DataTableColumn.value` (the
4334
4482
  * accessor sort/search/filter/export share): `editValue` returns whatever TYPE the
4335
4483
  * editor itself expects -- a real `boolean` for `editor: "switch"`/`type:
@@ -4478,6 +4626,7 @@ function SelectEditor({ column, value, pending, onCommit, onCancel }) {
4478
4626
  ]
4479
4627
  }),
4480
4628
  /*#__PURE__*/ jsx(SelectPositioner, {
4629
+ [EDITOR_PORTAL_ATTR]: "",
4481
4630
  children: /*#__PURE__*/ jsxs(SelectContent, {
4482
4631
  children: [
4483
4632
  searchable && /*#__PURE__*/ jsx(SelectSearchField, {
@@ -4553,6 +4702,7 @@ function ReferenceEditor({ column, value, pending, onCommit, onCancel }) {
4553
4702
  ]
4554
4703
  }),
4555
4704
  /*#__PURE__*/ jsx(SelectPositioner, {
4705
+ [EDITOR_PORTAL_ATTR]: "",
4556
4706
  children: /*#__PURE__*/ jsxs(SelectContent, {
4557
4707
  children: [
4558
4708
  /*#__PURE__*/ jsx(SelectSearchField, {
@@ -5168,8 +5318,14 @@ function ColumnBodyCells({ columns, row, classNames, locale, selectable, stickyF
5168
5318
  function isInside(target) {
5169
5319
  return (cellRef.current?.contains(target) ?? false) || (popoverRef.current?.contains(target) ?? false) || (expandOverlayRef.current?.contains(target) ?? false);
5170
5320
  }
5321
+ // The select/reference/actor editor's option list is portaled to `document.body` by
5322
+ // floating-ui, outside both `cellRef` and `expandOverlayRef`; `editors.tsx` marks its
5323
+ // positioner with `EDITOR_PORTAL_ATTR`, so a click on an option is not an outside click.
5324
+ function isInsideEditorPortal(target) {
5325
+ return target instanceof Element && target.closest(`[${EDITOR_PORTAL_ATTR}]`) != null;
5326
+ }
5171
5327
  function handlePointerDown(event) {
5172
- if (!isInside(event.target)) {
5328
+ if (!isInside(event.target) && !isInsideEditorPortal(event.target)) {
5173
5329
  setOverlayOpen(false);
5174
5330
  setPopoverStyle(null);
5175
5331
  closePeek();
@@ -11043,7 +11199,13 @@ const toDateOrNull = (v)=>{
11043
11199
  return f.op === "before" ? d < cd : d > cd;
11044
11200
  }
11045
11201
  const t = asText(raw).toLowerCase();
11046
- if (f.op === "isanyof") return (f.values ?? []).some((v)=>v.toLowerCase() === t);
11202
+ if (f.op === "isanyof") {
11203
+ const wanted = (f.values ?? []).map((v)=>v.toLowerCase());
11204
+ const cells = Array.isArray(raw) ? raw.map((x)=>asText(x).toLowerCase()) : [
11205
+ t
11206
+ ];
11207
+ return cells.some((c)=>wanted.includes(c));
11208
+ }
11047
11209
  const v = f.value.trim().toLowerCase();
11048
11210
  if (f.op === "contains") return t.includes(v);
11049
11211
  if (f.op === "doesnotcontain") return !t.includes(v);
@@ -11691,7 +11853,7 @@ function RowCount({ shown, total, countLabel }) {
11691
11853
  }
11692
11854
 
11693
11855
  function DataTableInner(props, ref) {
11694
- const { columns, data, surface, className, locale = "en-US", sort, defaultSort, onSortChange, sortIcon, selectable, selectedIds, defaultSelectedIds, onSelectionChange, selectAllLabel = "Select all rows", getRowSelectionLabel = ()=>"Select row", selectionCheckedIcon, selectionIndeterminateIcon, bulkActions, clearSelectionLabel, query, defaultQuery, onQueryChange, searchPlaceholder = "Search…", searchLabel = "Search table", toolbarSorts, defaultToolbarSorts, onToolbarSortsChange, toolbarFilters, defaultToolbarFilters, onToolbarFiltersChange, showCount = true, countLabel, toolbarActions, columnMenu = false, reorderable = true, resizable = true, columnOrder, defaultColumnOrder, onColumnOrderChange, hiddenColumnKeys, defaultHiddenColumnKeys, onHiddenColumnKeysChange, columnWidths, defaultColumnWidths, onColumnWidthsChange, columnLabels, defaultColumnLabels, onColumnLabelsChange, pinnedColumnKeys, defaultPinnedColumnKeys, onPinnedColumnKeysChange, onColumnsChange, columnCalcOps, defaultColumnCalcOps, onColumnCalcOpsChange, stickyFirst = false, stickyLast = false, onEdit, cellContextMenu = true, onViewEditHistory, cellContextMenuItems, interactive = false, onRowClick, onAddRow, addRowLabel = "New record", loading = false, loadingLabel = "Loading...", error, empty = "No results.", onLoadMore, hasMore, loadingMore, scrollProps, ...restProps } = props;
11856
+ const { columns, data, surface, className, locale = "en-US", sort, defaultSort, onSortChange, sortIcon, selectable, selectedIds, defaultSelectedIds, onSelectionChange, selectAllLabel = "Select all rows", getRowSelectionLabel = ()=>"Select row", selectionCheckedIcon, selectionIndeterminateIcon, bulkActions, clearSelectionLabel, query, defaultQuery, onQueryChange, searchPlaceholder = "Search…", searchLabel = "Search table", toolbarSorts, defaultToolbarSorts, onToolbarSortsChange, toolbarFilters, defaultToolbarFilters, onToolbarFiltersChange, showCount = true, countLabel, toolbarActions, columnMenu = false, reorderable = true, resizable = true, columnOrder, defaultColumnOrder, onColumnOrderChange, hiddenColumnKeys, defaultHiddenColumnKeys, onHiddenColumnKeysChange, columnWidths, defaultColumnWidths, onColumnWidthsChange, columnLabels, defaultColumnLabels, onColumnLabelsChange, pinnedColumnKeys, defaultPinnedColumnKeys, onPinnedColumnKeysChange, onColumnsChange, columnCalcOps, defaultColumnCalcOps, onColumnCalcOpsChange, stickyFirst = false, stickyLast = false, onEdit, cellContextMenu = true, onViewEditHistory, cellContextMenuItems, interactive = false, onRowClick, onAddRow, addRowLabel = "New record", loading = false, loadingLabel = "Loading...", error, empty = "No results.", onLoadMore, hasMore, loadingMore, scrollProps, virtualized = false, rowHeight, overscan, ...restProps } = props;
11695
11857
  const classNames = dataTable({
11696
11858
  surface,
11697
11859
  interactive
@@ -11864,6 +12026,9 @@ function DataTableInner(props, ref) {
11864
12026
  onLoadMore: onLoadMore,
11865
12027
  hasMore: hasMore,
11866
12028
  loadingMore: loadingMore,
12029
+ virtualized: virtualized,
12030
+ rowHeight: rowHeight,
12031
+ overscan: overscan,
11867
12032
  ...restProps,
11868
12033
  children: [
11869
12034
  onColumnsChange && /*#__PURE__*/ jsx(ColumnsChangeEffect, {
@@ -11896,7 +12061,9 @@ function DataTableInner(props, ref) {
11896
12061
  }),
11897
12062
  /*#__PURE__*/ jsx(DataTableScrollBody, {
11898
12063
  scrollProps: scrollProps,
12064
+ virtualized: virtualized,
11899
12065
  children: /*#__PURE__*/ jsxs(DataTableTable, {
12066
+ "aria-rowcount": virtualized ? sortedData.length + 1 : undefined,
11900
12067
  children: [
11901
12068
  /*#__PURE__*/ jsx(DataTableColGroup, {
11902
12069
  columns: columns,
@@ -11952,39 +12119,47 @@ function DataTableInner(props, ref) {
11952
12119
  })
11953
12120
  }) : /*#__PURE__*/ jsxs(Fragment, {
11954
12121
  children: [
11955
- sortedData.map((row)=>/*#__PURE__*/ jsxs(DataTableRow, {
11956
- rowId: row.id,
11957
- "data-clickable": onRowClick ? "" : undefined,
11958
- onClick: onRowClick ? ()=>onRowClick(row) : undefined,
11959
- children: [
11960
- selectable && /*#__PURE__*/ jsx(SelectionCell, {
11961
- // The row itself carries `onClick={onRowClick}` above, and a
11962
- // native click on this cell's checkbox bubbles up through it
11963
- // (React attaches DataTableRow's handler on the <tr>, which
11964
- // sits between the checkbox and anything a consumer could stop
11965
- // it from at -- so this has to live here, not in the consumer).
11966
- // Without this, checking a row's box also fires `onRowClick`.
11967
- onClick: (event)=>event.stopPropagation(),
11968
- stickyFirst: stickyFirst,
11969
- children: /*#__PURE__*/ jsx(RowCheckbox, {
11970
- label: getRowSelectionLabel(row),
11971
- checkedIcon: selectionCheckedIcon ?? DEFAULT_CHECKED_ICON
11972
- })
11973
- }),
11974
- /*#__PURE__*/ jsx(ColumnBodyCells, {
11975
- columns: columns,
11976
- row: row,
11977
- classNames: classNames,
11978
- locale: locale,
11979
- selectable: !!selectable,
11980
- stickyFirst: stickyFirst,
11981
- stickyLast: resolvedStickyLast,
11982
- cellContextMenu: cellContextMenu,
11983
- onViewEditHistory: onViewEditHistory,
11984
- cellContextMenuItems: cellContextMenuItems
11985
- })
11986
- ]
11987
- }, row.id)),
12122
+ /*#__PURE__*/ jsx(WindowedRows, {
12123
+ rows: sortedData,
12124
+ virtualized: virtualized,
12125
+ colSpan: totalCols,
12126
+ children: (windowRows, firstIndex)=>/*#__PURE__*/ jsx(Fragment, {
12127
+ children: windowRows.map((row, offset)=>/*#__PURE__*/ jsxs(DataTableRow, {
12128
+ rowId: row.id,
12129
+ "aria-rowindex": virtualized ? firstIndex + offset + 2 : undefined,
12130
+ "data-clickable": onRowClick ? "" : undefined,
12131
+ onClick: onRowClick ? ()=>onRowClick(row) : undefined,
12132
+ children: [
12133
+ selectable && /*#__PURE__*/ jsx(SelectionCell, {
12134
+ // The row itself carries `onClick={onRowClick}` above, and a
12135
+ // native click on this cell's checkbox bubbles up through it
12136
+ // (React attaches DataTableRow's handler on the <tr>, which
12137
+ // sits between the checkbox and anything a consumer could stop
12138
+ // it from at -- so this has to live here, not in the consumer).
12139
+ // Without this, checking a row's box also fires `onRowClick`.
12140
+ onClick: (event)=>event.stopPropagation(),
12141
+ stickyFirst: stickyFirst,
12142
+ children: /*#__PURE__*/ jsx(RowCheckbox, {
12143
+ label: getRowSelectionLabel(row),
12144
+ checkedIcon: selectionCheckedIcon ?? DEFAULT_CHECKED_ICON
12145
+ })
12146
+ }),
12147
+ /*#__PURE__*/ jsx(ColumnBodyCells, {
12148
+ columns: columns,
12149
+ row: row,
12150
+ classNames: classNames,
12151
+ locale: locale,
12152
+ selectable: !!selectable,
12153
+ stickyFirst: stickyFirst,
12154
+ stickyLast: resolvedStickyLast,
12155
+ cellContextMenu: cellContextMenu,
12156
+ onViewEditHistory: onViewEditHistory,
12157
+ cellContextMenuItems: cellContextMenuItems
12158
+ })
12159
+ ]
12160
+ }, row.id))
12161
+ })
12162
+ }),
11988
12163
  onLoadMore && /*#__PURE__*/ jsx(InfiniteScrollRow, {
11989
12164
  colSpan: totalCols,
11990
12165
  classNames: classNames,
@@ -12085,7 +12260,69 @@ const DataTableTable = withContext$1(DataTableTable$1, "table");
12085
12260
  * out of sync between the three. A column with no explicit width gets a bare `<col>`
12086
12261
  * (no `style`): `table-layout: fixed`'s own algorithm gives every such column an equal
12087
12262
  * share of whatever space is left, matching the old flex grid's `flex: 1 1 0%`
12088
- * default with no extra code needed here. */ function DataTableColGroup({ columns, selectable, stickyFirst }) {
12263
+ * default with no extra code needed here. */ /**
12264
+ * Row virtualization's render slice. Lives inside `DataTableRoot` (it reads the
12265
+ * windowing state off context, which `DataTableInner` above can't) and hands its
12266
+ * children the visible `[start, end)` slice of `rows` plus that slice's first index,
12267
+ * flanked by a spacer row each side that stands in for everything cut off -- so the
12268
+ * scroll height stays `rows.length * rowHeight` and the scrollbar matches the full
12269
+ * data set. `virtualized` off: every row, no spacers, the pre-0.12 output exactly.
12270
+ */ function WindowedRows({ rows, virtualized, colSpan, children }) {
12271
+ const { virtual } = useDataTableContext();
12272
+ const windowed = React.useMemo(()=>virtualized ? computeVirtualWindow({
12273
+ count: rows.length,
12274
+ rowHeight: virtual.rowHeight,
12275
+ scrollTop: virtual.scrollTop,
12276
+ viewportHeight: virtual.viewportHeight,
12277
+ overscan: virtual.overscan
12278
+ }) : null, [
12279
+ virtualized,
12280
+ rows.length,
12281
+ virtual.rowHeight,
12282
+ virtual.scrollTop,
12283
+ virtual.viewportHeight,
12284
+ virtual.overscan
12285
+ ]);
12286
+ if (!windowed) return /*#__PURE__*/ jsx(Fragment, {
12287
+ children: children(rows, 0)
12288
+ });
12289
+ return /*#__PURE__*/ jsxs(Fragment, {
12290
+ children: [
12291
+ windowed.topSpacer > 0 && /*#__PURE__*/ jsx(VirtualSpacerRow, {
12292
+ height: windowed.topSpacer,
12293
+ colSpan: colSpan
12294
+ }),
12295
+ children(rows.slice(windowed.start, windowed.end), windowed.start),
12296
+ windowed.bottomSpacer > 0 && /*#__PURE__*/ jsx(VirtualSpacerRow, {
12297
+ height: windowed.bottomSpacer,
12298
+ colSpan: colSpan
12299
+ })
12300
+ ]
12301
+ });
12302
+ }
12303
+ /** A plain `<tr><td colSpan>` of a given height -- deliberately NOT a `DataTableRow`/
12304
+ * `DataTableCell` (no hover tint, no cell chrome, no border, no 36px min height).
12305
+ * `colSpan` covers every column, so the `<colgroup>` still drives widths and sticky
12306
+ * columns are unaffected. */ function VirtualSpacerRow({ height, colSpan }) {
12307
+ return /*#__PURE__*/ jsx("tr", {
12308
+ "data-virtual-spacer": "",
12309
+ style: {
12310
+ height
12311
+ },
12312
+ children: /*#__PURE__*/ jsx("td", {
12313
+ "aria-hidden": "true",
12314
+ colSpan: colSpan,
12315
+ style: {
12316
+ height,
12317
+ padding: 0,
12318
+ border: 0,
12319
+ display: "table-cell",
12320
+ lineHeight: 0
12321
+ }
12322
+ })
12323
+ });
12324
+ }
12325
+ function DataTableColGroup({ columns, selectable, stickyFirst }) {
12089
12326
  // MUST use the same pinned-first ordering every OTHER row (header/body/footer)
12090
12327
  // renders in (`useEffectiveColumns`'s own doc comment) -- a `<colgroup>`'s `<col>`s
12091
12328
  // are matched positionally to each row's cells, so if a pinned column visually moves
@@ -12160,7 +12397,7 @@ const DataTableSelectionCell = withSelectionCell(DataTableCell$1, "cell");
12160
12397
  // to clip), so it never introduces a second vertical scrollbar next to whatever
12161
12398
  // ambient container the whole DataTable already sits in.
12162
12399
  const DataTableScrollBodyBase = withContext$1(Primitive.div, "scrollArea");
12163
- function DataTableScrollBody({ children, scrollProps }) {
12400
+ function DataTableScrollBody({ children, virtualized, scrollProps }) {
12164
12401
  const { scrollAreaRef, scrolledStart, scrolledEnd, onScroll } = useDataTableContext();
12165
12402
  const { className: scrollClassName, style: scrollStyle, ...otherScrollProps } = scrollProps ?? {};
12166
12403
  return /*#__PURE__*/ jsx(DataTableScrollBodyBase, {
@@ -12168,7 +12405,13 @@ function DataTableScrollBody({ children, scrollProps }) {
12168
12405
  ref: scrollAreaRef,
12169
12406
  onScroll: onScroll,
12170
12407
  className: scrollClassName,
12171
- style: scrollStyle,
12408
+ // Virtualized: the browser's scroll anchoring would try to hold the visible rows
12409
+ // steady as the spacer heights change under it, fighting the scroll position
12410
+ // this windowing itself derives from -- turn it off.
12411
+ style: virtualized ? {
12412
+ ...scrollStyle,
12413
+ overflowAnchor: "none"
12414
+ } : scrollStyle,
12172
12415
  "data-scrolled-start": dataAttr(scrolledStart),
12173
12416
  "data-scrolled-end": dataAttr(scrolledEnd),
12174
12417
  children: children
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crispy-seed/data-table",
3
- "version": "0.11.2",
3
+ "version": "0.12.1",
4
4
  "description": "Self-contained, publishable build of seed-design's Attio-measured DataTable, ResizablePanel and RecordPreview — one install, one CSS import.",
5
5
  "repository": {
6
6
  "type": "git",