@sproutsocial/seeds-react-list 0.0.2 → 0.1.1

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,353 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import Experimental_VirtualizedDataList from "./VirtualizedDataList";
4
+ import ListItemContent from "./ListItemContent";
5
+ import DataItemSkeleton from "./DataItemSkeleton";
6
+ import Box from "@sproutsocial/seeds-react-box";
7
+ import type { TypeIconName } from "@sproutsocial/seeds-react-icon";
8
+ import {
9
+ QueryClient,
10
+ QueryClientProvider,
11
+ useInfiniteQuery,
12
+ } from "@tanstack/react-query";
13
+ import Message from "@sproutsocial/seeds-react-message";
14
+ import { Icon } from "@sproutsocial/seeds-react-icon";
15
+ import Skeleton from "@sproutsocial/seeds-react-skeleton";
16
+
17
+ const queryClient = new QueryClient();
18
+
19
+ const meta = {
20
+ title: "Components/Experimental_VirtualizedDataList",
21
+ component: Experimental_VirtualizedDataList,
22
+ decorators: [
23
+ (Story) => (
24
+ <QueryClientProvider client={queryClient}>
25
+ <Box maxWidth="500px" height="600px">
26
+ <Story />
27
+ </Box>
28
+ </QueryClientProvider>
29
+ ),
30
+ ],
31
+ parameters: {
32
+ docs: {
33
+ description: {
34
+ component:
35
+ "An experimental virtualized list component for rendering large datasets efficiently. This component uses virtual scrolling to only render visible items, providing better performance for lists with thousands of items.",
36
+ },
37
+ },
38
+ },
39
+ } satisfies Meta<typeof Experimental_VirtualizedDataList>;
40
+
41
+ export default meta;
42
+ type Story<T = unknown> = StoryObj<
43
+ Meta<typeof Experimental_VirtualizedDataList<T>>
44
+ >;
45
+
46
+ interface ListItemData {
47
+ id: string;
48
+ text: string;
49
+ details?: string;
50
+ aside?: string;
51
+ iconName?: TypeIconName;
52
+ }
53
+
54
+ // Generate a large dataset for virtualization demo
55
+ const generateLargeDataset = (count: number): ListItemData[] => {
56
+ return Array.from({ length: count }, (_, i) => ({
57
+ id: `virtual-${i + 1}`,
58
+ text: `Virtualized Item ${i + 1}`,
59
+ details: `This is item number ${i + 1} in a virtualized list ${
60
+ i % 5 === 0
61
+ ? "with lots of extra extra extra extra extra extra extra text to demonstrate variable height items"
62
+ : ""
63
+ }`,
64
+ aside: `#${i + 1}`,
65
+ iconName: i % 2 === 0 ? "tag-solid" : ("tag-outline" as TypeIconName),
66
+ }));
67
+ };
68
+
69
+ const largeDataset = generateLargeDataset(1000);
70
+
71
+ export const Basic: Story<ListItemData> = {
72
+ name: "Basic - 1000 Items",
73
+ args: {
74
+ data: largeDataset,
75
+ renderItem: (item) => (
76
+ <ListItemContent
77
+ key={item.id}
78
+ text={item.text}
79
+ details={item.details}
80
+ aside={item.aside}
81
+ />
82
+ ),
83
+ },
84
+ };
85
+
86
+ export const WithIcons: Story<ListItemData> = {
87
+ name: "With Icons - 1000 Items",
88
+ args: {
89
+ data: largeDataset,
90
+ renderItem: (item) => (
91
+ <ListItemContent
92
+ key={item.id}
93
+ text={item.text}
94
+ details={item.details}
95
+ aside={item.aside}
96
+ iconName={item.iconName}
97
+ />
98
+ ),
99
+ },
100
+ };
101
+
102
+ const Header = ({ children }: { children: React.ReactNode }) => {
103
+ return (
104
+ <Box
105
+ backgroundColor="container.background.base"
106
+ border="1px solid"
107
+ borderColor="container.border.base"
108
+ borderWidth={500}
109
+ style={{ height: "60px" }}
110
+ p={300}
111
+ >
112
+ {children}
113
+ </Box>
114
+ );
115
+ };
116
+
117
+ export const WithHeader: Story<ListItemData> = {
118
+ name: "With Header",
119
+ args: {
120
+ data: largeDataset,
121
+ Header: <Header>List Header - Scroll to see it hide</Header>,
122
+ renderItem: (item) => (
123
+ <ListItemContent
124
+ key={item.id}
125
+ text={item.text}
126
+ details={item.details}
127
+ aside={item.aside}
128
+ iconName={item.iconName}
129
+ />
130
+ ),
131
+ },
132
+ };
133
+
134
+ export const CustomEstimatedSize: Story<ListItemData> = {
135
+ name: "Custom Estimated Item Size",
136
+ args: {
137
+ data: largeDataset,
138
+ estimateItemSize: 80,
139
+ renderItem: (item) => (
140
+ <ListItemContent
141
+ key={item.id}
142
+ text={item.text}
143
+ details={item.details}
144
+ aside={item.aside}
145
+ iconName={item.iconName}
146
+ />
147
+ ),
148
+ },
149
+ };
150
+
151
+ // Infinite scroll example
152
+ async function fetchServerPage(
153
+ limit: number,
154
+ offset: number = 0
155
+ ): Promise<{ rows: ListItemData[]; nextOffset: number; hasMore: boolean }> {
156
+ const rows = Array.from({ length: limit }, (_, i) => ({
157
+ id: `async-${offset * limit + i}`,
158
+ text: `Async loaded row #${offset * limit + i}`,
159
+ details: `Details for async row #${offset * limit + i}`,
160
+ aside: `#${offset * limit + i}`,
161
+ iconName: "tag-outline" as TypeIconName,
162
+ }));
163
+
164
+ await new Promise((r) => setTimeout(r, 500));
165
+
166
+ return { rows, nextOffset: offset + 1, hasMore: offset < 10 };
167
+ }
168
+
169
+ const InfiniteScrollExample = () => {
170
+ const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage } =
171
+ useInfiniteQuery({
172
+ queryKey: ["virtualized-projects"],
173
+ queryFn: (ctx) => fetchServerPage(30, ctx.pageParam),
174
+ getNextPageParam: (lastGroup) =>
175
+ lastGroup.hasMore ? lastGroup.nextOffset : undefined,
176
+ initialPageParam: 0,
177
+ });
178
+
179
+ const allRows = data ? data.pages.flatMap((d) => d.rows) : [];
180
+
181
+ if (isLoading) {
182
+ return <Box p={400}>Loading initial data...</Box>;
183
+ }
184
+
185
+ return (
186
+ <Experimental_VirtualizedDataList
187
+ data={allRows}
188
+ renderItem={(item) => (
189
+ <ListItemContent
190
+ key={item.id}
191
+ text={item.text}
192
+ details={item.details}
193
+ aside={item.aside}
194
+ iconName={item.iconName}
195
+ />
196
+ )}
197
+ onEndReached={fetchNextPage}
198
+ hasNextPage={hasNextPage}
199
+ isFetchingNextPage={isFetchingNextPage}
200
+ Header={
201
+ <Box
202
+ bg="container.background.decorative.neutral"
203
+ p={400}
204
+ display="flex"
205
+ alignItems="center"
206
+ justifyContent="center"
207
+ >
208
+ Infinite Scroll List - {allRows.length} items loaded
209
+ </Box>
210
+ }
211
+ />
212
+ );
213
+ };
214
+
215
+ export const InfiniteScroll: Story = {
216
+ name: "Infinite Scroll",
217
+ render: () => <InfiniteScrollExample />,
218
+ };
219
+
220
+ // Messages example
221
+ interface MessageData {
222
+ id: string;
223
+ text: string;
224
+ profileName: string;
225
+ }
226
+
227
+ const generateMessageData = (count: number): MessageData[] => {
228
+ return Array.from({ length: count }, (_, i) => ({
229
+ id: `message-${i + 1}`,
230
+ text: `This is message number ${i + 1}. ${
231
+ i % 3 === 0
232
+ ? "This message has some extra content to make it longer and demonstrate variable height virtualization."
233
+ : ""
234
+ }`,
235
+ profileName: `User ${i + 1}`,
236
+ }));
237
+ };
238
+
239
+ const messageData = generateMessageData(500);
240
+
241
+ const StoryMessage = ({ item }: { item: MessageData }) => {
242
+ return (
243
+ <Message density="condensed">
244
+ <Message.Header>
245
+ <Box display="flex" alignItems="center">
246
+ <Message.Avatar mr={350} appearance="leaf" name={item.profileName} />
247
+ {item.profileName}
248
+ </Box>
249
+ <Box>
250
+ <Message.Checkbox
251
+ id={`message-checkbox-${item.id}`}
252
+ ariaLabel="Message Checkbox"
253
+ onChange={() => {}}
254
+ />
255
+ </Box>
256
+ </Message.Header>
257
+ <Message.Body>{item.text}</Message.Body>
258
+ <Message.Footer>
259
+ <>
260
+ <Box ml={-350}>
261
+ <Message.Button>
262
+ <Icon name="comments-outline" mr={200} aria-hidden />
263
+ </Message.Button>
264
+ </Box>
265
+ <Box>
266
+ <Message.Button aria-label="Heart Icon">
267
+ <Icon name="heart-outline" aria-hidden />
268
+ </Message.Button>
269
+ <Message.Button aria-label="Tag Icon">
270
+ <Icon name="tag-outline" aria-hidden />
271
+ </Message.Button>
272
+ </Box>
273
+ </>
274
+ </Message.Footer>
275
+ </Message>
276
+ );
277
+ };
278
+
279
+ export const WithMessages: Story<MessageData> = {
280
+ name: "With Messages - 500 Items",
281
+ render: () => (
282
+ <Experimental_VirtualizedDataList
283
+ data={messageData}
284
+ estimateItemSize={120}
285
+ renderItem={(item) => <StoryMessage item={item} />}
286
+ Header={
287
+ <Box
288
+ bg="container.background.decorative.neutral"
289
+ p={400}
290
+ display="flex"
291
+ alignItems="center"
292
+ justifyContent="center"
293
+ >
294
+ Messages ({messageData.length})
295
+ </Box>
296
+ }
297
+ />
298
+ ),
299
+ };
300
+
301
+ // Variable height items
302
+ const generateVariableHeightData = (count: number): ListItemData[] => {
303
+ return Array.from({ length: count }, (_, i) => ({
304
+ id: `var-${i + 1}`,
305
+ text: `Item ${i + 1}`,
306
+ details:
307
+ i % 3 === 0
308
+ ? "This item has a lot of details. ".repeat(5)
309
+ : i % 2 === 0
310
+ ? "This item has medium details. ".repeat(2)
311
+ : "Short details.",
312
+ aside: `#${i + 1}`,
313
+ iconName: "tag-outline" as TypeIconName,
314
+ }));
315
+ };
316
+
317
+ const variableHeightData = generateVariableHeightData(200);
318
+
319
+ export const VariableHeights: Story<ListItemData> = {
320
+ name: "Variable Heights - 200 Items",
321
+ args: {
322
+ data: variableHeightData,
323
+ estimateItemSize: 60,
324
+ renderItem: (item) => (
325
+ <ListItemContent
326
+ key={item.id}
327
+ text={item.text}
328
+ details={item.details}
329
+ aside={item.aside}
330
+ iconName={item.iconName}
331
+ />
332
+ ),
333
+ },
334
+ };
335
+
336
+ export const CustomHeaderSettings: Story<ListItemData> = {
337
+ name: "Custom Header Settings",
338
+ args: {
339
+ data: largeDataset,
340
+ Header: <Header>Custom Header - Hides after 200px depth</Header>,
341
+ removeHeaderDepth: 200,
342
+ headerHeight: 83,
343
+ renderItem: (item) => (
344
+ <ListItemContent
345
+ key={item.id}
346
+ text={item.text}
347
+ details={item.details}
348
+ aside={item.aside}
349
+ iconName={item.iconName}
350
+ />
351
+ ),
352
+ },
353
+ };
@@ -0,0 +1,122 @@
1
+ import * as React from "react";
2
+ import List from "./List";
3
+ import ListItem from "./ListItem";
4
+ import Box from "@sproutsocial/seeds-react-box";
5
+ import Loader from "@sproutsocial/seeds-react-loader";
6
+ import { useVirtualizer } from "@tanstack/react-virtual";
7
+ import type { TypeDataListProps } from "./DataListTypes";
8
+ import { useInView } from "react-intersection-observer";
9
+ import styled from "styled-components";
10
+
11
+ const HeaderContent = styled(Box)`
12
+ width: 100%;
13
+ transition: transform 0.4s ease-in-out;
14
+ background-color: red;
15
+ `;
16
+
17
+ const Experimental_VirtualizedDataList = <T,>({
18
+ as = "ul",
19
+ data,
20
+ renderItem,
21
+ estimateItemSize = 50,
22
+ onEndReached,
23
+ hasNextPage,
24
+ isFetchingNextPage,
25
+ Header,
26
+ removeHeaderDepth = 500,
27
+ headerHeight = 60,
28
+ ...rest
29
+ }: TypeDataListProps<T>) => {
30
+ const afterInView = useInView();
31
+
32
+ const parentRef = React.useRef<HTMLDivElement>(null);
33
+
34
+ const count = data.length;
35
+
36
+ const virtualizer = useVirtualizer({
37
+ count,
38
+ getScrollElement: () => parentRef.current,
39
+ estimateSize: () => estimateItemSize,
40
+ overscan: 5,
41
+ });
42
+
43
+ const items = virtualizer.getVirtualItems();
44
+ const offset = virtualizer.scrollOffset;
45
+
46
+ const transform =
47
+ offset && offset > removeHeaderDepth
48
+ ? `translateY(-${headerHeight}px)`
49
+ : "translateY(0px)";
50
+
51
+ React.useEffect(() => {
52
+ if (afterInView.inView) {
53
+ onEndReached?.();
54
+ }
55
+ }, [onEndReached, afterInView.inView]);
56
+
57
+ return (
58
+ <div
59
+ ref={parentRef}
60
+ className="List"
61
+ style={{
62
+ width: "100%",
63
+ height: "100%",
64
+ overflowY: "auto",
65
+ contain: "strict",
66
+ }}
67
+ >
68
+ {Header && (
69
+ <HeaderContent
70
+ style={{
71
+ position: "sticky",
72
+ transform,
73
+ top: 0,
74
+ left: 0,
75
+ width: "100%",
76
+ zIndex: 1,
77
+ }}
78
+ id="header"
79
+ >
80
+ {Header}
81
+ </HeaderContent>
82
+ )}
83
+ <List
84
+ as={as}
85
+ style={{
86
+ height: `${virtualizer.getTotalSize()}px`,
87
+ width: "100%",
88
+ position: "relative",
89
+ }}
90
+ >
91
+ {items.map((virtualRow) => {
92
+ return (
93
+ <ListItem
94
+ key={virtualRow.key}
95
+ data-index={virtualRow.index}
96
+ ref={virtualizer.measureElement}
97
+ style={{
98
+ position: "absolute",
99
+ top: 0,
100
+ left: 0,
101
+ width: "100%",
102
+ transform: `translateY(${
103
+ virtualRow.start - virtualizer.options.scrollMargin
104
+ }px)`,
105
+ }}
106
+ >
107
+ {renderItem(data[virtualRow.index] as T)}
108
+ </ListItem>
109
+ );
110
+ })}
111
+ </List>
112
+ <div ref={afterInView.ref}>
113
+ <Loader delay={false} />
114
+ </div>
115
+ </div>
116
+ );
117
+ };
118
+
119
+ Experimental_VirtualizedDataList.displayName =
120
+ "Experimental_VirtualizedDataList";
121
+
122
+ export default Experimental_VirtualizedDataList;
@@ -0,0 +1,55 @@
1
+ import * as React from "react";
2
+ import { faker } from "@faker-js/faker";
3
+
4
+ import { useVirtualizer, useWindowVirtualizer } from "@tanstack/react-virtual";
5
+
6
+ const randomNumber = (min: number, max: number) =>
7
+ faker.number.int({ min, max });
8
+
9
+ const sentences = new Array(10000)
10
+ .fill(true)
11
+ .map(() => faker.lorem.sentence(randomNumber(20, 70)));
12
+
13
+ export function WindowExample() {
14
+ const parentRef = React.useRef<HTMLDivElement | null>(null);
15
+
16
+ const virtualizer = useWindowVirtualizer({
17
+ count: 10000,
18
+ estimateSize: () => 35,
19
+ overscan: 5,
20
+ scrollMargin: parentRef.current?.offsetTop ?? 0,
21
+ });
22
+
23
+ return (
24
+ <>
25
+ <div ref={parentRef} className="List">
26
+ <div
27
+ style={{
28
+ height: `${virtualizer.getTotalSize()}px`,
29
+ width: "100%",
30
+ position: "relative",
31
+ }}
32
+ >
33
+ {virtualizer.getVirtualItems().map((item) => (
34
+ <div
35
+ key={item.key}
36
+ className={item.index % 2 ? "ListItemOdd" : "ListItemEven"}
37
+ style={{
38
+ position: "absolute",
39
+ top: 0,
40
+ left: 0,
41
+ width: "100%",
42
+ height: `${item.size}px`,
43
+ transform: `translateY(${
44
+ item.start - virtualizer.options.scrollMargin
45
+ }px)`,
46
+ }}
47
+ >
48
+ Row {item.index}
49
+ </div>
50
+ ))}
51
+ </div>
52
+ </div>
53
+ </>
54
+ );
55
+ }