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
+76 -105
View File
@@ -1,16 +1,17 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { playlistName } from "../lib/playlistName";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// A small popover (portaled to body, so the feed grid can't clip it) for toggling a
// video's membership in the user's playlists, with inline "new playlist" creation.
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
// toggles plus a free-text creator, so it follows that file's two conventions — typeahead OFF and
// the input swallowing non-Escape keys, since character and arrow keys belong to the text field.
export default function AddToPlaylist({
videoId,
className,
@@ -21,60 +22,23 @@ export default function AddToPlaylist({
const { t } = useTranslation();
const qc = useQueryClient();
const fade = useScrollFade();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
offset: 6,
typeahead: false,
});
const membership = useQuery({
queryKey: qk.playlistMembership(videoId),
queryFn: () => api.playlists(videoId),
enabled: open,
});
// Re-place once the panel is actually rendered (so we know its real height) and whenever
// its content — hence height — changes, so the flip-above decision uses the true size.
useLayoutEffect(() => {
if (open) place();
}, [open, membership.data]);
function place() {
const r = triggerRef.current?.getBoundingClientRect();
if (!r) return;
const width = 240;
const margin = 8;
// Use the panel's real height once it's mounted; estimate on the first open.
const h = panelRef.current?.offsetHeight ?? 300;
let top = r.bottom + 6;
if (top + h > window.innerHeight - margin) {
// Not enough room below — open upward, clamped to the viewport.
top = Math.max(margin, r.top - h - 6);
}
const left = Math.min(Math.max(margin, r.left), window.innerWidth - width - margin);
setCoords({ top: Math.round(top), left: Math.round(left) });
}
function toggleOpen(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (!open) place();
setOpen((o) => !o);
}
useDismiss(open, () => setOpen(false), [panelRef, triggerRef]);
// Keep the panel anchored to the trigger as the page resizes/scrolls while open.
useEffect(() => {
if (!open) return;
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [open]);
function refresh() {
qc.invalidateQueries({ queryKey: qk.playlistMembership(videoId) });
qc.invalidateQueries({ queryKey: qk.playlists() });
@@ -95,8 +59,7 @@ export default function AddToPlaylist({
}
}
async function createAndAdd(e: React.FormEvent) {
e.preventDefault();
async function createAndAdd() {
const name = newName.trim();
if (!name || busy) return;
setBusy(true);
@@ -117,8 +80,15 @@ export default function AddToPlaylist({
return (
<>
<button
ref={triggerRef}
onClick={toggleOpen}
ref={pop.refs.setReference}
{...pop.getReferenceProps({
// The trigger sits on a feed card that opens the player on click, so the toggle must not
// reach it.
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
},
})}
title={t("playlists.addToPlaylist")}
aria-label={t("playlists.addToPlaylist")}
className={className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"}
@@ -126,59 +96,60 @@ export default function AddToPlaylist({
<ListPlus className="w-4 h-4" />
</button>
{open &&
createPortal(
<div
ref={panelRef}
style={{ position: "fixed", top: coords.top, left: coords.left, width: 240 }}
className="z-overlay glass-menu rounded-xl p-2 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="text-xs text-muted px-1.5 pb-1.5">{t("playlists.addToPlaylist")}</div>
<div
ref={fade.ref}
style={fade.style}
className="max-h-56 overflow-y-auto no-scrollbar"
>
{lists.length === 0 && !membership.isLoading && (
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
)}
{lists.map((pl) => (
<button
key={pl.id}
onClick={() => toggle(pl)}
disabled={busy}
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg hover:bg-card/60 transition disabled:opacity-50"
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2 shadow-2xl">
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
would otherwise reach the feed card that hosts the trigger and start playback. */}
<div onClick={(e) => e.stopPropagation()}>
<div className="text-xs text-muted px-1.5 pb-1.5">{t("playlists.addToPlaylist")}</div>
<div ref={fade.ref} style={fade.style} className="max-h-56 overflow-y-auto no-scrollbar">
{lists.length === 0 && !membership.isLoading && (
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
)}
{lists.map((pl) => (
<PopoverItem
key={pl.id}
role="menuitemradio"
checked={pl.has_video}
onSelect={() => toggle(pl)}
disabled={busy}
label={playlistName(pl, t)}
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-none transition hover:bg-card/60 disabled:opacity-50 data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)]"
>
<span
className={`grid place-items-center w-4 h-4 rounded border ${
pl.has_video ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
<span
className={`grid place-items-center w-4 h-4 rounded border ${
pl.has_video ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{pl.has_video && <Check className="w-3 h-3" />}
</span>
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube className="w-3 h-3 shrink-0 text-muted" />
)}
</button>
))}
</div>
<form
onSubmit={createAndAdd}
className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5"
>
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("playlists.newPlaylist")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
</form>
</div>,
document.body,
)}
{pl.has_video && <Check className="w-3 h-3" />}
</span>
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube className="w-3 h-3 shrink-0 text-muted" />
)}
</PopoverItem>
))}
</div>
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
// Escape bubbles to the popover's dismiss; every other key is text editing here and
// must not reach the surrounding menu's list navigation.
if (e.key === "Escape") return;
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
void createAndAdd();
}
}}
placeholder={t("playlists.newPlaylist")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
</div>
</div>
</PopoverSurface>
</>
);
}
+17 -1
View File
@@ -82,6 +82,10 @@ export interface AnchoredPopoverOptions {
/** Type-to-focus a matching item by label (menu/listbox only). Default on; turn OFF for menus that
* contain a free-text input, where character keys belong to the text field. */
typeahead?: boolean;
/** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
selectedIndex?: number | null;
}
export type AnchoredPopover = ReturnType<typeof useAnchoredPopover>;
@@ -149,6 +153,7 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
listRef: elementsRef,
activeIndex,
onNavigate: setActiveIndex,
selectedIndex: opts.selectedIndex ?? null,
loop: true,
// Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens,
// which strands focus on the menu container — arrows then can't enter the roving list at all).
@@ -273,11 +278,15 @@ export function PopoverSurface({
focusManaged,
initialFocus,
passthrough,
ariaLabel,
}: {
popover: AnchoredPopover;
className?: string;
style?: CSSProperties;
children: ReactNode;
/** Accessible name for the panel itself — a menu whose trigger is icon-only has nothing else to
* take its name from. */
ariaLabel?: string;
focusManaged?: boolean;
initialFocus?: number | React.MutableRefObject<HTMLElement | null>;
/** The surface never receives pointer events — for a passive caption that must not swallow a click
@@ -321,6 +330,7 @@ export function PopoverSurface({
<div
ref={refs.setFloating}
{...floatingProps}
aria-label={ariaLabel}
className={zClass}
style={{
...floatingPropStyle,
@@ -366,7 +376,13 @@ export function PopoverSurface({
<FloatingFocusManager
context={context}
modal={false}
initialFocus={initialFocus}
// On a roving list, useListNavigation owns the initial focus — so tell the focus manager
// NOT to also focus the panel itself (-1). Without this, a `selectedIndex` menu ends up
// with focus stranded on the container: that path focuses its item SYNCHRONOUSLY in a
// layout effect, and the manager's own initial focus then runs after it and wins. The
// no-selectedIndex path only escapes the same fate because it defers its focus to a
// microtask/rAF while it waits for the item list to populate.
initialFocus={initialFocus ?? (isNav ? -1 : undefined)}
returnFocus
>
{positioned}
+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>
</>
);
}
+51 -100
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { Check, ChevronDown, type LucideIcon } from "lucide-react";
import clsx from "clsx";
import { useDismiss } from "../lib/useDismiss";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
export interface ViewOption<T extends string> {
id: T;
@@ -13,9 +13,11 @@ export interface ViewOption<T extends string> {
// 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 <body>).
// 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,
@@ -34,19 +36,6 @@ export default function ViewSwitcher<T extends string>({
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<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(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.
@@ -54,55 +43,24 @@ export default function ViewSwitcher<T extends string>({
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 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;
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 (
<div className="relative">
<>
<button
ref={btnRef}
onClick={() => (open ? close() : openMenu())}
aria-haspopup="menu"
aria-expanded={open}
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}`}
@@ -115,46 +73,39 @@ export default function ViewSwitcher<T extends string>({
className={`w-3 h-3 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
{open && (
<div
ref={menuRef}
role="menu"
aria-label={label}
onKeyDown={onMenuKeyDown}
className="glass-menu absolute right-0 top-full mt-1 z-chrome 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, i) => {
const Icon = o.icon;
const on = o.id === value;
return (
<button
key={o.id}
ref={(el) => {
itemRefs.current[i] = el;
}}
role="menuitemradio"
aria-checked={on}
tabIndex={i === rovingIdx ? 0 : -1}
onClick={() => {
onChange(o.id);
close();
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left transition ${
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" />}
</button>
);
})}
</div>
<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>
)}
</div>
</PopoverSurface>
</>
);
}
+33 -32
View File
@@ -1,10 +1,10 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useLayoutEffect, useState, type ReactNode } from "react";
import { qk } from "./queryKeys";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { SlidersHorizontal } from "lucide-react";
import { useDismiss } from "./useDismiss";
import { api } from "./api";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "../components/AnchoredPopover";
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
@@ -131,43 +131,41 @@ export function DetailCustomizeMenu({
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const btnRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useDismiss(open, () => setOpen(false), [menuRef, btnRef]);
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
return (
<div className="relative shrink-0">
<div className="shrink-0">
<button
ref={btnRef}
onClick={() => setOpen((v) => !v)}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
title={t("plex.info.customize")}
className={`p-1.5 rounded-lg ${overlay ? "text-white/80 hover:bg-white/15" : "text-muted hover:bg-surface hover:text-fg"}`}
>
<SlidersHorizontal className="w-4 h-4" />
</button>
{open && (
<div
ref={menuRef}
className="glass-menu absolute right-0 top-full z-menu mt-1 w-52 rounded-xl p-1.5 text-sm"
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
>
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
{hasCast && (
<PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />
)}
{hasRelated && onToggleRelated && (
<PrefToggle
label={t("plex.info.prefRelated")}
on={showRelated ?? true}
onClick={onToggleRelated}
/>
)}
{extra}
</div>
)}
<PopoverSurface
popover={pop}
className="glass-menu w-52 rounded-xl p-1.5 text-sm"
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
>
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
{hasCast && (
<PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />
)}
{hasRelated && onToggleRelated && (
<PrefToggle
label={t("plex.info.prefRelated")}
on={showRelated ?? true}
onClick={onToggleRelated}
/>
)}
{extra}
</PopoverSurface>
</div>
);
}
/** One switch row inside `DetailCustomizeMenu` — a `PopoverItem`, so the rows callers pass through
* `extra` join the same roving-focus list as the built-in ones. */
export function PrefToggle({
label,
on,
@@ -178,9 +176,12 @@ export function PrefToggle({
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-surface"
<PopoverItem
role="menuitemradio"
checked={on}
onSelect={onClick}
label={label}
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left outline-none hover:bg-surface data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)]"
>
<span>{label}</span>
<span
@@ -190,6 +191,6 @@ export function PrefToggle({
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`}
/>
</span>
</button>
</PopoverItem>
);
}