From d840ad1afe3e8074cf2b876395d0f438671c7bda Mon Sep 17 00:00:00 2001 From: baegofda Date: Thu, 27 Jun 2024 02:24:29 +0900 Subject: [PATCH] DESIGN-2 FixedVirtualList --- .../FixedVirtualList.stories.tsx | 122 ++++++++++++++++++ .../FixedVirtualList/FixedVirtualListItem.tsx | 30 +++++ .../Virtual/FixedVirtualList/index.tsx | 69 ++++++++++ .../Virtual/FixedVirtualList/types/index.ts | 34 +++++ src/index.ts | 2 + 5 files changed, 257 insertions(+) create mode 100644 src/core/components/Virtual/FixedVirtualList/FixedVirtualList.stories.tsx create mode 100644 src/core/components/Virtual/FixedVirtualList/FixedVirtualListItem.tsx create mode 100644 src/core/components/Virtual/FixedVirtualList/index.tsx create mode 100644 src/core/components/Virtual/FixedVirtualList/types/index.ts diff --git a/src/core/components/Virtual/FixedVirtualList/FixedVirtualList.stories.tsx b/src/core/components/Virtual/FixedVirtualList/FixedVirtualList.stories.tsx new file mode 100644 index 0000000..4c2666c --- /dev/null +++ b/src/core/components/Virtual/FixedVirtualList/FixedVirtualList.stories.tsx @@ -0,0 +1,122 @@ +import clsx from 'clsx'; +import { Meta } from '@storybook/react'; +import React, { memo, useEffect, useState } from 'react'; + +import { FixedVirtualListProps } from '@/core/components/Virtual/FixedVirtualList/types'; +import FixedVirtualList from './index'; + +const meta = { + title: 'core/Virtual/FixedVirtualList', + component: FixedVirtualList, + argTypes: { + itemHeight: { + control: 'number', + description: 'FixedVirtualList item Height', + }, + containerHeight: { + control: 'number', + description: 'FixedVirtualList container Height', + }, + }, +} satisfies Meta; + +export default meta; + +export const Default = ({ + itemHeight = 90, + containerHeight = 500, +}: Pick) => { + const [isLoading, setIsLoading] = useState(true); + const [images, setImages] = useState< + { + id: string; + author: string; + width: number; + height: number; + url: string; + download_url: string; + }[] + >([]); + const itemsTotalCount = images.length; + + const getRandomImageList = async () => { + try { + const res = await fetch('https://picsum.photos/v2/list?page=2&limit=100'); + const data = await res.json(); + + setImages(data); + setIsLoading(false); + } catch (e) { + console.error(e); + setIsLoading(false); + } + }; + + useEffect(() => { + getRandomImageList(); + }, []); + + if (isLoading) { + return
Loading...
; + } + + if (itemsTotalCount === 0) { + return
Empty...
; + } + + return ( + + {({ startIndex, endIndex, getTopPosition }) => + images.slice(startIndex, endIndex).map((image, index) => { + const { id, author, download_url } = image; + + return ( + + + + + ); + }) + } + + ); +}; + +const AuthorComponent = memo(({ author }: { author: string }) => { + return
{author}
; +}); + +const ImageComponent = memo(({ src }: { src: string }) => { + const [isLoading, setIsLoading] = useState(true); + + return ( + <> + {isLoading && ( +
+ Loading Image... +
+ )} + setIsLoading(false)} + /> + + ); +}); diff --git a/src/core/components/Virtual/FixedVirtualList/FixedVirtualListItem.tsx b/src/core/components/Virtual/FixedVirtualList/FixedVirtualListItem.tsx new file mode 100644 index 0000000..d330a6a --- /dev/null +++ b/src/core/components/Virtual/FixedVirtualList/FixedVirtualListItem.tsx @@ -0,0 +1,30 @@ +import React, { ElementType, memo, PropsWithChildren } from 'react'; +import clsx from 'clsx'; + +import { FixedVirtualListItemProps } from '@/core/components/Virtual/FixedVirtualList/types'; + +const FixedVirtualListItem = ({ + topPosition, + className, + element: Element, + children, + height, +}: PropsWithChildren>) => { + const Component: React.ElementType = Element || 'div'; + const classNames = clsx( + 'absolute left-0 right-0 top-0 flex items-center will-change-transform', + className, + ); + const style = { + transform: `translateY(${topPosition})`, + height: `${height}px`, + }; + + return ( + + {children} + + ); +}; + +export default memo(FixedVirtualListItem); diff --git a/src/core/components/Virtual/FixedVirtualList/index.tsx b/src/core/components/Virtual/FixedVirtualList/index.tsx new file mode 100644 index 0000000..aebb5a4 --- /dev/null +++ b/src/core/components/Virtual/FixedVirtualList/index.tsx @@ -0,0 +1,69 @@ +import React, { ElementType, memo, useCallback, useRef, useState } from 'react'; +import clsx from 'clsx'; + +import FixedVirtualListItem from '@/core/components/Virtual/FixedVirtualList/FixedVirtualListItem'; +import { + FixedVirtualListProps, + GetTopPositionParams, +} from '@/core/components/Virtual/FixedVirtualList/types'; + +const FixedVirtualList = < + T extends ElementType = 'div', + P extends ElementType = 'div', +>({ + containerHeight, + itemHeight, + itemsTotalCount, + rootElement: RootElement, + listElement: ListElement, + className, + children, +}: FixedVirtualListProps) => { + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const visibleCount = Math.ceil(containerHeight / itemHeight); + const totalItemsHeight = itemHeight * itemsTotalCount; + const classNames = clsx('overflow-y-auto', className); + const RootComponent: React.ElementType = RootElement || 'div'; + const ListComponent: React.ElementType = ListElement || 'div'; + const startIndex = Math.floor(scrollTop / itemHeight); + const endIndex = Math.min(itemsTotalCount - 1, startIndex + visibleCount); + + const handleScroll = () => { + if (containerRef.current) { + setScrollTop(containerRef.current.scrollTop); + } + }; + + const getTopPosition = useCallback( + ({ index }: GetTopPositionParams) => + `${(startIndex + index) * itemHeight}px`, + [startIndex, itemHeight], + ); + + return ( + + + {children({ + startIndex, + endIndex: endIndex + 1, + getTopPosition, + })} + + + ); +}; + +export default FixedVirtualList; + +FixedVirtualList.item = FixedVirtualListItem; diff --git a/src/core/components/Virtual/FixedVirtualList/types/index.ts b/src/core/components/Virtual/FixedVirtualList/types/index.ts new file mode 100644 index 0000000..4a8d1e4 --- /dev/null +++ b/src/core/components/Virtual/FixedVirtualList/types/index.ts @@ -0,0 +1,34 @@ +import { ElementType, HTMLAttributes, ReactNode } from 'react'; + +export interface FixedVirtualChildrenProps { + startIndex: number; + endIndex: number; + getTopPosition: ({ index }: GetTopPositionParams) => string; +} + +export interface GetTopPositionParams { + index: number; +} + +export interface FixedVirtualListProps< + T extends ElementType = 'div', + P extends ElementType = 'div', +> extends Pick, 'className'> { + containerHeight: number; + itemHeight: number; + itemsTotalCount: number; + rootElement?: T; + listElement?: P; + children: ({ + startIndex, + endIndex, + getTopPosition, + }: FixedVirtualChildrenProps) => ReactNode; +} + +export interface FixedVirtualListItemProps + extends Pick, 'className'> { + element?: T; + topPosition: string; + height: number; +} diff --git a/src/index.ts b/src/index.ts index 4ffc632..fade61e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,8 @@ export { default as DropdownBase } from '@/core/components/Dropdown/DropdownBase export { default as DropdownBaseItem } from '@/core/components/Dropdown/DropdownBase/DropdownItem'; export { default as DropdownBaseItems } from '@/core/components/Dropdown/DropdownBase/DropdownItems'; export { default as DropdownBaseTrigger } from '@/core/components/Dropdown/DropdownBase/DropdownTrigger'; +export { default as FixedVirtualList } from '@/core/components/Virtual/FixedVirtualList'; +export { default as FixedVirtualListItem } from '@/core/components/Virtual/FixedVirtualList/FixedVirtualListItem'; export { default as DropdownFilter } from '@/core/components/Dropdown/DropdownFilter'; export { default as DropdownFilterItem } from '@/core/components/Dropdown/DropdownFilter/DropdownFilterItem'; export { default as DropdownFilterItems } from '@/core/components/Dropdown/DropdownFilter/DropdownFilterItems';