AdvisoryBuilderWorkshop / frontend /src /components /HeaderNavDropdown.tsx
NeonClary's picture
Workshop nav, experiment editing, and saved chats
31091fd verified
Raw History Blame Contribute Delete
3 kB
import { useEffect, useId, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
export type HeaderNavItem = {
to: string
label: string
}
type Props = {
label: string
active: boolean
items: HeaderNavItem[]
/** When set with onPinnedChange, pin state is controlled by the parent. */
pinnedOpen: boolean
onPinnedChange: (pinned: boolean) => void
}
/**
* Header topic menu: hover opens transiently; click pins until click again,
* menu item navigation, Escape, or outside click.
*/
export function HeaderNavDropdown({
label,
active,
items,
pinnedOpen,
onPinnedChange,
}: Props) {
const menuId = useId()
const ref = useRef<HTMLDivElement>(null)
const onPinnedChangeRef = useRef(onPinnedChange)
const [hoverOpen, setHoverOpen] = useState(false)
const open = pinnedOpen || hoverOpen
useEffect(() => {
onPinnedChangeRef.current = onPinnedChange
}, [onPinnedChange])
useEffect(() => {
if (!open) return
function onPointerDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onPinnedChangeRef.current(false)
setHoverOpen(false)
}
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onPinnedChangeRef.current(false)
setHoverOpen(false)
}
}
document.addEventListener('mousedown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('mousedown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [open])
const closeAll = () => {
onPinnedChange(false)
setHoverOpen(false)
}
const triggerClass = `header-nav-link header-nav-trigger${
active ? ' header-nav-link--current' : ''
}${open ? ' header-nav-trigger--open' : ''}`
return (
<div
className="header-nav-dropdown"
ref={ref}
onMouseEnter={() => setHoverOpen(true)}
onMouseLeave={() => {
if (!pinnedOpen) setHoverOpen(false)
}}
>
<button
type="button"
className={triggerClass}
aria-expanded={open}
aria-haspopup="menu"
aria-controls={menuId}
onClick={() => {
const next = !pinnedOpen
onPinnedChange(next)
setHoverOpen(next)
}}
>
<span>{label}</span>
<span className="header-nav-caret" aria-hidden>
▾
</span>
</button>
{open && (
<div className="header-nav-menu" role="menu" id={menuId}>
{items.map((item) => (
<Link
key={item.to}
to={item.to}
role="menuitem"
className="header-nav-menu-item"
onClick={closeAll}
>
{item.label}
</Link>
))}
</div>
)}
</div>
)
}