Skip to content

Commit

Permalink
add experimental RecycleScroller
Browse files Browse the repository at this point in the history
  • Loading branch information
YieldRay committed Nov 28, 2024
1 parent 583c72b commit 3a4cdfb
Show file tree
Hide file tree
Showing 13 changed files with 192 additions and 29 deletions.
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion src/components/carousel/Carousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/components/text-field/TextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,13 @@ export const TextField = forwardRef<
/**
* For access the internal input element
*
* [warn]: (for typescript user) use `const ref = useRef<HTMLInputElement | undefined>()` to create a MutableRefObject
* ![WARNING]: (for typescript user) use `const ref = useRef<HTMLInputElement | undefined>()` to create a MutableRefObject
*/
inputRef?: ReactRef<HTMLInputElement | undefined>
/**
* For access the internal textarea element
*
* [warn]: (for typescript user) use `const ref = useRef<HTMLInputElement | undefined>()` to create a MutableRefObject
* ![WARNING]: (for typescript user) use `const ref = useRef<HTMLInputElement | undefined>()` to create a MutableRefObject
*/
textareaRef?: ReactRef<HTMLTextAreaElement | undefined>
/**
Expand Down
2 changes: 1 addition & 1 deletion src/components/time-picker/TimePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
153 changes: 153 additions & 0 deletions src/composition/RecycleScroller/RecycleScroller.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'

interface RecycleScrollerProps<T> {
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<T>(
{
data,
itemHeight,
windowHeight,
children,
bufferSize = 3,
defaultRevealedIndex = 0,
}: RecycleScrollerProps<T>,
ref: React.ForwardedRef<RecycleScrollerHandle>,
) {
const containerRef = useRef<HTMLDivElement>(null)
const [isScrolling, setIsScrolling] = useState(false)
const scrollingTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
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<HTMLDivElement>) => {
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 (
<div
ref={containerRef}
style={{
height: windowHeight,
overflow: 'auto',
position: 'relative',
}}
onScroll={handleScroll}
>
<div style={{ height: totalHeight, position: 'relative' }}>
{visibleItems.map(({ item, index, top }) => (
<div
key={index}
style={{
position: 'absolute',
top,
height: itemHeight,
width: '100%',
}}
>
{children(item, index)}
</div>
))}
</div>
</div>
)
})
1 change: 1 addition & 0 deletions src/composition/RecycleScroller/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './RecycleScroller'
3 changes: 3 additions & 0 deletions src/composition/ScrollArea/ScrollArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/composition/Skeleton/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { forwardRef } from 'react'
import './Skeleton.scss'

/**
* Highly experimental!
*/
export const Skeleton = forwardRef<
HTMLDivElement,
Partial<React.CSSProperties>
Expand Down
2 changes: 1 addition & 1 deletion src/composition/SodaImage/SodaImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down
3 changes: 2 additions & 1 deletion src/composition/SodaTransition/SodaTransition.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 16 additions & 17 deletions src/composition/TooltipHolder/TooltipHolder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -54,8 +53,8 @@ export const TooltipHolder = forwardRef<
open: 150,
close: 0,
},
},
ref,
}: TooltipProps,
ref: React.ForwardedRef<TooltipHolderHandle>,
) {
const [isOpen, setIsOpen] = useState(false)

Expand Down
3 changes: 3 additions & 0 deletions src/composition/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/use-collapsible.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Expand Down

0 comments on commit 3a4cdfb

Please sign in to comment.