AddToPlaylist, LanguageSwitcher, the Plex DetailCustomizeMenu and ViewSwitcher each had their own anchoring, dismissal and (for ViewSwitcher) keyboard engine. All four now use the platform, so their items are PopoverItems with real menuitemradio/aria-checked, roving focus, typeahead and flip/shift anchoring. Two platform additions this needed: - `selectedIndex`: opening a single-choice menu must land on the choice already in effect, which is what ViewSwitcher's hand-rolled engine did by seeding its roving index to the current option. - `ariaLabel` on PopoverSurface: an icon-only trigger leaves the panel with no name. Fixed while wiring `selectedIndex` up: it stranded focus on the menu container. That path focuses its item synchronously in a layout effect, and FloatingFocusManager's own initial focus then ran afterwards and took focus back; the no-selectedIndex path only escaped this because it defers focus to a microtask while waiting for the item list to populate. On a roving list the list navigation owns initial focus, so the manager is now told to take none (initialFocus -1). Behaviour changes worth naming: - The header LanguageSwitcher no longer opens on hover. The platform focuses the first item on open, so a hover-open would yank focus off whatever the user was doing. Both variants are now click-only, like every other menu in the app. - PrefToggle became a PopoverItem, so the rows callers inject through the menu's `extra` prop join the same roving list as the built-in ones. - AddToPlaylist fetches its list on open, so on the first paint there are no items to focus and focus stays on the trigger (as it did before the migration); arrows move into the list once it lands. Verified in-browser: ViewSwitcher opens on the checked mode, arrows rove, Escape closes and refocuses the trigger; the ChannelTags pilot is unregressed; AddToPlaylist anchors bottom-start without the click reaching the feed card underneath; the rail language menu keeps its right-edge, bottom-aligned anchoring.
112 lines
4.5 KiB
TypeScript
112 lines
4.5 KiB
TypeScript
import { useState } from "react";
|
|
import { Check, ChevronDown, type LucideIcon } from "lucide-react";
|
|
import clsx from "clsx";
|
|
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
|
|
|
export interface ViewOption<T extends string> {
|
|
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<T extends string>({
|
|
value,
|
|
options,
|
|
onChange,
|
|
label,
|
|
labelClassName = "hidden",
|
|
}: {
|
|
value: T;
|
|
options: readonly ViewOption<T>[];
|
|
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 (
|
|
<>
|
|
<button
|
|
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}`}
|
|
className="shrink-0 inline-flex items-center gap-1 pl-2 pr-1 py-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
|
>
|
|
<CurrentIcon className="w-4 h-4 shrink-0" />
|
|
<span className={clsx("text-sm whitespace-nowrap", labelClassName)}>{current.label}</span>
|
|
<ChevronDown
|
|
className={`w-3 h-3 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
|
/>
|
|
</button>
|
|
<PopoverSurface
|
|
popover={pop}
|
|
ariaLabel={label}
|
|
className="glass-menu min-w-44 p-1.5 rounded-xl animate-[popIn_0.16s_ease]"
|
|
>
|
|
{/* 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. */}
|
|
<div role="group">
|
|
{options.map((o) => {
|
|
const Icon = o.icon;
|
|
const on = o.id === value;
|
|
return (
|
|
<PopoverItem
|
|
key={o.id}
|
|
role="menuitemradio"
|
|
checked={on}
|
|
onSelect={() => {
|
|
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-none transition data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] ${
|
|
on ? "text-accent" : "text-fg hover:bg-card"
|
|
}`}
|
|
>
|
|
<Icon className="w-4 h-4 shrink-0" />
|
|
<span className="flex-1 truncate">{o.label}</span>
|
|
{on && <Check className="w-3.5 h-3.5 shrink-0" />}
|
|
</PopoverItem>
|
|
);
|
|
})}
|
|
</div>
|
|
</PopoverSurface>
|
|
</>
|
|
);
|
|
}
|