From 3a4cdfb92f1f87d7c67e0500ac94301dfd36fdee Mon Sep 17 00:00:00 2001 From: YieldRay <24633623+YieldRay@users.noreply.github.com> Date: Thu, 28 Nov 2024 09:03:54 +0000 Subject: [PATCH] add experimental RecycleScroller --- package.json | 10 +- src/components/carousel/Carousel.tsx | 2 +- src/components/text-field/TextField.tsx | 4 +- src/components/time-picker/TimePicker.tsx | 2 +- .../RecycleScroller/RecycleScroller.tsx | 153 ++++++++++++++++++ src/composition/RecycleScroller/index.ts | 1 + src/composition/ScrollArea/ScrollArea.tsx | 3 + src/composition/Skeleton/Skeleton.tsx | 3 + src/composition/SodaImage/SodaImage.tsx | 2 +- .../SodaTransition/SodaTransition.tsx | 3 +- .../TooltipHolder/TooltipHolder.tsx | 33 ++-- src/composition/index.ts | 3 + src/hooks/use-collapsible.ts | 2 +- 13 files changed, 192 insertions(+), 29 deletions(-) create mode 100644 src/composition/RecycleScroller/RecycleScroller.tsx create mode 100644 src/composition/RecycleScroller/index.ts diff --git a/package.json b/package.json index fdddbad..8bfec34 100644 --- a/package.json +++ b/package.json @@ -35,20 +35,20 @@ "@storybook/react-vite": "^8.4.5", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.3", - "chromatic": "^11.18.1", + "@vitejs/plugin-react": "^4.3.4", + "chromatic": "^11.19.0", "clsx": "^2.1.1", "eslint": "^9.15.0", "eslint-plugin-react-hooks": "^5.1.0-rc-f9ebd85a-20240925", "eslint-plugin-react-refresh": "^0.4.14", "glob": "^10.4.5", "globals": "^15.12.0", - "prettier": "^3.3.3", + "prettier": "^3.4.1", "sass": "^1.81.0", "storybook": "^8.4.5", "styled-jsx": "^5.1.6", - "typescript": "^5.6.3", - "typescript-eslint": "^8.15.0", + "typescript": "^5.7.2", + "typescript-eslint": "^8.16.0", "vite": "^5.4.11", "vite-plugin-dts": "^4.3.0" }, diff --git a/src/components/carousel/Carousel.tsx b/src/components/carousel/Carousel.tsx index b448361..2cf50a1 100644 --- a/src/components/carousel/Carousel.tsx +++ b/src/components/carousel/Carousel.tsx @@ -14,7 +14,7 @@ interface ItemWithFlex extends Item { } /** - * [warn]: Incomplete implementation, and only three visible items is implemented, + * ![WARNING]: Incomplete implementation, and only three visible items is implemented, * make sure the array length is multiple of 3 * * @specs https://m3.material.io/components/carousel/specs diff --git a/src/components/text-field/TextField.tsx b/src/components/text-field/TextField.tsx index e04fc5e..6d6f664 100644 --- a/src/components/text-field/TextField.tsx +++ b/src/components/text-field/TextField.tsx @@ -44,13 +44,13 @@ export const TextField = forwardRef< /** * For access the internal input element * - * [warn]: (for typescript user) use `const ref = useRef()` to create a MutableRefObject + * ![WARNING]: (for typescript user) use `const ref = useRef()` to create a MutableRefObject */ inputRef?: ReactRef /** * For access the internal textarea element * - * [warn]: (for typescript user) use `const ref = useRef()` to create a MutableRefObject + * ![WARNING]: (for typescript user) use `const ref = useRef()` to create a MutableRefObject */ textareaRef?: ReactRef /** diff --git a/src/components/time-picker/TimePicker.tsx b/src/components/time-picker/TimePicker.tsx index 54587bf..8a30036 100644 --- a/src/components/time-picker/TimePicker.tsx +++ b/src/components/time-picker/TimePicker.tsx @@ -14,7 +14,7 @@ import { IconButton } from '../icon-button' type TimeValue = readonly [hour: number, minute: number] /** - * [warn]: data itself always use 24 hours system, + * ![WARNING]: data itself always use 24 hours system, * but it's appearance varies by changing the `use24hourSystem` property * * @specs https://m3.material.io/components/time-pickers/specs diff --git a/src/composition/RecycleScroller/RecycleScroller.tsx b/src/composition/RecycleScroller/RecycleScroller.tsx new file mode 100644 index 0000000..01050cd --- /dev/null +++ b/src/composition/RecycleScroller/RecycleScroller.tsx @@ -0,0 +1,153 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' + +interface RecycleScrollerProps { + data: T[] + itemHeight: number + windowHeight: number + children: (item: T, index: number) => React.ReactNode + bufferSize?: number + defaultRevealedIndex?: number +} + +export interface RecycleScrollerHandle { + scrollToIndex: (index: number) => void +} + +/** + * Highly experimental! + */ +export const RecycleScroller = forwardRef(function RecycleScroller( + { + data, + itemHeight, + windowHeight, + children, + bufferSize = 3, + defaultRevealedIndex = 0, + }: RecycleScrollerProps, + ref: React.ForwardedRef, +) { + const containerRef = useRef(null) + const [isScrolling, setIsScrolling] = useState(false) + const scrollingTimeoutRef = useRef>() + const initialScrollApplied = useRef(false) + + const totalHeight = data.length * itemHeight + + // Force re-render when scrolling + const [, forceUpdate] = useState({}) + + const getVisibleItems = useCallback(() => { + const scrollTop = containerRef.current?.scrollTop ?? 0 + const startIndex = Math.max( + 0, + Math.floor(scrollTop / itemHeight) - bufferSize, + ) + const endIndex = Math.min( + data.length, + Math.ceil((scrollTop + windowHeight) / itemHeight) + bufferSize, + ) + + return data.slice(startIndex, endIndex).map((item, index) => ({ + item, + index: startIndex + index, + top: (startIndex + index) * itemHeight, + })) + }, [data, itemHeight, windowHeight, bufferSize]) + + const visibleItems = useMemo(getVisibleItems, [ + getVisibleItems, + isScrolling, + ]) + + const handleScroll = useCallback( + (_event: React.UIEvent) => { + forceUpdate({}) + setIsScrolling(true) + + if (scrollingTimeoutRef.current) { + clearTimeout(scrollingTimeoutRef.current) + } + + scrollingTimeoutRef.current = setTimeout(() => { + setIsScrolling(false) + }, 150) + }, + [], + ) + + const scrollToIndex = useCallback( + (index: number) => { + if (containerRef.current) { + const scrollPosition = Math.max(0, index * itemHeight) + containerRef.current.scrollTop = scrollPosition + forceUpdate({}) + } + }, + [itemHeight], + ) + + // initial scroll position + useEffect(() => { + if ( + defaultRevealedIndex != null && + !initialScrollApplied.current && + containerRef.current + ) { + initialScrollApplied.current = true + scrollToIndex(defaultRevealedIndex) + } + }, [defaultRevealedIndex, scrollToIndex]) + + useEffect(() => { + return () => { + if (scrollingTimeoutRef.current) { + clearTimeout(scrollingTimeoutRef.current) + } + } + }, []) + + useImperativeHandle( + ref, + () => ({ + scrollToIndex, + }), + [scrollToIndex], + ) + + return ( +
+
+ {visibleItems.map(({ item, index, top }) => ( +
+ {children(item, index)} +
+ ))} +
+
+ ) +}) diff --git a/src/composition/RecycleScroller/index.ts b/src/composition/RecycleScroller/index.ts new file mode 100644 index 0000000..c235172 --- /dev/null +++ b/src/composition/RecycleScroller/index.ts @@ -0,0 +1 @@ +export * from './RecycleScroller' diff --git a/src/composition/ScrollArea/ScrollArea.tsx b/src/composition/ScrollArea/ScrollArea.tsx index 1a54dd5..41ff17b 100644 --- a/src/composition/ScrollArea/ScrollArea.tsx +++ b/src/composition/ScrollArea/ScrollArea.tsx @@ -2,6 +2,9 @@ import './ScrollArea.scss' import { useLayoutEffect, useRef } from 'react' import { refCSSProperties, useMergeRefs } from '@/hooks/use-merge' +/** + * Highly experimental! + */ export const ScrollArea = ({ color, children, diff --git a/src/composition/Skeleton/Skeleton.tsx b/src/composition/Skeleton/Skeleton.tsx index cc22427..5346754 100644 --- a/src/composition/Skeleton/Skeleton.tsx +++ b/src/composition/Skeleton/Skeleton.tsx @@ -1,6 +1,9 @@ import { forwardRef } from 'react' import './Skeleton.scss' +/** + * Highly experimental! + */ export const Skeleton = forwardRef< HTMLDivElement, Partial diff --git a/src/composition/SodaImage/SodaImage.tsx b/src/composition/SodaImage/SodaImage.tsx index 55b7841..870c9ca 100644 --- a/src/composition/SodaImage/SodaImage.tsx +++ b/src/composition/SodaImage/SodaImage.tsx @@ -202,7 +202,7 @@ export const SodaImage = forwardRef< } }, timeout) } - // [warn]: only changes of `src` trigger this function to re-cache + // ![WARNING]: only changes of `src` trigger this function to re-cache // eslint-disable-next-line react-hooks/exhaustive-deps }, [src]) diff --git a/src/composition/SodaTransition/SodaTransition.tsx b/src/composition/SodaTransition/SodaTransition.tsx index 5828134..ed660a8 100644 --- a/src/composition/SodaTransition/SodaTransition.tsx +++ b/src/composition/SodaTransition/SodaTransition.tsx @@ -15,7 +15,8 @@ import { ExtendProps, TagNameString } from '@/utils/type' * * If you prefer an imperative approach to animate DOM elements, this library uses the [Web Animations API](https://developer.mozilla.org/docs/Web/API/Web_Animations_API) internally to minimize dependencies. However, you might find [Motion One](https://npm.im/motion) to be a better fit for such needs. * - * [warn]: To activate CSS transitions, the `transition` property should be set to `entering` or `exiting`. Alternatively, manage all transitions by setting the `transition` property in the `style` attribute. + * ![WARNING]: To activate CSS transitions, the `transition` property should be set to `entering` or `exiting`. + * Alternatively, manage all transitions by setting the `transition` property in the `style` attribute. */ export const SodaTransition = forwardRef< HTMLElement, diff --git a/src/composition/TooltipHolder/TooltipHolder.tsx b/src/composition/TooltipHolder/TooltipHolder.tsx index 0895f1f..ea36d6f 100644 --- a/src/composition/TooltipHolder/TooltipHolder.tsx +++ b/src/composition/TooltipHolder/TooltipHolder.tsx @@ -24,27 +24,26 @@ export interface TooltipHolderHandle { open: boolean } +export interface TooltipProps { + content?: React.ReactNode + trigger?: React.ReactNode + placement?: Placement + delay?: + | number + | { + open?: number + close?: number + } + zIndex?: number +} + /** * Just a simple wrapper of `floating-ui` for convenience, * can use ref to manually toggle it. * * You may use `floating-ui` directly for better control. */ -export const TooltipHolder = forwardRef< - TooltipHolderHandle, - { - content?: React.ReactNode - trigger?: React.ReactNode - placement?: Placement - delay?: - | number - | { - open?: number - close?: number - } - zIndex?: number - } ->(function TooltipHolder( +export const TooltipHolder = forwardRef(function TooltipHolder( { placement = 'top', zIndex = 2, @@ -54,8 +53,8 @@ export const TooltipHolder = forwardRef< open: 150, close: 0, }, - }, - ref, + }: TooltipProps, + ref: React.ForwardedRef, ) { const [isOpen, setIsOpen] = useState(false) diff --git a/src/composition/index.ts b/src/composition/index.ts index 7e34d4c..9e60998 100644 --- a/src/composition/index.ts +++ b/src/composition/index.ts @@ -4,8 +4,11 @@ export * from './Details' export * from './IconRippleButton' export * from './NestedMenu' export * from './PopoverHolder' +export * from './RecycleScroller' export * from './Scrim' +export * from './ScrollArea' export * from './Select' +export * from './Skeleton' export * from './SodaImage' export * from './SodaTransition' export * from './Table' diff --git a/src/hooks/use-collapsible.ts b/src/hooks/use-collapsible.ts index b747fb7..aa599c3 100644 --- a/src/hooks/use-collapsible.ts +++ b/src/hooks/use-collapsible.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' /** - * [warn]: also need to set `overflow:hidden` and optional set `transition:all 200ms` + * ![WARNING]: also need to set `overflow:hidden` and optional set `transition:all 200ms` * * No padding and margin should be set */