fix(ui): no button drops the keyboard focus when its own action disables it

A focused button that turns natively disabled loses the focus to <body> in
Chrome. The app disables the very button that was pressed on ~60 sites — a
mutation pending, a pager at its last page, undo with nothing left, a player
at the end of its queue, a form gone clean — so a keyboard user was thrown
out of the page on every such press.

One primitive instead of a fix per site: ui/FocusSafeButton renders a
focused-and-disabled (or busy) button as aria-disabled with a click guard,
and applies the native attribute again once the focus is elsewhere. It asks
the document at render time as well as listening for focus events, because
a window without focus fires none (measured). ui/Button and ui/form's Switch
are built on it; 57 hand-rolled buttons swapped their tag for it keeping
their classes, and three hand-copies of the rule (the channel page's Block
and Hide, ChannelDiscovery's Subscribe) now pass `busy`. The soft state's
dim look is one rule in index.css.

`local/focus-safe-disabled` refuses a new raw <button disabled>, pinned by
focusSafe.contract.test.ts. lib/plexDetailUi moved to components/: it is
components, and lib may not import the primitive.

Measured on localdev in Chrome: the audit log's Next button keeps the focus
on reaching the last page, reads aria-disabled, and the guarded click does
nothing.
This commit is contained in:
2026-09-19 08:07:25 +02:00
parent bc138e3b37
commit 7a47223a58
39 changed files with 368 additions and 159 deletions
@@ -0,0 +1,35 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
// `local/focus-safe-disabled` (eslint.config.js): a raw `<button disabled>` throws a focused keyboard
// user to <body> the moment it turns disabled. ~60 were swept onto ui/FocusSafeButton; this pins that
// the rule keeping a new one out actually fires.
const RULE = "local/focus-safe-disabled";
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
}
describe("a raw button that can be disabled", () => {
it("is refused, statically or not", async () => {
expect(
await hits("export const A = ({ p }: { p: boolean }) => <button disabled={p}>x</button>;\n"),
).toBe(1);
expect(await hits("export const B = () => <button disabled>x</button>;\n")).toBe(1);
});
it("leaves a raw button with no disabled, and the primitive, alone", async () => {
expect(await hits("export const C = () => <button onClick={() => {}}>x</button>;\n")).toBe(0);
expect(
await hits(
"export const D = (p: { d: boolean }) => <button disabled={p.d}>x</button>;\n",
"src/components/ui/FocusSafeButton.tsx",
),
).toBe(0);
});
});
-271
View File
@@ -1,271 +0,0 @@
import { useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Check,
CheckCheck,
Play,
SlidersHorizontal,
type LucideIcon,
} from "lucide-react";
import { useDismiss } from "./useDismiss";
// 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
// per-user display prefs (art background / cast row — same keys as the movie info page), and the
// customize menu that toggles them. The prefs hook, backdrop helpers and epLabel live in plexDetail.ts.
// A metadata value that filters the grid when clicked (if onClick is wired), else plain text. Shared
// by the movie info page (PlexInfo) and the unified grid / show pages (PlexBrowse). The optional
// `title` gives a hover hint (e.g. "Filter by 1998"); callers that don't need it just omit it.
export function Filterable({
onClick,
title,
className,
children,
}: {
onClick?: () => void;
title?: string;
className?: string;
children: ReactNode;
}) {
if (!onClick) return <span className={className}>{children}</span>;
return (
<button
onClick={onClick}
title={title}
className={`${className ?? ""} cursor-pointer hover:text-accent transition`}
>
{children}
</button>
);
}
export function DetailCustomizeMenu({
artBg,
onToggleArtBg,
showCast,
onToggleCast,
hasCast,
showRelated,
onToggleRelated,
hasRelated,
crossfade,
onToggleCrossfade,
overlay,
extra,
}: {
artBg: boolean;
onToggleArtBg: () => void;
showCast: boolean;
onToggleCast: () => void;
hasCast: boolean;
showRelated?: boolean;
onToggleRelated?: () => void;
hasRelated?: boolean;
// Crossfade toggle (E5 S8) — offered only when the art backdrop itself is on; omitted where a page
// doesn't wire it (e.g. the season page reuses the show's backdrop prefs without its own control).
crossfade?: boolean;
onToggleCrossfade?: () => void;
overlay?: boolean; // white-on-dark button styling when floated over the player
extra?: ReactNode; // additional toggles rendered after the built-in ones (e.g. strip-source buckets)
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const btnRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useDismiss(open, () => setOpen(false), [menuRef, btnRef]);
// Deliberately NOT on the R8 popover platform. In the player's OVERLAY variant this menu renders
// inside PlexPlayer's `transform: scale(1.25)` windowed subtree, and the platform portals its panel
// out to <body> — which would paint it at 1× while the surrounding player UI is at 1.25×. A plain
// `absolute` child stays in the scaled subtree and matches, in both this variant and the unscaled
// detail-page variant. Same reason the player's own gear/tracks menus stay hand-rolled; migrating
// any of them needs the platform to portal INTO a caller-named (scaled) container.
return (
<div className="relative shrink-0">
<button
ref={btnRef}
onClick={() => setOpen((v) => !v)}
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} />
{artBg && onToggleCrossfade && (
<PrefToggle
label={t("plex.info.prefCrossfade")}
on={crossfade ?? true}
onClick={onToggleCrossfade}
/>
)}
{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>
)}
</div>
);
}
/** One switch row inside `DetailCustomizeMenu`. Independent on/off toggles (all can be on at once),
* so a plain button, not a menuitemradio. */
export function PrefToggle({
label,
on,
onClick,
}: {
label: string;
on: boolean;
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"
>
<span>{label}</span>
<span
className={`relative h-4 w-7 rounded-full transition ${on ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`}
/>
</span>
</button>
);
}
// --- Series detail-page shared UI (the show page + the season subpage share these) -------------------
// Moved out of PlexBrowse — small building blocks the show/season detail views render verbatim.
export function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
return (
<button
onClick={onBack}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs mb-4"
>
<ArrowLeft className="w-4 h-4" />
{label}
</button>
);
}
export function ActionBtn({
onClick,
icon: Icon,
label,
sub,
primary,
disabled,
}: {
onClick: () => void;
icon: LucideIcon;
label: string;
sub?: string;
primary?: boolean;
disabled?: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:opacity-50 ${
primary ? "btn-primary shadow-xs" : "glass-card glass-hover"
}`}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="flex flex-col items-start leading-tight">
<span>{label}</span>
{sub && <span className="text-2xs font-normal opacity-70">{sub}</span>}
</span>
</button>
);
}
/** The shared detail-page hero shell used by BOTH the show page and the season subpage (they were
* copy-paste siblings). Owns the glass frame, the play/resume poster button, the customize-menu slot,
* and the flex info column; the caller passes the customize menu + the title/metadata/actions as
* `children`. Poster is slightly larger on the show page (default) than the season page. */
export function DetailHero({
thumb,
posterTitle,
posterDisabled,
showPlayOverlay,
onPosterClick,
posterSizeClass = "w-32 sm:w-44",
customizeMenu,
children,
}: {
thumb: string;
posterTitle: string;
posterDisabled: boolean;
showPlayOverlay: boolean;
onPosterClick: () => void;
posterSizeClass?: string;
customizeMenu: ReactNode;
children: ReactNode;
}) {
return (
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
<button
onClick={onPosterClick}
disabled={posterDisabled}
className={`group/poster relative ${posterSizeClass} shrink-0 self-start`}
title={posterTitle}
>
<img
src={thumb}
alt=""
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
/>
{showPlayOverlay && (
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
<Play className="w-10 h-10 text-white" fill="currentColor" />
</div>
)}
</button>
<div className="min-w-0 flex-1">{children}</div>
</div>
);
}
/** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both
* the show and season heroes; the labels differ, so the caller passes both. */
export function WatchedToggle({
watched,
busy,
onToggle,
watchedLabel,
unwatchedLabel,
}: {
watched: boolean;
busy?: boolean;
onToggle: () => void;
watchedLabel: string;
unwatchedLabel: string;
}) {
return (
<ActionBtn
onClick={onToggle}
disabled={busy}
icon={watched ? CheckCheck : Check}
label={watched ? watchedLabel : unwatchedLabel}
/>
);
}