open-navigator / web_app /src /components /CensusMetricBrowserPanel.tsx
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
6.33 kB
import { useEffect, useMemo, useState } from 'react'
import {
BuildingLibraryIcon,
ChartBarIcon,
ChevronLeftIcon,
HeartIcon,
MinusIcon,
PlusIcon,
ShieldCheckIcon,
Squares2X2Icon,
UsersIcon,
} from '@heroicons/react/24/outline'
import { InfoHelpTrigger } from './InfoHelpTrigger'
import { groupMetricsByTheme, themeIdForMetric } from '../utils/censusMetricGroups'
/**
* Leading glyph per top-level theme, keyed by the taxonomy's theme id
* (../utils/censusMetricGroups). Anything without an explicit entry — including
* the trailing "More measures" bucket — falls back to a neutral grid icon.
*/
const THEME_ICONS: Record<string, typeof ChartBarIcon> = {
economy: ChartBarIcon,
people: UsersIcon,
health: HeartIcon,
crime: ShieldCheckIcon,
government: BuildingLibraryIcon,
}
interface CensusMetricBrowserPanelProps {
metrics: ReadonlyArray<{ slug: string; label: string }>
metricSlug: string
metricFullHelp: string
onPick: (slug: string) => void
/** Collapse the panel back to the bare rail. */
onCollapse: () => void
}
/**
* Persistent, collapsible metric browser that sits beside the map's display
* rail. Metrics are organised into two levels — top-level themes (Economy,
* People, Health, …) each holding sub-grouped metrics (Income, Housing, …) —
* from the shared taxonomy (../utils/censusMetricGroups). The theme owning the
* active metric starts expanded so the current selection is always visible.
* Collapsed themes show a "+"; expanded themes show "−". Themes with no metrics
* yet (Crime, Government) still expand, to an empty placeholder. The header
* dropdown remains the small-screen affordance — this panel is desktop-only.
*/
export default function CensusMetricBrowserPanel({
metrics,
metricSlug,
metricFullHelp,
onPick,
onCollapse,
}: CensusMetricBrowserPanelProps) {
const themes = useMemo(() => groupMetricsByTheme(metrics), [metrics])
// Track which themes are expanded. The theme holding the active metric is
// opened on mount and whenever the selection jumps to a different theme (e.g.
// via the URL), without stomping a user's manual expand/collapse elsewhere.
const activeThemeId = themeIdForMetric(metricSlug)
const [open, setOpen] = useState<Record<string, boolean>>(() => ({ [activeThemeId]: true }))
useEffect(() => {
setOpen((prev) => (prev[activeThemeId] ? prev : { ...prev, [activeThemeId]: true }))
}, [activeThemeId])
return (
<div className="flex h-full w-72 flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex items-center justify-between gap-2 border-b border-slate-200 px-3 py-2.5">
<span className="inline-flex items-center gap-1.5 text-sm font-semibold text-slate-900">
Metrics
<InfoHelpTrigger help={metricFullHelp} topic="Metric" align="left" />
</span>
<button
type="button"
onClick={onCollapse}
className="rounded-md p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
aria-label="Collapse metric panel"
title="Collapse metric panel"
>
<ChevronLeftIcon className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto overscroll-contain p-2">
{themes.map((t) => {
const isOpen = !!open[t.id]
const Icon = THEME_ICONS[t.id] ?? Squares2X2Icon
return (
<div key={t.id} className="overflow-hidden rounded-lg border border-slate-200">
<button
type="button"
onClick={() => setOpen((prev) => ({ ...prev, [t.id]: !prev[t.id] }))}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-sm font-semibold text-slate-800 hover:bg-slate-50"
aria-expanded={isOpen}
>
<Icon className="h-[18px] w-[18px] shrink-0 text-slate-500" aria-hidden />
<span className="min-w-0 flex-1 truncate">{t.title}</span>
{/* Expand/collapse affordance — "+" stays visible when collapsed. */}
{isOpen ? (
<MinusIcon className="h-4 w-4 shrink-0 text-slate-400" aria-hidden />
) : (
<PlusIcon className="h-4 w-4 shrink-0 text-slate-400" aria-hidden />
)}
</button>
{isOpen ? (
<div className="border-t border-slate-200 p-1.5">
{t.groups.length === 0 ? (
<p className="px-2 py-2 text-[12px] italic leading-snug text-slate-400">
No metrics yet
</p>
) : (
t.groups.map((g) => (
<div key={g.id} className="mb-1 last:mb-0">
<div className="px-2 pb-0.5 pt-1.5 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
{g.shortTitle ?? g.title}
</div>
<ul>
{g.metrics.map((m) => {
const selected = m.slug === metricSlug
return (
<li key={m.slug}>
<button
type="button"
onClick={() => onPick(m.slug)}
aria-current={selected ? 'true' : undefined}
className={`block w-full rounded-md px-2 py-2 text-left text-sm leading-snug ${
selected
? 'bg-indigo-100 font-medium text-indigo-900'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`}
>
{m.label}
</button>
</li>
)
})}
</ul>
</div>
))
)}
</div>
) : null}
</div>
)
})}
</div>
</div>
)
}