fix(player): the player's own focus grabs leave a tabbed-to control alone

Turning ready, the pointer leaving the video, the window regaining focus, a wheel over it and
leaving fullscreen each put the focus back on the card unconditionally, so a viewer who had
tabbed to a control right after opening started over from the first button on the next Tab.
They now take it back only when it sits in the iframe or off the card.
This commit is contained in:
2026-10-10 01:14:31 +02:00
parent c003a48244
commit 3438aeb695
5 changed files with 80 additions and 23 deletions
@@ -50,6 +50,8 @@ beforeAll(() => {
// --- the fake IFrame API ------------------------------------------------------------------------ // --- the fake IFrame API ------------------------------------------------------------------------
const clock = { current: 0, duration: 3000 }; const clock = { current: 0, duration: 3000 };
const started: { videoId: string; start: number | undefined }[] = []; const started: { videoId: string; start: number | undefined }[] = [];
// The last player's event handlers, so a test can say "the player is ready" when it wants to.
let playerEvents: { onReady?: () => void } = {};
const base = { const base = {
getCurrentTime: () => clock.current, getCurrentTime: () => clock.current,
getDuration: () => clock.duration, getDuration: () => clock.duration,
@@ -61,8 +63,12 @@ const fakePlayer = new Proxy(base, {
get: (t, k) => (k in t ? t[k as keyof typeof t] : k === "then" ? undefined : () => undefined), get: (t, k) => (k in t ? t[k as keyof typeof t] : k === "then" ? undefined : () => undefined),
}); });
window.YT = { window.YT = {
Player: function (_el: unknown, opts: { videoId: string; playerVars: { start?: number } }) { Player: function (
_el: unknown,
opts: { videoId: string; playerVars: { start?: number }; events?: { onReady?: () => void } },
) {
started.push({ videoId: opts.videoId, start: opts.playerVars.start }); started.push({ videoId: opts.videoId, start: opts.playerVars.start });
playerEvents = opts.events ?? {};
return fakePlayer; return fakePlayer;
} as never, } as never,
}; };
@@ -331,6 +337,40 @@ describe("PlayerModal — the dialog contract", () => {
} }
}); });
// The card takes the focus back on its own when it may have slipped into the iframe: the player
// turning ready, the pointer leaving the video, the window regaining focus. None of those may take
// it off a control the viewer tabbed to, or their next Tab starts over from the first button.
it.each([
["the player turns ready", () => playerEvents.onReady?.()],
[
"the pointer leaves the video",
() => {
fireEvent.mouseLeave(document.querySelector(".player-stage")!);
},
],
[
"the window regains focus",
() => {
fireEvent.focus(window);
},
],
])("leaves the focus on a control the viewer tabbed to when %s", async (_why, happen) => {
open();
await waitFor(() => expect(playerEvents.onReady).toBeTypeOf("function"));
const control = within(screen.getByTestId("player-modal")).getByTestId("player-close");
control.focus();
act(happen);
expect(document.activeElement).toBe(control);
});
it("takes the focus back from the iframe when the player turns ready", async () => {
open();
await waitFor(() => expect(playerEvents.onReady).toBeTypeOf("function"));
(document.activeElement as HTMLElement).blur();
act(() => playerEvents.onReady?.());
expect(screen.getByTestId("player-modal").contains(document.activeElement)).toBe(true);
});
it("keeps Tab inside the player", () => { it("keeps Tab inside the player", () => {
open(); open();
const dialog = screen.getByTestId("player-modal"); const dialog = screen.getByTestId("player-modal");
+22 -5
View File
@@ -242,6 +242,22 @@ export default function PlayerModal({
// useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their // useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their
// dep lists too. // dep lists too.
const focusModal = useCallback(() => cardRef.current?.focus(), []); const focusModal = useCallback(() => cardRef.current?.focus(), []);
// What the player does on its own (turning ready, the pointer leaving the video, the window
// regaining focus, a wheel over it, leaving fullscreen) takes the focus back only when it may have
// slipped into the iframe or off the card. Taking it off a control the viewer tabbed to restarted
// their Tab from the first button.
const reclaimFocus = useCallback(() => {
const card = cardRef.current;
const active = document.activeElement;
if (
card &&
active instanceof HTMLElement &&
active.tagName !== "IFRAME" &&
card.contains(active)
)
return;
card?.focus();
}, []);
// The card fits the window, video AND what sits under it (the queue strip, title, channel row): the // The card fits the window, video AND what sits under it (the queue strip, title, channel row): the
// video is 16:9 at the card's width, so on a short window (133% zoom on a 1080p screen: 685px) a // video is 16:9 at the card's width, so on a short window (133% zoom on a 1080p screen: 685px) a
// full-width card ran past the bottom and cut its channel row. `below` is that chrome's // full-width card ran past the bottom and cut its channel row. `below` is that chrome's
@@ -300,12 +316,12 @@ export default function PlayerModal({
const { volumeUi, reapplyStored, nudgeVolume } = useYtVolume({ const { volumeUi, reapplyStored, nudgeVolume } = useYtVolume({
playerRef, playerRef,
dialogRef, dialogRef,
focusModal, reclaimFocus,
}); });
const { nativeControls, setNativeControls } = useNativeControlsYield({ const { nativeControls, setNativeControls } = useNativeControlsYield({
playerRef, playerRef,
stageRef, stageRef,
focusModal, reclaimFocus,
}); });
const { const {
isFullscreen, isFullscreen,
@@ -315,7 +331,7 @@ export default function PlayerModal({
toggleTrueFullscreen, toggleTrueFullscreen,
exitBigView, exitBigView,
setMaximized, setMaximized,
} = useYtBigView({ stageRef, playerRef, focusModal }); } = useYtBigView({ stageRef, playerRef, reclaimFocus });
// The player can navigate to other videos (YouTube links in a description). The // The player can navigate to other videos (YouTube links in a description). The
// currently-playing id may differ from the active item. // currently-playing id may differ from the active item.
@@ -550,7 +566,8 @@ export default function PlayerModal({
// Keyboard shortcuts. The chords live in the shared keymap (`ytPlayer` // Keyboard shortcuts. The chords live in the shared keymap (`ytPlayer`
// scope) so the help sheet lists them; only the behaviour behind each id is here. // scope) so the help sheet lists them; only the behaviour behind each id is here.
// These fire only while focus is on our page, not inside the cross-origin player iframe — so // These fire only while focus is on our page, not inside the cross-origin player iframe — so
// we focus the modal card on open and again on player-ready (autoplay can grab focus). // we focus the modal card on open, and take it back on player-ready (autoplay can grab focus)
// unless the viewer has since tabbed to a control (`reclaimFocus`).
// //
// The handler itself is a useEffectEvent (React 19.2+): it is bound ONCE per player, but it reads // The handler itself is a useEffectEvent (React 19.2+): it is bound ONCE per player, but it reads
// whatever is current at the moment a key arrives, which is what the disabled dep list used to // whatever is current at the moment a key arrives, which is what the disabled dep list used to
@@ -654,7 +671,7 @@ export default function PlayerModal({
setPlayerError, setPlayerError,
setLiveData, setLiveData,
reapplyStored, reapplyStored,
focusModal, reclaimFocus,
advanceOnEnd, advanceOnEnd,
sessionPosRef, sessionPosRef,
qc, qc,
@@ -39,7 +39,7 @@ function mountPlayer(setPlayerError: (code: number | null) => void) {
setPlayerError, setPlayerError,
setLiveData: () => {}, setLiveData: () => {},
reapplyStored: () => {}, reapplyStored: () => {},
focusModal: () => {}, reclaimFocus: () => {},
advanceOnEnd: () => {}, advanceOnEnd: () => {},
sessionPosRef: { current: new Map() }, sessionPosRef: { current: new Map() },
qc: new QueryClient(), qc: new QueryClient(),
@@ -81,7 +81,7 @@ function mountPlayer(qc: QueryClient, sessionPos: Map<string, number>) {
setPlayerError: () => {}, setPlayerError: () => {},
setLiveData: () => {}, setLiveData: () => {},
reapplyStored: () => {}, reapplyStored: () => {},
focusModal: () => {}, reclaimFocus: () => {},
advanceOnEnd: () => {}, advanceOnEnd: () => {},
sessionPosRef: { current: sessionPos }, sessionPosRef: { current: sessionPos },
qc, qc,
+15 -15
View File
@@ -81,11 +81,11 @@ function loadYouTubeApi(): Promise<YTNamespace> {
export function useYtVolume({ export function useYtVolume({
playerRef, playerRef,
dialogRef, dialogRef,
focusModal, reclaimFocus,
}: { }: {
playerRef: RefObject<YTPlayer | null>; playerRef: RefObject<YTPlayer | null>;
dialogRef: RefObject<HTMLDivElement | null>; dialogRef: RefObject<HTMLDivElement | null>;
focusModal: () => void; reclaimFocus: () => void;
}): { }): {
/** Level (0–100) to flash in the on-player overlay, or null when hidden. */ /** Level (0–100) to flash in the on-player overlay, or null when hidden. */
volumeUi: number | null; volumeUi: number | null;
@@ -168,7 +168,7 @@ export function useYtVolume({
) )
return; return;
e.preventDefault(); e.preventDefault();
focusModal(); // re-grab focus if it had slipped into the native controls reclaimFocus(); // re-grab the focus if it had slipped into the native controls
nudgeVolume(e.deltaY < 0 ? 5 : -5); nudgeVolume(e.deltaY < 0 ? 5 : -5);
}); });
useEffect(() => { useEffect(() => {
@@ -204,11 +204,11 @@ export function useYtVolume({
export function useNativeControlsYield({ export function useNativeControlsYield({
playerRef, playerRef,
stageRef, stageRef,
focusModal, reclaimFocus,
}: { }: {
playerRef: RefObject<YTPlayer | null>; playerRef: RefObject<YTPlayer | null>;
stageRef: RefObject<HTMLDivElement | null>; stageRef: RefObject<HTMLDivElement | null>;
focusModal: () => void; reclaimFocus: () => void;
}): { }): {
nativeControls: boolean; nativeControls: boolean;
setNativeControls: (on: boolean) => void; setNativeControls: (on: boolean) => void;
@@ -221,7 +221,7 @@ export function useNativeControlsYield({
}); });
const rearmOurControls = useEffectEvent(() => { const rearmOurControls = useEffectEvent(() => {
setNativeControls(false); setNativeControls(false);
focusModal(); reclaimFocus();
}); });
useEffect(() => { useEffect(() => {
// activeElement settles on the tick after blur, so defer the check. // activeElement settles on the tick after blur, so defer the check.
@@ -249,11 +249,11 @@ export function useNativeControlsYield({
export function useYtBigView({ export function useYtBigView({
stageRef, stageRef,
playerRef, playerRef,
focusModal, reclaimFocus,
}: { }: {
stageRef: RefObject<HTMLDivElement | null>; stageRef: RefObject<HTMLDivElement | null>;
playerRef: RefObject<YTPlayer | null>; playerRef: RefObject<YTPlayer | null>;
focusModal: () => void; reclaimFocus: () => void;
}): { }): {
isFullscreen: boolean; isFullscreen: boolean;
maximized: boolean; maximized: boolean;
@@ -299,7 +299,7 @@ export function useYtBigView({
const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame); const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame);
isFullscreenRef.current = ours; // read by the once-bound keydown handler isFullscreenRef.current = ours; // read by the once-bound keydown handler
setIsFullscreen(ours); // drives the overlay's pointer-events setIsFullscreen(ours); // drives the overlay's pointer-events
if (!fsEl) focusModal(); if (!fsEl) reclaimFocus();
}); });
useEffect(() => { useEffect(() => {
return onFullscreenChange(() => syncFullscreen()); // both spellings — see lib/fullscreen return onFullscreenChange(() => syncFullscreen()); // both spellings — see lib/fullscreen
@@ -348,7 +348,7 @@ export function useYtProgress({
setPlayerError, setPlayerError,
setLiveData, setLiveData,
reapplyStored, reapplyStored,
focusModal, reclaimFocus,
advanceOnEnd, advanceOnEnd,
sessionPosRef, sessionPosRef,
qc, qc,
@@ -364,7 +364,7 @@ export function useYtProgress({
setPlayerError: (code: number | null) => void; setPlayerError: (code: number | null) => void;
setLiveData: (d: { title?: string; author?: string } | null) => void; setLiveData: (d: { title?: string; author?: string } | null) => void;
reapplyStored: () => void; reapplyStored: () => void;
focusModal: () => void; reclaimFocus: () => void;
advanceOnEnd: () => void; advanceOnEnd: () => void;
// Positions this player session has actually STORED, per video id. Written from the server's // Positions this player session has actually STORED, per video id. Written from the server's
// answer, read back by the modal's resume so stepping next→prev inside one session does not // answer, read back by the modal's resume so stepping next→prev inside one session does not
@@ -382,7 +382,7 @@ export function useYtProgress({
setPlayerError, setPlayerError,
setLiveData, setLiveData,
reapplyStored, reapplyStored,
focusModal, reclaimFocus,
advanceOnEnd, advanceOnEnd,
qc, qc,
})); }));
@@ -396,7 +396,7 @@ export function useYtProgress({
setPlayerError, setPlayerError,
setLiveData, setLiveData,
reapplyStored, reapplyStored,
focusModal, reclaimFocus,
advanceOnEnd, advanceOnEnd,
qc, qc,
} = itemSnapshot(); } = itemSnapshot();
@@ -458,7 +458,7 @@ export function useYtProgress({
}, },
events: { events: {
onReady: () => { onReady: () => {
focusModal(); reclaimFocus();
reapplyStored(); reapplyStored();
}, },
onStateChange: (e: YTPlayerEvent) => { onStateChange: (e: YTPlayerEvent) => {
@@ -554,7 +554,7 @@ export function useYtProgress({
// and refs (which never change). // and refs (which never change).
// //
// The render values the body uses — `resumeAt`, `setWatched`, `advanceOnEnd`, `reapplyStored`, // The render values the body uses — `resumeAt`, `setWatched`, `advanceOnEnd`, `reapplyStored`,
// `focusModal`, `qc`, … — are a PER-ITEM SNAPSHOT, taken once through `itemSnapshot` when this // `reclaimFocus`, `qc`, … — are a PER-ITEM SNAPSHOT, taken once through `itemSnapshot` when this
// player is built: they describe the video it was built for, and re-reading them mid-session // player is built: they describe the video it was built for, and re-reading them mid-session
// would be the bug, not the fix. `setWatched` is the clearest case: bound to the item, it lets // would be the bug, not the fix. `setWatched` is the clearest case: bound to the item, it lets
// this cleanup's late auto-mark land on the item being LEFT, which the modal then ignores. What // this cleanup's late auto-mark land on the item being LEFT, which the modal then ignores. What