Decouple faded scroll component from behavior

This commit is contained in:
Sam Becker 2025-03-26 22:34:50 -05:00
parent fa9b62f34b
commit 29c3c7f167
3 changed files with 58 additions and 53 deletions

View File

@ -1,20 +1,15 @@
import clsx from 'clsx/lite'; import clsx from 'clsx/lite';
import { import { HTMLAttributes, RefObject } from 'react';
HTMLAttributes, import useFadedScroll from './useFadedScroll';
RefObject,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
export default function FadedScroll({ export default function FadedScroll({
ref: containerRef, ref,
direction = 'vertical', direction = 'vertical',
fadeHeight = 24, fadeHeight = 24,
hideScrollbar, hideScrollbar,
className, className,
classNameContent, classNameContent,
style,
children, children,
...props ...props
}: HTMLAttributes<HTMLDivElement> & { }: HTMLAttributes<HTMLDivElement> & {
@ -24,52 +19,22 @@ export default function FadedScroll({
classNameContent?: string classNameContent?: string
hideScrollbar?: boolean hideScrollbar?: boolean
}) { }) {
const contentRef = useRef<HTMLDivElement>(null); const { maskImage } = useFadedScroll(ref, direction, fadeHeight);
const [position, setPosition] = useState<'start' | 'middle' | 'end'>('start');
const isVertical = direction === 'vertical';
useEffect(() => {
const ref = contentRef.current;
if (ref) {
const handleScroll = () => {
const isStart = isVertical
? ref.scrollTop === 0
: ref.scrollLeft === 0;
const isEnd = isVertical
? ref.scrollHeight - ref.scrollTop === ref.clientHeight
: ref.scrollWidth - ref.scrollLeft === ref.clientWidth;
setPosition(isStart ? 'start' : isEnd ? 'end' : 'middle');
};
ref.addEventListener('scroll', handleScroll);
return () => ref.removeEventListener('scroll', handleScroll);
}
}, [isVertical]);
const maskImage = useMemo(() => {
// eslint-disable-next-line max-len
let mask = `linear-gradient(to ${isVertical ? 'bottom' : 'right'}, transparent, black `;
mask += `${position !== 'start' ? fadeHeight : 0}px, black calc(100% - `;
mask += `${position !== 'end' ? fadeHeight : 0}px), transparent)`;
return mask;
}, [fadeHeight, isVertical, position]);
return <div return <div
{...props} {...props}
ref={containerRef} ref={ref}
className={clsx( className={clsx(
isVertical direction === 'vertical'
? 'overflow-y-hidden' ? 'overflow-y-hidden'
: 'overflow-x-hidden', : 'overflow-x-hidden',
className, className,
)} )}
style={{ maskImage }} style={{ maskImage, ...style }}
> >
<div <div
ref={contentRef}
className={clsx( className={clsx(
isVertical direction === 'vertical'
? 'max-h-full overflow-y-auto' ? 'max-h-full overflow-y-auto'
: 'max-w-full overflow-x-auto', : 'max-w-full overflow-x-auto',
hideScrollbar && '[scrollbar-width:none]', hideScrollbar && '[scrollbar-width:none]',

View File

@ -155,7 +155,7 @@ export default function CommandKClient({
const ref = useRef<HTMLInputElement>(null); const ref = useRef<HTMLInputElement>(null);
const mobileViewportHeight = useVisualViewportHeight(); const mobileViewportHeight = useVisualViewportHeight();
const heightMinimum = '20rem'; const heightMinimum = '18rem';
const maxHeight = useMemo(() => { const maxHeight = useMemo(() => {
const positionY = ref.current?.getBoundingClientRect().y; const positionY = ref.current?.getBoundingClientRect().y;
return mobileViewportHeight && positionY return mobileViewportHeight && positionY
@ -573,14 +573,16 @@ export default function CommandKClient({
</span>} </span>}
</div> </div>
</div> </div>
<Command.List className={clsx( <Command.List
'relative overflow-y-auto', className={clsx(
'mx-3 pt-3', 'overflow-y-auto',
)} style={{ 'mx-3 pt-3',
maxHeight, )} style={{
// eslint-disable-next-line max-len maxHeight,
maskImage: 'linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent)', // eslint-disable-next-line max-len
}}> maskImage: 'linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent)',
}}
>
<div className="pb-1 md:pb-2"> <div className="pb-1 md:pb-2">
<Command.Empty className="mt-1 pl-3 text-dim pb-4"> <Command.Empty className="mt-1 pl-3 text-dim pb-4">
{isLoading ? 'Searching ...' : 'No results found'} {isLoading ? 'Searching ...' : 'No results found'}

View File

@ -0,0 +1,38 @@
import { RefObject, useEffect, useMemo, useState } from 'react';
export default function useFadedScroll(
containerRef?: RefObject<HTMLDivElement | null>,
direction: 'vertical' | 'horizontal' = 'vertical',
fadeHeight = 24,
) {
const [position, setPosition] = useState<'start' | 'middle' | 'end'>('start');
const isVertical = direction === 'vertical';
const ref = containerRef?.current?.children[0] as HTMLElement;
useEffect(() => {
if (ref) {
const handleScroll = () => {
const isStart = isVertical
? ref.scrollTop === 0
: ref.scrollLeft === 0;
const isEnd = isVertical
? ref.scrollHeight - ref.scrollTop === ref.clientHeight
: ref.scrollWidth - ref.scrollLeft === ref.clientWidth;
setPosition(isStart ? 'start' : isEnd ? 'end' : 'middle');
};
ref.addEventListener('scroll', handleScroll);
return () => ref.removeEventListener('scroll', handleScroll);
}
}, [ref, isVertical]);
const maskImage = useMemo(() => {
// eslint-disable-next-line max-len
let mask = `linear-gradient(to ${isVertical ? 'bottom' : 'right'}, transparent, black `;
mask += `${position !== 'start' ? fadeHeight : 0}px, black calc(100% - `;
mask += `${position !== 'end' ? fadeHeight : 0}px), transparent)`;
return mask;
}, [fadeHeight, isVertical, position]);
return { maskImage };
}