From 28ba378f112f4156f65a8ed866c61f157bda6bde Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 27 Mar 2025 11:23:20 -0500 Subject: [PATCH] Generalize component, refine cmdk scroll --- src/admin/AdminNavClient.tsx | 6 +- .../{FadedScroll.tsx => MaskedScroll.tsx} | 19 +- src/components/cmdk/CommandKClient.tsx | 208 +++++++++--------- src/components/useFadedScroll.ts | 38 ---- src/components/useMaskedScroll.ts | 52 +++++ src/photo/PhotoGridPage.tsx | 7 +- 6 files changed, 173 insertions(+), 157 deletions(-) rename src/components/{FadedScroll.tsx => MaskedScroll.tsx} (65%) delete mode 100644 src/components/useFadedScroll.ts create mode 100644 src/components/useMaskedScroll.ts diff --git a/src/admin/AdminNavClient.tsx b/src/admin/AdminNavClient.tsx index e6465b95..276e0764 100644 --- a/src/admin/AdminNavClient.tsx +++ b/src/admin/AdminNavClient.tsx @@ -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', )}> - 0 && ({count})} )} - + & { - ref?: RefObject - direction?: 'vertical' | 'horizontal' - fadeHeight?: number +}: HTMLAttributes & +MaskedScrollExternalProps & { classNameContent?: string hideScrollbar?: boolean }) { - const { maskImage } = useFadedScroll(ref, direction, fadeHeight); + const ref = useRef(null); + + const { maskImage } = useMaskedScroll({ ref, direction, fadeHeight }); return
(null); + const refInput = useRef(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(null); + const { maskImage, updateMask } = useMaskedScroll({ + ref: refScroll, + listenForScrollEvents: false, + }); // Manage action/path waiting state const [keyWaiting, setKeyWaiting] = useState(); @@ -548,7 +555,7 @@ export default function CommandKClient({ )}>
setQueryLive(e.currentTarget.value)} className={clsx( 'w-full min-w-0!', @@ -574,108 +581,105 @@ export default function CommandKClient({
-
- - {isLoading ? 'Searching ...' : 'No results found'} - -
- {queriedSections - .concat(categorySections) - .concat(sectionPages) - .concat(adminSection) - .concat(clientSections) - .filter(({ items }) => items.length > 0) - .map(({ heading, accessory, items }) => - - {accessory && -
{accessory}
} - {heading} -
} - 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 { - 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); } - } + + {isLoading ? 'Searching ...' : 'No results found'} + +
+ {queriedSections + .concat(categorySections) + .concat(sectionPages) + .concat(adminSection) + .concat(clientSections) + .filter(({ items }) => items.length > 0) + .map(({ heading, accessory, items }) => + + {accessory && +
{accessory}
} + {heading} +
} + 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 { + 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} - />; - })} - )} -
- {footer && !queryLive && -
- {footer} -
} + } + }} + accessory={accessory} + annotation={annotation} + annotationAria={annotationAria} + loading={key === keyWaiting} + disabled={isPending && key !== keyWaiting} + />; + })} + )}
+ {footer && !queryLive && +
+ {footer} +
} diff --git a/src/components/useFadedScroll.ts b/src/components/useFadedScroll.ts deleted file mode 100644 index 7e9bd50a..00000000 --- a/src/components/useFadedScroll.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { RefObject, useEffect, useMemo, useState } from 'react'; - -export default function useFadedScroll( - containerRef?: RefObject, - 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 }; -} diff --git a/src/components/useMaskedScroll.ts b/src/components/useMaskedScroll.ts new file mode 100644 index 00000000..fc0317d7 --- /dev/null +++ b/src/components/useMaskedScroll.ts @@ -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 + 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 }; +} diff --git a/src/photo/PhotoGridPage.tsx b/src/photo/PhotoGridPage.tsx index 21c939e5..b984b20d 100644 --- a/src/photo/PhotoGridPage.tsx +++ b/src/photo/PhotoGridPage.tsx @@ -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={ - - + } canSelect />