Skip to content

Commit

Permalink
Add infinite scroll functionality with loading indicator to Scroll co…
Browse files Browse the repository at this point in the history
…mponent
  • Loading branch information
galangel committed Jan 24, 2025
1 parent bfb2753 commit 96976e0
Show file tree
Hide file tree
Showing 6 changed files with 119 additions and 19 deletions.
51 changes: 40 additions & 11 deletions src/components/Scroll/Scroll.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Meta, Preview, StoryObj } from '@storybook/react';
import { Scroll } from './index';
import { Item, Items } from './types';
import { useState } from 'react';

const preview: Preview = {
title: 'Components/Scroll',
Expand Down Expand Up @@ -115,17 +116,6 @@ const item: Item['render'] = () => {
);
};

const createItems = (headerCount: number, itemCount: number, level: number = 1): Items => {
if (headerCount === 0 || level === 0) {
return Array.from({ length: itemCount }, () => ({ render: item }));
}

return Array.from({ length: headerCount }, () => ({
render: HeaderCollapse(level),
nestedItems: createItems(headerCount, itemCount, level - 1),
}));
};

export const BasicExample: Story = {
render: (args) => {
const createItems = (headerCount: number, itemCount: number, level: number = 1): Items => {
Expand Down Expand Up @@ -248,3 +238,42 @@ export const ScrollToIdExample: Story = {
headerBehavior: 'none',
},
};

export const infiniteScrollExample: Story = {
render: (args) => {
const [updatesLeft, setUpdatesLeft] = useState(5);
const createItems = (headerCount: number, itemCount: number, level: number = 1): Items => {
if (headerCount === 0 || level === 0) {
return Array.from({ length: itemCount }, () => ({ render: item }));
}

return Array.from({ length: headerCount }, () => ({
render: Header(level),
nestedItems: createItems(headerCount, itemCount, level - 1),
}));
};
const [myitems, setMyItems] = useState<Items>([...createItems(1, 20)]);

const handleBottomReached = async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
setUpdatesLeft((prev) => prev - 1);
setMyItems((prev) => [...prev, ...createItems(1, 20)]);
};

const loading = {
onBottomReached: updatesLeft > 0 ? handleBottomReached : undefined,
// render: (isLoading: boolean) => <div>{isLoading ? 'Please wait!' : 'End of the list'}</div>,
};

return (
<div style={{ width: '400px', height: '400px' }}>
<Scroll {...args} items={myitems} loading={loading} />
</div>
);
},
args: {
stickTo: 'all',
scrollBehavior: 'smooth',
headerBehavior: 'none',
},
};
7 changes: 4 additions & 3 deletions src/components/Scroll/Scroll.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import React from 'react';
import { HeadersProvider } from './Scroll.provider';
import { ScrollList } from './ScrollList';
import type { HeaderBehavior, Items, StickTo } from './types';
import type { HeaderBehavior, Items, Loading, StickTo } from './types';

interface IScrollProps {
stickTo?: StickTo;
scrollBehavior?: ScrollBehavior;
headerBehavior?: HeaderBehavior;
items: Items;
loading?: Loading;
}

export const Scroll: React.FC<IScrollProps> = ({ stickTo, scrollBehavior, headerBehavior, items }) => {
export const Scroll: React.FC<IScrollProps> = ({ stickTo, scrollBehavior, headerBehavior, loading, items }) => {
return (
<HeadersProvider headerBehavior={headerBehavior} stickTo={stickTo} scrollBehavior={scrollBehavior}>
<ScrollList items={items} />
<ScrollList loading={loading} items={items} />
</HeadersProvider>
);
};
32 changes: 27 additions & 5 deletions src/components/Scroll/ScrollList.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import React, { useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { useScrollContext } from './Scroll.provider';
import { Items } from './types';
import { Items, Loading } from './types';
import { getItems } from './util/get-items';
import { ScrollLoading } from './ScrollLoading';

interface IScrollListProps {
items: Items;
loading?: Loading;
}

export const ScrollList: React.FC<IScrollListProps> = ({ items }) => {
export const ScrollList: React.FC<IScrollListProps> = ({ items, loading }) => {
const { setListRef, headerBehavior, collapsedPaths } = useScrollContext();

const [isLoading, setisLoading] = React.useState(false);
const listRef = useRef(null);

useEffect(() => {
Expand All @@ -18,9 +20,29 @@ export const ScrollList: React.FC<IScrollListProps> = ({ items }) => {
}
}, [listRef]);

const handleScroll = useCallback(
(e: React.UIEvent<HTMLUListElement>) => {
if (items.length === 0 || !loading?.onBottomReached) return;

const target = e.target as HTMLUListElement;
if (Math.floor(target.scrollHeight - target.scrollTop) < target.clientHeight + 10) {
if (!target.dataset.loading) {
target.dataset.loading = 'true';
setisLoading(true);
loading.onBottomReached().finally(() => {
target.dataset.loading = '';
setisLoading(false);
});
}
}
},
[items.length === 0, loading?.onBottomReached],
);

return (
<ul ref={listRef} className={`scroll-list`}>
<ul ref={listRef} className={`scroll-list`} onScroll={loading?.onBottomReached ? handleScroll : undefined}>
{getItems({ items, headerBehavior, collapsedPaths })}
{loading?.render ? loading.render(isLoading) : <ScrollLoading loading={isLoading} />}
</ul>
);
};
11 changes: 11 additions & 0 deletions src/components/Scroll/ScrollLoading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from 'react';

interface IScrollLoadingProps {
loading: boolean;
}

export const ScrollLoading: React.FC<IScrollLoadingProps> = ({ loading }) => {
if (!loading) return null;

return <li role="listitem" aria-label="Scroll Loading" className={`scroll-loading ${loading ? 'loading' : ''}`}></li>;
};
5 changes: 5 additions & 0 deletions src/components/Scroll/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ export type Collapse = {
isOpen: boolean;
};

export type Loading = {
onBottomReached?: () => Promise<void>;
render?: (isLoading: boolean) => JSX.Element;
};

type RenderProps = { collapse?: Collapse };

export type Item = {
Expand Down
32 changes: 32 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,35 @@
border: none;
list-style: none;
}

.scroll-loading {
display: flex;
width: 100%;
box-sizing: border-box;
position: relative;
padding: 0;
margin: 0;
border: none;
list-style: none;
height: 4px;
background-color: #e0e0e0;
overflow: hidden;
}

.scroll-loading::before {
content: '';
display: block;
width: 100%;
height: 100%;
background-color: #3b82f6;
animation: loading 1s linear infinite;
}

@keyframes loading {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}

0 comments on commit 96976e0

Please sign in to comment.