From 96976e074dc95bc3145687e8ecf96e1aba9b1252 Mon Sep 17 00:00:00 2001 From: galangel Date: Sat, 25 Jan 2025 00:27:13 +0200 Subject: [PATCH] Add infinite scroll functionality with loading indicator to Scroll component --- src/components/Scroll/Scroll.stories.tsx | 51 +++++++++++++++++++----- src/components/Scroll/Scroll.tsx | 7 ++-- src/components/Scroll/ScrollList.tsx | 32 ++++++++++++--- src/components/Scroll/ScrollLoading.tsx | 11 +++++ src/components/Scroll/types.ts | 5 +++ src/index.css | 32 +++++++++++++++ 6 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 src/components/Scroll/ScrollLoading.tsx diff --git a/src/components/Scroll/Scroll.stories.tsx b/src/components/Scroll/Scroll.stories.tsx index 4cee861..46416af 100644 --- a/src/components/Scroll/Scroll.stories.tsx +++ b/src/components/Scroll/Scroll.stories.tsx @@ -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', @@ -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 => { @@ -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([...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) =>
{isLoading ? 'Please wait!' : 'End of the list'}
, + }; + + return ( +
+ +
+ ); + }, + args: { + stickTo: 'all', + scrollBehavior: 'smooth', + headerBehavior: 'none', + }, +}; diff --git a/src/components/Scroll/Scroll.tsx b/src/components/Scroll/Scroll.tsx index b60029f..f388715 100644 --- a/src/components/Scroll/Scroll.tsx +++ b/src/components/Scroll/Scroll.tsx @@ -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 = ({ stickTo, scrollBehavior, headerBehavior, items }) => { +export const Scroll: React.FC = ({ stickTo, scrollBehavior, headerBehavior, loading, items }) => { return ( - + ); }; diff --git a/src/components/Scroll/ScrollList.tsx b/src/components/Scroll/ScrollList.tsx index aae9051..447608f 100644 --- a/src/components/Scroll/ScrollList.tsx +++ b/src/components/Scroll/ScrollList.tsx @@ -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 = ({ items }) => { +export const ScrollList: React.FC = ({ items, loading }) => { const { setListRef, headerBehavior, collapsedPaths } = useScrollContext(); - + const [isLoading, setisLoading] = React.useState(false); const listRef = useRef(null); useEffect(() => { @@ -18,9 +20,29 @@ export const ScrollList: React.FC = ({ items }) => { } }, [listRef]); + const handleScroll = useCallback( + (e: React.UIEvent) => { + 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 ( -
    +
      {getItems({ items, headerBehavior, collapsedPaths })} + {loading?.render ? loading.render(isLoading) : }
    ); }; diff --git a/src/components/Scroll/ScrollLoading.tsx b/src/components/Scroll/ScrollLoading.tsx new file mode 100644 index 0000000..4b23c40 --- /dev/null +++ b/src/components/Scroll/ScrollLoading.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +interface IScrollLoadingProps { + loading: boolean; +} + +export const ScrollLoading: React.FC = ({ loading }) => { + if (!loading) return null; + + return
  • ; +}; diff --git a/src/components/Scroll/types.ts b/src/components/Scroll/types.ts index 4e2d94d..4313366 100644 --- a/src/components/Scroll/types.ts +++ b/src/components/Scroll/types.ts @@ -8,6 +8,11 @@ export type Collapse = { isOpen: boolean; }; +export type Loading = { + onBottomReached?: () => Promise; + render?: (isLoading: boolean) => JSX.Element; +}; + type RenderProps = { collapse?: Collapse }; export type Item = { diff --git a/src/index.css b/src/index.css index 14f8623..eda4359 100644 --- a/src/index.css +++ b/src/index.css @@ -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%); + } +}