Skip to content

Commit

Permalink
DESIGN-2 FixedVirtualList
Browse files Browse the repository at this point in the history
  • Loading branch information
baegofda committed Jul 2, 2024
1 parent e4581dd commit d840ad1
Show file tree
Hide file tree
Showing 5 changed files with 257 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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<typeof FixedVirtualList>;

export default meta;

export const Default = ({
itemHeight = 90,
containerHeight = 500,
}: Pick<FixedVirtualListProps, 'itemHeight' | 'containerHeight'>) => {
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 <div className={'animate-bounce'}>Loading...</div>;
}

if (itemsTotalCount === 0) {
return <div className={'animate-bounce'}>Empty...</div>;
}

return (
<FixedVirtualList
listElement={'ul'}
itemHeight={itemHeight}
containerHeight={containerHeight}
itemsTotalCount={itemsTotalCount}
className={'w-[500px] bg-gray-02'}
>
{({ startIndex, endIndex, getTopPosition }) =>
images.slice(startIndex, endIndex).map((image, index) => {
const { id, author, download_url } = image;

return (
<FixedVirtualList.item
key={id}
element={'li'}
topPosition={getTopPosition({ index })}
height={itemHeight}
className={'gap-x-3'}
>
<ImageComponent key={download_url} src={download_url} />
<AuthorComponent key={author} author={author} />
</FixedVirtualList.item>
);
})
}
</FixedVirtualList>
);
};

const AuthorComponent = memo(({ author }: { author: string }) => {
return <div className={'text-primary-03'}>{author}</div>;
});

const ImageComponent = memo(({ src }: { src: string }) => {
const [isLoading, setIsLoading] = useState(true);

return (
<>
{isLoading && (
<div
className={'flex aspect-video w-[150px] items-center justify-center'}
>
Loading Image...
</div>
)}
<img
src={src}
className={clsx('aspect-video', isLoading ? 'hidden' : 'block')}
width={150}
alt=''
onLoad={() => setIsLoading(false)}
/>
</>
);
});
Original file line number Diff line number Diff line change
@@ -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 = <T extends ElementType = 'div'>({
topPosition,
className,
element: Element,
children,
height,
}: PropsWithChildren<FixedVirtualListItemProps<T>>) => {
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 (
<Component className={classNames} style={style}>
{children}
</Component>
);
};

export default memo(FixedVirtualListItem);
69 changes: 69 additions & 0 deletions src/core/components/Virtual/FixedVirtualList/index.tsx
Original file line number Diff line number Diff line change
@@ -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<T, P>) => {
const containerRef = useRef<HTMLDivElement | null>(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 (
<RootComponent
ref={containerRef}
className={classNames}
onScroll={handleScroll}
style={{
height: `${containerHeight}px`,
}}
>
<ListComponent
className={'relative'}
style={{ height: `${totalItemsHeight}px` }}
>
{children({
startIndex,
endIndex: endIndex + 1,
getTopPosition,
})}
</ListComponent>
</RootComponent>
);
};

export default FixedVirtualList;

FixedVirtualList.item = FixedVirtualListItem;
34 changes: 34 additions & 0 deletions src/core/components/Virtual/FixedVirtualList/types/index.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLAttributes<HTMLElement>, 'className'> {
containerHeight: number;
itemHeight: number;
itemsTotalCount: number;
rootElement?: T;
listElement?: P;
children: ({
startIndex,
endIndex,
getTopPosition,
}: FixedVirtualChildrenProps) => ReactNode;
}

export interface FixedVirtualListItemProps<T extends ElementType = 'div'>
extends Pick<HTMLAttributes<HTMLElement>, 'className'> {
element?: T;
topPosition: string;
height: number;
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down

0 comments on commit d840ad1

Please sign in to comment.