@wallarm-org/design-system 0.52.0-rc-feature-shell.1 → 0.52.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.
Files changed (49) hide show
  1. package/dist/components/NavRail/NavRail.d.ts +0 -1
  2. package/dist/components/NavRail/NavRail.js +2 -2
  3. package/dist/components/NavRail/classes.js +1 -1
  4. package/dist/components/Table/TableBody/TableBody.js +6 -1
  5. package/dist/components/Table/TableBody/TableBodyVirtualizedContainer.js +7 -2
  6. package/dist/components/Table/TableBody/TableBodyVirtualizedCore.js +6 -1
  7. package/dist/components/Table/TableBody/TableBodyVirtualizedWindow.js +7 -2
  8. package/dist/components/Table/TableBody/lib/measureRowElement.d.ts +10 -0
  9. package/dist/components/Table/TableBody/lib/measureRowElement.js +7 -0
  10. package/dist/components/Table/TableBody/useResetVirtualizerOnDataChange.d.ts +3 -3
  11. package/dist/components/Table/TableBody/useResetVirtualizerOnDataChange.js +8 -6
  12. package/dist/components/Table/TableContext/TableProvider.js +9 -3
  13. package/dist/components/Table/TableContext/types.d.ts +4 -1
  14. package/dist/components/Table/TableInner/TableInnerContainer.js +10 -4
  15. package/dist/components/Table/TableInner/TableInnerWindow.js +10 -4
  16. package/dist/components/Table/TableLoadingState.d.ts +12 -1
  17. package/dist/components/Table/TableLoadingState.js +4 -3
  18. package/dist/components/Table/hooks/index.d.ts +1 -1
  19. package/dist/components/Table/hooks/index.js +2 -2
  20. package/dist/components/Table/hooks/infiniteScroll/index.d.ts +1 -0
  21. package/dist/components/Table/hooks/infiniteScroll/index.js +2 -0
  22. package/dist/components/Table/hooks/infiniteScroll/useInfiniteScroll.d.ts +19 -0
  23. package/dist/components/Table/hooks/infiniteScroll/useInfiniteScroll.js +36 -0
  24. package/dist/components/Table/hooks/infiniteScroll/useInitialAnchor.d.ts +16 -0
  25. package/dist/components/Table/hooks/infiniteScroll/useInitialAnchor.js +28 -0
  26. package/dist/components/Table/hooks/infiniteScroll/usePrependScrollAnchor.d.ts +27 -0
  27. package/dist/components/Table/hooks/infiniteScroll/usePrependScrollAnchor.js +70 -0
  28. package/dist/components/Table/hooks/infiniteScroll/useScrollEdge.d.ts +20 -0
  29. package/dist/components/Table/hooks/{useEndReached.js → infiniteScroll/useScrollEdge.js} +15 -11
  30. package/dist/components/Table/lib/constants.d.ts +5 -0
  31. package/dist/components/Table/lib/constants.js +4 -1
  32. package/dist/components/Table/lib/detectDataChange.d.ts +4 -0
  33. package/dist/components/Table/lib/detectDataChange.js +7 -0
  34. package/dist/components/Table/lib/getRowKey.d.ts +4 -0
  35. package/dist/components/Table/lib/getRowKey.js +2 -0
  36. package/dist/components/Table/lib/index.d.ts +3 -1
  37. package/dist/components/Table/lib/index.js +4 -2
  38. package/dist/components/Table/mocks.d.ts +14 -0
  39. package/dist/components/Table/mocks.js +61 -1
  40. package/dist/components/Table/types.d.ts +17 -1
  41. package/dist/components/TopHeader/TopHeader.d.ts +0 -1
  42. package/dist/components/TopHeader/TopHeader.js +2 -2
  43. package/dist/hooks/useOverflowItems.d.ts +6 -0
  44. package/dist/hooks/useOverflowItems.js +44 -14
  45. package/dist/hooks/useOverflowItems.scheduler.d.ts +16 -0
  46. package/dist/hooks/useOverflowItems.scheduler.js +25 -0
  47. package/dist/metadata/components.json +22 -12
  48. package/package.json +1 -1
  49. package/dist/components/Table/hooks/useEndReached.d.ts +0 -19
@@ -1,17 +1,20 @@
1
1
  import { useEffect, useRef } from "react";
