'use client'; import { ChevronDown, Search, X } from 'lucide-react'; import { useEffect, useId, useMemo, useRef, useState } from 'react'; import { t } from '@/i18n'; import { cn } from '@/lib/cn'; import { topicById } from '@/lib/topics'; import { toIndicatorOption, type IndicatorOption } from '@/lib/indicator-options'; export { toIndicatorOption, type IndicatorOption }; /** * Indicator picker for the analytical views: a button showing the current indicator, opening a searchable * list grouped by topic (featured first). Client-side filtering over the full list (≈ 260 rows, passed from * the server). Keyboard: type to filter, ↑↓ ↵ esc. Closes on outside pointerdown. */ export function IndicatorSelect({ options, value, onChange, label, className, size = 'md', align = 'left', }: { options: IndicatorOption[]; value: string; onChange: (slug: string) => void; label?: string; className?: string; size?: 'sm' | 'md'; align?: 'left' | 'right'; }) { const id = useId(); const [open, setOpen] = useState(false); const [q, setQ] = useState(''); const [active, setActive] = useState(0); const wrap = useRef(null); const input = useRef(null); const current = options.find((o) => o.slug === value) ?? null; useEffect(() => { if (!open) return; setTimeout(() => input.current?.focus(), 20); const onDoc = (e: PointerEvent) => { if (!wrap.current?.contains(e.target as Node)) setOpen(false); }; document.addEventListener('pointerdown', onDoc); return () => document.removeEventListener('pointerdown', onDoc); }, [open]); const groups = useMemo(() => { const ql = q.trim().toLowerCase(); const pool = ql ? options.filter((o) => `${o.name} ${o.short_name ?? ''} ${o.slug} ${o.unit ?? ''}`.toLowerCase().includes(ql)) : options; const featured = ql ? [] : pool.filter((o) => o.featured); const byTopic = new Map(); for (const o of pool) { const k = o.topic ?? 'other'; if (!byTopic.has(k)) byTopic.set(k, []); byTopic.get(k)!.push(o); } const out: Array<{ key: string; label: string; items: IndicatorOption[] }> = []; if (featured.length) out.push({ key: 'featured', label: t('control.featured'), items: featured }); for (const [k, items] of Array.from(byTopic.entries()).sort((a, b) => (topicById(a[0])?.order ?? 99) - (topicById(b[0])?.order ?? 99))) out.push({ key: k, label: topicById(k)?.name ?? k, items }); return out; }, [options, q]); const flat = useMemo(() => groups.flatMap((g) => g.items), [groups]); useEffect(() => setActive(0), [q]); const pick = (o: IndicatorOption) => { onChange(o.slug); setOpen(false); setQ(''); }; const onKey = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => Math.min(flat.length - 1, a + 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => Math.max(0, a - 1)); } else if (e.key === 'Enter') { e.preventDefault(); const o = flat[active]; if (o) pick(o); } else if (e.key === 'Escape') { setOpen(false); } }; useEffect(() => { if (!open) return; const el = wrap.current?.querySelector(`[data-idx="${active}"]`); el?.scrollIntoView({ block: 'nearest' }); }, [active, open]); return (
{open ? (
setQ(e.target.value)} onKeyDown={onKey} placeholder={t('control.searchIndicators')} aria-label={t('control.searchIndicators')} role="combobox" aria-expanded={open} aria-controls={`${id}-list`} aria-autocomplete="list" aria-activedescendant={flat[active] ? `${id}-opt-${flat[active].slug}` : undefined} autoComplete="off" className="h-10 w-full rounded-sm border border-rule bg-paper pl-8 pr-8 text-sm outline-none placeholder:text-ink-3 focus:border-accent" /> {q ? ( ) : null}
    {flat.length === 0 ?
  • {t('control.noMatch')}
  • : null} {groups.map((g) => (
  • {g.label}
      {g.items.map((o) => { const i = flat.indexOf(o); const sel = o.slug === value; return (
    • ); })}
  • ))}
) : null}
); } /** Small segmented control (radio group look) used by the analytical views for view/mode switches. */ export function Segmented({ value, onChange, options, label, className, size = 'md' }: { value: T; onChange: (v: T) => void; options: Array<{ value: T; label: string; icon?: React.ReactNode }>; label: string; className?: string; size?: 'sm' | 'md' }) { return (
{options.map((o) => ( ))}
); }