@crispy-seed/data-table 0.11.2 → 0.12.0

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
  /**
@@ -1510,7 +1555,7 @@ const ROOT_MARGIN = "160px";
1510
1555
  * wires this to useDataTableScroll's own scroll container) so a BOUNDED table
1511
1556
  * triggers off its own internal scroll, not the browsing viewport.
1512
1557
  */ function useDataTableInfiniteScroll(props) {
1513
- const { onLoadMore, hasMore = true, loadingMore: loadingMoreProp, scrollRootRef } = props;
1558
+ const { onLoadMore, hasMore = true, loadingMore: loadingMoreProp, scrollRootRef, virtualized = false } = props;
1514
1559
  const [internalLoadingMore, setInternalLoadingMore] = React.useState(false);
1515
1560
  const loadingMore = loadingMoreProp ?? internalLoadingMore;
1516
1561
  // Mirrors the latest props into a ref the observer callback reads -- the callback
@@ -1550,6 +1595,7 @@ const ROOT_MARGIN = "160px";
1550
1595
  const [sentinelNode, setSentinelNode] = React.useState(null);
1551
1596
  const sentinelRef = React.useCallback((node)=>setSentinelNode(node), []);
1552
1597
  React.useEffect(()=>{
1598
+ if (virtualized) return;
1553
1599
  if (!sentinelNode || !onLoadMore || typeof IntersectionObserver === "undefined") return;
1554
1600
  // Effects run after every ref callback of the SAME commit (including
1555
1601
  // `scrollRootRef`'s own, set by useDataTableScroll's `scrollAreaRef`) regardless
@@ -1570,6 +1616,23 @@ const ROOT_MARGIN = "160px";
1570
1616
  sentinelNode,
1571
1617
  onLoadMore,
1572
1618
  scrollRootRef,
1619
+ triggerLoadMore,
1620
+ virtualized
1621
+ ]);
1622
+ // Virtualized mode's trigger: near the bottom of the scroll container (same gate --
1623
+ // `hasMore`, not already loading -- via `triggerLoadMore`). Called on every scroll
1624
+ // and again after the row count / loading state changes (a page that lands while
1625
+ // the user is still at the bottom, or too short to overflow, must chain the next
1626
+ // load rather than wait for a scroll that can never come).
1627
+ const checkScrollPosition = React.useCallback(()=>{
1628
+ const element = scrollRootRef?.current;
1629
+ if (!virtualized || !element) return;
1630
+ if (isNearBottom(element.scrollTop, element.clientHeight, element.scrollHeight)) {
1631
+ triggerLoadMore();
1632
+ }
1633
+ }, [
1634
+ virtualized,
1635
+ scrollRootRef,
1573
1636
  triggerLoadMore
1574
1637
  ]);
1575
1638
  return {
@@ -1583,7 +1646,9 @@ const ROOT_MARGIN = "160px";
1583
1646
  /** Fires `onLoadMore` directly, subject to the same `hasMore`/`loadingMore` gate
1584
1647
  * the observer itself uses -- exposed for a consumer that wants a manual retry
1585
1648
  * affordance (e.g. after a failed load) rather than only the observer's own
1586
- * scroll trigger. */ triggerLoadMore
1649
+ * scroll trigger. */ triggerLoadMore,
1650
+ /** Virtualized mode's scroll-position check (no-op otherwise); `useDataTable`
1651
+ * calls it from the scroll container's `onScroll`. */ checkScrollPosition
1587
1652
  };
1588
1653
  }
1589
1654
 
@@ -1595,19 +1660,53 @@ const ROOT_MARGIN = "160px";
1595
1660
  * real ref to a real scrollable element, re-measured on every scroll (and whenever the
1596
1661
  * styled layer calls `remeasure`, e.g. after a column resize/reorder changes the
1597
1662
  * scrollable width without the element itself ever firing a `scroll` event).
