Skip to content

Commit 69ee1e6

Browse files
committed
feat(pkg.gridvirtualizer): add gridvirtualizer
1 parent 19f8f02 commit 69ee1e6

10 files changed

Lines changed: 443 additions & 0 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { useRef } from 'react';
2+
import type { Meta, StoryObj } from '@storybook/react';
3+
import { GridVirtualizer } from '@xipkg/gridvirtualizer';
4+
5+
type TMaterialItem = {
6+
id: number;
7+
title: string;
8+
color: string;
9+
};
10+
11+
const generateItems = (count: number): TMaterialItem[] => {
12+
const colors = [
13+
'#FF6B6B',
14+
'#4ECDC4',
15+
'#45B7D1',
16+
'#96CEB4',
17+
'#FFEAA7',
18+
'#DDA0DD',
19+
'#FF8C94',
20+
'#88D8A8',
21+
];
22+
23+
return Array.from({ length: count }, (_, index) => ({
24+
id: index + 1,
25+
title: `Item ${index + 1}`,
26+
color: colors[index % colors.length],
27+
}));
28+
};
29+
30+
const items = generateItems(100);
31+
32+
const meta = {
33+
title: 'Components/GridVirtualizer',
34+
component: GridVirtualizer,
35+
parameters: {
36+
layout: 'centered',
37+
},
38+
tags: ['autodocs'],
39+
argTypes: {
40+
items: {
41+
description: 'Массив данных',
42+
},
43+
defaultRowHeight: {
44+
control: 'number',
45+
description: 'Высота строки',
46+
defaultValue: 100,
47+
},
48+
minItemWidth: {
49+
control: 'number',
50+
description: 'Ширина компонента, который рендерится',
51+
defaultValue: 0,
52+
},
53+
gap: {
54+
control: 'number',
55+
defaultValue: 0,
56+
},
57+
maxColumns: {
58+
control: 'number',
59+
description: 'Количество колонок в сетке',
60+
defaultValue: 1,
61+
},
62+
isSingleColumn: {
63+
control: 'boolean',
64+
description: 'Grid-сетка в одну колонку для мобильных версий',
65+
defaultValue: false,
66+
},
67+
overscan: {
68+
control: 'number',
69+
description:
70+
'Количество дополнительных строк, которые виртуализатор рендерит за пределами видимой области (сверху и снизу)',
71+
defaultValue: 5,
72+
},
73+
},
74+
} satisfies Meta<typeof GridVirtualizer>;
75+
76+
export default meta;
77+
type Story = StoryObj<typeof meta>;
78+
79+
type TCard = {
80+
label: string;
81+
};
82+
83+
const Card = ({ label }: TCard) => (
84+
<div className="hover:border-brand-80 border-gray-20 bg-gray-0 w-full cursor-pointer rounded-md border p-2 transition-all duration-200">
85+
<span>{label}</span>
86+
</div>
87+
);
88+
89+
const DefaultGridVirtualizer = (args: React.ComponentProps<typeof GridVirtualizer>) => {
90+
console.log('args', args);
91+
const parentRef = useRef<HTMLDivElement>(null);
92+
return (
93+
<div
94+
ref={parentRef}
95+
className="bg-gray-10 h-[200px] w-[400px] flex-1 overflow-auto"
96+
style={{
97+
scrollbarWidth: 'none' /* Firefox */,
98+
msOverflowStyle: 'none' /* IE/Edge */,
99+
}}
100+
>
101+
<GridVirtualizer
102+
{...args}
103+
parentRef={parentRef}
104+
items={items}
105+
renderItem={(item) => <Card key={item.id} label={item.title} />}
106+
/>
107+
</div>
108+
);
109+
};
110+
111+
export const Default: Story = {
112+
render: DefaultGridVirtualizer,
113+
args: {
114+
defaultRowHeight: 30,
115+
minItemWidth: 100,
116+
gap: 0,
117+
maxColumns: 4,
118+
isSingleColumn: false,
119+
overscan: 5,
120+
} as React.ComponentProps<typeof GridVirtualizer>,
121+
};
122+
123+
export const SingleColumn: Story = {
124+
render: DefaultGridVirtualizer,
125+
args: {
126+
defaultRowHeight: 30,
127+
minItemWidth: 100,
128+
gap: 0,
129+
maxColumns: 4,
130+
isSingleColumn: true,
131+
overscan: 5,
132+
} as React.ComponentProps<typeof GridVirtualizer>,
133+
};
134+
135+
export const TwoColumnsGap: Story = {
136+
render: DefaultGridVirtualizer,
137+
args: {
138+
defaultRowHeight: 30,
139+
minItemWidth: 100,
140+
gap: 5,
141+
maxColumns: 3,
142+
isSingleColumn: false,
143+
overscan: 5,
144+
} as React.ComponentProps<typeof GridVirtualizer>,
145+
};

