fix(plex): the player's ⋯ and tracks lists are real menus, and every close returns the focus

They were hand-built divs: no menu roles, no arrow keys, and Escape left the
focus on <body>. Both run on the popover engine now (media-tone rows), and the
tuning panel is a labelled dialog that takes the focus and hands it back to ⋯.
This commit is contained in:
2026-09-27 14:22:37 +02:00
parent 9bedd96774
commit a975ab627a
8 changed files with 224 additions and 90 deletions
+19 -1
View File
@@ -1,4 +1,4 @@
import { type RefObject } from "react";
import { useLayoutEffect, type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { Slider, Toggle } from "./plexPlayerControls";
@@ -18,6 +18,9 @@ export function PlexPlayerSettings(props: {
// which is the honest shape — the element is null until the div mounts. The old `RefObject<T>` lied
// about that and 18's types let it.
menuRef: RefObject<HTMLDivElement | null>;
/** The ⋯ that opened the panel: the focus goes back to it when the panel closes with the focus
* inside — by Escape, Back or the ⋯ itself — instead of falling to <body> (E28 review D3). */
returnFocusTo: RefObject<HTMLElement | null>;
settingsTabs: { key: SettingsTab; label: string }[];
activeTab: SettingsTab;
setSettingsTab: (tab: SettingsTab) => void;
@@ -28,6 +31,7 @@ export function PlexPlayerSettings(props: {
}) {
const {
menuRef,
returnFocusTo,
settingsTabs,
activeTab,
setSettingsTab,
@@ -37,9 +41,23 @@ export function PlexPlayerSettings(props: {
now,
} = props;
const { t } = useTranslation();
// Into the panel on open (its first tab), and back to the ⋯ on close. The cleanup runs while the
// panel is still in the DOM, so "the focus was inside" is read before its removal drops it to
// <body>; focus the reader moved elsewhere (a click on the video) stays where they put it.
useLayoutEffect(() => {
const panel = menuRef.current;
const back = returnFocusTo.current; // the ⋯ outlives the panel: it is the panel's own trigger
panel?.querySelector<HTMLElement>("button")?.focus({ preventScroll: true });
return () => {
if (panel?.contains(document.activeElement))
queueMicrotask(() => back?.focus({ preventScroll: true }));
};
}, [menuRef, returnFocusTo]);
return (
<div
ref={menuRef}
role="dialog"
aria-label={t("plex.player.settings")}
onWheel={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"