feat(popover): migrate the click menus onto the platform (R8 S4b)

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.
This commit is contained in:
2026-07-31 00:02:14 +02:00
parent 2756c21dbd
commit 280de2e592
5 changed files with 240 additions and 360 deletions
+63 -122
View File
@@ -1,15 +1,16 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useState } from "react";
import { Check, Globe } from "lucide-react";
import { LANGUAGES, type LangCode } from "../i18n";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// Compact language picker (globe + current code). Presentational: the parent decides what
// changing the language does (set it; persist server-side when signed in).
//
// Two variants: "header" (legacy inline dropdown, opens below) and "rail" (used in the left
// nav's bottom icon cluster — an icon-only button whose menu is portaled to <body> and
// anchored to the right + above the button, escaping the nav's backdrop-filter which would
// otherwise trap an absolutely-positioned popover).
// Two variants, now one implementation on the R8 popover platform: "header" opens below the pill
// (used on the landing page and the setup wizard) and "rail" is an icon-only button in the left
// nav's bottom cluster whose menu opens to the RIGHT, bottom-aligned. Both used to hand-roll their
// own anchoring, and the header one also opened on hover — dropped on purpose: the platform focuses
// the first item when a menu opens, so a hover-open would yank focus off whatever the user was doing.
export default function LanguageSwitcher({
value,
onChange,
@@ -22,128 +23,68 @@ export default function LanguageSwitcher({
variant?: "header" | "rail";
}) {
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [pos, setPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0];
function openNow() {
if (closeTimer.current) clearTimeout(closeTimer.current);
setOpen(true);
}
function closeSoon() {
closeTimer.current = setTimeout(() => setOpen(false), 200);
}
function toggleRail() {
if (!open) {
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom });
}
setOpen((o) => !o);
}
// Rail popover: dismiss on outside click / Escape (it's portaled, so a contains() check
// spans both the button and the floating panel).
useEffect(() => {
if (variant !== "rail" || !open) return;
function onDoc(e: MouseEvent) {
const target = e.target as Node;
if (panelRef.current?.contains(target) || btnRef.current?.contains(target)) return;
setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, [variant, open]);
const menu = (
<div ref={panelRef} className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]">
{LANGUAGES.map((l) => (
<button
key={l.code}
onClick={() => {
onChange(l.code);
setOpen(false);
}}
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<span>{l.label}</span>
{l.code === value && <Check className="w-4 h-4 text-accent" />}
</button>
))}
</div>
const currentIdx = Math.max(
0,
LANGUAGES.findIndex((l) => l.code === value),
);
const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0];
const rail = variant === "rail";
if (variant === "rail") {
return (
<>
<button
ref={btnRef}
onClick={toggleRail}
title={current.label}
aria-label={current.label}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Globe className="w-5 h-5" />
{/* Current-language badge (HU/EN) so the active language reads at a glance without
opening the menu. The ring matches the page background to detach it from the icon. */}
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
placement: rail ? "right-end" : align === "right" ? "bottom-end" : "bottom-start",
offset: rail ? 8 : 4,
// Single choice: opening lands on the language already in use.
selectedIndex: currentIdx,
});
return (
<>
<button
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
title={current.label}
aria-label={current.label}
className={
rail
? "relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
: "flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-card transition"
}
>
<Globe className={rail ? "w-5 h-5" : "w-4 h-4"} />
{rail ? (
/* Current-language badge (HU/EN) so the active language reads at a glance without
opening the menu. The ring matches the page background to detach it from the icon. */
<span className="absolute -bottom-1 -right-1 text-[8px] font-bold leading-none px-1 py-[1px] rounded bg-accent text-accent-fg ring-2 ring-bg">
{current.code.toUpperCase()}
</span>
</button>
{open &&
createPortal(
<div
className="z-rail"
style={{ position: "fixed", left: pos.left, bottom: pos.bottom }}
>
{menu}
</div>,
document.body,
)}
</>
);
}
return (
<div className="relative" onMouseEnter={openNow} onMouseLeave={closeSoon}>
<button
onClick={() => setOpen((o) => !o)}
title={current.label}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-card transition"
>
<Globe className="w-4 h-4" />
<span className="text-xs font-semibold uppercase">{current.code}</span>
) : (
<span className="text-xs font-semibold uppercase">{current.code}</span>
)}
</button>
{open && (
<div
className={`glass-menu absolute ${
align === "right" ? "right-0" : "left-0"
} mt-1 w-40 rounded-xl p-1.5 z-rail animate-[popIn_0.16s_ease]`}
>
{LANGUAGES.map((l) => (
<button
key={l.code}
onClick={() => {
onChange(l.code);
setOpen(false);
}}
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<span>{l.label}</span>
{l.code === value && <Check className="w-4 h-4 text-accent" />}
</button>
))}
</div>
)}
</div>
<PopoverSurface
popover={pop}
className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
{LANGUAGES.map((l) => (
<PopoverItem
key={l.code}
role="menuitemradio"
checked={l.code === value}
onSelect={() => {
onChange(l.code);
setOpen(false);
}}
label={l.label}
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted outline-none transition hover:text-fg hover:bg-card data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg"
>
<span>{l.label}</span>
{l.code === value && <Check className="w-4 h-4 text-accent" />}
</PopoverItem>
))}
</PopoverSurface>
</>
);
}