package-lock.json

Lines changed: 89 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { RefObject, useCallback, useLayoutEffect } from 'react';
2+
import { useVirtualizer } from '@tanstack/react-virtual';
3+
import { useResponsiveGrid } from './hooks';
4+
5+
type TItem = {
6+
id: number;
7+
};
8+
9+
export type TGridVirtualizer<T> = {
10+
items: T[];
11+
renderItem: (item: T) => React.ReactNode;
12+
className?: string;
13+
parentRef: RefObject<HTMLDivElement | null>;
14+
defaultRowHeight?: number;
15+
minItemWidth?: number;
16+
gap?: number;
17+
maxColumns?: number;
18+
isSingleColumn?: boolean;
19+
overscan?: number;
20+
};
21+
22+
export const GridVirtualizer = <T extends TItem>({
23+
items,
24+
renderItem,
25+
className,
26+
parentRef,
27+
defaultRowHeight = 100,
28+
minItemWidth = 0,
29+
gap = 0,
30+
maxColumns = 1,
31+
isSingleColumn = false,
32+
overscan = 5,
33+
...props
34+
}: TGridVirtualizer<T>) => {
35+
const { colCount: responsiveColCount } = useResponsiveGrid(
36+
parentRef,
37+
minItemWidth,
38+
gap,
39+
maxColumns,
40+
);
41+
const colCount = isSingleColumn ? 1 : responsiveColCount;
42+
43+
const rowVirtualizer = useVirtualizer({
44+
count: Math.ceil(items.length / colCount),
45+
getScrollElement: () => parentRef.current,
46+
estimateSize: () => defaultRowHeight + gap,
47+
overscan,
48+
});
49+
50+
const measureRow = useCallback(
51+
(element: HTMLDivElement | null) => {
52+
if (element) {
53+
rowVirtualizer.measureElement(element);
54+
}
55+
},
56+
[rowVirtualizer],
57+
);
58+
59+
useLayoutEffect(() => {
60+
rowVirtualizer.measure();
61+
}, [items.length, colCount, rowVirtualizer]);
62+
63+
return (
64+
<div
65+
className={className}
66+
style={{
67+
height: `${rowVirtualizer.getTotalSize()}px`,
68+
width: '100%',
69+
position: 'relative',
70+
}}
71+
{...props}
72+
>
73+
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
74+
const startIndex = virtualItem.index * colCount;
75+
const endIndex = Math.min(startIndex + colCount, items.length);
76+
const rowItems = items.slice(startIndex, endIndex);
77+
78+
return (
79+
<div
80+
key={virtualItem.key}
81+
data-index={virtualItem.index}
82+
ref={measureRow}
83+
style={{
84+
position: 'absolute',
85+
top: 0,
86+
left: 0,
87+
width: '100%',
88+
display: 'grid',
89+
gap,
90+
paddingBottom: gap,
91+
gridTemplateColumns: `repeat(${colCount}, minmax(${minItemWidth}px, 1fr)`,
92+
transform: `translateY(${virtualItem.start}px)`,
93+
}}
94+
>
95+
{rowItems.map((item) => (
96+
<div key={item.id}>{renderItem(item)}</div>
97+
))}
98+
</div>
99+
);
100+
})}
101+
</div>
102+
);
103+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useResponsiveGrid } from './useResponsiveGrid';
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useEffect, useState, RefObject } from 'react';
2+
import { calculateColCount } from '../utils';
3+
4+
export const useResponsiveGrid = (
5+
parentRef: RefObject<HTMLDivElement | null>,
6+
itemWidth: number,
7+
gap: number,
8+
maxColumns: number,
9+
) => {
10+
const [colCount, setColCount] = useState(1);
11+
12+
// Вычисляем количество колонок
13+
useEffect(() => {
14+
const el = parentRef.current;
15+
if (!el) return;
16+
17+
const ro = new ResizeObserver(([entry]) => {
18+
const { width } = entry.contentRect;
19+
const cols = calculateColCount({ width, maxColumns, gap, itemWidth });
20+
setColCount(cols);
21+
});
22+
ro.observe(el);
23+
24+
setColCount(calculateColCount({ width: el.clientWidth, maxColumns, gap, itemWidth }));
25+
26+
return () => ro.disconnect();
27+
}, [parentRef, itemWidth, maxColumns, gap]);
28+
29+
return { colCount };
30+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { GridVirtualizer } from './GridVirtualizer';

0 commit comments

Comments
 (0)