"use client"; import { useEffect, useId, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; export type SideFlowItem = { label: string; href: string; note?: string }; type SideFlowProps = { items: SideFlowItem[]; /** href of the active link at start. Defaults to the first item. */ active?: string; /** Called on click. Call e.preventDefault() to stop the browser following the link. */ onSelect?: (item: SideFlowItem, e: MouseEvent) => void; side?: "left" | "right"; /** Keep the panel inside the nearest `relative` parent instead of the whole screen. */ contained?: boolean; /** Heading at the top of the panel. */ title?: ReactNode; /** Anything at the bottom of the panel, e.g. contact links. */ footer?: ReactNode; /** Text on the open button. */ trigger?: ReactNode; triggerClassName?: string; /** Colour of the active marker and numbers. */ accent?: string; /** Name read out by screen readers. */ label?: string; /** Panel look. */ className?: string; }; /* SideFlow: a side panel slides in and its links flow in one after another. Esc, the backdrop or a link closes it. */ export function SideFlow({ items, active, onSelect, side = "left", contained = false, title = "Menu", footer, trigger = "Menu", triggerClassName = "inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-2 text-sm text-white outline-none hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/70", accent = "#a78bfa", label = "Site", className = "border-white/10 bg-[#120f22] text-white", }: SideFlowProps) { const reduce = useReducedMotion(); const uid = useId(); const button = useRef(null); const panel = useRef(null); const [open, setOpen] = useState(false); const [current, setCurrent] = useState(active ?? items[0]?.href); const pos = contained ? "absolute" : "fixed"; const from = side === "left" ? "-100%" : "100%"; useEffect(() => { if (!open) return; panel.current?.querySelector("a[href]")?.focus(); if (contained) return; const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prev; }; }, [open, contained]); const close = () => { setOpen(false); button.current?.focus(); }; // Keep Tab inside the panel while it is open. const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") return close(); if (e.key !== "Tab") return; const f = panel.current?.querySelectorAll("a[href], button"); if (!f?.length) return; const first = f[0]; const last = f[f.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }; return ( <> {open && ( <>

{title}

{footer &&
{footer}
}
)}
); }