diff --git a/packages/apsara-ui/src/InfiniteScroll/InfiniteScroll.stories.tsx b/packages/apsara-ui/src/InfiniteScroll/InfiniteScroll.stories.tsx new file mode 100644 index 00000000..2c640ee5 --- /dev/null +++ b/packages/apsara-ui/src/InfiniteScroll/InfiniteScroll.stories.tsx @@ -0,0 +1,134 @@ +import React, { useCallback, useEffect, useState } from "react"; +import InfiniteScroll from "./InfiniteScroll"; +import styled from "styled-components"; + +export default { + title: "Data Display/InfiniteScroll", + component: InfiniteScroll, +}; + +interface User { + key: number; + name: string; + status: "active" | "inactive"; + age: number; + address: string; +} + +function getData(page = 1, page_size = 10): User[] { + return new Array(page * page_size).fill(0).map((_, index) => { + return { + key: index, + name: `name ${index}`, + status: index % 2 ? "active" : "inactive", + age: index, + address: `A${index} Downing Street`, + }; + }); +} + +function getDataAsync(page = 1, page_size = 10): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(getData(page, page_size)); + }, 1000); + }); +} + +export const basic = () => { + const [data, setData] = useState([]); + const [isFetching, setIsFetching] = useState(false); + + const fetchData = useCallback(() => { + setIsFetching(true); + getDataAsync() + .then((d) => setData((curr) => curr.concat(d))) + .finally(() => setIsFetching(false)); + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const onScroll = () => { + if (isFetching) return; + + fetchData(); + }; + + return ( +
+ + items={data} + renderItem={(item) => } + onBottomScroll={onScroll} + isLoading={isFetching} + /> +
+ ); +}; + +const Card = ({ user }: { user: User }) => { + return ( + +
{user.name}
+
+
+
Address
+
{user.address}
+
+
+
Age
+
{user.age}
+
+
+
+ ); +}; + +const UserCard = styled.div` + margin-bottom: 16px; + border-radius: 2px; + box-shadow: rgb(179 179 179 / 31%) 0px 1px 5px; + + .title { + margin: 10px; + color: #4d85f4; + font-size: 16px; + } + + .description { + margin-top: 20px; + word-break: break-all; + } + + .body { + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 0px 20px 12px 20px; + + > .body-left { + margin-top: 16px; + flex: 1; + } + + > .body-right { + width: 240px; + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: space-between; + + > div { + display: flex; + align-items: flex-end; + flex-direction: column; + } + + .tag_content { + text-transform: uppercase; + } + } + } +`; diff --git a/packages/apsara-ui/src/InfiniteScroll/InfiniteScroll.tsx b/packages/apsara-ui/src/InfiniteScroll/InfiniteScroll.tsx new file mode 100644 index 00000000..ad182bed --- /dev/null +++ b/packages/apsara-ui/src/InfiniteScroll/InfiniteScroll.tsx @@ -0,0 +1,82 @@ +import React, { useEffect, useRef } from "react"; + +interface InfiniteScrollProps { + renderItem: (item: T) => React.ReactNode; + onBottomScroll: () => void; + items: T[]; + height?: string; + style?: React.CSSProperties; + isLoading?: boolean; + noMoreData?: boolean; + containerRef?: React.RefObject; + threshold?: number; + loadingComponent?: React.ReactNode; + noMoreDataComponent?: React.ReactNode; +} + +const InfiniteScroll = ({ + renderItem, + onBottomScroll, + items, + style, + height, + isLoading, + noMoreData, + containerRef, + threshold = 1, + loadingComponent, + noMoreDataComponent, +}: InfiniteScrollProps) => { + const defaultContainerRef = useRef(null); + + useEffect(() => { + const containerElem = containerRef ? containerRef.current : defaultContainerRef.current; + if (!containerElem) return; + + const onScroll = () => { + if (isBottom(containerElem, threshold)) { + onBottomScroll(); + } + }; + + containerElem.addEventListener("scroll", onScroll); + + return () => { + containerElem.removeEventListener("scroll", onScroll); + }; + }, [containerRef, defaultContainerRef, onBottomScroll, threshold]); + + const loadingComp = loadingComponent || ; + + const scrollStyle: React.CSSProperties = !containerRef + ? { + height: height || "calc(100%-0px)", + overflow: "scroll", + position: "relative", + ...style, + } + : {}; + + return ( +
+ {items?.map(renderItem)} + {isLoading && loadingComp} + {!noMoreData && noMoreDataComponent} +
+ ); +}; + +const DefaultLoading = () =>
Loading...
; + +const isBottom = (elem: HTMLElement, threshold: number): boolean => { + if (!elem) return false; + + const { scrollTop, scrollHeight, clientHeight } = elem; + + const a = scrollTop + clientHeight; + const b = scrollHeight - threshold; + + return a >= b; +}; + +export default InfiniteScroll; diff --git a/packages/apsara-ui/src/InfiniteScroll/index.tsx b/packages/apsara-ui/src/InfiniteScroll/index.tsx new file mode 100644 index 00000000..4d39641c --- /dev/null +++ b/packages/apsara-ui/src/InfiniteScroll/index.tsx @@ -0,0 +1 @@ +export default "./InfiniteScroll"; diff --git a/packages/apsara-ui/src/index.ts b/packages/apsara-ui/src/index.ts index 40f8f568..ac625ec8 100644 --- a/packages/apsara-ui/src/index.ts +++ b/packages/apsara-ui/src/index.ts @@ -52,6 +52,7 @@ import { TooltipPlacement } from "./Tooltip/Tooltip"; import Modal from "./Modal"; import Alert from "./Alert"; import { DotBadge } from "./Badge"; +import InfiniteScroll from "./InfiniteScroll"; export { DynamicList } from "./DynamicList"; export * from "./Notification"; @@ -103,6 +104,7 @@ export { Slider, Themes, InfoModal, + InfiniteScroll, DiffTimeline, InputNumber, DatePicker,