import { useState } from "react"; import { Check, type LucideIcon } from "lucide-react"; import clsx from "clsx"; import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { SelectTrigger } from "./ui/SelectTrigger"; 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 was the house's textbook menu, with its own hand-rolled roving-tabindex/arrow/Escape engine. // It now rides the R8 popover platform like every other menu, which supplies the same model // (arrows/Home/End rove, Escape closes and hands focus back to the trigger, Tab out closes) plus the // two things the hand-rolled version lacked: typeahead, and anchoring that flips and shifts instead // of being absolutely positioned inside whatever ancestor happens to clip it. 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); // 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), ); const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end", // Opening a "pick one of these modes" menu should land on the mode that is already picked. selectedIndex: currentIdx, }); const current = options[currentIdx]; if (!current) return null; const CurrentIcon = current.icon; return ( <> } ref={pop.refs.setReference} {...pop.getReferenceProps()} // The aria-label carries the mode's name at every size — the visible text below is a // nicety for wide windows, not the accessible name. aria-label={`${label}: ${current.label}`} title={`${label}: ${current.label}`} > {current.label} {/* 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) => { const Icon = o.icon; const on = o.id === value; return ( { onChange(o.id); setOpen(false); }} label={o.label} className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left outline-hidden transition ${ on ? "text-accent" : "text-fg" }`} > {o.label} {on && } ); })}
); }