import { useEffect, useRef, useState } from "react"; import { Check, ChevronDown, type LucideIcon } from "lucide-react"; import clsx from "clsx"; import { useDismiss } from "../lib/useDismiss"; export interface ViewOption { id: T; label: string; icon: LucideIcon; } // "How should this list look" — the current mode's icon in the toolbar, the named modes in a // menu behind it. Generic over the mode id so any module's list can adopt it (the feed's // density modes; the managers' layouts) without re-deriving the menu behaviour. // // This is a real menu, so it carries the whole keyboard model the role promises: arrows/Home/End // move a roving focus, Escape closes, and closing always hands focus back to the trigger (the // menu unmounts under the focused item, which would otherwise strand focus on ). export default function ViewSwitcher({ value, options, onChange, label, labelClassName = "hidden", }: { value: T; options: readonly ViewOption[]; onChange: (v: T) => void; label: string; /** Where the current mode's NAME is visible beside the icon — a Tailwind display class, e.g. * `"hidden xl:inline"`. The caller sets it because only it knows how crowded its own toolbar is: * the feed's is packed (show chips, content chips, source, count, sort) and can't spare the room * until xl, while an emptier one could show it always. Defaults to icon-only. */ labelClassName?: string; }) { const [open, setOpen] = useState(false); // Which item holds the roving tabindex; seeded to the active one when the menu opens. const [focusIdx, setFocusIdx] = useState(0); const btnRef = useRef(null); const menuRef = useRef(null); const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); function close({ refocus = true }: { refocus?: boolean } = {}) { setOpen(false); if (refocus) btnRef.current?.focus(); } // Outside-click must NOT refocus the trigger — that would steal focus from whatever the user // just clicked. Escape is handled on the menu itself (below), where returning focus is right. useDismiss(open, () => close({ refocus: false }), [btnRef, menuRef]); // A `value` outside `options` is a caller bug; degrade to the first option rather than render // nothing. The menu then shows no checked item, which is honest — nothing IS selected. const currentIdx = Math.max( 0, options.findIndex((o) => o.id === value) ); // Seeding the roving index HERE — rather than in an effect keyed on `open` — matters: an effect // would only schedule the update, so the focus effect below would still see the previous index // and focus the wrong item for one render before correcting. function openMenu() { setFocusIdx(currentIdx); setOpen(true); } // Clamped at render, so a caller whose option list shrinks while the menu is open can't leave // the roving tabindex pointing past the end (which would make every item un-tabbable). const rovingIdx = Math.min(focusIdx, options.length - 1); useEffect(() => { if (open) itemRefs.current[rovingIdx]?.focus(); }, [open, rovingIdx]); const current = options[currentIdx]; if (!current) return null; const CurrentIcon = current.icon; function onMenuKeyDown(e: React.KeyboardEvent) { const last = options.length - 1; if (e.key === "Escape") { // Stop it reaching useDismiss's document listener, which would close without refocusing. e.stopPropagation(); close(); } else if (e.key === "ArrowDown") { e.preventDefault(); setFocusIdx((i) => (i >= last ? 0 : i + 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setFocusIdx((i) => (i <= 0 ? last : i - 1)); } else if (e.key === "Home") { e.preventDefault(); setFocusIdx(0); } else if (e.key === "End") { e.preventDefault(); setFocusIdx(last); } else if (e.key === "Tab") { // Tabbing out of a menu closes it, but the focus is moving on by itself. close({ refocus: false }); } } return (
{open && (
{/* The modes are one exclusive choice, so the radio items need a group to scope their checked-ness to — otherwise they read as independent checkable items. */}
{options.map((o, i) => { const Icon = o.icon; const on = o.id === value; return ( ); })}
)}
); }