Generalize <MaskedScroll /> component, refine cmdk scroll

This commit is contained in:
Sam Becker 2025-03-27 11:23:20 -05:00
parent 29c3c7f167
commit 28ba378f11
6 changed files with 173 additions and 157 deletions

View File

@ -20,7 +20,7 @@ import { FaRegClock } from 'react-icons/fa';
import AdminAppInfoIcon from './AdminAppInfoIcon';
import AdminInfoNav from './AdminInfoNav';
import LinkWithLoaderBadge from '@/components/LinkWithLoaderBadge';
import FadedScroll from '@/components/FadedScroll';
import MaskedScroll from '@/components/MaskedScroll';
// Updates from past 5 minutes considered recent
const areTimesRecent = (dates: Date[]) => dates
@ -70,7 +70,7 @@ export default function AdminNavClient({
'flex gap-2 pb-3',
'border-b border-gray-200 dark:border-gray-800',
)}>
<FadedScroll
<MaskedScroll
className="grow -mx-1"
classNameContent="flex gap-0.5 md:gap-1.5"
direction="horizontal"
@ -90,7 +90,7 @@ export default function AdminNavClient({
{count > 0 &&
<span>({count})</span>}
</LinkWithLoaderBadge>)}
</FadedScroll>
</MaskedScroll>
<LinkWithIconLoader
href={includeInsights
? PATH_ADMIN_INSIGHTS

View File

@ -1,9 +1,8 @@
import clsx from 'clsx/lite';
import { HTMLAttributes, RefObject } from 'react';
import useFadedScroll from './useFadedScroll';
import { HTMLAttributes, useRef } from 'react';
import useMaskedScroll, { MaskedScrollExternalProps } from './useMaskedScroll';
export default function FadedScroll({
ref,
export default function MaskedScroll({
direction = 'vertical',
fadeHeight = 24,
hideScrollbar,
@ -12,18 +11,17 @@ export default function FadedScroll({
style,
children,
...props
}: HTMLAttributes<HTMLDivElement> & {
ref?: RefObject<HTMLDivElement | null>
direction?: 'vertical' | 'horizontal'
fadeHeight?: number
}: HTMLAttributes<HTMLDivElement> &
MaskedScrollExternalProps & {
classNameContent?: string
hideScrollbar?: boolean
}) {
const { maskImage } = useFadedScroll(ref, direction, fadeHeight);
const ref = useRef<HTMLDivElement>(null);
const { maskImage } = useMaskedScroll({ ref, direction, fadeHeight });
return <div
{...props}
ref={ref}
className={clsx(
direction === 'vertical'
? 'overflow-y-hidden'
@ -33,6 +31,7 @@ export default function FadedScroll({
style={{ maskImage, ...style }}
>
<div
ref={ref}
className={clsx(
direction === 'vertical'
? 'max-h-full overflow-y-auto'

View File

@ -72,6 +72,7 @@ import IconFocalLength from '../icons/IconFocalLength';
import IconFilmSimulation from '../icons/IconFilmSimulation';
import IconLock from '../icons/IconLock';
import useVisualViewportHeight from '@/utility/useVisualViewport';
import useMaskedScroll from '../useMaskedScroll';
const DIALOG_TITLE = 'Global Command-K Menu';
const DIALOG_DESCRIPTION = 'For searching photos, views, and settings';
@ -153,15 +154,21 @@ export default function CommandKClient({
const isOpenRef = useRef(isOpen);
const ref = useRef<HTMLInputElement>(null);
const refInput = useRef<HTMLInputElement>(null);
const mobileViewportHeight = useVisualViewportHeight();
const heightMinimum = '18rem';
const heightMaximum = '18rem';
const maxHeight = useMemo(() => {
const positionY = ref.current?.getBoundingClientRect().y;
const positionY = refInput.current?.getBoundingClientRect().y;
return mobileViewportHeight && positionY
? `min(${mobileViewportHeight - positionY - 32}px, ${heightMinimum})`
: heightMinimum;
? `min(${mobileViewportHeight - positionY - 32}px, ${heightMaximum})`
: heightMaximum;
}, [mobileViewportHeight]);
const refScroll = useRef<HTMLDivElement>(null);
const { maskImage, updateMask } = useMaskedScroll({
ref: refScroll,
listenForScrollEvents: false,
});
// Manage action/path waiting state
const [keyWaiting, setKeyWaiting] = useState<string>();
@ -548,7 +555,7 @@ export default function CommandKClient({
)}>
<div className="relative">
<Command.Input
ref={ref}
ref={refInput}
onChangeCapture={(e) => setQueryLive(e.currentTarget.value)}
className={clsx(
'w-full min-w-0!',
@ -574,108 +581,105 @@ export default function CommandKClient({
</div>
</div>
<Command.List
ref={refScroll}
onScroll={updateMask}
className={clsx(
'overflow-y-auto',
'mx-3 pt-3',
)} style={{
maxHeight,
// eslint-disable-next-line max-len
maskImage: 'linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent)',
}}
'mx-3 py-2',
)}
style={{ maxHeight, maskImage }}
>
<div className="pb-1 md:pb-2">
<Command.Empty className="mt-1 pl-3 text-dim pb-4">
{isLoading ? 'Searching ...' : 'No results found'}
</Command.Empty>
<div className="space-y-2.5">
{queriedSections
.concat(categorySections)
.concat(sectionPages)
.concat(adminSection)
.concat(clientSections)
.filter(({ items }) => items.length > 0)
.map(({ heading, accessory, items }) =>
<Command.Group
key={heading}
heading={<div className={clsx(
'flex items-center',
'px-2 pb-0.5',
isPending && 'opacity-20',
)}>
{accessory &&
<div className="w-5">{accessory}</div>}
{heading}
</div>}
className={clsx(
'uppercase',
'select-none',
'[&>*:first-child]:py-1',
'[&>*:first-child]:font-medium',
'[&>*:first-child]:text-dim',
'[&>*:first-child]:text-xs',
'[&>*:first-child]:tracking-wider',
)}
>
{items.map(({
label,
explicitKey,
keywords,
accessory,
annotation,
annotationAria,
path,
action,
}) => {
const key = `${heading} ${explicitKey ?? label}`;
return <CommandKItem
key={key}
label={label}
value={key}
keywords={keywords}
onSelect={() => {
if (action) {
const result = action();
if (result instanceof Promise) {
setKeyWaiting(key);
setIsWaitingForAction(true);
result.then(shouldClose => {
shouldCloseAfterWaiting.current =
shouldClose === true;
setIsWaitingForAction(false);
});
} else {
if (!path) { setIsOpen?.(false); }
}
<Command.Empty className="mt-1 pl-3 text-dim pb-4">
{isLoading ? 'Searching ...' : 'No results found'}
</Command.Empty>
<div className="space-y-2.5">
{queriedSections
.concat(categorySections)
.concat(sectionPages)
.concat(adminSection)
.concat(clientSections)
.filter(({ items }) => items.length > 0)
.map(({ heading, accessory, items }) =>
<Command.Group
key={heading}
heading={<div className={clsx(
'flex items-center',
'px-2 pb-0.5',
isPending && 'opacity-20',
)}>
{accessory &&
<div className="w-5">{accessory}</div>}
{heading}
</div>}
className={clsx(
'uppercase',
'select-none',
'[&>*:first-child]:py-1',
'[&>*:first-child]:font-medium',
'[&>*:first-child]:text-dim',
'[&>*:first-child]:text-xs',
'[&>*:first-child]:tracking-wider',
)}
>
{items.map(({
label,
explicitKey,
keywords,
accessory,
annotation,
annotationAria,
path,
action,
}) => {
const key = `${heading} ${explicitKey ?? label}`;
return <CommandKItem
key={key}
label={label}
value={key}
keywords={keywords}
onSelect={() => {
if (action) {
const result = action();
if (result instanceof Promise) {
setKeyWaiting(key);
setIsWaitingForAction(true);
result.then(shouldClose => {
shouldCloseAfterWaiting.current =
shouldClose === true;
setIsWaitingForAction(false);
});
} else {
if (!path) { setIsOpen?.(false); }
}
if (path) {
if (path !== pathname) {
setKeyWaiting(key);
shouldCloseAfterWaiting.current = true;
startTransition(() => {
router.push(path, { scroll: true });
});
} else {
setIsOpen?.(false);
}
}
if (path) {
if (path !== pathname) {
setKeyWaiting(key);
shouldCloseAfterWaiting.current = true;
startTransition(() => {
router.push(path, { scroll: true });
});
} else {
setIsOpen?.(false);
}
}}
accessory={accessory}
annotation={annotation}
annotationAria={annotationAria}
loading={key === keyWaiting}
disabled={isPending && key !== keyWaiting}
/>;
})}
</Command.Group>)}
</div>
{footer && !queryLive &&
<div className={clsx(
'text-center text-base text-dim pt-2 sm:pt-3',
'pb-2.5',
)}>
{footer}
</div>}
}
}}
accessory={accessory}
annotation={annotation}
annotationAria={annotationAria}
loading={key === keyWaiting}
disabled={isPending && key !== keyWaiting}
/>;
})}
</Command.Group>)}
</div>
{footer && !queryLive &&
<div className={clsx(
'text-center text-base text-dim pt-2 sm:pt-3',
'pb-2.5',
)}>
{footer}
</div>}
</Command.List>
</Modal>
</Command.Dialog>

View File

@ -1,38 +0,0 @@
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 };
}

View File

@ -0,0 +1,52 @@
import { RefObject, useCallback, useEffect, useMemo, useState } from 'react';
export interface MaskedScrollExternalProps {
direction?: 'vertical' | 'horizontal'
fadeHeight?: number
}
export default function useMaskedScroll({
ref: containerRef,
direction = 'vertical',
fadeHeight = 24,
// Disable when calling 'updateMask' explicitly
listenForScrollEvents = true,
}: MaskedScrollExternalProps & {
ref: RefObject<HTMLDivElement | null>
listenForScrollEvents?: boolean
}) {
const [position, setPosition] = useState<'start' | 'middle' | 'end'>('start');
const isVertical = direction === 'vertical';
const updateMask = useCallback(() => {
const ref = containerRef?.current;
if (ref) {
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');
}
}, [containerRef, isVertical]);
useEffect(() => {
const ref = containerRef?.current;
if (ref && listenForScrollEvents) {
ref.addEventListener('scroll', updateMask);
return () => ref.removeEventListener('scroll', updateMask);
}
}, [containerRef, updateMask, listenForScrollEvents]);
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, updateMask };
}

View File

@ -9,7 +9,7 @@ import { useAppState } from '@/state/AppState';
import clsx from 'clsx/lite';
import { PhotoSetCategories } from '@/category';
import useElementHeight from '@/utility/useElementHeight';
import FadedScroll from '@/components/FadedScroll';
import MaskedScroll from '@/components/MaskedScroll';
export default function PhotoGridPage({
photos,
@ -36,8 +36,7 @@ export default function PhotoGridPage({
photos={photos}
count={photosCount}
sidebar={
<FadedScroll
ref={ref}
<MaskedScroll
className={clsx(
'sticky top-0 -mb-5 -mt-5',
'max-h-screen h-full',
@ -52,7 +51,7 @@ export default function PhotoGridPage({
containerHeight,
}}
/>
</FadedScroll>
</MaskedScroll>
}
canSelect
/>