Generalize <MaskedScroll /> component, refine cmdk scroll
This commit is contained in:
parent
29c3c7f167
commit
28ba378f11
@ -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
|
||||
|
||||
@ -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'
|
||||
@ -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>
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
52
src/components/useMaskedScroll.ts
Normal file
52
src/components/useMaskedScroll.ts
Normal 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 };
|
||||
}
|
||||
@ -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
|
||||
/>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user