Handle content overflow on large photos with masked component

This commit is contained in:
Sam Becker 2025-03-27 22:55:18 -05:00
parent 3364c26b0c
commit 02319da5c7
6 changed files with 67 additions and 67 deletions

View File

@ -71,8 +71,7 @@ export default function AdminNavClient({
'border-b border-gray-200 dark:border-gray-800',
)}>
<MaskedScroll
className="grow -mx-1"
classNameContent="flex gap-0.5 md:gap-1.5"
className="grow -mx-1 flex gap-0.5 md:gap-1.5"
direction="horizontal"
>
{items.map(({ label, href, count }) =>

View File

@ -4,16 +4,14 @@ import useMaskedScroll, { MaskedScrollExternalProps } from './useMaskedScroll';
export default function MaskedScroll({
direction = 'vertical',
fadeHeight = 24,
fadeHeight,
hideScrollbar,
className,
classNameContent,
style,
children,
...props
}: HTMLAttributes<HTMLDivElement> &
MaskedScrollExternalProps & {
classNameContent?: string
hideScrollbar?: boolean
}) {
const ref = useRef<HTMLDivElement>(null);
@ -22,25 +20,16 @@ MaskedScrollExternalProps & {
return <div
{...props}
ref={ref}
className={clsx(
direction === 'vertical'
? 'overflow-y-hidden'
: 'overflow-x-hidden',
? 'max-h-full overflow-y-scroll'
: 'max-w-full overflow-x-scroll',
hideScrollbar && '[scrollbar-width:none]',
className,
)}
style={{ maskImage, ...style }}
>
<div
ref={ref}
className={clsx(
direction === 'vertical'
? 'max-h-full overflow-y-auto'
: 'max-w-full overflow-x-auto',
hideScrollbar && '[scrollbar-width:none]',
classNameContent,
)}
>
{children}
</div>
{children}
</div>;
}

View File

@ -167,7 +167,7 @@ export default function CommandKClient({
const refScroll = useRef<HTMLDivElement>(null);
const { maskImage, updateMask } = useMaskedScroll({
ref: refScroll,
listenForScrollEvents: false,
updateMaskOnEvents: false,
});
// Manage action/path waiting state
@ -207,7 +207,11 @@ export default function CommandKClient({
useEffect(() => {
isOpenRef.current = isOpen;
}, [isOpen]);
if (isOpen) {
const timeout = setTimeout(updateMask, 100);
return () => clearTimeout(timeout);
}
}, [isOpen, updateMask]);
useEffect(() => {
const down = (e: KeyboardEvent) => {
@ -556,7 +560,10 @@ export default function CommandKClient({
<div className="relative">
<Command.Input
ref={refInput}
onChangeCapture={(e) => setQueryLive(e.currentTarget.value)}
onChangeCapture={(e) => {
setQueryLive(e.currentTarget.value);
updateMask();
}}
className={clsx(
'w-full min-w-0!',
'focus:ring-0',
@ -585,14 +592,15 @@ export default function CommandKClient({
onScroll={updateMask}
className={clsx(
'overflow-y-auto',
'mx-3 py-2',
'mx-3 pt-2 pb-3.5',
'[&>*>*>*]:mt-2.5',
)}
style={{ maxHeight, maskImage }}
style={{ maskImage, maxHeight }}
>
<Command.Empty className="mt-1 pl-3 text-dim pb-4">
{isLoading ? 'Searching ...' : 'No results found'}
</Command.Empty>
<div className="space-y-2.5">
<div className="-mt-2.5">
<Command.Empty className="mt-1 pl-3 text-dim pb-1">
{isLoading ? 'Searching ...' : 'No results found'}
</Command.Empty>
{queriedSections
.concat(categorySections)
.concat(sectionPages)
@ -604,7 +612,8 @@ export default function CommandKClient({
key={heading}
heading={<div className={clsx(
'flex items-center',
'px-2 pb-0.5',
'px-2 py-1! pb-0.5',
'text-xs font-medium text-dim tracking-wider',
isPending && 'opacity-20',
)}>
{accessory &&
@ -614,11 +623,6 @@ export default function CommandKClient({
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(({
@ -672,14 +676,14 @@ export default function CommandKClient({
/>;
})}
</Command.Group>)}
{footer && !queryLive &&
<div className={clsx(
'text-center text-base text-dim pt-1',
'pb-2',
)}>
{footer}
</div>}
</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

@ -10,41 +10,48 @@ export default function useMaskedScroll({
direction = 'vertical',
fadeHeight = 24,
// Disable when calling 'updateMask' explicitly
listenForScrollEvents = true,
updateMaskOnEvents = true,
}: MaskedScrollExternalProps & {
ref: RefObject<HTMLDivElement | null>
listenForScrollEvents?: boolean
updateMaskOnEvents?: boolean
}) {
const [position, setPosition] = useState<'start' | 'middle' | 'end'>('start');
const [position, setPosition] = useState({ start: true, end: true });
const isVertical = direction === 'vertical';
const updateMask = useCallback(() => {
const ref = containerRef?.current;
if (ref) {
const isStart = isVertical
const start = isVertical
? ref.scrollTop === 0
: ref.scrollLeft === 0;
const isEnd = isVertical
const end = isVertical
? ref.scrollHeight - ref.scrollTop === ref.clientHeight
: ref.scrollWidth - ref.scrollLeft === ref.clientWidth;
setPosition(isStart ? 'start' : isEnd ? 'end' : 'middle');
setPosition({ start, end });
}
}, [containerRef, isVertical]);
useEffect(() => {
const ref = containerRef?.current;
if (ref && listenForScrollEvents) {
ref.addEventListener('scroll', updateMask);
return () => ref.removeEventListener('scroll', updateMask);
if (ref) {
updateMask();
if (updateMaskOnEvents) {
ref.onscroll = updateMask;
ref.onresize = updateMask;
return () => {
ref.onscroll = null;
ref.onresize = null;
};
}
}
}, [containerRef, updateMask, listenForScrollEvents]);
}, [containerRef, updateMask, updateMaskOnEvents]);
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)`;
let mask = `linear-gradient(to ${isVertical ? 'bottom' : 'right'}, `;
mask += 'transparent, black ';
mask += `${!position.start ? fadeHeight : 0}px, black calc(100% - `;
mask += `${!position.end ? fadeHeight : 0}px), transparent)`;
return mask;
}, [fadeHeight, isVertical, position]);

View File

@ -39,9 +39,8 @@ export default function PhotoGridPage({
<MaskedScroll
className={clsx(
'sticky top-0 -mb-5 -mt-5',
'max-h-screen h-full',
'max-h-screen py-4',
)}
classNameContent="py-4"
fadeHeight={36}
hideScrollbar
>

View File

@ -48,6 +48,7 @@ import PhotoRecipeOverlay from '@/recipe/PhotoRecipeOverlay';
import PhotoRecipe from '@/recipe/PhotoRecipe';
import PhotoLens from '@/lens/PhotoLens';
import { lensFromPhoto } from '@/lens';
import MaskedScroll from '@/components/MaskedScroll';
export default function PhotoLarge({
photo,
@ -258,16 +259,17 @@ export default function PhotoLarge({
</Link>}
classNameSide="relative"
contentSide={
<div className="absolute inset-0">
<DivDebugBaselineGrid className={clsx(
'sticky top-4 self-start -translate-y-1',
'overflow-y-scroll',
'max-h-full',
)}>
<div className={clsx(
<div className="md:absolute inset-0">
<MaskedScroll
className="sticky top-4 self-start"
fadeHeight={36}
hideScrollbar
>
<DivDebugBaselineGrid className={clsx(
'-mt-1',
'grid grid-cols-2 md:grid-cols-1',
'gap-x-0.5 sm:gap-x-1 gap-y-baseline',
'pb-6',
'mb-6 md:mb-4',
)}>
{/* Meta */}
<div className="pr-3 md:pr-0">
@ -459,8 +461,8 @@ export default function PhotoLarge({
</div>
</div>
</div>
</div>
</DivDebugBaselineGrid>
</DivDebugBaselineGrid>
</MaskedScroll>
</div>}
/>
);