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:
@@ -50,6 +50,8 @@ beforeAll(() => {
|
||||
// --- the fake IFrame API ------------------------------------------------------------------------
|
||||
const clock = { current: 0, duration: 3000 };
|
||||
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 = {
|
||||
getCurrentTime: () => clock.current,
|
||||
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),
|
||||
});
|
||||
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 });
|
||||
playerEvents = opts.events ?? {};
|
||||
return fakePlayer;
|
||||
} 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", () => {
|
||||
open();
|
||||
const dialog = screen.getByTestId("player-modal");
|
||||
|
||||
@@ -242,6 +242,22 @@ export default function PlayerModal({
|
||||
// useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their
|
||||
// dep lists too.
|
||||
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
|
||||
// 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
|
||||
@@ -300,12 +316,12 @@ export default function PlayerModal({
|
||||
const { volumeUi, reapplyStored, nudgeVolume } = useYtVolume({
|
||||
playerRef,
|
||||
dialogRef,
|
||||
focusModal,
|
||||
reclaimFocus,
|
||||
});
|
||||
const { nativeControls, setNativeControls } = useNativeControlsYield({
|
||||
playerRef,
|
||||
stageRef,
|
||||
focusModal,
|
||||
reclaimFocus,
|
||||
});
|
||||
const {
|
||||
isFullscreen,
|
||||
@@ -315,7 +331,7 @@ export default function PlayerModal({
|
||||
toggleTrueFullscreen,
|
||||
exitBigView,
|
||||
setMaximized,
|
||||
} = useYtBigView({ stageRef, playerRef, focusModal });
|
||||
} = useYtBigView({ stageRef, playerRef, reclaimFocus });
|
||||
|
||||
// The player can navigate to other videos (YouTube links in a description). The
|
||||
// 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`
|
||||
// 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
|
||||
// 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
|
||||
// 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,
|
||||
setLiveData,
|
||||
reapplyStored,
|
||||
focusModal,
|
||||
reclaimFocus,
|
||||
advanceOnEnd,
|
||||
sessionPosRef,
|
||||
qc,
|
||||
|
||||
@@ -39,7 +39,7 @@ function mountPlayer(setPlayerError: (code: number | null) => void) {
|
||||
setPlayerError,
|
||||
setLiveData: () => {},
|
||||
reapplyStored: () => {},
|
||||
focusModal: () => {},
|
||||
reclaimFocus: () => {},
|
||||
advanceOnEnd: () => {},
|
||||
sessionPosRef: { current: new Map() },
|
||||
qc: new QueryClient(),
|
||||
|
||||
@@ -81,7 +81,7 @@ function mountPlayer(qc: QueryClient, sessionPos: Map<string, number>) {
|
||||
setPlayerError: () => {},
|
||||
setLiveData: () => {},
|
||||
reapplyStored: () => {},
|
||||
focusModal: () => {},
|
||||
reclaimFocus: () => {},
|
||||
advanceOnEnd: () => {},
|
||||
sessionPosRef: { current: sessionPos },
|
||||
qc,
|
||||
|
||||
@@ -81,11 +81,11 @@ function loadYouTubeApi(): Promise<YTNamespace> {
|
||||
export function useYtVolume({
|
||||
playerRef,
|
||||
dialogRef,
|
||||
focusModal,
|
||||
reclaimFocus,
|
||||
}: {
|
||||
playerRef: RefObject<YTPlayer | null>;
|
||||
dialogRef: RefObject<HTMLDivElement | null>;
|
||||
focusModal: () => void;
|
||||
reclaimFocus: () => void;
|
||||
}): {
|
||||
/** Level (0–100) to flash in the on-player overlay, or null when hidden. */
|
||||
volumeUi: number | null;
|
||||
@@ -168,7 +168,7 @@ export function useYtVolume({
|
||||
)
|
||||
return;
|
||||
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);
|
||||
});
|
||||
useEffect(() => {
|
||||
@@ -204,11 +204,11 @@ export function useYtVolume({
|
||||
export function useNativeControlsYield({
|
||||
playerRef,
|
||||
stageRef,
|
||||
focusModal,
|
||||
reclaimFocus,
|
||||
}: {
|
||||
playerRef: RefObject<YTPlayer | null>;
|
||||
stageRef: RefObject<HTMLDivElement | null>;
|
||||
focusModal: () => void;
|
||||
reclaimFocus: () => void;
|
||||
}): {
|
||||
nativeControls: boolean;
|
||||
setNativeControls: (on: boolean) => void;
|
||||
@@ -221,7 +221,7 @@ export function useNativeControlsYield({
|
||||
});
|
||||
const rearmOurControls = useEffectEvent(() => {
|
||||
setNativeControls(false);
|
||||
focusModal();
|
||||
reclaimFocus();
|
||||
});
|
||||
useEffect(() => {
|
||||
// activeElement settles on the tick after blur, so defer the check.
|
||||
@@ -249,11 +249,11 @@ export function useNativeControlsYield({
|
||||
export function useYtBigView({
|
||||
stageRef,
|
||||
playerRef,
|
||||
focusModal,
|
||||
reclaimFocus,
|
||||
}: {
|
||||
stageRef: RefObject<HTMLDivElement | null>;
|
||||
playerRef: RefObject<YTPlayer | null>;
|
||||
focusModal: () => void;
|
||||
reclaimFocus: () => void;
|
||||
}): {
|
||||
isFullscreen: boolean;
|
||||
maximized: boolean;
|
||||
@@ -299,7 +299,7 @@ export function useYtBigView({
|
||||
const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame);
|
||||
isFullscreenRef.current = ours; // read by the once-bound keydown handler
|
||||
setIsFullscreen(ours); // drives the overlay's pointer-events
|
||||
if (!fsEl) focusModal();
|
||||
if (!fsEl) reclaimFocus();
|
||||
});
|
||||
useEffect(() => {
|
||||
return onFullscreenChange(() => syncFullscreen()); // both spellings — see lib/fullscreen
|
||||
@@ -348,7 +348,7 @@ export function useYtProgress({
|
||||
setPlayerError,
|
||||
setLiveData,
|
||||
reapplyStored,
|
||||
focusModal,
|
||||
reclaimFocus,
|
||||
advanceOnEnd,
|
||||
sessionPosRef,
|
||||
qc,
|
||||
@@ -364,7 +364,7 @@ export function useYtProgress({
|
||||
setPlayerError: (code: number | null) => void;
|
||||
setLiveData: (d: { title?: string; author?: string } | null) => void;
|
||||
reapplyStored: () => void;
|
||||
focusModal: () => void;
|
||||
reclaimFocus: () => void;
|
||||
advanceOnEnd: () => void;
|
||||
// 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
|
||||
@@ -382,7 +382,7 @@ export function useYtProgress({
|
||||
setPlayerError,
|
||||
setLiveData,
|
||||
reapplyStored,
|
||||
focusModal,
|
||||
reclaimFocus,
|
||||
advanceOnEnd,
|
||||
qc,
|
||||
}));
|
||||
@@ -396,7 +396,7 @@ export function useYtProgress({
|
||||
setPlayerError,
|
||||
setLiveData,
|
||||
reapplyStored,
|
||||
focusModal,
|
||||
reclaimFocus,
|
||||
advanceOnEnd,
|
||||
qc,
|
||||
} = itemSnapshot();
|
||||
@@ -458,7 +458,7 @@ export function useYtProgress({
|
||||
},
|
||||
events: {
|
||||
onReady: () => {
|
||||
focusModal();
|
||||
reclaimFocus();
|
||||
reapplyStored();
|
||||
},
|
||||
onStateChange: (e: YTPlayerEvent) => {
|
||||
@@ -554,7 +554,7 @@ export function useYtProgress({
|
||||
// and refs (which never change).
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user