2
- import { TABLE_END_REACHED_THRESHOLD } from "../lib/index.js";
3
- const COOLDOWN_MS = 200;
4
- const useEndReached = ({ mode, scrollRef, onEndReached, threshold = TABLE_END_REACHED_THRESHOLD })=>{
2
+ import { SCROLL_EDGE_COOLDOWN_MS } from "../../lib/index.js";
3
+ const useScrollEdge = ({ edge, mode, scrollRef, onReached, threshold, enabled = true })=>{
5
4
  const firedRef = useRef(false);
6
5
  const lastFiredAtRef = useRef(0);
7
- const onEndReachedRef = useRef(onEndReached);
6
+ const onReachedRef = useRef(onReached);
8
7
  useEffect(()=>{
9
- onEndReachedRef.current = onEndReached;
8
+ onReachedRef.current = onReached;
9
+ });
10
+ const enabledRef = useRef(enabled);
11
+ useEffect(()=>{
12
+ enabledRef.current = enabled;
10
13
  });
11
14
  useEffect(()=>{
12
15
  const check = ()=>{
13
- const callback = onEndReachedRef.current;
14
- if (!callback) return;
16
+ const callback = onReachedRef.current;
17
+ if (!callback || !enabledRef.current) return;
15
18
  let scrollTop;
16
19
  let clientHeight;
17
20
  let scrollHeight;
@@ -26,10 +29,10 @@ const useEndReached = ({ mode, scrollRef, onEndReached, threshold = TABLE_END_RE
26
29
  clientHeight = el.clientHeight;
27
30
  scrollHeight = el.scrollHeight;
28
31
  }
29
- const distanceToBottom = scrollHeight - scrollTop - clientHeight;
30
- if (distanceToBottom <= threshold) {
32
+ const distance = 'start' === edge ? scrollTop : scrollHeight - scrollTop - clientHeight;
33
+ if (distance <= threshold) {
31
34
  const now = Date.now();
32
- if (!firedRef.current && now - lastFiredAtRef.current >= COOLDOWN_MS) {
35
+ if (!firedRef.current && now - lastFiredAtRef.current >= SCROLL_EDGE_COOLDOWN_MS) {
33
36
  firedRef.current = true;
34
37
  lastFiredAtRef.current = now;
35
38
  callback();
@@ -46,9 +49,10 @@ const useEndReached = ({ mode, scrollRef, onEndReached, threshold = TABLE_END_RE
46
49
  target.removeEventListener('scroll', check);
47
50
  };
48
51
  }, [
52
+ edge,
49
53
  mode,
50
54
  scrollRef,
51
55
  threshold
52
56
  ]);
53
57
  };
54
- export { useEndReached };
58
+ export { useScrollEdge };
@@ -1,4 +1,6 @@
1
1
  export declare const TABLE_SKELETON_ROWS = 12;
2
+ /** Short on purpose: the prepend loader's height pushes visible rows down. */
3
+ export declare const TABLE_PREPEND_SKELETON_ROWS = 6;
2
4
  export declare const TABLE_VIRTUALIZATION_OVERSCAN = 6;
3
5
  export declare const TABLE_MIN_COLUMN_WIDTH = 96;
4
6
  export declare const TABLE_SELECT_COLUMN_ID = "_selection";
@@ -6,6 +8,9 @@ export declare const TABLE_SELECT_COLUMN_WIDTH = 33;
6
8
  export declare const TABLE_EXPAND_COLUMN_ID = "_expand";
7
9
  export declare const TABLE_EXPAND_COLUMN_WIDTH = 33;
8
10
  export declare const TABLE_END_REACHED_THRESHOLD = 200;
11
+ export declare const TABLE_START_REACHED_THRESHOLD = 200;
12
+ /** Minimum time (ms) between successive edge-reached callbacks. */
13
+ export declare const SCROLL_EDGE_COOLDOWN_MS = 200;
9
14
  /** Resolve text-align class from column meta */
10
15
  export declare const getAlignClass: (meta?: {
11
16
  align?: string;
@@ -1,4 +1,5 @@
1
1
  const TABLE_SKELETON_ROWS = 12;
2
+ const TABLE_PREPEND_SKELETON_ROWS = 6;
2
3
  const TABLE_VIRTUALIZATION_OVERSCAN = 6;
3
4
  const TABLE_MIN_COLUMN_WIDTH = 96;
4
5
  const TABLE_SELECT_COLUMN_ID = '_selection';
@@ -6,6 +7,8 @@ const TABLE_SELECT_COLUMN_WIDTH = 33;
6
7
  const TABLE_EXPAND_COLUMN_ID = '_expand';
7
8
  const TABLE_EXPAND_COLUMN_WIDTH = 33;
8
9
  const TABLE_END_REACHED_THRESHOLD = 200;
10
+ const TABLE_START_REACHED_THRESHOLD = 200;
11
+ const SCROLL_EDGE_COOLDOWN_MS = 200;
9
12
  const RIGHT_ALIGNED_SORT_TYPES = new Set([
10
13
  'number',
11
14
  'score',
@@ -56,4 +59,4 @@ const SORT_LABELS = {
56
59
  'Smallest on top'
57
60
  ]
58
61
  };
59
- export { SORT_LABELS, TABLE_END_REACHED_THRESHOLD, TABLE_EXPAND_COLUMN_ID, TABLE_EXPAND_COLUMN_WIDTH, TABLE_MIN_COLUMN_WIDTH, TABLE_SELECT_COLUMN_ID, TABLE_SELECT_COLUMN_WIDTH, TABLE_SKELETON_ROWS, TABLE_VIRTUALIZATION_OVERSCAN, getAlignClass, getExpandBorderClass };
62
+ export { SCROLL_EDGE_COOLDOWN_MS, SORT_LABELS, TABLE_END_REACHED_THRESHOLD, TABLE_EXPAND_COLUMN_ID, TABLE_EXPAND_COLUMN_WIDTH, TABLE_MIN_COLUMN_WIDTH, TABLE_PREPEND_SKELETON_ROWS, TABLE_SELECT_COLUMN_ID, TABLE_SELECT_COLUMN_WIDTH, TABLE_SKELETON_ROWS, TABLE_START_REACHED_THRESHOLD, TABLE_VIRTUALIZATION_OVERSCAN, getAlignClass, getExpandBorderClass };
@@ -0,0 +1,4 @@
1
+ /** Classifies a data change by comparing the previous first row id to the new rows. */
2
+ export declare const detectDataChange: (prevFirstRowId: string | undefined, rows: {
3
+ id: string;
4
+ }[]) => "prepend" | "replace" | "none";
@@ -0,0 +1,7 @@
1
+ const detectDataChange = (prevFirstRowId, rows)=>{
2
+ const currentFirstId = rows[0]?.id;
3
+ if (void 0 === prevFirstRowId) return 'none';
4
+ if (currentFirstId === prevFirstRowId) return 'none';
5
+ return rows.some((r)=>r.id === prevFirstRowId) ? 'prepend' : 'replace';
6
+ };
7
+ export { detectDataChange };
@@ -0,0 +1,4 @@
1
+ /** Stable virtualizer item key: the row id, or the index as fallback. */
2
+ export declare const getRowKey: (rows: {
3
+ id: string;
4
+ }[], index: number) => string | number;
@@ -0,0 +1,2 @@
1
+ const getRowKey = (rows, index)=>rows[index]?.id ?? index;
2
+ export { getRowKey };
@@ -1,9 +1,11 @@
1
- export { getAlignClass, getExpandBorderClass, SORT_LABELS, TABLE_END_REACHED_THRESHOLD, TABLE_EXPAND_COLUMN_ID, TABLE_EXPAND_COLUMN_WIDTH, TABLE_MIN_COLUMN_WIDTH, TABLE_SELECT_COLUMN_ID, TABLE_SELECT_COLUMN_WIDTH, TABLE_SKELETON_ROWS, TABLE_VIRTUALIZATION_OVERSCAN, } from './constants';
1
+ export { getAlignClass, getExpandBorderClass, SCROLL_EDGE_COOLDOWN_MS, SORT_LABELS, TABLE_END_REACHED_THRESHOLD, TABLE_EXPAND_COLUMN_ID, TABLE_EXPAND_COLUMN_WIDTH, TABLE_MIN_COLUMN_WIDTH, TABLE_PREPEND_SKELETON_ROWS, TABLE_SELECT_COLUMN_ID, TABLE_SELECT_COLUMN_WIDTH, TABLE_SKELETON_ROWS, TABLE_START_REACHED_THRESHOLD, TABLE_VIRTUALIZATION_OVERSCAN, } from './constants';
2
2
  export { createExpandColumn } from './createExpandColumn';
3
3
  export { createSelectionColumn } from './createSelectionColumn';
4
4
  export { createTableColumnHelper } from './createTableColumnHelper';
5
+ export { detectDataChange } from './detectDataChange';
5
6
  export { getDndStyles } from './getDndStyles';
6
7
  export { getPinningStyles } from './getPinningStyles';
8
+ export { getRowKey } from './getRowKey';
7
9
  export { isLastPinnedLeft } from './isLastPinnedLeft';
8
10
  export { useColumnDnd } from './useColumnDnd';
9
11
  export { useContainerWidth } from './useContainerWidth';
@@ -1,10 +1,12 @@
1
- import { SORT_LABELS, TABLE_END_REACHED_THRESHOLD, TABLE_EXPAND_COLUMN_ID, TABLE_EXPAND_COLUMN_WIDTH, TABLE_MIN_COLUMN_WIDTH, TABLE_SELECT_COLUMN_ID, TABLE_SELECT_COLUMN_WIDTH, TABLE_SKELETON_ROWS, TABLE_VIRTUALIZATION_OVERSCAN, getAlignClass, getExpandBorderClass } from "./constants.js";
1
+ import { SCROLL_EDGE_COOLDOWN_MS, SORT_LABELS, TABLE_END_REACHED_THRESHOLD, TABLE_EXPAND_COLUMN_ID, TABLE_EXPAND_COLUMN_WIDTH, TABLE_MIN_COLUMN_WIDTH, TABLE_PREPEND_SKELETON_ROWS, TABLE_SELECT_COLUMN_ID, TABLE_SELECT_COLUMN_WIDTH, TABLE_SKELETON_ROWS, TABLE_START_REACHED_THRESHOLD, TABLE_VIRTUALIZATION_OVERSCAN, getAlignClass, getExpandBorderClass } from "./constants.js";
2
2
  import { createExpandColumn } from "./createExpandColumn.js";
3
3
  import { createSelectionColumn } from "./createSelectionColumn.js";
4
4
  import { createTableColumnHelper } from "./createTableColumnHelper.js";
5
+ import { detectDataChange } from "./detectDataChange.js";
5
6
  import { getDndStyles } from "./getDndStyles.js";
6
7
  import { getPinningStyles } from "./getPinningStyles.js";
8
+ import { getRowKey } from "./getRowKey.js";
7
9
  import { isLastPinnedLeft } from "./isLastPinnedLeft.js";
8
10
  import { useColumnDnd } from "./useColumnDnd.js";
9
11
  import { useContainerWidth } from "./useContainerWidth.js";
10
- export { SORT_LABELS, TABLE_END_REACHED_THRESHOLD, TABLE_EXPAND_COLUMN_ID, TABLE_EXPAND_COLUMN_WIDTH, TABLE_MIN_COLUMN_WIDTH, TABLE_SELECT_COLUMN_ID, TABLE_SELECT_COLUMN_WIDTH, TABLE_SKELETON_ROWS, TABLE_VIRTUALIZATION_OVERSCAN, createExpandColumn, createSelectionColumn, createTableColumnHelper, getAlignClass, getDndStyles, getExpandBorderClass, getPinningStyles, isLastPinnedLeft, useColumnDnd, useContainerWidth };
12
+ export { SCROLL_EDGE_COOLDOWN_MS, SORT_LABELS, TABLE_END_REACHED_THRESHOLD, TABLE_EXPAND_COLUMN_ID, TABLE_EXPAND_COLUMN_WIDTH, TABLE_MIN_COLUMN_WIDTH, TABLE_PREPEND_SKELETON_ROWS, TABLE_SELECT_COLUMN_ID, TABLE_SELECT_COLUMN_WIDTH, TABLE_SKELETON_ROWS, TABLE_START_REACHED_THRESHOLD, TABLE_VIRTUALIZATION_OVERSCAN, createExpandColumn, createSelectionColumn, createTableColumnHelper, detectDataChange, getAlignClass, getDndStyles, getExpandBorderClass, getPinningStyles, getRowKey, isLastPinnedLeft, useColumnDnd, useContainerWidth };
@@ -59,3 +59,17 @@ export declare const useInfiniteData: () => {
59
59
  totalItems: number;
60
60
  fetchNextPage: () => void;
61
61
  };
62
+ /**
63
+ * Mock for bidirectional infinite scroll: opens a window around an anchor row
64
+ * and exposes cursor-style fetchers for both directions.
65
+ */
66
+ export declare const useBidirectionalData: () => {
67
+ data: SecurityEvent[];
68
+ anchorId: string | undefined;
69
+ isFetchingPrev: boolean;
70
+ isFetchingNext: boolean;
71
+ hasPrev: boolean;
72
+ hasNext: boolean;
73
+ fetchPrevPage: () => void;
74
+ fetchNextPage: () => void;
75
+ };
@@ -953,6 +953,9 @@ const fullFeaturedColumns = headerColumns.map((col)=>{
953
953
  });
954
954
  const INFINITE_PAGE_SIZE = 50;
955
955
  const INFINITE_MAX_ITEMS = 500;
956
+ const BIDIRECTIONAL_TOTAL = 500;
957
+ const BIDIRECTIONAL_PAGE_SIZE = 50;
958
+ const BIDIRECTIONAL_INITIAL_ANCHOR_INDEX = 250;
956
959
  const useInfiniteData = ()=>{
957
960
  const allData = useMemo(()=>createLargeSecurityEvents(INFINITE_MAX_ITEMS), []);
958
961
  const [data, setData] = useState(()=>allData.slice(0, INFINITE_PAGE_SIZE));
@@ -978,4 +981,61 @@ const useInfiniteData = ()=>{
978
981
  fetchNextPage
979
982
  };
980
983
  };
981
- export { createLargeGroupedData, createLargeSecurityEvents, fullFeaturedColumns, groupedHeaderData, headerColumnHelper, headerColumnIds, headerColumns, multiplySecurityEvents, renderSecurityPreviewContent, renderSecurityPreviewHeader, securityColumnHelper, securityColumnIds, securityColumns, securityEvents, useInfiniteData };
984
+ const useBidirectionalData = ()=>{
985
+ const allData = useMemo(()=>createLargeSecurityEvents(BIDIRECTIONAL_TOTAL), []);
986
+ const initialStart = Math.max(0, BIDIRECTIONAL_INITIAL_ANCHOR_INDEX - BIDIRECTIONAL_PAGE_SIZE);
987
+ const initialEnd = Math.min(allData.length, BIDIRECTIONAL_INITIAL_ANCHOR_INDEX + BIDIRECTIONAL_PAGE_SIZE);
988
+ const [range, setRange] = useState({
989
+ start: initialStart,
990
+ end: initialEnd
991
+ });
992
+ const [isFetchingPrev, setIsFetchingPrev] = useState(false);
993
+ const [isFetchingNext, setIsFetchingNext] = useState(false);
994
+ const data = useMemo(()=>allData.slice(range.start, range.end), [
995
+ allData,
996
+ range
997
+ ]);
998
+ const anchorId = allData[BIDIRECTIONAL_INITIAL_ANCHOR_INDEX]?.id;
999
+ const hasPrev = range.start > 0;
1000
+ const hasNext = range.end < allData.length;
1001
+ const fetchPrevPage = useCallback(()=>{
1002
+ if (isFetchingPrev || range.start <= 0) return;
1003
+ setIsFetchingPrev(true);
1004
+ setTimeout(()=>{
1005
+ setRange((prev)=>({
1006
+ ...prev,
1007
+ start: Math.max(0, prev.start - BIDIRECTIONAL_PAGE_SIZE)
1008
+ }));
1009
+ setIsFetchingPrev(false);
1010
+ }, 600);
1011
+ }, [
1012
+ isFetchingPrev,
1013
+ range.start
1014
+ ]);
1015
+ const fetchNextPage = useCallback(()=>{
1016
+ if (isFetchingNext || range.end >= allData.length) return;
1017
+ setIsFetchingNext(true);
1018
+ setTimeout(()=>{
1019
+ setRange((prev)=>({
1020
+ ...prev,
1021
+ end: Math.min(allData.length, prev.end + BIDIRECTIONAL_PAGE_SIZE)
1022
+ }));
1023
+ setIsFetchingNext(false);
1024
+ }, 600);
1025
+ }, [
1026
+ isFetchingNext,
1027
+ range.end,
1028
+ allData.length
1029
+ ]);
1030
+ return {
1031
+ data,
1032
+ anchorId,
1033
+ isFetchingPrev,
1034
+ isFetchingNext,
1035
+ hasPrev,
1036
+ hasNext,
1037
+ fetchPrevPage,
1038
+ fetchNextPage
1039
+ };
1040
+ };
1041
+ export { createLargeGroupedData, createLargeSecurityEvents, fullFeaturedColumns, groupedHeaderData, headerColumnHelper, headerColumnIds, headerColumns, multiplySecurityEvents, renderSecurityPreviewContent, renderSecurityPreviewHeader, securityColumnHelper, securityColumnIds, securityColumns, securityEvents, useBidirectionalData, useInfiniteData };
@@ -143,6 +143,12 @@ export interface TableProps<T> extends TestableProps {
143
143
  columns: TableColumnDef<T>[];
144
144
  /** Show skeleton rows */
145
145
  isLoading?: boolean;
146
+ /**
147
+ * Show skeleton rows above the first row while a previous page is being
148
+ * fetched (bidirectional infinite scroll — the start-edge counterpart of
149
+ * `isLoading`). Pair with `onStartReached`.
150
+ */
151
+ isLoadingPrevious?: boolean;
146
152
  /** Number of skeleton rows to display when loading (default: 6) */
147
153
  skeletonCount?: number;
148
154
  /** Slot for TableActionBar, TableEmptyState, and other compound components */
@@ -188,10 +194,20 @@ export interface TableProps<T> extends TestableProps {
188
194
  virtualized?: TableVirtualized;
189
195
  estimateRowHeight?: (index: number) => number;
190
196
  overscan?: number;
191
- /** Callback fired when the user scrolls near the end of the table */
197
+ /** Callback fired when the user scrolls near the end (bottom) of the table */
192
198
  onEndReached?: () => void;
193
199
  /** Distance from the bottom (in px) to trigger onEndReached (default: 200) */
194
200
  onEndReachedThreshold?: number;
201
+ /** Callback fired when the user scrolls near the start (top) of the table */
202
+ onStartReached?: () => void;
203
+ /** Distance from the top (in px) to trigger onStartReached (default: 200) */
204
+ onStartReachedThreshold?: number;
205
+ /**
206
+ * Row id to anchor the initial scroll position to. The table scrolls this row
207
+ * into view on mount and arms the edge detectors only after that initial
208
+ * scroll settles. Use for deep-linking into the middle of a dataset.
209
+ */
210
+ initialScrollToRowId?: string;
195
211
  /** Callback fired when the master cell is clicked. Receives the row ID. */
196
212
  onMasterCellClick?: (rowId: string) => void;
197
213
  /** ID of the currently active (highlighted) row, or null. Controls row highlighting via data-preview-active attribute. */
@@ -3,6 +3,5 @@ import { type TestableProps } from '../../utils/testId';
3
3
  export interface TopHeaderProps extends HTMLAttributes<HTMLDivElement>, TestableProps {
4
4
  ref?: Ref<HTMLDivElement>;
5
5
  children?: ReactNode;
6
- appeared?: boolean;
7
6
  }
8
7
  export declare const TopHeader: FC<TopHeaderProps>;
@@ -1,14 +1,14 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
2
  import { cn } from "../../utils/cn.js";
3
3
  import { TestIdProvider } from "../../utils/testId.js";
4
- const TopHeader = ({ ref, appeared, className, children, 'data-testid': testId, ...props })=>/*#__PURE__*/ jsx(TestIdProvider, {
4
+ const TopHeader = ({ ref, className, children, 'data-testid': testId, ...props })=>/*#__PURE__*/ jsx(TestIdProvider, {
5
5
  value: testId,
6
6
  children: /*#__PURE__*/ jsx("div", {
7
7
  ...props,
8
8
  ref: ref,
9
9
  "data-slot": "top-header",
10
10
  "data-testid": testId,
11
- className: cn('flex items-center justify-between pl-7 pr-12 py-6', 'transition-opacity duration-200 ease-in-out', false === appeared && 'opacity-0', className),
11
+ className: cn('flex items-center justify-between pl-7 pr-12 py-6', className),
12
12
  children: children
13
13
  })
14
14
  });
@@ -1,5 +1,11 @@
1
1
  import { type ReactElement, type RefObject } from 'react';
2
2
  export interface UseOverflowItemsOptions<T> {
3
+ /**
4
+ * Invariant: a given `items` array identity must always render to the same
5
+ * widths. Measurements are cached by array identity regardless of the
6
+ * renderers/reserveSpace, so changing those to alter widths needs a fresh
7
+ * array.
8
+ */
3
9
  items: T[];
4
10
  renderItem: (item: T) => ReactElement;
5
11
  renderMeasurementItem?: (item: T) => ReactElement;
@@ -1,17 +1,26 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useLayoutEffect, useRef, useState } from "react";
3
3
  import { calculateVisibleCount } from "./useOverflowItems.helpers.js";
4
+ import { scheduleOverflowMeasurement } from "./useOverflowItems.scheduler.js";
5
+ const crossMountCache = new WeakMap();
4
6
  function useOverflowItems({ items, renderItem, renderMeasurementItem, overflowRenderer, reserveSpace = 60 }) {
5
7
  const containerRef = useRef(null);
6
- const [visibleCount, setVisibleCount] = useState(items.length);
8
+ const [visibleCount, setVisibleCount] = useState(()=>{
9
+ const cached = items.length > 0 ? crossMountCache.get(items) : void 0;
10
+ if (cached) return Math.min(cached.lastCount, items.length);
11
+ return Math.min(items.length, 1);
12
+ });
13
+ const itemsRef = useRef(items);
14
+ itemsRef.current = items;
7
15
  const measurementRefs = useRef([]);
8
16
  const indicatorRef = useRef(null);
17
+ const measurementLayerRef = useRef(null);
9
18
  const cacheRef = useRef({
10
19
  widths: [],
11
20
  gap: 0,
12
21
  indicatorWidth: reserveSpace
13
22
  });
14
- const recompute = useCallback(()=>{
23
+ const recompute = useCallback((availableWidth)=>{
15
24
  const container = containerRef.current;
16
25
  if (!container) return;
17
26
  const { widths, gap, indicatorWidth } = cacheRef.current;
@@ -19,13 +28,14 @@ function useOverflowItems({ items, renderItem, renderMeasurementItem, overflowRe
19
28
  const next = calculateVisibleCount({
20
29
  itemWidths: widths,
21
30
  gap,
22
- availableWidth: container.offsetWidth,
31
+ availableWidth: availableWidth ?? container.offsetWidth,
23
32
  indicatorWidth
24
33
  });
34
+ const entry = crossMountCache.get(itemsRef.current);
35
+ if (entry) entry.lastCount = next;
25
36
  setVisibleCount((prev)=>prev === next ? prev : next);
26
37
  }, []);
27
38
  useLayoutEffect(()=>{
28
- const container = containerRef.current;
29
39
  if (0 === items.length) {
30
40
  cacheRef.current = {
31
41
  widths: [],
@@ -35,15 +45,34 @@ function useOverflowItems({ items, renderItem, renderMeasurementItem, overflowRe
35
45
  setVisibleCount(0);
36
46
  return;
37
47
  }
38
- const gap = container ? Number.parseFloat(getComputedStyle(container).gap || '0') || 0 : 0;
39
- const widths = measurementRefs.current.slice(0, items.length).map((ref)=>ref?.offsetWidth ?? 0);
40
- const indicatorWidth = indicatorRef.current?.offsetWidth || reserveSpace;
41
- cacheRef.current = {
42
- widths,
43
- gap,
44
- indicatorWidth
45
- };
46
- recompute();
48
+ const cached = crossMountCache.get(items);
49
+ if (cached) {
50
+ cacheRef.current = cached;
51
+ return scheduleOverflowMeasurement(()=>{
52
+ const availableWidth = containerRef.current?.offsetWidth ?? 0;
53
+ return ()=>recompute(availableWidth);
54
+ });
55
+ }
56
+ measurementLayerRef.current?.style.removeProperty('display');
57
+ return scheduleOverflowMeasurement(()=>{
58
+ const container = containerRef.current;
59
+ const gap = container ? Number.parseFloat(getComputedStyle(container).gap || '0') || 0 : 0;
60
+ const widths = measurementRefs.current.slice(0, items.length).map((ref)=>ref?.offsetWidth ?? 0);
61
+ const indicatorWidth = indicatorRef.current?.offsetWidth || reserveSpace;
62
+ const availableWidth = container?.offsetWidth ?? 0;
63
+ return ()=>{
64
+ const entry = {
65
+ widths,
66
+ gap,
67
+ indicatorWidth,
68
+ lastCount: items.length
69
+ };
70
+ cacheRef.current = entry;
71
+ crossMountCache.set(items, entry);
72
+ recompute(availableWidth);
73
+ measurementLayerRef.current?.style.setProperty('display', 'none');
74
+ };
75
+ });
47
76
  }, [
48
77
  items,
49
78
  renderItem,
@@ -75,9 +104,10 @@ function useOverflowItems({ items, renderItem, renderMeasurementItem, overflowRe
75
104
  const hiddenItems = items.slice(visibleCount);
76
105
  const hiddenCount = hiddenItems.length;
77
106
  const MeasurementContainer = useCallback(()=>{
78
- if (0 === items.length) return null;
107
+ if (0 === items.length || crossMountCache.has(items)) return null;
79
108
  const renderMeasure = renderMeasurementItem || renderItem;
80
109
  return /*#__PURE__*/ jsxs("div", {
110
+ ref: measurementLayerRef,
81
111
  className: "absolute invisible pointer-events-none",
82
112
  "aria-hidden": "true",
83
113
  children: [
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Shared read/write batch for overflow measurements. Many instances mount in
3
+ * one commit (a virtualized row chunk × several overflow cells); measuring in
4
+ * each layout effect reflows per instance, since its setState flushes before
5
+ * the next reads `offsetWidth`. One pre-paint microtask runs all reads against
6
+ * clean layout, then all writes — React batches them, no interleaving.
7
+ */
8
+ type WritePhase = () => void;
9
+ type ReadPhase = () => WritePhase;
10
+ /**
11
+ * Queue a measurement for the next pre-paint flush. Returns a cancel
12
+ * function — call it on effect cleanup so unmounted or re-rendered
13
+ * instances never measure stale refs.
14
+ */
15
+ export declare const scheduleOverflowMeasurement: (read: ReadPhase) => (() => void);
16
+ export {};
@@ -0,0 +1,25 @@
1
+ const queue = new Set();
2
+ let flushScheduled = false;
3
+ const flush = ()=>{
4
+ flushScheduled = false;
5
+ const reads = Array.from(queue);
6
+ queue.clear();
7
+ const writes = [];
8
+ for (const read of reads)try {
9
+ writes.push(read());
10
+ } catch {}
11
+ for (const write of writes)try {
12
+ write();
13
+ } catch {}
14
+ };
15
+ const scheduleOverflowMeasurement = (read)=>{
16
+ queue.add(read);
17
+ if (!flushScheduled) {
18
+ flushScheduled = true;
19
+ queueMicrotask(flush);
20
+ }
21
+ return ()=>{
22
+ queue.delete(read);
23
+ };
24
+ };
25
+ export { scheduleOverflowMeasurement };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": "0.51.2",
3
- "generatedAt": "2026-06-03T00:07:48.454Z",
3
+ "generatedAt": "2026-06-03T10:23:04.183Z",
4
4
  "components": [
5
5
  {
6
6
  "name": "Accordion",
@@ -4436,7 +4436,7 @@
4436
4436
  "examples": [
4437
4437
  {
4438
4438
  "name": "Basic",
4439
- "code": "() => {\n const pathname = useLocationPathname();\n const activeProduct = deriveProduct(pathname);\n\n const [appeared, setAppeared] = useState(true);\n const [loading, setLoading] = useState(true);\n const [sidebarMode, setSidebarMode] = useState<SidebarMode>('adaptive');\n const { theme, setTheme } = useTheme();\n const collapsed = sidebarMode === 'adaptive' && activeProduct !== 'home';\n\n return (\n <AppShell>\n <AppShellHeader>\n <TopHeader appeared={appeared}>\n <TopHeaderLogo href='/'>\n <WallarmLogo />\n </TopHeaderLogo>\n\n <TopHeaderActions>\n {loading ? (\n <>\n <Skeleton width='150px' height='20px' rounded={6} />\n <TopHeaderSeparator />\n <Skeleton width='150px' height='20px' rounded={6} />\n </>\n ) : (\n <>\n <Button\n variant='ghost'\n size='small'\n color='neutral'\n className='p-4 gap-6 rounded-6'\n >\n <Code size='s' color='secondary'>\n Search Wallarm\n </Code>\n <Kbd size='xsmall'>⌘ K</Kbd>\n </Button>\n\n <TopHeaderSeparator />\n\n <Button variant='ghost' size='small' color='neutral' className='py-4 rounded-6'>\n <Text size='xs' weight='medium'>\n Tenant Name\n </Text>\n <span className='text-text-tertiary mx-[-2px]'>•</span>\n <Code size='s' color='secondary'>\n 12345\n </Code>\n <ChevronUpDown className='!icon-sm' />\n </Button>\n </>\n )}\n\n <Tooltip>\n <TooltipTrigger asChild>\n <Button variant='ghost' size='small' color='neutral' aria-label='Wallarm Updates'>\n <Bell />\n </Button>\n </TooltipTrigger>\n <TooltipContent>Wallarm updates</TooltipContent>\n </Tooltip>\n\n <QuickHelpDropdown />\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail collapsed={collapsed} appeared={appeared}>\n <NavRailBody>\n <NavRailItem\n icon={Home}\n label='Home'\n shortcut={['G', 'H']}\n active={activeProduct === 'home'}\n onClick={() => navigateToProduct('home')}\n />\n <RecentDropdown />\n\n <NavRailSeparator />\n\n {loading ? (\n <NavRailSkeleton />\n ) : (\n <>\n <NavRailItem\n icon={CircleDashed}\n label='Edge'\n shortcut={['G', 'E']}\n active={activeProduct === 'edge'}\n onClick={() => navigateToProduct('edge')}\n />\n <NavRailItem\n icon={CircleDashed}\n label='AI Hypervisor'\n shortcut={['G', 'A']}\n active={activeProduct === 'ai-hypervisor'}\n onClick={() => navigateToProduct('ai-hypervisor')}\n />\n <NavRailItem\n icon={CircleDashed}\n label='Infra Discovery'\n shortcut={['G', 'I']}\n active={activeProduct === 'infra-discovery'}\n onClick={() => navigateToProduct('infra-discovery')}\n />\n <NavRailItem\n icon={CircleDashed}\n label='Security Testing'\n shortcut={['G', 'T']}\n active={activeProduct === 'security-testing'}\n onClick={() => navigateToProduct('security-testing')}\n />\n </>\n )}\n </NavRailBody>\n\n <NavRailFooter>\n <NavRailItem\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n active={activeProduct === 'settings'}\n onClick={() => navigateToProduct('settings')}\n />\n <AccountDropdown\n sidebarMode={sidebarMode}\n onSidebarModeChange={setSidebarMode}\n theme={theme}\n onThemeChange={setTheme}\n />\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote>\n <div className='flex gap-8 absolute top-4 right-4 z-10'>\n <Button variant='ghost' size='small' color='neutral' onClick={() => setAppeared(v => !v)}>\n {appeared ? 'Hide menu' : 'Show menu'}\n </Button>\n\n <Button variant='ghost' size='small' color='neutral' onClick={() => setLoading(v => !v)}>\n {loading ? 'Finish loading' : 'Start loading'}\n </Button>\n </div>\n\n <RemoteForProduct product={activeProduct} />\n </AppShellRemote>\n </AppShell>\n );\n}"
4439
+ "code": "() => {\n const pathname = useLocationPathname();\n const activeProduct = deriveProduct(pathname);\n\n const [loading, setLoading] = useState(true);\n const [sidebarMode, setSidebarMode] = useState<SidebarMode>('adaptive');\n const { theme, setTheme } = useTheme();\n const collapsed = sidebarMode === 'adaptive' && activeProduct !== 'home';\n\n return (\n <AppShell>\n <AppShellHeader>\n <TopHeader>\n <TopHeaderLogo href='/'>\n <WallarmLogo />\n </TopHeaderLogo>\n\n <TopHeaderActions>\n {loading ? (\n <>\n <Skeleton width='150px' height='20px' rounded={6} />\n <TopHeaderSeparator />\n <Skeleton width='150px' height='20px' rounded={6} />\n </>\n ) : (\n <>\n <Button\n variant='ghost'\n size='small'\n color='neutral'\n className='p-4 gap-6 rounded-6'\n >\n <Code size='s' color='secondary'>\n Search Wallarm\n </Code>\n <Kbd size='xsmall'>⌘ K</Kbd>\n </Button>\n\n <TopHeaderSeparator />\n\n <Button variant='ghost' size='small' color='neutral' className='py-4 rounded-6'>\n <Text size='xs' weight='medium'>\n Tenant Name\n </Text>\n <span className='text-text-tertiary mx-[-2px]'>•</span>\n <Code size='s' color='secondary'>\n 12345\n </Code>\n <ChevronUpDown className='!icon-sm' />\n </Button>\n </>\n )}\n\n <Tooltip>\n <TooltipTrigger asChild>\n <Button variant='ghost' size='small' color='neutral' aria-label='Wallarm Updates'>\n <Bell />\n </Button>\n </TooltipTrigger>\n <TooltipContent>Wallarm updates</TooltipContent>\n </Tooltip>\n\n <QuickHelpDropdown />\n </TopHeaderActions>\n </TopHeader>\n </AppShellHeader>\n\n <AppShellRail>\n <NavRail collapsed={collapsed}>\n <NavRailBody>\n <NavRailItem\n icon={Home}\n label='Home'\n shortcut={['G', 'H']}\n active={activeProduct === 'home'}\n onClick={() => navigateToProduct('home')}\n />\n <RecentDropdown />\n\n <NavRailSeparator />\n\n {loading ? (\n <NavRailSkeleton />\n ) : (\n <>\n <NavRailItem\n icon={CircleDashed}\n label='Edge'\n shortcut={['G', 'E']}\n active={activeProduct === 'edge'}\n onClick={() => navigateToProduct('edge')}\n />\n <NavRailItem\n icon={CircleDashed}\n label='AI Hypervisor'\n shortcut={['G', 'A']}\n active={activeProduct === 'ai-hypervisor'}\n onClick={() => navigateToProduct('ai-hypervisor')}\n />\n <NavRailItem\n icon={CircleDashed}\n label='Infra Discovery'\n shortcut={['G', 'I']}\n active={activeProduct === 'infra-discovery'}\n onClick={() => navigateToProduct('infra-discovery')}\n />\n <NavRailItem\n icon={CircleDashed}\n label='Security Testing'\n shortcut={['G', 'T']}\n active={activeProduct === 'security-testing'}\n onClick={() => navigateToProduct('security-testing')}\n />\n </>\n )}\n </NavRailBody>\n\n <NavRailFooter>\n <NavRailItem\n icon={Settings}\n label='Settings'\n shortcut={['G', 'S']}\n active={activeProduct === 'settings'}\n onClick={() => navigateToProduct('settings')}\n />\n <AccountDropdown\n sidebarMode={sidebarMode}\n onSidebarModeChange={setSidebarMode}\n theme={theme}\n onThemeChange={setTheme}\n />\n </NavRailFooter>\n </NavRail>\n </AppShellRail>\n\n <AppShellRemote>\n <div className='absolute top-4 right-4 z-10'>\n <Button variant='ghost' size='small' color='neutral' onClick={() => setLoading(v => !v)}>\n {loading ? 'Finish loading' : 'Start loading'}\n </Button>\n </div>\n\n <RemoteForProduct product={activeProduct} />\n </AppShellRemote>\n </AppShell>\n );\n}"
4440
4440
  }
4441
4441
  ]
4442
4442
  },
@@ -27826,11 +27826,6 @@
27826
27826
  "required": false,
27827
27827
  "defaultValue": "false"
27828
27828
  },
27829
- {
27830
- "name": "appeared",
27831
- "type": "boolean | undefined",
27832
- "required": false
27833
- },
27834
27829
  {
27835
27830
  "name": "defaultChecked",
27836
27831
  "type": "boolean | undefined",
@@ -51523,6 +51518,12 @@
51523
51518
  "description": "Show skeleton rows",
51524
51519
  "defaultValue": "false"
51525
51520
  },
51521
+ {
51522
+ "name": "isLoadingPrevious",
51523
+ "type": "boolean | undefined",
51524
+ "required": false,
51525
+ "description": "Show skeleton rows above the first row while a previous page is being\nfetched (bidirectional infinite scroll — the start-edge counterpart of\n`isLoading`). Pair with `onStartReached`."
51526
+ },
51526
51527
  {
51527
51528
  "name": "skeletonCount",
51528
51529
  "type": "number | undefined",
@@ -51623,6 +51624,12 @@
51623
51624
  "type": "number | undefined",
51624
51625
  "required": false
51625
51626
  },
51627
+ {
51628
+ "name": "initialScrollToRowId",
51629
+ "type": "string | undefined",
51630
+ "required": false,
51631
+ "description": "Row id to anchor the initial scroll position to. The table scrolls this row\ninto view on mount and arms the edge detectors only after that initial\nscroll settles. Use for deep-linking into the middle of a dataset."
51632
+ },
51626
51633
  {
51627
51634
  "name": "activeRowId",
51628
51635
  "type": "string | null | undefined",
@@ -51778,6 +51785,14 @@
51778
51785
  "name": "InfiniteScrollWindow",
51779
51786
  "code": "() => {\n const { data, isFetching, hasMore, totalItems, fetchNextPage } = useInfiniteData();\n\n return (\n <VStack gap={8}>\n <Text size='sm' color='secondary'>\n Loaded {data.length} of {totalItems} rows {isFetching && '— loading...'}\n {!hasMore && ' — all loaded'}\n </Text>\n <Table\n data={data}\n columns={securityColumns}\n getRowId={row => row.id}\n virtualized='window'\n isLoading={isFetching}\n onEndReached={fetchNextPage}\n onEndReachedThreshold={300}\n />\n </VStack>\n );\n}"
51780
51787
  },
51788
+ {
51789
+ "name": "BidirectionalInfiniteScroll",
51790
+ "code": "() => {\n const {\n data,\n anchorId,\n isFetchingPrev,\n isFetchingNext,\n hasPrev,\n hasNext,\n fetchPrevPage,\n fetchNextPage,\n } = useBidirectionalData();\n\n return (\n <VStack gap={8}>\n <Text size='sm' color='secondary'>\n Window of {data.length} rows around the anchor — scroll up for top skeletons, down for\n bottom ones{(isFetchingPrev || isFetchingNext) && ' — loading...'}\n {!hasPrev && ' — top reached'}\n {!hasNext && ' — bottom reached'}\n </Text>\n <Table\n className='h-500'\n data={data}\n columns={securityColumns}\n getRowId={row => row.id}\n virtualized='container'\n isLoading={isFetchingNext}\n isLoadingPrevious={isFetchingPrev}\n initialScrollToRowId={anchorId}\n onStartReached={fetchPrevPage}\n onStartReachedThreshold={200}\n onEndReached={fetchNextPage}\n onEndReachedThreshold={200}\n />\n </VStack>\n );\n}"
51791
+ },
51792
+ {
51793
+ "name": "BidirectionalInfiniteScrollWindow",
51794
+ "code": "() => {\n const {\n data,\n anchorId,\n isFetchingPrev,\n isFetchingNext,\n hasPrev,\n hasNext,\n fetchPrevPage,\n fetchNextPage,\n } = useBidirectionalData();\n\n return (\n <VStack gap={8}>\n <Text size='sm' color='secondary'>\n Window of {data.length} rows around the anchor\n {(isFetchingPrev || isFetchingNext) && ' — loading...'}\n {!hasPrev && ' — top reached'}\n {!hasNext && ' — bottom reached'}\n </Text>\n <Table\n data={data}\n columns={securityColumns}\n getRowId={row => row.id}\n virtualized='window'\n isLoading={isFetchingNext}\n isLoadingPrevious={isFetchingPrev}\n initialScrollToRowId={anchorId}\n onStartReached={fetchPrevPage}\n onStartReachedThreshold={200}\n onEndReached={fetchNextPage}\n onEndReachedThreshold={200}\n />\n </VStack>\n );\n}"
51795
+ },
51781
51796
  {
51782
51797
  "name": "HeaderColumnDescription",
51783
51798
  "code": "() => {\n const [sorting, setSorting] = useState<TableSortingState>([]);\n\n const columns = useMemo<TableColumnDef<(typeof securityEvents)[number]>[]>(\n () =>\n securityColumns.map(col => {\n const key = 'accessorKey' in col ? col.accessorKey : undefined;\n if (key === 'objectName') {\n return {\n ...col,\n meta: {\n ...col.meta,\n description: { type: 'text' as const, content: 'Target resource' },\n },\n };\n }\n if (key === 'sourceIp') {\n return {\n ...col,\n meta: {\n ...col.meta,\n description: { type: 'tooltip' as const, content: 'Request origin IP' },\n },\n };\n }\n if (key === 'requests') {\n return {\n ...col,\n meta: { ...col.meta, description: { type: 'tooltip' as const, content: 'Total hits' } },\n };\n }\n if (key === 'parameter') {\n return {\n ...col,\n meta: {\n ...col.meta,\n description: { type: 'text' as const, content: 'Affected param' },\n },\n };\n }\n return col;\n }),\n [],\n );\n\n return (\n <Table\n data={securityEvents}\n columns={columns}\n getRowId={row => row.id}\n sorting={sorting}\n onSortingChange={setSorting}\n />\n );\n}"
@@ -54322,11 +54337,6 @@
54322
54337
  "name": "TopHeader",
54323
54338
  "importPath": "@wallarm-org/design-system/TopHeader",
54324
54339
  "props": [
54325
- {
54326
- "name": "appeared",
54327
- "type": "boolean | undefined",
54328
- "required": false
54329
- },
54330
54340
  {
54331
54341
  "name": "defaultChecked",
54332
54342
  "type": "boolean | undefined",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wallarm-org/design-system",
3
- "version": "0.52.0-rc-feature-shell.1",
3
+ "version": "0.52.0",
4
4
  "description": "Core design system library with React components and Storybook documentation",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -1,19 +0,0 @@
1
- import { type RefObject } from 'react';
2
- type ScrollMode = 'container' | 'window';
3
- interface UseEndReachedOptions {
4
- mode: ScrollMode;
5
- /** Scroll element ref — required for `container` mode */
6
- scrollRef?: RefObject<HTMLElement | null>;
7
- onEndReached?: () => void;
8
- threshold?: number;
9
- }
10
- /**
11
- * Fires `onEndReached` once when the user scrolls within `threshold` px
12
- * of the bottom. Re-arms automatically after the user scrolls back up
13
- * past the threshold or when the scroll height grows (new data loaded).
14
- *
15
- * A cooldown guard prevents rapid re-fires that can occur when new rows
16
- * are appended (scrollHeight grows → firedRef resets → still at bottom).
17
- */
18
- export declare const useEndReached: ({ mode, scrollRef, onEndReached, threshold, }: UseEndReachedOptions) => void;
19
- export {};