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,
|
||||
|
||||
Reference in New Issue
Block a user