@noya-app/noya-designsystem 0.1.70 → 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,439 @@
1
+ "use client";
2
+
3
+ import React, {
4
+ CSSProperties,
5
+ ForwardedRef,
6
+ ReactNode,
7
+ UIEventHandler,
8
+ useCallback,
9
+ useImperativeHandle,
10
+ useLayoutEffect,
11
+ useMemo,
12
+ useRef,
13
+ useState,
14
+ } from "react";
15
+ import { flushSync } from "react-dom";
16
+ import { IVirtualizedList } from "./IVirtualizedList";
17
+ import { ScrollArea2 } from "./ScrollArea2";
18
+
19
+ export type ItemMeasurement = {
20
+ index: number;
21
+ size: number;
22
+ start: number;
23
+ end: number;
24
+ };
25
+
26
+ export type VirtualizedRenderItem = (index: number) => ReactNode;
27
+
28
+ type VirtualizedPadding =
29
+ | number
30
+ | {
31
+ top?: number;
32
+ bottom?: number;
33
+ };
34
+
35
+ type PaddingValues = {
36
+ top: number;
37
+ bottom: number;
38
+ };
39
+
40
+ type MeasurementResult = {
41
+ measurements: ItemMeasurement[];
42
+ totalHeight: number;
43
+ };
44
+
45
+ type VisibleRange = {
46
+ start: number;
47
+ end: number;
48
+ };
49
+
50
+ export function calculateScrollOffsetForIndex({
51
+ measurements,
52
+ height,
53
+ totalHeight,
54
+ targetIndex,
55
+ currentScrollTop,
56
+ }: {
57
+ measurements: ItemMeasurement[];
58
+ height: number;
59
+ totalHeight: number;
60
+ targetIndex: number;
61
+ currentScrollTop: number;
62
+ }): number | undefined {
63
+ if (!measurements.length) return;
64
+
65
+ const index = Math.min(Math.max(targetIndex, 0), measurements.length - 1);
66
+ const measurement = measurements[index];
67
+
68
+ if (!measurement) return;
69
+
70
+ const viewportTop = currentScrollTop;
71
+ const viewportBottom = viewportTop + height;
72
+ const itemTop = measurement.start;
73
+ const itemBottom = measurement.end;
74
+
75
+ let nextScrollTop = viewportTop;
76
+
77
+ if (itemTop < viewportTop) {
78
+ nextScrollTop = itemTop;
79
+ } else if (itemBottom > viewportBottom) {
80
+ const canAlignTop = height >= measurement.size;
81
+
82
+ nextScrollTop = canAlignTop ? itemTop : itemBottom - height;
83
+ } else {
84
+ return;
85
+ }
86
+
87
+ const maxScrollTop = Math.max(0, totalHeight - height);
88
+ nextScrollTop = Math.max(0, Math.min(nextScrollTop, maxScrollTop));
89
+
90
+ if (nextScrollTop === viewportTop) return;
91
+
92
+ return nextScrollTop;
93
+ }
94
+
95
+ export function resolvePaddingValues(
96
+ padding?: VirtualizedPadding
97
+ ): PaddingValues {
98
+ if (typeof padding === "number") {
99
+ return { top: padding, bottom: padding };
100
+ }
101
+
102
+ return {
103
+ top: padding?.top ?? 0,
104
+ bottom: padding?.bottom ?? 0,
105
+ };
106
+ }
107
+
108
+ export function clampGapValue(gap: number | undefined): number {
109
+ return Math.max(0, gap ?? 0);
110
+ }
111
+
112
+ export function buildMeasurements({
113
+ itemCount,
114
+ getItemHeight,
115
+ paddingTop,
116
+ paddingBottom,
117
+ gap,
118
+ }: {
119
+ itemCount: number;
120
+ getItemHeight: (index: number) => number;
121
+ paddingTop: number;
122
+ paddingBottom: number;
123
+ gap: number;
124
+ }): MeasurementResult {
125
+ const measurements: ItemMeasurement[] = [];
126
+ let offset = paddingTop;
127
+
128
+ for (let index = 0; index < itemCount; index += 1) {
129
+ const size = Math.max(0, getItemHeight(index));
130
+ const start = offset;
131
+ const end = start + size;
132
+ measurements.push({ index, size, start, end });
133
+ offset = end + gap;
134
+ }
135
+
136
+ const totalHeight = measurements.length
137
+ ? measurements[measurements.length - 1].end + paddingBottom
138
+ : paddingTop + paddingBottom;
139
+
140
+ return { measurements, totalHeight };
141
+ }
142
+
143
+ export function findFirstVisibleIndex(
144
+ measurements: ItemMeasurement[],
145
+ offset: number
146
+ ): number {
147
+ if (!measurements.length) return 0;
148
+
149
+ let low = 0;
150
+ let high = measurements.length - 1;
151
+ let match = measurements.length - 1;
152
+
153
+ while (low <= high) {
154
+ const middle = Math.floor((low + high) / 2);
155
+ const measurement = measurements[middle];
156
+
157
+ if (measurement.end > offset) {
158
+ match = middle;
159
+ high = middle - 1;
160
+ } else {
161
+ low = middle + 1;
162
+ }
163
+ }
164
+
165
+ return match;
166
+ }
167
+
168
+ export function calculateVisibleRange({
169
+ measurements,
170
+ scrollTop,
171
+ height,
172
+ overscan,
173
+ }: {
174
+ measurements: ItemMeasurement[];
175
+ scrollTop: number;
176
+ height: number;
177
+ overscan: number;
178
+ }): VisibleRange {
179
+ if (!measurements.length) {
180
+ return { start: 0, end: -1 };
181
+ }
182
+
183
+ const viewBottom = scrollTop + height;
184
+ const firstVisible = findFirstVisibleIndex(measurements, scrollTop);
185
+ let lastVisibleExclusive = firstVisible;
186
+
187
+ while (
188
+ lastVisibleExclusive < measurements.length &&
189
+ measurements[lastVisibleExclusive].start < viewBottom
190
+ ) {
191
+ lastVisibleExclusive += 1;
192
+ }
193
+
194
+ const lastVisible = Math.max(
195
+ firstVisible,
196
+ Math.min(measurements.length - 1, lastVisibleExclusive - 1)
197
+ );
198
+ const overscanCount = Math.max(0, Math.floor(overscan));
199
+
200
+ return {
201
+ start: Math.max(0, firstVisible - overscanCount),
202
+ end: Math.min(measurements.length - 1, lastVisible + overscanCount),
203
+ };
204
+ }
205
+
206
+ export type VirtualizedProps = {
207
+ height: number;
208
+ itemCount: number;
209
+ getItemHeight: (index: number) => number;
210
+ renderItem: VirtualizedRenderItem;
211
+ overscan?: number;
212
+ className?: string;
213
+ style?: CSSProperties;
214
+ innerClassName?: string;
215
+ innerStyle?: CSSProperties;
216
+ onScroll?: (scrollTop: number) => void;
217
+ itemKey?: (index: number) => string | number;
218
+ initialScrollOffset?: number;
219
+ /**
220
+ * When true (default) we flush scroll updates with `flushSync`
221
+ * to avoid visual gaps during fast scrolling.
222
+ */
223
+ syncScrollUpdates?: boolean;
224
+ /**
225
+ * Optional padding applied above and below the content. Numbers apply to both.
226
+ */
227
+ padding?: VirtualizedPadding;
228
+ /**
229
+ * Optional constant gap inserted between items.
230
+ */
231
+ gap?: number;
232
+ /**
233
+ * Render content inside ScrollArea2 instead of a plain div.
234
+ */
235
+ useScrollArea?: boolean;
236
+ /**
237
+ * Optional class applied to the ScrollArea viewport when `useScrollArea` is true.
238
+ */
239
+ scrollAreaViewportClassName?: string;
240
+ };
241
+
242
+ const DEFAULT_OVERSCAN = 2;
243
+
244
+ const VirtualizedInner = React.forwardRef(function Virtualized(
245
+ {
246
+ height,
247
+ itemCount,
248
+ getItemHeight,
249
+ renderItem,
250
+ overscan = DEFAULT_OVERSCAN,
251
+ className,
252
+ style,
253
+ innerClassName,
254
+ innerStyle,
255
+ onScroll,
256
+ itemKey,
257
+ initialScrollOffset = 0,
258
+ syncScrollUpdates = true,
259
+ padding,
260
+ gap = 0,
261
+ useScrollArea = false,
262
+ scrollAreaViewportClassName,
263
+ }: VirtualizedProps,
264
+ forwardedRef: ForwardedRef<IVirtualizedList>
265
+ ) {
266
+ const containerRef = useRef<HTMLDivElement | null>(null);
267
+ const setScrollElement = useCallback((element: HTMLDivElement | null) => {
268
+ containerRef.current = element;
269
+ }, []);
270
+ const [scrollTop, setScrollTop] = useState(initialScrollOffset);
271
+ const pendingInitialOffset = useRef<number | null>(initialScrollOffset);
272
+
273
+ useLayoutEffect(() => {
274
+ if (pendingInitialOffset.current == null) return;
275
+ if (containerRef.current) {
276
+ containerRef.current.scrollTop = pendingInitialOffset.current;
277
+ setScrollTop(pendingInitialOffset.current);
278
+ }
279
+ pendingInitialOffset.current = null;
280
+ }, []);
281
+
282
+ const paddingValues = useMemo(() => resolvePaddingValues(padding), [padding]);
283
+
284
+ const gapValue = useMemo(() => clampGapValue(gap), [gap]);
285
+
286
+ const { measurements, totalHeight } = useMemo(
287
+ () =>
288
+ buildMeasurements({
289
+ itemCount,
290
+ getItemHeight,
291
+ paddingTop: paddingValues.top,
292
+ paddingBottom: paddingValues.bottom,
293
+ gap: gapValue,
294
+ }),
295
+ [
296
+ gapValue,
297
+ getItemHeight,
298
+ itemCount,
299
+ paddingValues.bottom,
300
+ paddingValues.top,
301
+ ]
302
+ );
303
+
304
+ const visibleRange = useMemo(
305
+ () =>
306
+ calculateVisibleRange({
307
+ measurements,
308
+ scrollTop,
309
+ height,
310
+ overscan,
311
+ }),
312
+ [height, measurements, overscan, scrollTop]
313
+ );
314
+
315
+ const handleScroll = useCallback<UIEventHandler<HTMLDivElement>>(
316
+ (event) => {
317
+ const nextOffset = event.currentTarget.scrollTop;
318
+
319
+ if (nextOffset === scrollTop) return;
320
+
321
+ if (syncScrollUpdates) {
322
+ flushSync(() => {
323
+ setScrollTop(nextOffset);
324
+ });
325
+ } else {
326
+ setScrollTop(nextOffset);
327
+ }
328
+ onScroll?.(nextOffset);
329
+ },
330
+ [onScroll, scrollTop, syncScrollUpdates]
331
+ );
332
+
333
+ useImperativeHandle(
334
+ forwardedRef,
335
+ () => ({
336
+ scrollToIndex(index: number) {
337
+ const container = containerRef.current;
338
+ if (!container) return;
339
+
340
+ const nextScrollTop = calculateScrollOffsetForIndex({
341
+ measurements,
342
+ height,
343
+ totalHeight,
344
+ targetIndex: index,
345
+ currentScrollTop: container.scrollTop,
346
+ });
347
+
348
+ if (nextScrollTop == null) return;
349
+
350
+ container.scrollTop = nextScrollTop;
351
+ setScrollTop(nextScrollTop);
352
+ },
353
+ }),
354
+ [height, measurements, totalHeight]
355
+ );
356
+
357
+ const containerStyle = useMemo<CSSProperties>(
358
+ () => ({
359
+ height,
360
+ overflowY: "auto",
361
+ overflowX: "hidden",
362
+ ...style,
363
+ }),
364
+ [height, style]
365
+ );
366
+
367
+ const scrollAreaStyle = useMemo<CSSProperties>(
368
+ () => ({
369
+ height,
370
+ ...style,
371
+ }),
372
+ [height, style]
373
+ );
374
+
375
+ const totalContent = (
376
+ <div
377
+ className={innerClassName}
378
+ style={{
379
+ position: "relative",
380
+ width: "100%",
381
+ height: totalHeight,
382
+ ...innerStyle,
383
+ }}
384
+ >
385
+ {visibleRange.end >= visibleRange.start &&
386
+ Array.from(
387
+ { length: visibleRange.end - visibleRange.start + 1 },
388
+ (_, offset) => {
389
+ const index = visibleRange.start + offset;
390
+ const measurement = measurements[index];
391
+ const key = itemKey ? itemKey(index) : index;
392
+
393
+ return (
394
+ <div
395
+ key={key}
396
+ style={{
397
+ position: "absolute",
398
+ top: measurement.start,
399
+ height: measurement.size,
400
+ width: "100%",
401
+ }}
402
+ >
403
+ {renderItem(index)}
404
+ </div>
405
+ );
406
+ }
407
+ )}
408
+ </div>
409
+ );
410
+
411
+ if (useScrollArea) {
412
+ return (
413
+ <ScrollArea2
414
+ className={className}
415
+ style={scrollAreaStyle}
416
+ viewportClassName={scrollAreaViewportClassName}
417
+ viewportProps={{
418
+ ref: setScrollElement,
419
+ onScroll: handleScroll,
420
+ }}
421
+ >
422
+ {totalContent}
423
+ </ScrollArea2>
424
+ );
425
+ }
426
+
427
+ return (
428
+ <div
429
+ ref={setScrollElement}
430
+ className={className}
431
+ style={containerStyle}
432
+ onScroll={handleScroll}
433
+ >
434
+ {totalContent}
435
+ </div>
436
+ );
437
+ });
438
+
439
+ export const Virtualized = React.memo(VirtualizedInner);
@@ -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";