1598
- */ function useDataTableScroll() {
1663
+ */ function useDataTableScroll(props = {}) {
1664
+ const { virtualized = false, rowHeight = DEFAULT_VIRTUAL_ROW_HEIGHT } = props;
1665
+ const overscan = props.overscan ?? DEFAULT_VIRTUAL_OVERSCAN;
1599
1666
  const elementRef = React.useRef(null);
1600
1667
  const [scrolledStart, setScrolledStart] = React.useState(false);
1601
1668
  const [scrolledEnd, setScrolledEnd] = React.useState(false);
1669
+ const [viewport, setViewport] = React.useState({
1670
+ scrollTop: 0,
1671
+ viewportHeight: 0
1672
+ });
1602
1673
  const remeasure = React.useCallback(()=>{
1603
1674
  const element = elementRef.current;
1604
1675
  if (!element) return;
1676
+ if (virtualized) {
1677
+ // Row 0 starts below the sticky header, so measure from there. Snapping to whole
1678
+ // rows keeps re-renders to one per row scrolled (not one per scroll event); the
1679
+ // extra `rowHeight` of viewport makes up for the rows the snap rounded away.
1680
+ const headerHeight = element.querySelector("thead")?.offsetHeight ?? 0;
1681
+ const scrollTop = Math.max(0, element.scrollTop - headerHeight);
1682
+ const snapped = Math.floor(scrollTop / rowHeight) * rowHeight;
1683
+ const viewportHeight = element.clientHeight + rowHeight;
1684
+ setViewport((prev)=>prev.scrollTop === snapped && prev.viewportHeight === viewportHeight ? prev : {
1685
+ scrollTop: snapped,
1686
+ viewportHeight
1687
+ });
1688
+ }
1605
1689
  setScrolledStart(element.scrollLeft > 0);
1606
1690
  // A 1px slop: sub-pixel layout can leave `scrollLeft + clientWidth` a fraction
1607
1691
  // short of `scrollWidth` even when fully scrolled, which would otherwise leave
1608
1692
  // the trailing shadow stuck on forever.
1609
1693
  setScrolledEnd(element.scrollLeft + element.clientWidth < element.scrollWidth - 1);
1610
- }, []);
1694
+ }, [
1695
+ virtualized,
1696
+ rowHeight
1697
+ ]);
1698
+ // The container can resize without scrolling (window resize, a bounded parent
1699
+ // changing height) -- the viewport height feeds the window, so re-measure then too.
1700
+ React.useEffect(()=>{
1701
+ const element = elementRef.current;
1702
+ if (!virtualized || !element || typeof ResizeObserver === "undefined") return;
1703
+ const observer = new ResizeObserver(()=>remeasure());
1704
+ observer.observe(element);
1705
+ return ()=>observer.disconnect();
1706
+ }, [
1707
+ virtualized,
1708
+ remeasure
1709
+ ]);
1611
1710
  const scrollAreaRef = React.useCallback((node)=>{
1612
1711
  elementRef.current = node;
1613
1712
  if (node) remeasure();
@@ -1623,6 +1722,12 @@ const ROOT_MARGIN = "160px";
1623
1722
  scrolledEnd,
1624
1723
  onScroll,
1625
1724
  remeasure,
1725
+ /** Row-virtualization state; `enabled: false` (the default) means render every row. */ virtual: {
1726
+ enabled: virtualized,
1727
+ rowHeight,
1728
+ overscan,
1729
+ ...viewport
1730
+ },
1626
1731
  // The raw ref object itself (not just the callback ref above) -- useDataTable.ts
1627
1732
  // hands this to useDataTableInfiniteScroll as the IntersectionObserver `root` for
1628
1733
  // its sentinel row, so a scrolled/bounded table (DATATABLE-ATTIO-SPEC.md's
@@ -2021,7 +2126,7 @@ function useDataTable(props) {
2021
2126
  const toolbarFilter = useDataTableToolbarFilter(props);
2022
2127
  const columns = useDataTableColumns(props);
2023
2128
  const columnCalc = useDataTableColumnCalc(props);
2024
- const scroll = useDataTableScroll();
2129
+ const scroll = useDataTableScroll(props);
2025
2130
  const edit = useDataTableEdit(props);
2026
2131
  // Feature C (#12) -- which single cell (if any) is showing its read-only "show
2027
2132
  // full content" overlay. Independent of `edit` above -- see
@@ -2035,6 +2140,28 @@ function useDataTable(props) {
2035
2140
  ...props,
2036
2141
  scrollRootRef: scroll.elementRef
2037
2142
  });
2143
+ // Virtualized mode swaps the sentinel's IntersectionObserver for a scroll-position
2144
+ // check: on every scroll (below), and again whenever the row count or loading state
2145
+ // changes -- new rows can leave the user still at the bottom.
2146
+ const { checkScrollPosition } = infiniteScroll;
2147
+ const rowCount = props.rowIds?.length;
2148
+ // biome-ignore lint/correctness/useExhaustiveDependencies: rowCount/loadingMore/hasMore are the triggers, not values read in the effect
2149
+ React.useEffect(()=>{
2150
+ checkScrollPosition();
2151
+ }, [
2152
+ checkScrollPosition,
2153
+ rowCount,
2154
+ infiniteScroll.loadingMore,
2155
+ infiniteScroll.hasMore
2156
+ ]);
2157
+ const { onScroll: measureScroll } = scroll;
2158
+ const onScroll = React.useCallback(()=>{
2159
+ measureScroll();
2160
+ checkScrollPosition();
2161
+ }, [
2162
+ measureScroll,
2163
+ checkScrollPosition
2164
+ ]);
2038
2165
  // Unifies `sort` (the header's own single click-to-sort column, above) with
2039
2166
  // `toolbarSort.toolbarSorts` (the toolbar Sort popover's ordered list, above) into
2040
2167
  // ONE state instead of two -- before this, a header click only ever moved `sort`,
@@ -2146,6 +2273,7 @@ function useDataTable(props) {
2146
2273
  ...edit,
2147
2274
  ...peek,
2148
2275
  ...infiniteScroll,
2276
+ onScroll,
2149
2277
  rootProps: domUtils.elementProps({
2150
2278
  role: "table"
2151
2279
  })
@@ -2188,7 +2316,7 @@ function useDataTableRowContext({ strict = true } = {}) {
2188
2316
  return context;
2189
2317
  }
2190
2318
  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;
2319
+ 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
2320
  const api = useDataTable({
2193
2321
  sort,
2194
2322
  defaultSort,
@@ -2230,7 +2358,10 @@ const DataTableRoot = /*#__PURE__*/ React.forwardRef((props, ref)=>{
2230
2358
  onEdit,
2231
2359
  onLoadMore,
2232
2360
  hasMore,
2233
- loadingMore
2361
+ loadingMore,
2362
+ virtualized,
2363
+ rowHeight,
2364
+ overscan
2234
2365
  });
2235
2366
  return /*#__PURE__*/ jsxRuntime.jsx(DataTableProvider, {
2236
2367
  value: api,
@@ -11710,7 +11841,7 @@ function RowCount({ shown, total, countLabel }) {
11710
11841
  }
11711
11842
 
11712
11843
  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;
11844
+ 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
11845
  const classNames = dataTable({
11715
11846
  surface,
11716
11847
  interactive
@@ -11883,6 +12014,9 @@ function DataTableInner(props, ref) {
11883
12014
  onLoadMore: onLoadMore,
11884
12015
  hasMore: hasMore,
11885
12016
  loadingMore: loadingMore,
12017
+ virtualized: virtualized,
12018
+ rowHeight: rowHeight,
12019
+ overscan: overscan,
11886
12020
  ...restProps,
11887
12021
  children: [
11888
12022
  onColumnsChange && /*#__PURE__*/ jsxRuntime.jsx(ColumnsChangeEffect, {
@@ -11915,7 +12049,9 @@ function DataTableInner(props, ref) {
11915
12049
  }),
11916
12050
  /*#__PURE__*/ jsxRuntime.jsx(DataTableScrollBody, {
11917
12051
  scrollProps: scrollProps,
12052
+ virtualized: virtualized,
11918
12053
  children: /*#__PURE__*/ jsxRuntime.jsxs(DataTableTable, {
12054
+ "aria-rowcount": virtualized ? sortedData.length + 1 : undefined,
11919
12055
  children: [
11920
12056
  /*#__PURE__*/ jsxRuntime.jsx(DataTableColGroup, {
11921
12057
  columns: columns,
@@ -11971,39 +12107,47 @@ function DataTableInner(props, ref) {
11971
12107
  })
11972
12108
  }) : /*#__PURE__*/ jsxRuntime.jsxs(jsxRuntime.Fragment, {
11973
12109
  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)),
12110
+ /*#__PURE__*/ jsxRuntime.jsx(WindowedRows, {
12111
+ rows: sortedData,
12112
+ virtualized: virtualized,
12113
+ colSpan: totalCols,
12114
+ children: (windowRows, firstIndex)=>/*#__PURE__*/ jsxRuntime.jsx(jsxRuntime.Fragment, {
12115
+ children: windowRows.map((row, offset)=>/*#__PURE__*/ jsxRuntime.jsxs(DataTableRow, {
12116
+ rowId: row.id,
12117
+ "aria-rowindex": virtualized ? firstIndex + offset + 2 : undefined,
12118
+ "data-clickable": onRowClick ? "" : undefined,
12119
+ onClick: onRowClick ? ()=>onRowClick(row) : undefined,
12120
+ children: [
12121
+ selectable && /*#__PURE__*/ jsxRuntime.jsx(SelectionCell, {
12122
+ // The row itself carries `onClick={onRowClick}` above, and a
12123
+ // native click on this cell's checkbox bubbles up through it
12124
+ // (React attaches DataTableRow's handler on the <tr>, which
12125
+ // sits between the checkbox and anything a consumer could stop
12126
+ // it from at -- so this has to live here, not in the consumer).
12127
+ // Without this, checking a row's box also fires `onRowClick`.
12128
+ onClick: (event)=>event.stopPropagation(),
12129
+ stickyFirst: stickyFirst,
12130
+ children: /*#__PURE__*/ jsxRuntime.jsx(RowCheckbox, {
12131
+ label: getRowSelectionLabel(row),
12132
+ checkedIcon: selectionCheckedIcon ?? DEFAULT_CHECKED_ICON
12133
+ })
12134
+ }),
12135
+ /*#__PURE__*/ jsxRuntime.jsx(ColumnBodyCells, {
12136
+ columns: columns,
12137
+ row: row,
12138
+ classNames: classNames,
12139
+ locale: locale,
12140
+ selectable: !!selectable,
12141
+ stickyFirst: stickyFirst,
12142
+ stickyLast: resolvedStickyLast,
12143
+ cellContextMenu: cellContextMenu,
12144
+ onViewEditHistory: onViewEditHistory,
12145
+ cellContextMenuItems: cellContextMenuItems
12146
+ })
12147
+ ]
12148
+ }, row.id))
12149
+ })
12150
+ }),
12007
12151
  onLoadMore && /*#__PURE__*/ jsxRuntime.jsx(InfiniteScrollRow, {
12008
12152
  colSpan: totalCols,
12009
12153
  classNames: classNames,
@@ -12104,7 +12248,69 @@ const DataTableTable = withContext$1(DataTableTable$1, "table");
12104
12248
  * out of sync between the three. A column with no explicit width gets a bare `<col>`
12105
12249
  * (no `style`): `table-layout: fixed`'s own algorithm gives every such column an equal
12106
12250
  * 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 }) {
12251
+ * default with no extra code needed here. */ /**
12252
+ * Row virtualization's render slice. Lives inside `DataTableRoot` (it reads the
12253
+ * windowing state off context, which `DataTableInner` above can't) and hands its
12254
+ * children the visible `[start, end)` slice of `rows` plus that slice's first index,
12255
+ * flanked by a spacer row each side that stands in for everything cut off -- so the
12256
+ * scroll height stays `rows.length * rowHeight` and the scrollbar matches the full
12257
+ * data set. `virtualized` off: every row, no spacers, the pre-0.12 output exactly.
12258
+ */ function WindowedRows({ rows, virtualized, colSpan, children }) {
12259
+ const { virtual } = useDataTableContext();
12260
+ const windowed = React__namespace.useMemo(()=>virtualized ? computeVirtualWindow({
12261
+ count: rows.length,
12262
+ rowHeight: virtual.rowHeight,
12263
+ scrollTop: virtual.scrollTop,
12264
+ viewportHeight: virtual.viewportHeight,
12265
+ overscan: virtual.overscan
12266
+ }) : null, [
12267
+ virtualized,
12268
+ rows.length,
12269
+ virtual.rowHeight,
12270
+ virtual.scrollTop,
12271
+ virtual.viewportHeight,
12272
+ virtual.overscan
12273
+ ]);
12274
+ if (!windowed) return /*#__PURE__*/ jsxRuntime.jsx(jsxRuntime.Fragment, {
12275
+ children: children(rows, 0)
12276
+ });
12277
+ return /*#__PURE__*/ jsxRuntime.jsxs(jsxRuntime.Fragment, {
12278
+ children: [
12279
+ windowed.topSpacer > 0 && /*#__PURE__*/ jsxRuntime.jsx(VirtualSpacerRow, {
12280
+ height: windowed.topSpacer,
12281
+ colSpan: colSpan
12282
+ }),
12283
+ children(rows.slice(windowed.start, windowed.end), windowed.start),
12284
+ windowed.bottomSpacer > 0 && /*#__PURE__*/ jsxRuntime.jsx(VirtualSpacerRow, {
12285
+ height: windowed.bottomSpacer,
12286
+ colSpan: colSpan
12287
+ })
12288
+ ]
12289
+ });
12290
+ }
12291
+ /** A plain `<tr><td colSpan>` of a given height -- deliberately NOT a `DataTableRow`/
12292
+ * `DataTableCell` (no hover tint, no cell chrome, no border, no 36px min height).
12293
+ * `colSpan` covers every column, so the `<colgroup>` still drives widths and sticky
12294
+ * columns are unaffected. */ function VirtualSpacerRow({ height, colSpan }) {
12295
+ return /*#__PURE__*/ jsxRuntime.jsx("tr", {
12296
+ "data-virtual-spacer": "",
12297
+ style: {
12298
+ height
12299
+ },
12300
+ children: /*#__PURE__*/ jsxRuntime.jsx("td", {
12301
+ "aria-hidden": "true",
12302
+ colSpan: colSpan,
12303
+ style: {
12304
+ height,
12305
+ padding: 0,
12306
+ border: 0,
12307
+ display: "table-cell",
12308
+ lineHeight: 0
12309
+ }
12310
+ })
12311
+ });
12312
+ }
12313
+ function DataTableColGroup({ columns, selectable, stickyFirst }) {
12108
12314
  // MUST use the same pinned-first ordering every OTHER row (header/body/footer)
12109
12315
  // renders in (`useEffectiveColumns`'s own doc comment) -- a `<colgroup>`'s `<col>`s
12110
12316
  // are matched positionally to each row's cells, so if a pinned column visually moves
@@ -12179,7 +12385,7 @@ const DataTableSelectionCell = withSelectionCell(DataTableCell$1, "cell");
12179
12385
  // to clip), so it never introduces a second vertical scrollbar next to whatever
12180
12386
  // ambient container the whole DataTable already sits in.
12181
12387
  const DataTableScrollBodyBase = withContext$1(Primitive.div, "scrollArea");
12182
- function DataTableScrollBody({ children, scrollProps }) {
12388
+ function DataTableScrollBody({ children, virtualized, scrollProps }) {
12183
12389
  const { scrollAreaRef, scrolledStart, scrolledEnd, onScroll } = useDataTableContext();
12184
12390
  const { className: scrollClassName, style: scrollStyle, ...otherScrollProps } = scrollProps ?? {};
12185
12391
  return /*#__PURE__*/ jsxRuntime.jsx(DataTableScrollBodyBase, {
@@ -12187,7 +12393,13 @@ function DataTableScrollBody({ children, scrollProps }) {
12187
12393
  ref: scrollAreaRef,
12188
12394
  onScroll: onScroll,
12189
12395
  className: scrollClassName,
12190
- style: scrollStyle,
12396
+ // Virtualized: the browser's scroll anchoring would try to hold the visible rows
12397
+ // steady as the spacer heights change under it, fighting the scroll position
12398
+ // this windowing itself derives from -- turn it off.
12399
+ style: virtualized ? {
12400
+ ...scrollStyle,
12401
+ overflowAnchor: "none"
12402
+ } : scrollStyle,
12191
12403
  "data-scrolled-start": domUtils.dataAttr(scrolledStart),
12192
12404
  "data-scrolled-end": domUtils.dataAttr(scrolledEnd),
12193
12405
  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
  /**
@@ -1491,7 +1536,7 @@ const ROOT_MARGIN = "160px";
1491
1536
  * wires this to useDataTableScroll's own scroll container) so a BOUNDED table
1492
1537
  * triggers off its own internal scroll, not the browsing viewport.
1493
1538
  */ function useDataTableInfiniteScroll(props) {
1494
- const { onLoadMore, hasMore = true, loadingMore: loadingMoreProp, scrollRootRef } = props;
1539
+ const { onLoadMore, hasMore = true, loadingMore: loadingMoreProp, scrollRootRef, virtualized = false } = props;
1495
1540
  const [internalLoadingMore, setInternalLoadingMore] = useState(false);
1496
1541
  const loadingMore = loadingMoreProp ?? internalLoadingMore;
1497
1542
  // Mirrors the latest props into a ref the observer callback reads -- the callback
@@ -1531,6 +1576,7 @@ const ROOT_MARGIN = "160px";
1531
1576
  const [sentinelNode, setSentinelNode] = useState(null);
1532
1577
  const sentinelRef = useCallback((node)=>setSentinelNode(node), []);
1533
1578
  useEffect(()=>{
1579
+ if (virtualized) return;
1534
1580
  if (!sentinelNode || !onLoadMore || typeof IntersectionObserver === "undefined") return;
1535
1581
  // Effects run after every ref callback of the SAME commit (including
1536
1582
  // `scrollRootRef`'s own, set by useDataTableScroll's `scrollAreaRef`) regardless
@@ -1551,6 +1597,23 @@ const ROOT_MARGIN = "160px";
1551
1597
  sentinelNode,
1552
1598
  onLoadMore,
1553
1599
  scrollRootRef,
1600
+ triggerLoadMore,
1601
+ virtualized
1602
+ ]);
1603
+ // Virtualized mode's trigger: near the bottom of the scroll container (same gate --
1604
+ // `hasMore`, not already loading -- via `triggerLoadMore`). Called on every scroll
1605
+ // and again after the row count / loading state changes (a page that lands while
1606
+ // the user is still at the bottom, or too short to overflow, must chain the next
1607
+ // load rather than wait for a scroll that can never come).
1608
+ const checkScrollPosition = useCallback(()=>{
1609
+ const element = scrollRootRef?.current;
1610
+ if (!virtualized || !element) return;
1611
+ if (isNearBottom(element.scrollTop, element.clientHeight, element.scrollHeight)) {
1612
+ triggerLoadMore();
1613
+ }
1614
+ }, [
1615
+ virtualized,
1616
+ scrollRootRef,
1554
1617
  triggerLoadMore
1555
1618
  ]);
1556
1619
  return {
@@ -1564,7 +1627,9 @@ const ROOT_MARGIN = "160px";
1564
1627
  /** Fires `onLoadMore` directly, subject to the same `hasMore`/`loadingMore` gate
1565
1628
  * the observer itself uses -- exposed for a consumer that wants a manual retry
1566
1629
  * affordance (e.g. after a failed load) rather than only the observer's own
1567
- * scroll trigger. */ triggerLoadMore
1630
+ * scroll trigger. */ triggerLoadMore,
1631
+ /** Virtualized mode's scroll-position check (no-op otherwise); `useDataTable`
1632
+ * calls it from the scroll container's `onScroll`. */ checkScrollPosition
1568
1633
  };
1569
1634
  }
1570
1635
 
@@ -1576,19 +1641,53 @@ const ROOT_MARGIN = "160px";
1576
1641
  * real ref to a real scrollable element, re-measured on every scroll (and whenever the
1577
1642
  * styled layer calls `remeasure`, e.g. after a column resize/reorder changes the
1578
1643
  * scrollable width without the element itself ever firing a `scroll` event).
1579
- */ function useDataTableScroll() {
1644
+ */ function useDataTableScroll(props = {}) {
1645
+ const { virtualized = false, rowHeight = DEFAULT_VIRTUAL_ROW_HEIGHT } = props;
1646
+ const overscan = props.overscan ?? DEFAULT_VIRTUAL_OVERSCAN;
1580
1647
  const elementRef = useRef(null);
1581
1648
  const [scrolledStart, setScrolledStart] = useState(false);
1582
1649
  const [scrolledEnd, setScrolledEnd] = useState(false);
1650
+ const [viewport, setViewport] = useState({
1651
+ scrollTop: 0,
1652
+ viewportHeight: 0
1653
+ });
1583
1654
  const remeasure = useCallback(()=>{
1584
1655
  const element = elementRef.current;
1585
1656
  if (!element) return;
1657
+ if (virtualized) {
1658
+ // Row 0 starts below the sticky header, so measure from there. Snapping to whole
1659
+ // rows keeps re-renders to one per row scrolled (not one per scroll event); the
1660
+ // extra `rowHeight` of viewport makes up for the rows the snap rounded away.
1661
+ const headerHeight = element.querySelector("thead")?.offsetHeight ?? 0;
1662
+ const scrollTop = Math.max(0, element.scrollTop - headerHeight);
1663
+ const snapped = Math.floor(scrollTop / rowHeight) * rowHeight;
1664
+ const viewportHeight = element.clientHeight + rowHeight;
1665
+ setViewport((prev)=>prev.scrollTop === snapped && prev.viewportHeight === viewportHeight ? prev : {
1666
+ scrollTop: snapped,
1667
+ viewportHeight
1668
+ });
1669
+ }
1586
1670
  setScrolledStart(element.scrollLeft > 0);
1587
1671
  // A 1px slop: sub-pixel layout can leave `scrollLeft + clientWidth` a fraction
1588
1672
  // short of `scrollWidth` even when fully scrolled, which would otherwise leave
1589
1673
  // the trailing shadow stuck on forever.
1590
1674
  setScrolledEnd(element.scrollLeft + element.clientWidth < element.scrollWidth - 1);
1591
- }, []);
1675
+ }, [
1676
+ virtualized,
1677
+ rowHeight
1678
+ ]);
1679
+ // The container can resize without scrolling (window resize, a bounded parent
1680
+ // changing height) -- the viewport height feeds the window, so re-measure then too.
1681
+ useEffect(()=>{
1682
+ const element = elementRef.current;
1683
+ if (!virtualized || !element || typeof ResizeObserver === "undefined") return;
1684
+ const observer = new ResizeObserver(()=>remeasure());
1685
+ observer.observe(element);
1686
+ return ()=>observer.disconnect();
1687
+ }, [
1688
+ virtualized,
1689
+ remeasure
1690
+ ]);
1592
1691
  const scrollAreaRef = useCallback((node)=>{
1593
1692
  elementRef.current = node;
1594
1693
  if (node) remeasure();
@@ -1604,6 +1703,12 @@ const ROOT_MARGIN = "160px";
1604
1703
  scrolledEnd,
1605
1704
  onScroll,
1606
1705
  remeasure,
1706
+ /** Row-virtualization state; `enabled: false` (the default) means render every row. */ virtual: {
1707
+ enabled: virtualized,
1708
+ rowHeight,
1709
+ overscan,
1710
+ ...viewport
1711
+ },
1607
1712
  // The raw ref object itself (not just the callback ref above) -- useDataTable.ts
1608
1713
  // hands this to useDataTableInfiniteScroll as the IntersectionObserver `root` for
1609
1714
  // its sentinel row, so a scrolled/bounded table (DATATABLE-ATTIO-SPEC.md's
@@ -2002,7 +2107,7 @@ function useDataTable(props) {
2002
2107
  const toolbarFilter = useDataTableToolbarFilter(props);
2003
2108
  const columns = useDataTableColumns(props);
2004
2109
  const columnCalc = useDataTableColumnCalc(props);
2005
- const scroll = useDataTableScroll();
2110
+ const scroll = useDataTableScroll(props);
2006
2111
  const edit = useDataTableEdit(props);
2007
2112
  // Feature C (#12) -- which single cell (if any) is showing its read-only "show
2008
2113
  // full content" overlay. Independent of `edit` above -- see
@@ -2016,6 +2121,28 @@ function useDataTable(props) {
2016
2121
  ...props,
2017
2122
  scrollRootRef: scroll.elementRef
2018
2123
  });
2124
+ // Virtualized mode swaps the sentinel's IntersectionObserver for a scroll-position
2125
+ // check: on every scroll (below), and again whenever the row count or loading state
2126
+ // changes -- new rows can leave the user still at the bottom.
2127
+ const { checkScrollPosition } = infiniteScroll;
2128
+ const rowCount = props.rowIds?.length;
2129
+ // biome-ignore lint/correctness/useExhaustiveDependencies: rowCount/loadingMore/hasMore are the triggers, not values read in the effect
2130
+ useEffect(()=>{
2131
+ checkScrollPosition();
2132
+ }, [
2133
+ checkScrollPosition,
2134
+ rowCount,
2135
+ infiniteScroll.loadingMore,
2136
+ infiniteScroll.hasMore
2137
+ ]);
2138
+ const { onScroll: measureScroll } = scroll;
2139
+ const onScroll = useCallback(()=>{
2140
+ measureScroll();
2141
+ checkScrollPosition();
2142
+ }, [
2143
+ measureScroll,
2144
+ checkScrollPosition
2145
+ ]);
2019
2146
  // Unifies `sort` (the header's own single click-to-sort column, above) with
2020
2147
  // `toolbarSort.toolbarSorts` (the toolbar Sort popover's ordered list, above) into
2021
2148
  // ONE state instead of two -- before this, a header click only ever moved `sort`,
@@ -2127,6 +2254,7 @@ function useDataTable(props) {
2127
2254
  ...edit,
2128
2255
  ...peek,
2129
2256
  ...infiniteScroll,
2257
+ onScroll,
2130
2258
  rootProps: elementProps({
2131
2259
  role: "table"
2132
2260
  })
@@ -2169,7 +2297,7 @@ function useDataTableRowContext({ strict = true } = {}) {
2169
2297
  return context;
2170
2298
  }
2171
2299
  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;
2300
+ 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
2301
  const api = useDataTable({
2174
2302
  sort,
2175
2303
  defaultSort,
@@ -2211,7 +2339,10 @@ const DataTableRoot = /*#__PURE__*/ forwardRef((props, ref)=>{
2211
2339
  onEdit,
2212
2340
  onLoadMore,
2213
2341
  hasMore,
2214
- loadingMore
2342
+ loadingMore,
2343
+ virtualized,
2344
+ rowHeight,
2345
+ overscan
2215
2346
  });
2216
2347
  return /*#__PURE__*/ jsx(DataTableProvider, {
2217
2348
  value: api,
@@ -11691,7 +11822,7 @@ function RowCount({ shown, total, countLabel }) {
11691
11822
  }
11692
11823
 
11693
11824
  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;
11825
+ 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
11826
  const classNames = dataTable({
11696
11827
  surface,
11697
11828
  interactive
@@ -11864,6 +11995,9 @@ function DataTableInner(props, ref) {
11864
11995
  onLoadMore: onLoadMore,
11865
11996
  hasMore: hasMore,
11866
11997
  loadingMore: loadingMore,
11998
+ virtualized: virtualized,
11999
+ rowHeight: rowHeight,
12000
+ overscan: overscan,
11867
12001
  ...restProps,
11868
12002
  children: [
11869
12003
  onColumnsChange && /*#__PURE__*/ jsx(ColumnsChangeEffect, {
@@ -11896,7 +12030,9 @@ function DataTableInner(props, ref) {
11896
12030
  }),
11897
12031
  /*#__PURE__*/ jsx(DataTableScrollBody, {
11898
12032
  scrollProps: scrollProps,
12033
+ virtualized: virtualized,
11899
12034
  children: /*#__PURE__*/ jsxs(DataTableTable, {
12035
+ "aria-rowcount": virtualized ? sortedData.length + 1 : undefined,
11900
12036
  children: [
11901
12037
  /*#__PURE__*/ jsx(DataTableColGroup, {
11902
12038
  columns: columns,
@@ -11952,39 +12088,47 @@ function DataTableInner(props, ref) {
11952
12088
  })
11953
12089
  }) : /*#__PURE__*/ jsxs(Fragment, {
11954
12090
  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)),
12091
+ /*#__PURE__*/ jsx(WindowedRows, {
12092
+ rows: sortedData,
12093
+ virtualized: virtualized,
12094
+ colSpan: totalCols,
12095
+ children: (windowRows, firstIndex)=>/*#__PURE__*/ jsx(Fragment, {
12096
+ children: windowRows.map((row, offset)=>/*#__PURE__*/ jsxs(DataTableRow, {
12097
+ rowId: row.id,
12098
+ "aria-rowindex": virtualized ? firstIndex + offset + 2 : undefined,
12099
+ "data-clickable": onRowClick ? "" : undefined,
12100
+ onClick: onRowClick ? ()=>onRowClick(row) : undefined,
12101
+ children: [
12102
+ selectable && /*#__PURE__*/ jsx(SelectionCell, {
12103
+ // The row itself carries `onClick={onRowClick}` above, and a
12104
+ // native click on this cell's checkbox bubbles up through it
12105
+ // (React attaches DataTableRow's handler on the <tr>, which
12106
+ // sits between the checkbox and anything a consumer could stop
12107
+ // it from at -- so this has to live here, not in the consumer).
12108
+ // Without this, checking a row's box also fires `onRowClick`.
12109
+ onClick: (event)=>event.stopPropagation(),
12110
+ stickyFirst: stickyFirst,
12111
+ children: /*#__PURE__*/ jsx(RowCheckbox, {
12112
+ label: getRowSelectionLabel(row),
12113
+ checkedIcon: selectionCheckedIcon ?? DEFAULT_CHECKED_ICON
12114
+ })
12115
+ }),
12116
+ /*#__PURE__*/ jsx(ColumnBodyCells, {
12117
+ columns: columns,
12118
+ row: row,
12119
+ classNames: classNames,
12120
+ locale: locale,
12121
+ selectable: !!selectable,
12122
+ stickyFirst: stickyFirst,
12123
+ stickyLast: resolvedStickyLast,
12124
+ cellContextMenu: cellContextMenu,
12125
+ onViewEditHistory: onViewEditHistory,
12126
+ cellContextMenuItems: cellContextMenuItems
12127
+ })
12128
+ ]
12129
+ }, row.id))
12130
+ })
12131
+ }),
11988
12132
  onLoadMore && /*#__PURE__*/ jsx(InfiniteScrollRow, {
11989
12133
  colSpan: totalCols,
11990
12134
  classNames: classNames,
@@ -12085,7 +12229,69 @@ const DataTableTable = withContext$1(DataTableTable$1, "table");
12085
12229
  * out of sync between the three. A column with no explicit width gets a bare `<col>`
12086
12230
  * (no `style`): `table-layout: fixed`'s own algorithm gives every such column an equal
12087
12231
  * 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 }) {
12232
+ * default with no extra code needed here. */ /**
12233
+ * Row virtualization's render slice. Lives inside `DataTableRoot` (it reads the
12234
+ * windowing state off context, which `DataTableInner` above can't) and hands its
12235
+ * children the visible `[start, end)` slice of `rows` plus that slice's first index,
12236
+ * flanked by a spacer row each side that stands in for everything cut off -- so the
12237
+ * scroll height stays `rows.length * rowHeight` and the scrollbar matches the full
12238
+ * data set. `virtualized` off: every row, no spacers, the pre-0.12 output exactly.
12239
+ */ function WindowedRows({ rows, virtualized, colSpan, children }) {
12240
+ const { virtual } = useDataTableContext();
12241
+ const windowed = React.useMemo(()=>virtualized ? computeVirtualWindow({
12242
+ count: rows.length,
12243
+ rowHeight: virtual.rowHeight,
12244
+ scrollTop: virtual.scrollTop,
12245
+ viewportHeight: virtual.viewportHeight,
12246
+ overscan: virtual.overscan
12247
+ }) : null, [
12248
+ virtualized,
12249
+ rows.length,
12250
+ virtual.rowHeight,
12251
+ virtual.scrollTop,
12252
+ virtual.viewportHeight,
12253
+ virtual.overscan
12254
+ ]);
12255
+ if (!windowed) return /*#__PURE__*/ jsx(Fragment, {
12256
+ children: children(rows, 0)
12257
+ });
12258
+ return /*#__PURE__*/ jsxs(Fragment, {
12259
+ children: [
12260
+ windowed.topSpacer > 0 && /*#__PURE__*/ jsx(VirtualSpacerRow, {
12261
+ height: windowed.topSpacer,
12262
+ colSpan: colSpan
12263
+ }),
12264
+ children(rows.slice(windowed.start, windowed.end), windowed.start),
12265
+ windowed.bottomSpacer > 0 && /*#__PURE__*/ jsx(VirtualSpacerRow, {
12266
+ height: windowed.bottomSpacer,
12267
+ colSpan: colSpan
12268
+ })
12269
+ ]
12270
+ });
12271
+ }
12272
+ /** A plain `<tr><td colSpan>` of a given height -- deliberately NOT a `DataTableRow`/
12273
+ * `DataTableCell` (no hover tint, no cell chrome, no border, no 36px min height).
12274
+ * `colSpan` covers every column, so the `<colgroup>` still drives widths and sticky
12275
+ * columns are unaffected. */ function VirtualSpacerRow({ height, colSpan }) {
12276
+ return /*#__PURE__*/ jsx("tr", {
12277
+ "data-virtual-spacer": "",
12278
+ style: {
12279
+ height
12280
+ },
12281
+ children: /*#__PURE__*/ jsx("td", {
12282
+ "aria-hidden": "true",
12283
+ colSpan: colSpan,
12284
+ style: {
12285
+ height,
12286
+ padding: 0,
12287
+ border: 0,
12288
+ display: "table-cell",
12289
+ lineHeight: 0
12290
+ }
12291
+ })
12292
+ });
12293
+ }
12294
+ function DataTableColGroup({ columns, selectable, stickyFirst }) {
12089
12295
  // MUST use the same pinned-first ordering every OTHER row (header/body/footer)
12090
12296
  // renders in (`useEffectiveColumns`'s own doc comment) -- a `<colgroup>`'s `<col>`s
12091
12297
  // are matched positionally to each row's cells, so if a pinned column visually moves
@@ -12160,7 +12366,7 @@ const DataTableSelectionCell = withSelectionCell(DataTableCell$1, "cell");
12160
12366
  // to clip), so it never introduces a second vertical scrollbar next to whatever
12161
12367
  // ambient container the whole DataTable already sits in.
12162
12368
  const DataTableScrollBodyBase = withContext$1(Primitive.div, "scrollArea");
12163
- function DataTableScrollBody({ children, scrollProps }) {
12369
+ function DataTableScrollBody({ children, virtualized, scrollProps }) {
12164
12370
  const { scrollAreaRef, scrolledStart, scrolledEnd, onScroll } = useDataTableContext();
12165
12371
  const { className: scrollClassName, style: scrollStyle, ...otherScrollProps } = scrollProps ?? {};
12166
12372
  return /*#__PURE__*/ jsx(DataTableScrollBodyBase, {
@@ -12168,7 +12374,13 @@ function DataTableScrollBody({ children, scrollProps }) {
12168
12374
  ref: scrollAreaRef,
12169
12375
  onScroll: onScroll,
12170
12376
  className: scrollClassName,
12171
- style: scrollStyle,
12377
+ // Virtualized: the browser's scroll anchoring would try to hold the visible rows
12378
+ // steady as the spacer heights change under it, fighting the scroll position
12379
+ // this windowing itself derives from -- turn it off.
12380
+ style: virtualized ? {
12381
+ ...scrollStyle,
12382
+ overflowAnchor: "none"
12383
+ } : scrollStyle,
12172
12384
  "data-scrolled-start": dataAttr(scrolledStart),
12173
12385
  "data-scrolled-end": dataAttr(scrolledEnd),
12174
12386
  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.0",
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",