Files
tolaria/src/components/TypeCustomizePopover.tsx
2026-05-26 10:52:53 +02:00

256 lines
7.6 KiB
TypeScript

import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
import { MagnifyingGlass } from '@phosphor-icons/react'
import { ICON_OPTIONS, type IconEntry } from '../utils/iconRegistry'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { translate, type AppLocale } from '../lib/i18n'
import { AccentColorPicker } from './AccentColorPicker'
function filterIcons(icons: IconEntry[], query: string): IconEntry[] {
if (!query) return icons
const lower = query.toLowerCase()
return icons.filter((o) => o.name.includes(lower))
}
interface TypeCustomizePopoverProps {
currentIcon: string | null
currentColor: string | null
currentTemplate: string | null
onChangeIcon: (icon: string) => void
onChangeColor: (color: string) => void
onChangeTemplate: (template: string) => void
onClose: () => void
showTemplate?: boolean
showDone?: boolean
surface?: 'popover' | 'inline'
locale?: AppLocale
}
interface ColorSectionProps {
selectedColor: string | null
locale: AppLocale
onSelectColor: (key: string) => void
}
interface IconSectionProps {
selectedIcon: string | null
search: string
filteredIcons: IconEntry[]
locale: AppLocale
onSearchChange: (query: string) => void
onSelectIcon: (name: string) => void
}
interface TemplateSectionProps {
templateText: string
locale: AppLocale
onTemplateChange: (value: string) => void
}
const ICON_PICKER_ICON_SIZE = 18
const ICON_PICKER_ICON_CLASS_NAME = 'size-[18px]'
interface DebouncedCallback {
flush: () => void
run: (value: string) => void
}
/** Debounce a callback by `delay` ms. Pending work is flushed on unmount. */
function useDebouncedCallback(fn: (v: string) => void, delay: number): DebouncedCallback {
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const pendingValueRef = useRef<string | null>(null)
const fnRef = useRef(fn)
useEffect(() => { fnRef.current = fn })
const flush = useCallback(() => {
clearTimeout(timerRef.current)
timerRef.current = undefined
if (pendingValueRef.current === null) return
const value = pendingValueRef.current
pendingValueRef.current = null
fnRef.current(value)
}, [])
const run = useCallback((value: string) => {
clearTimeout(timerRef.current)
pendingValueRef.current = value
timerRef.current = setTimeout(flush, delay)
}, [delay, flush])
useEffect(() => () => { flush() }, [flush])
return useMemo(() => ({ flush, run }), [flush, run])
}
function ColorSection({ selectedColor, locale, onSelectColor }: ColorSectionProps) {
return (
<>
<div className="font-mono-overline mb-2 text-muted-foreground">{translate(locale, 'customize.color')}</div>
<AccentColorPicker
className="mb-3 gap-2"
selectedColor={selectedColor}
onSelectColor={onSelectColor}
size={24}
/>
</>
)
}
function IconSection({
selectedIcon,
search,
filteredIcons,
locale,
onSearchChange,
onSelectIcon,
}: IconSectionProps) {
return (
<>
<div className="font-mono-overline mb-2 text-muted-foreground">{translate(locale, 'customize.icon')}</div>
<div className="relative mb-2">
<MagnifyingGlass
size={14}
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
/>
<Input
type="text"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
placeholder={translate(locale, 'customize.searchIcons')}
className="h-7 pl-7 pr-2 py-1 text-[12px]"
/>
</div>
<div
className="grid gap-1 overflow-y-auto"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(30px, 1fr))', maxHeight: 160 }}
>
{filteredIcons.length === 0 ? (
<div className="w-full py-6 text-center text-[12px] text-muted-foreground">
{translate(locale, 'customize.noIconsFound')}
</div>
) : (
filteredIcons.map(({ name, Icon }) => (
<Button
key={name}
type="button"
variant="ghost"
size="icon-xs"
className={cn(
'h-[30px] w-[30px] justify-self-center rounded p-0 transition-colors',
selectedIcon === name
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
onClick={() => onSelectIcon(name)}
title={name}
aria-label={name}
>
<Icon size={ICON_PICKER_ICON_SIZE} className={ICON_PICKER_ICON_CLASS_NAME} />
</Button>
))
)}
</div>
</>
)
}
function TemplateSection({ templateText, locale, onTemplateChange }: TemplateSectionProps) {
return (
<>
<div className="font-mono-overline mb-2 mt-3 text-muted-foreground">{translate(locale, 'customize.template')}</div>
<Textarea
value={templateText}
onChange={(event) => onTemplateChange(event.target.value)}
placeholder={translate(locale, 'customize.templatePlaceholder')}
className="min-h-20 max-h-[200px] resize-y px-2 py-1.5 text-[12px] font-mono"
data-testid="template-textarea"
/>
</>
)
}
function DoneSection({ locale, onClose }: { locale: AppLocale; onClose: () => void }) {
return (
<div className="mt-3 flex justify-end">
<Button
type="button"
variant="ghost"
size="xs"
className="text-muted-foreground hover:text-foreground"
onClick={onClose}
>
{translate(locale, 'customize.done')}
</Button>
</div>
)
}
export function TypeCustomizePopover({
currentIcon,
currentColor,
currentTemplate,
onChangeIcon,
onChangeColor,
onChangeTemplate,
onClose,
showTemplate = true,
showDone = true,
surface = 'popover',
locale = 'en',
}: TypeCustomizePopoverProps) {
const [selectedColor, setSelectedColor] = useState(currentColor)
const [selectedIcon, setSelectedIcon] = useState(currentIcon)
const [search, setSearch] = useState('')
const [templateText, setTemplateText] = useState(currentTemplate ?? '')
const filteredIcons = useMemo(() => filterIcons(ICON_OPTIONS, search), [search])
const debouncedSaveTemplate = useDebouncedCallback(onChangeTemplate, 500)
const handleColorClick = (key: string) => {
setSelectedColor(key)
onChangeColor(key)
}
const handleIconClick = (name: string) => {
setSelectedIcon(name)
onChangeIcon(name)
}
const handleTemplateChange = (value: string) => {
setTemplateText(value)
debouncedSaveTemplate.run(value)
}
const handleDone = () => {
debouncedSaveTemplate.flush()
onClose()
}
return (
<div
className={cn(
'text-popover-foreground z-50',
surface === 'popover' && 'rounded-lg border bg-popover shadow-md',
)}
style={surface === 'popover' ? { width: 320, padding: 12 } : undefined}
>
<ColorSection selectedColor={selectedColor} locale={locale} onSelectColor={handleColorClick} />
<IconSection
selectedIcon={selectedIcon}
search={search}
filteredIcons={filteredIcons}
locale={locale}
onSearchChange={setSearch}
onSelectIcon={handleIconClick}
/>
{showTemplate && (
<TemplateSection templateText={templateText} locale={locale} onTemplateChange={handleTemplateChange} />
)}
{showDone && <DoneSection locale={locale} onClose={handleDone} />}
</div>
)
}