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
+5 -4
View File
@@ -61,6 +61,7 @@ import {
useYtVolume,
} from "../lib/playerModalHooks";
import { useScrollFade } from "../lib/useScrollFade";
import { FocusSafeButton } from "./ui/FocusSafeButton";
// Experiment (branch experiment/inline-player): play the video in-app via the
// YouTube IFrame Player API (not a bare embed) so we can read playback position
@@ -539,7 +540,7 @@ export default function PlayerModal({
where there's no room beside the card. */}
<div className="relative flex w-full max-w-4xl max-h-full">
{hasQueue && (
<button
<FocusSafeButton
onClick={(e) => {
e.stopPropagation();
goPrev();
@@ -550,7 +551,7 @@ export default function PlayerModal({
className="absolute right-full top-0 bottom-0 mr-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
>
<ChevronLeft className="h-8 w-8" />
</button>
</FocusSafeButton>
)}
<div
ref={cardRef}
@@ -970,7 +971,7 @@ export default function PlayerModal({
</div>
</div>
{hasQueue && (
<button
<FocusSafeButton
onClick={(e) => {
e.stopPropagation();
goNext();
@@ -981,7 +982,7 @@ export default function PlayerModal({
className="absolute left-full top-0 bottom-0 ml-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
>
<ChevronRight className="h-8 w-8" />
</button>
</FocusSafeButton>
)}
</div>
{/* Toasts raised while the player is open (a failed Remove, a lost connection) — the app's own