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:
@@ -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,29 +96,24 @@ 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()}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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
|
||||
<PopoverItem
|
||||
key={pl.id}
|
||||
onClick={() => toggle(pl)}
|
||||
role="menuitemradio"
|
||||
checked={pl.has_video}
|
||||
onSelect={() => 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"
|
||||
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 ${
|
||||
@@ -161,24 +126,30 @@ export default function AddToPlaylist({
|
||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||
<Youtube className="w-3 h-3 shrink-0 text-muted" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
onSubmit={createAndAdd}
|
||||
className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</form>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
if (variant === "rail") {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
onClick={toggleRail}
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
title={current.label}
|
||||
aria-label={current.label}
|
||||
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
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="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. */}
|
||||
<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>
|
||||
)}
|
||||
</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]`}
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{LANGUAGES.map((l) => (
|
||||
<button
|
||||
<PopoverItem
|
||||
key={l.code}
|
||||
onClick={() => {
|
||||
role="menuitemradio"
|
||||
checked={l.code === value}
|
||||
onSelect={() => {
|
||||
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"
|
||||
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" />}
|
||||
</button>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]"
|
||||
<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, i) => {
|
||||
{options.map((o) => {
|
||||
const Icon = o.icon;
|
||||
const on = o.id === value;
|
||||
return (
|
||||
<button
|
||||
<PopoverItem
|
||||
key={o.id}
|
||||
ref={(el) => {
|
||||
itemRefs.current[i] = el;
|
||||
}}
|
||||
role="menuitemradio"
|
||||
aria-checked={on}
|
||||
tabIndex={i === rovingIdx ? 0 : -1}
|
||||
onClick={() => {
|
||||
checked={on}
|
||||
onSelect={() => {
|
||||
onChange(o.id);
|
||||
close();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left transition ${
|
||||
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" />}
|
||||
</button>
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,23 +131,20 @@ 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"
|
||||
<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} />
|
||||
@@ -162,12 +159,13 @@ export function DetailCustomizeMenu({
|
||||
/>
|
||||
)}
|
||||
{extra}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user