Create custom photo chooser menu

This commit is contained in:
Sam Becker 2026-02-28 21:20:54 -06:00
parent bb7c393021
commit 7182e6db0e
2 changed files with 123 additions and 46 deletions

View File

@ -0,0 +1,52 @@
import clsx from 'clsx/lite';
import { ReactNode } from 'react';
export default function SegmentMenu<T extends string>({
items,
selected,
onChange,
className,
}: {
items: {
value: T
icon?: ReactNode
iconSelected?: ReactNode
}[]
selected: T
onChange: (value: T) => void
className?: string
}) {
return (
<div className={clsx(
'flex justify-center gap-1',
className,
)}>
{items.map(({ value, icon, iconSelected }) => (
<button
key={value}
onClick={() => onChange(value)}
className={clsx(
'link',
'rounded-full',
value === selected
? 'bg-dim text-main'
: 'bg-transparent text-medium',
'flex items-center justify-center',
'h-7 min-w-14',
'active:bg-extra-dim',
)}
>
{icon
? selected === value && iconSelected
? iconSelected
: icon
: <span className={clsx(
'text-sm font-medium uppercase tracking-wider',
)}>
{value}
</span>}
</button>
))}
</div>
);
}

View File

@ -6,10 +6,34 @@ import ImageMedium from '@/components/image/ImageMedium';
import { menuSurfaceStyles } from '@/components/primitives/surface'; import { menuSurfaceStyles } from '@/components/primitives/surface';
import { GRID_SPACE_CLASSNAME } from '@/components'; import { GRID_SPACE_CLASSNAME } from '@/components';
import useDynamicPhoto from '../useDynamicPhoto'; import useDynamicPhoto from '../useDynamicPhoto';
import { IoClose, IoSearch } from 'react-icons/io5'; import { IoSearch } from 'react-icons/io5';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import usePhotoQuery from '../usePhotoQuery'; import usePhotoQuery from '../usePhotoQuery';
import Spinner from '@/components/Spinner'; import Spinner from '@/components/Spinner';
import { BiChevronDown } from 'react-icons/bi';
import SegmentMenu from '@/components/SegmentMenu';
import IconFavs from '@/components/icons/IconFavs';
import IconLock from '@/components/icons/IconLock';
type Mode = 'all' | 'favs' | 'hidden' | 'search';
const renderPhoto = ({
photo,
className,
onClick,
}: {
photo: Photo,
className?: string,
onClick?: () => void,
}) =>
<ImageMedium
src={photo.url}
alt={altTextForPhoto(photo)}
aspectRatio={photo.aspectRatio}
blurDataURL={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
{...{ className, onClick }}
/>;
export default function FieldsetPhotoChooser({ export default function FieldsetPhotoChooser({
label, label,
@ -26,10 +50,12 @@ export default function FieldsetPhotoChooser({
photosCount?: number photosCount?: number
photosHidden?: Photo[] photosHidden?: Photo[]
}) { }) {
const [mode, setMode] = useState<Mode>('all');
const showQuery = mode === 'search';
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [showQuery, setShowQuery] = useState(true);
// TODO: Move query into hook
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const { const {
photos: photosQuery, photos: photosQuery,
@ -60,21 +86,27 @@ export default function FieldsetPhotoChooser({
<FieldsetWithStatus {...{ label, value, onChange, type: 'hidden' }} /> <FieldsetWithStatus {...{ label, value, onChange, type: 'hidden' }} />
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<button type="button" className="p-1.5"> <button type="button" className="inline-flex flex-col p-1.5">
<span className={clsx(
'w-full',
'flex items-center gap-1',
'font-sans',
'text-xs text-medium font-medium uppercase tracking-wider',
)}>
<span className="grow truncate text-left">
Avatar
</span>
<BiChevronDown size={18} />
</span>
<span className={clsx( <span className={clsx(
'flex size-[6rem]', 'flex size-[6rem]',
'border border-medium rounded-[4px]', 'border border-medium rounded-[4px]',
'overflow-hidden select-none active:opacity-75', 'overflow-hidden select-none active:opacity-75',
)}> )}>
{photoAvatar && <ImageMedium {photoAvatar && renderPhoto({
src={photoAvatar.url} photo: photoAvatar,
alt={altTextForPhoto(photoAvatar)} className: clsx(isLoadingPhotoAvatar && 'opacity-50'),
aspectRatio={photoAvatar.aspectRatio} })}
blurDataURL={photoAvatar.blurData}
blurCompatibilityMode={
doesPhotoNeedBlurCompatibility(photoAvatar)}
className={clsx(isLoadingPhotoAvatar && 'opacity-50')}
/>}
</span> </span>
</button> </button>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@ -83,34 +115,32 @@ export default function FieldsetPhotoChooser({
onCloseAutoFocus={e => e.preventDefault()} onCloseAutoFocus={e => e.preventDefault()}
align="start" align="start"
sideOffset={10} sideOffset={10}
className={menuSurfaceStyles('z-20 px-1.5 py-1.5')} className={menuSurfaceStyles('z-20 px-1.5 py-1.5 rounded-2xl')}
> >
<div className={clsx( <div className={clsx(
GRID_SPACE_CLASSNAME, GRID_SPACE_CLASSNAME,
'w-[18rem] max-h-[20rem] rounded-[3px] overflow-y-auto', 'w-[18rem] max-h-[20rem] rounded-xl overflow-y-auto',
)}> )}>
<div className={clsx( <SegmentMenu
'flex items-center gap-1', className="pt-1 pb-2 px-1.5"
'text-medium text-xs font-medium uppercase tracking-wider', items={[{
'pt-1 pb-2 px-1.5', value: 'all',
)}> }, {
<div className="grow"> value: 'favs',
Choose photo icon: <IconFavs size={16} />,
</div> iconSelected: <IconFavs size={16} highlight />,
{isLoading }, {
? <Spinner /> value: 'hidden',
: showQuery icon: <IconLock size={15} />,
? <IoClose }, {
size={16} value: 'search',
className="cursor-pointer" icon: isLoading
onClick={() => setShowQuery(false)} ? <Spinner />
/> : <IoSearch size={16} />,
: <IoSearch }]}
size={16} selected={mode}
className="cursor-pointer" onChange={setMode}
onClick={() => setShowQuery(true)} />
/>}
</div>
{showQuery && {showQuery &&
<input <input
ref={inputRef} ref={inputRef}
@ -131,15 +161,10 @@ export default function FieldsetPhotoChooser({
'overflow-hidden select-none active:opacity-75', 'overflow-hidden select-none active:opacity-75',
)} )}
> >
<ImageMedium {renderPhoto({
src={photo.url} photo,
alt={altTextForPhoto(photo)} onClick: () => onChange(photo.id),
aspectRatio={photo.aspectRatio} })}
blurDataURL={photo.blurData}
blurCompatibilityMode={
doesPhotoNeedBlurCompatibility(photo)}
onClick={() => onChange(photo.id)}
/>
</span> </span>
))} ))}
</div> </div>