@noya-app/noya-designsystem 0.1.71 → 0.1.72

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.
@@ -0,0 +1,153 @@
1
+ import { expect, test } from "bun:test";
2
+ import {
3
+ buildMeasurements,
4
+ calculateScrollOffsetForIndex,
5
+ calculateVisibleRange,
6
+ clampGapValue,
7
+ findFirstVisibleIndex,
8
+ ItemMeasurement,
9
+ resolvePaddingValues,
10
+ } from "../Virtualized";
11
+
12
+ test("resolvePaddingValues handles numbers and objects", () => {
13
+ expect(resolvePaddingValues(8)).toEqual({ top: 8, bottom: 8 });
14
+ expect(resolvePaddingValues({ top: 4 })).toEqual({ top: 4, bottom: 0 });
15
+ expect(resolvePaddingValues(undefined)).toEqual({ top: 0, bottom: 0 });
16
+ });
17
+
18
+ test("clampGapValue avoids negatives", () => {
19
+ expect(clampGapValue(undefined)).toBe(0);
20
+ expect(clampGapValue(-10)).toBe(0);
21
+ expect(clampGapValue(6)).toBe(6);
22
+ });
23
+
24
+ test("buildMeasurements returns cumulative offsets and total height", () => {
25
+ const { measurements, totalHeight } = buildMeasurements({
26
+ itemCount: 3,
27
+ getItemHeight: (index) => 10 + index,
28
+ paddingTop: 5,
29
+ paddingBottom: 15,
30
+ gap: 4,
31
+ });
32
+
33
+ expect(measurements).toEqual([
34
+ { index: 0, size: 10, start: 5, end: 15 },
35
+ { index: 1, size: 11, start: 19, end: 30 },
36
+ { index: 2, size: 12, start: 34, end: 46 },
37
+ ]);
38
+ expect(totalHeight).toBe(61);
39
+ });
40
+
41
+ test("findFirstVisibleIndex binary searches measurement offsets", () => {
42
+ const measurements = [
43
+ { index: 0, size: 10, start: 0, end: 10 },
44
+ { index: 1, size: 10, start: 10, end: 20 },
45
+ { index: 2, size: 10, start: 20, end: 30 },
46
+ ];
47
+
48
+ expect(findFirstVisibleIndex(measurements, 0)).toBe(0);
49
+ expect(findFirstVisibleIndex(measurements, 5)).toBe(0);
50
+ expect(findFirstVisibleIndex(measurements, 10)).toBe(1);
51
+ expect(findFirstVisibleIndex(measurements, 25)).toBe(2);
52
+ });
53
+
54
+ test("calculateVisibleRange respects overscan and height", () => {
55
+ const measurements = [
56
+ { index: 0, size: 10, start: 0, end: 10 },
57
+ { index: 1, size: 10, start: 10, end: 20 },
58
+ { index: 2, size: 10, start: 20, end: 30 },
59
+ { index: 3, size: 10, start: 30, end: 40 },
60
+ { index: 4, size: 10, start: 40, end: 50 },
61
+ ];
62
+
63
+ const visible = calculateVisibleRange({
64
+ measurements,
65
+ scrollTop: 12,
66
+ height: 20,
67
+ overscan: 1,
68
+ });
69
+
70
+ expect(visible).toEqual({ start: 0, end: 4 });
71
+ });
72
+
73
+ const sampleMeasurements: ItemMeasurement[] = [
74
+ { index: 0, size: 30, start: 0, end: 30 },
75
+ { index: 1, size: 30, start: 30, end: 60 },
76
+ { index: 2, size: 30, start: 60, end: 90 },
77
+ { index: 3, size: 30, start: 90, end: 120 },
78
+ ];
79
+
80
+ const extendedMeasurements: ItemMeasurement[] = [
81
+ { index: 0, size: 30, start: 0, end: 30 },
82
+ { index: 1, size: 30, start: 30, end: 60 },
83
+ { index: 2, size: 30, start: 60, end: 90 },
84
+ { index: 3, size: 30, start: 90, end: 120 },
85
+ { index: 4, size: 30, start: 120, end: 150 },
86
+ { index: 5, size: 30, start: 150, end: 180 },
87
+ { index: 6, size: 30, start: 180, end: 210 },
88
+ ];
89
+
90
+ test("calculateScrollOffsetForIndex returns undefined when already visible", () => {
91
+ const offset = calculateScrollOffsetForIndex({
92
+ measurements: sampleMeasurements,
93
+ height: 60,
94
+ totalHeight: 120,
95
+ targetIndex: 1,
96
+ currentScrollTop: 30,
97
+ });
98
+
99
+ expect(offset).toBeUndefined();
100
+ });
101
+
102
+ test("calculateScrollOffsetForIndex scrolls up when item above viewport", () => {
103
+ const offset = calculateScrollOffsetForIndex({
104
+ measurements: sampleMeasurements,
105
+ height: 60,
106
+ totalHeight: 120,
107
+ targetIndex: 0,
108
+ currentScrollTop: 50,
109
+ });
110
+
111
+ expect(offset).toBe(0);
112
+ });
113
+
114
+ test("calculateScrollOffsetForIndex scrolls down when item below viewport", () => {
115
+ const offset = calculateScrollOffsetForIndex({
116
+ measurements: extendedMeasurements,
117
+ height: 60,
118
+ totalHeight: 210,
119
+ targetIndex: 4,
120
+ currentScrollTop: 0,
121
+ });
122
+
123
+ expect(offset).toBe(120);
124
+ });
125
+
126
+ test("calculateScrollOffsetForIndex clamps to end of content", () => {
127
+ const offset = calculateScrollOffsetForIndex({
128
+ measurements: sampleMeasurements,
129
+ height: 80,
130
+ totalHeight: 120,
131
+ targetIndex: 3,
132
+ currentScrollTop: 10,
133
+ });
134
+
135
+ expect(offset).toBe(40);
136
+ });
137
+
138
+ test("calculateScrollOffsetForIndex aligns bottom when item taller than viewport", () => {
139
+ const tallMeasurements: ItemMeasurement[] = [
140
+ { index: 0, size: 120, start: 0, end: 120 },
141
+ { index: 1, size: 120, start: 120, end: 240 },
142
+ ];
143
+
144
+ const offset = calculateScrollOffsetForIndex({
145
+ measurements: tallMeasurements,
146
+ height: 60,
147
+ totalHeight: 240,
148
+ targetIndex: 1,
149
+ currentScrollTop: 0,
150
+ });
151
+
152
+ expect(offset).toBe(180);
153
+ });
@@ -0,0 +1,85 @@
1
+ import { act, render } from "@testing-library/react";
2
+ import { expect, test } from "bun:test";
3
+ import React from "react";
4
+ import { Virtualized } from "../Virtualized";
5
+
6
+ test("virtualized window updates visible rows when scrolling", () => {
7
+ const items = Array.from({ length: 100 }, (_, index) => `Row ${index + 1}`);
8
+
9
+ const { container, getByTestId, queryByTestId } = render(
10
+ <Virtualized
11
+ height={100}
12
+ itemCount={items.length}
13
+ getItemHeight={() => 20}
14
+ renderItem={(index) => (
15
+ <div data-testid={`row-${index}`}>{items[index]}</div>
16
+ )}
17
+ />
18
+ );
19
+
20
+ const scroller = container.firstChild as HTMLDivElement;
21
+
22
+ expect(getByTestId("row-0")).toBeTruthy();
23
+
24
+ act(() => {
25
+ scroller.scrollTop = 200;
26
+ scroller.dispatchEvent(new Event("scroll"));
27
+ });
28
+
29
+ expect(queryByTestId("row-0")).toBeNull();
30
+ expect(getByTestId("row-10")).toBeTruthy();
31
+ });
32
+
33
+ test("accounts for padding and gap", () => {
34
+ const { container } = render(
35
+ <Virtualized
36
+ height={120}
37
+ itemCount={2}
38
+ getItemHeight={() => 20}
39
+ padding={{ top: 12, bottom: 8 }}
40
+ gap={6}
41
+ renderItem={(index) => <div>Row {index}</div>}
42
+ />
43
+ );
44
+
45
+ const scroller = container.firstChild as HTMLDivElement;
46
+ const inner = scroller.firstChild as HTMLDivElement;
47
+ const firstRow = inner.firstChild as HTMLDivElement;
48
+ const secondRow = firstRow.nextSibling as HTMLDivElement;
49
+
50
+ expect(inner.style.height).toBe("66px");
51
+ expect(firstRow.style.top).toBe("12px");
52
+ expect(secondRow.style.top).toBe("38px");
53
+ });
54
+
55
+ test("supports ScrollArea as the scroller", () => {
56
+ const items = Array.from({ length: 50 }, (_, index) => `Row ${index + 1}`);
57
+
58
+ const { container, getByTestId, queryByTestId } = render(
59
+ <Virtualized
60
+ height={120}
61
+ itemCount={items.length}
62
+ getItemHeight={() => 20}
63
+ useScrollArea
64
+ scrollAreaViewportClassName="virtualized-scrollarea-viewport"
65
+ renderItem={(index) => (
66
+ <div data-testid={`scroll-row-${index}`}>{items[index]}</div>
67
+ )}
68
+ />
69
+ );
70
+
71
+ const viewport = container.querySelector(
72
+ ".virtualized-scrollarea-viewport"
73
+ ) as HTMLDivElement;
74
+
75
+ expect(viewport).toBeTruthy();
76
+ expect(getByTestId("scroll-row-0")).toBeTruthy();
77
+
78
+ act(() => {
79
+ viewport.scrollTop = 200;
80
+ viewport.dispatchEvent(new Event("scroll"));
81
+ });
82
+
83
+ expect(queryByTestId("scroll-row-0")).toBeNull();
84
+ expect(getByTestId("scroll-row-10")).toBeTruthy();
85
+ });
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { createContext, ReactNode } from "react";
3
+ import { createContext } from "react";
4
4
  import {
5
5
  ListColorScheme,
6
6
  ListRowMarginType,
@@ -61,8 +61,3 @@ export const ListRowContext = createContext<ListRowContextValue>({
61
61
  isSectionHeader: false,
62
62
  });
63
63
  (ListRowContext as any).displayName = "ListRowContext";
64
-
65
- export const RenderItemContext = createContext<(index: number) => ReactNode>(
66
- () => null
67
- );
68
- (RenderItemContext as any).displayName = "RenderItemContext";
@@ -1,27 +1,20 @@
1
1
  "use client";
2
2
 
3
- import { Size } from "@noya-app/noya-geometry";
4
3
  import { range } from "@noya-app/noya-utils";
5
4
  import { forwardRefGeneric, memoGeneric } from "@noya-app/react-utils";
6
5
  import React, {
7
6
  CSSProperties,
8
7
  ForwardedRef,
9
8
  isValidElement,
10
- memo,
11
9
  ReactElement,
12
10
  ReactNode,
13
11
  useCallback,
14
- useContext,
15
- useImperativeHandle,
16
- useLayoutEffect,
17
12
  useMemo,
18
- useRef,
19
13
  } from "react";
20
- import { WindowScroller, WindowScrollerChildProps } from "react-virtualized";
21
- import { ListChildComponentProps, VariableSizeList } from "react-window";
22
14
  import { mergeConflictingClassNames } from "../../utils/classNames";
23
15
  import { IVirtualizedList } from "../IVirtualizedList";
24
16
  import { ScrollArea } from "../ScrollArea";
17
+ import { Virtualized } from "../Virtualized";
25
18
  import { SharedDragProps } from "../sorting/DragRegistration";
26
19
  import { Sortable } from "../sorting/Sortable";
27
20
  import {
@@ -36,7 +29,6 @@ import {
36
29
  ListViewContextValue,
37
30
  ListViewDraggingContext,
38
31
  ListViewDraggingContextValue,
39
- RenderItemContext,
40
32
  } from "./ListViewContexts";
41
33
  import {
42
34
  ListColorScheme,
@@ -56,129 +48,6 @@ export const SECTION_HEADER_LABEL_HEIGHT = 24;
56
48
  // use a taller height.
57
49
  export const TALL_ROW_HEIGHT = 50;
58
50
 
59
- const VirtualizedListRow = memo(function VirtualizedListRow({
60
- index,
61
- style,
62
- }: ListChildComponentProps) {
63
- const renderItem = useContext(RenderItemContext);
64
-
65
- return (
66
- <div key={index} style={style}>
67
- {renderItem(index)}
68
- </div>
69
- );
70
- });
71
-
72
- /* ----------------------------------------------------------------------------
73
- * VirtualizedList
74
- * ------------------------------------------------------------------------- */
75
-
76
- interface VirtualizedListProps<T> {
77
- size: Size;
78
- scrollElement: HTMLDivElement;
79
- items: T[];
80
- getItemHeight: (index: number) => number;
81
- keyExtractor: (index: number) => string;
82
- renderItem: (index: number) => ReactNode;
83
- }
84
-
85
- const VirtualizedListInner = forwardRefGeneric(function VirtualizedListInner<T>(
86
- {
87
- size,
88
- scrollElement,
89
- items,
90
- getItemHeight,
91
- keyExtractor,
92
- renderItem,
93
- }: VirtualizedListProps<T>,
94
- ref: ForwardedRef<IVirtualizedList>
95
- ) {
96
- const listRef = useRef<VariableSizeList<T> | null>(null);
97
-
98
- useImperativeHandle(ref, () => ({
99
- scrollToIndex(index) {
100
- listRef.current?.scrollToItem(index);
101
- },
102
- }));
103
-
104
- useLayoutEffect(() => {
105
- listRef.current?.resetAfterIndex(0);
106
- }, [
107
- // When items or the height resolver change, re-render the virtualized list
108
- // since VariableSizeList caches sizes per index.
109
- items,
110
- getItemHeight,
111
- ]);
112
-
113
- // Internally, react-virtualized updates these properties. We always want
114
- // to use our custom scroll element, so we override them. It may update
115
- // overflowX/Y individually in addition to `overflow`, so we include all 3.
116
- const listStyle = useMemo(
117
- (): CSSProperties => ({
118
- overflowX: "initial",
119
- overflowY: "initial",
120
- overflow: "initial",
121
- }),
122
- []
123
- );
124
-
125
- return (
126
- <RenderItemContext.Provider value={renderItem}>
127
- <WindowScroller
128
- scrollElement={scrollElement}
129
- style={useMemo(() => ({ flex: "1 1 auto" }), [])}
130
- >
131
- {useCallback(
132
- ({
133
- registerChild,
134
- onChildScroll,
135
- scrollTop,
136
- }: WindowScrollerChildProps & {
137
- // Added when noya-designsystem moved to a separate package.
138
- // I think this is a hack to get around the fact that react-virtualized
139
- // doesn't update on scroll unless we force it to by changing ref.
140
- // Either we're not using this the intended way or the types are wrong.
141
- registerChild: (element: any) => void;
142
- }) => (
143
- <div ref={registerChild}>
144
- <VariableSizeList<T>
145
- ref={listRef}
146
- // The list won't update on scroll unless we force it to by changing key
147
- key={scrollTop}
148
- style={listStyle}
149
- itemKey={keyExtractor}
150
- onScroll={({ scrollOffset }: { scrollOffset: number }) => {
151
- onChildScroll({ scrollTop: scrollOffset });
152
- }}
153
- initialScrollOffset={scrollTop}
154
- width={size.width}
155
- height={size.height}
156
- itemCount={items.length}
157
- itemSize={getItemHeight}
158
- estimatedItemSize={ROW_HEIGHT}
159
- >
160
- {VirtualizedListRow as any}
161
- </VariableSizeList>
162
- </div>
163
- ),
164
- [
165
- listStyle,
166
- keyExtractor,
167
- size.width,
168
- size.height,
169
- items.length,
170
- getItemHeight,
171
- ]
172
- )}
173
- </WindowScroller>
174
- </RenderItemContext.Provider>
175
- );
176
- });
177
-
178
- const VirtualizedList = memo(
179
- VirtualizedListInner
180
- ) as typeof VirtualizedListInner;
181
-
182
51
  /* ----------------------------------------------------------------------------
183
52
  * Root
184
53
  * ------------------------------------------------------------------------- */
@@ -198,7 +67,7 @@ export type ListViewRootProps<T> = {
198
67
  * Additionally, the key extracted with `keyExtractor` must match the ListItem.Row `id`.
199
68
  */
200
69
  sortable?: boolean;
201
- virtualized?: Size;
70
+ virtualized?: number;
202
71
 
203
72
  id?: string;
204
73
  className?: string;
@@ -432,9 +301,8 @@ export const ListViewRoot = memoGeneric(
432
301
  children
433
302
  );
434
303
 
435
- const withScrollable = (
436
- children: (scrollElementRef: HTMLDivElement | null) => ReactNode
437
- ) => (scrollable ? <ScrollArea>{children}</ScrollArea> : children(null));
304
+ const withScrollable = (children: ReactNode) =>
305
+ scrollable ? <ScrollArea>{children}</ScrollArea> : children;
438
306
 
439
307
  const getItemHeight = useCallback(
440
308
  (index: number) => {
@@ -528,23 +396,21 @@ export const ListViewRoot = memoGeneric(
528
396
  >
529
397
  {data.length === 0 && renderEmptyState
530
398
  ? renderEmptyState()
531
- : withScrollable((scrollElementRef: HTMLDivElement | null) =>
532
- withSortable(
533
- virtualized ? (
534
- <VirtualizedList<T>
535
- ref={forwardedRef}
536
- scrollElement={scrollElementRef!}
537
- items={data}
538
- size={virtualized}
539
- getItemHeight={getItemHeight}
540
- keyExtractor={getKey}
541
- renderItem={renderWrappedChild}
542
- />
543
- ) : (
544
- range(0, data.length).map(renderWrappedChild)
545
- )
399
+ : virtualized != null
400
+ ? withSortable(
401
+ <Virtualized
402
+ ref={forwardedRef}
403
+ height={virtualized}
404
+ itemCount={data.length}
405
+ getItemHeight={getItemHeight}
406
+ renderItem={renderWrappedChild}
407
+ itemKey={getKey}
408
+ useScrollArea={scrollable}
409
+ />
546
410
  )
547
- )}
411
+ : withScrollable(
412
+ withSortable(range(0, data.length).map(renderWrappedChild))
413
+ )}
548
414
  </div>
549
415
  </ListViewDraggingContext.Provider>
550
416
  </ListViewContext.Provider>
package/src/index.tsx CHANGED
@@ -85,6 +85,11 @@ export * from "./components/Tooltip";
85
85
  export * from "./components/TreeView";
86
86
  export * from "./components/UserPicker";
87
87
  export * from "./components/UserPointer";
88
+ export {
89
+ Virtualized,
90
+ type VirtualizedProps,
91
+ type VirtualizedRenderItem,
92
+ } from "./components/Virtualized";
88
93
  export * from "./components/workspace/types";
89
94
  export * from "./components/workspace/WorkspaceLayout";
90
95
  export * from "./components/workspace/WorkspaceSideContext";