open-navigator / web_app /src /components /HomeExploreQuickNav.tsx
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
12.3 kB
import { useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { ChevronDownIcon } from '@heroicons/react/24/outline'
import { homeExploreSectionHash, type HomeQuickNavGroupId } from '../data/exploreActionPhases'
import {
flyoutIcons,
homeQuickNavFlyoutItems,
resolveHomeFlyoutHref,
type HomeFlyoutItem,
} from '../data/homeQuickNavFlyouts'
const T = {
tealDark: '#1b3c39',
tealMid: '#2d6b65',
tealLight: '#4a9e96',
tealPale: '#e6f3f2',
accent: '#f0a500',
accentPale: '#fff4d6',
white: '#ffffff',
textDark: '#1a2e2d',
textMid: '#4a6665',
textLight: '#8aabaa',
border: '#ddecea',
} as const
/** Outline nav icons (reference: light gray idle, white on teal hover) */
function NavIcon({ children, stroke }: { children: ReactNode; stroke: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={stroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
{children}
</svg>
)
}
const NAV_ROW_ICONS: Record<HomeQuickNavGroupId, (stroke: string) => ReactNode> = {
cause: (stroke) => (
<NavIcon stroke={stroke}>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</NavIcon>
),
plan: (stroke) => (
<NavIcon stroke={stroke}>
<path d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.568.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z" />
</NavIcon>
),
find: (stroke) => (
<NavIcon stroke={stroke}>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</NavIcon>
),
track: (stroke) => (
<NavIcon stroke={stroke}>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</NavIcon>
),
build: (stroke) => (
<NavIcon stroke={stroke}>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</NavIcon>
),
}
const MARKETING_ROWS: { id: HomeQuickNavGroupId; label: string; sub: string }[] = [
{ id: 'cause', label: 'Choose a Cause', sub: 'Pick what matters to you first' },
{ id: 'plan', label: 'Make a Plan', sub: 'Personal and community paths, allies, and outcomes' },
{ id: 'find', label: 'Find Help', sub: 'Nonprofits, programs, and family supports' },
{ id: 'track', label: 'Track Decisions', sub: 'Meetings, budgets, maps, and verification' },
{ id: 'build', label: 'Build With Data', sub: 'Open datasets & API' },
]
/** Drill-down links: compact rows with a light motion on hover. */
function DrilldownLinkRow({ item, onPick, styleDelay }: { item: HomeFlyoutItem; onPick: () => void; styleDelay: number }) {
const href = resolveHomeFlyoutHref(item.to)
const icon = flyoutIcons[item.iconKey]('currentColor')
const className =
'group/drill flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm font-medium text-slate-800 transition-[background-color,transform,box-shadow] duration-200 hover:bg-white hover:shadow-sm hover:ring-1 hover:ring-slate-200/80 active:scale-[0.99]'
const iconWrap = (
<span className="flex h-7 w-7 shrink-0 items-center justify-center text-slate-500 transition-transform duration-200 group-hover/drill:scale-105 [&>svg]:block">
{icon}
</span>
)
const label = <span className="min-w-0 flex-1 leading-snug">{item.label}</span>
const motionStyle = { transitionDelay: `${styleDelay}ms` } as const
if (item.external) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={className}
style={motionStyle}
onClick={onPick}
>
{iconWrap}
{label}
</a>
)
}
return (
<Link to={href} className={className} style={motionStyle} onClick={onPick}>
{iconWrap}
{label}
</Link>
)
}
type HomeExploreQuickNavProps = {
/** e.g. close app layout mobile drawer after following a link */
onNavigate?: () => void
/**
* When > 0, clicking a section title waits this many ms before navigating to Explore.
* A second click while waiting cancels navigation and toggles the shortcut panel (same as the chevron).
* Use on Data explorer (and similar) so two quick taps expand without leaving the page first.
*/
deferSectionNavigationMs?: number
}
export default function HomeExploreQuickNav({
onNavigate,
deferSectionNavigationMs = 0,
}: HomeExploreQuickNavProps) {
const navigate = useNavigate()
const [expandedId, setExpandedId] = useState<HomeQuickNavGroupId | null>(null)
const pendingSectionNavRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(
() => () => {
if (pendingSectionNavRef.current) clearTimeout(pendingSectionNavRef.current)
},
[],
)
const closePanels = () => {
setExpandedId(null)
onNavigate?.()
}
function toggleExpanded(id: HomeQuickNavGroupId) {
setExpandedId((cur) => (cur === id ? null : id))
}
function handleSectionTitleClick(rowId: HomeQuickNavGroupId, exploreHref: string, e: MouseEvent<HTMLAnchorElement>) {
if (deferSectionNavigationMs <= 0) {
closePanels()
return
}
e.preventDefault()
if (pendingSectionNavRef.current != null) {
clearTimeout(pendingSectionNavRef.current)
pendingSectionNavRef.current = null
toggleExpanded(rowId)
return
}
pendingSectionNavRef.current = setTimeout(() => {
pendingSectionNavRef.current = null
closePanels()
navigate(exploreHref)
}, deferSectionNavigationMs)
}
return (
<div className="relative isolate flex h-full min-h-0 max-h-full w-full flex-col overflow-x-hidden overflow-y-hidden rounded-2xl border border-slate-200/90 bg-white p-3 shadow-[0_8px_32px_-8px_rgba(15,23,42,0.14)] ring-1 ring-slate-900/[0.06]">
<h3
className="mb-2 shrink-0 text-base font-bold text-slate-900"
style={{ fontFamily: "'DM Sans',sans-serif" }}
>
Quick Navigation
</h3>
<nav
className="min-h-0 flex-1 space-y-2 overflow-y-auto overscroll-contain pr-0.5"
aria-label="Quick navigation and explore paths"
>
{MARKETING_ROWS.map((row) => {
const items = homeQuickNavFlyoutItems(row.id)
const exploreHref = `/explore#${homeExploreSectionHash(row.id)}`
const isOpen = expandedId === row.id
const panelId = `quick-nav-panel-${row.id}`
const triggerId = `quick-nav-trigger-${row.id}`
return (
<div
key={row.id}
data-home-nav-row={row.id}
className={`overflow-hidden rounded-xl border transition-[border-color,box-shadow,background] duration-300 ease-out ${
isOpen
? 'border-teal-200/90 bg-gradient-to-b from-white via-teal-50/40 to-teal-50/[0.55] shadow-[0_6px_20px_-6px_rgba(15,118,110,0.22)] ring-1 ring-teal-900/[0.07]'
: 'border-slate-200/90 bg-white shadow-sm ring-1 ring-slate-900/[0.04] hover:border-slate-300/90 hover:shadow'
}`}
>
<div className="flex min-h-[3.25rem] items-stretch">
<Link
to={exploreHref}
onClick={(e) => handleSectionTitleClick(row.id, exploreHref, e)}
className={`mx-0 flex min-w-0 flex-1 cursor-pointer items-center gap-2 px-2 py-1.5 transition-[background-color,color] duration-200 sm:px-2.5 ${
isOpen ? 'bg-transparent' : 'hover:bg-slate-50/90'
}`}
style={{
borderLeftWidth: 3,
borderLeftStyle: 'solid',
borderLeftColor: isOpen ? T.accent : 'transparent',
}}
id={`quick-nav-heading-${row.id}`}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center" aria-hidden>
{NAV_ROW_ICONS[row.id](isOpen ? T.tealMid : '#94a3b8')}
</span>
<div className="min-w-0 flex-1 py-0.5">
<div
className="text-[13px] font-semibold leading-tight transition-colors duration-200"
style={{
color: isOpen ? T.tealDark : T.textDark,
fontFamily: "'DM Sans',sans-serif",
}}
>
{row.label}
</div>
<div
className="text-[10px] leading-snug transition-colors duration-200"
style={{
color: T.textMid,
fontFamily: "'DM Sans',sans-serif",
}}
>
{row.sub}
</div>
</div>
</Link>
<button
type="button"
id={triggerId}
aria-expanded={isOpen}
aria-controls={panelId}
className={`flex w-11 shrink-0 flex-col items-center justify-center border-l transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-teal-500/50 ${
isOpen
? 'border-teal-200/70 bg-teal-50/50 text-teal-800'
: 'border-slate-100 bg-white text-slate-400 hover:bg-slate-50 hover:text-slate-600'
}`}
title={isOpen ? 'Hide shortcuts' : 'Show shortcuts'}
onClick={(e) => {
e.preventDefault()
toggleExpanded(row.id)
}}
>
<ChevronDownIcon
className={`h-5 w-5 transition-transform duration-300 ease-out ${isOpen ? '-rotate-180' : 'rotate-0'}`}
aria-hidden
/>
<span className="sr-only">
{isOpen ? `Collapse shortcuts for ${row.label}` : `Expand shortcuts for ${row.label}`}
</span>
</button>
</div>
<div
id={panelId}
role="region"
aria-labelledby={`quick-nav-heading-${row.id}`}
className={`grid transition-[grid-template-rows] duration-300 ease-[cubic-bezier(0.33,1,0.68,1)] motion-reduce:transition-none ${
isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
}`}
>
<div className="min-h-0 overflow-hidden">
<div
aria-hidden={!isOpen}
className={`border-t px-1.5 pb-2 pt-1.5 ${isOpen ? 'border-teal-100/80' : 'border-transparent'}`}
>
<p
className="px-1.5 pb-1 pt-0.5 text-[10px] font-semibold uppercase tracking-wider text-teal-800/80"
style={{ fontFamily: "'DM Sans',sans-serif" }}
>
{row.label}
</p>
<div className="flex flex-col gap-0.5 rounded-lg bg-white/60 p-1 ring-1 ring-teal-900/[0.04] backdrop-blur-[2px]">
{items.map((item, i) => (
<DrilldownLinkRow key={item.id} item={item} onPick={closePanels} styleDelay={isOpen ? Math.min(i * 35, 200) : 0} />
))}
</div>
<Link
to={exploreHref}
onClick={closePanels}
className="mt-1.5 block rounded-md px-2 py-1.5 text-xs font-semibold text-teal-800/90 transition-colors hover:bg-teal-100/50 hover:text-teal-950"
>
Open Explore: {row.label} →
</Link>
</div>
</div>
</div>
</div>
)
})}
</nav>
</div>
)
}