fix(player): the card under the big view is out of the Tab order, and the focus leaves it and comes back
This commit is contained in:
@@ -1699,6 +1699,64 @@ describe("PlayerModal — the docked bar's fullscreen", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The big view covers the card, and its controls stayed in the Tab order under it: past the bar's
|
||||
// last button the focus went on, unseen, to the card's Previous, Download and Remove.
|
||||
describe("PlayerModal — the card under the big view", () => {
|
||||
const open = () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
return screen.getByLabelText("player.fullscreen");
|
||||
};
|
||||
|
||||
it("takes the card out of reach while the view is big, and gives it back", () => {
|
||||
const enter = open();
|
||||
expect(enter.closest("[inert]")).toBeNull();
|
||||
fireEvent.click(enter);
|
||||
expect(enter.closest("[inert]")).not.toBeNull();
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(enter.closest("[inert]")).toBeNull();
|
||||
});
|
||||
|
||||
it("walks a keyboard from the card's button to the way out, and back to where it was", () => {
|
||||
const enter = open();
|
||||
act(() => enter.focus()); // jsdom calls every focus :focus-visible — a key's focus, here
|
||||
fireEvent.click(enter);
|
||||
expect(document.activeElement).toBe(screen.getByLabelText("player.exitMaximize"));
|
||||
fireEvent.click(screen.getByLabelText("player.exitMaximize"));
|
||||
expect(document.activeElement).toBe(enter);
|
||||
});
|
||||
|
||||
it("does not leave a mouse on the way out, where a Space would leave the big view", () => {
|
||||
const enter = open();
|
||||
act(() => enter.focus());
|
||||
const matches = vi.spyOn(enter, "matches").mockReturnValue(false);
|
||||
fireEvent.click(enter);
|
||||
matches.mockRestore();
|
||||
const card = screen.getByRole("dialog").querySelector<HTMLElement>("[tabindex='-1']");
|
||||
expect(document.activeElement).toBe(card);
|
||||
fireEvent.click(screen.getByLabelText("player.exitMaximize"));
|
||||
expect(document.activeElement).toBe(card);
|
||||
});
|
||||
|
||||
it("does not strand the focus on the page when the bar goes with it", () => {
|
||||
const enter = open();
|
||||
fireEvent.click(enter);
|
||||
act(() => screen.getByLabelText("player.exitMaximize").focus());
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(document.activeElement).not.toBe(document.body);
|
||||
expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Clip mode: the panel sits under the video (the video is its preview), Escape closes the panel and
|
||||
// not the player, the scissors gets the focus back; in the maximised view the panel takes the docked
|
||||
// bar's place; a crop frame lies over the video and Escape turns it off before the panel closes.
|
||||
|
||||
@@ -450,7 +450,8 @@ export default function PlayerModal({
|
||||
// above it ends there.
|
||||
const clipDockRef = useRef<HTMLDivElement>(null);
|
||||
const [clipDockH, setClipDockH] = useState(0);
|
||||
const clipDocked = clipOpen && (maximized || isFullscreen);
|
||||
const bigView = maximized || isFullscreen;
|
||||
const clipDocked = clipOpen && bigView;
|
||||
// Its two places are two parents (the dock is IN the stage, the card's slot under it), and a panel
|
||||
// rendered in the other one is a new panel: the typed name, Loop and the focus were lost on every
|
||||
// F. So it renders once, into a host node of its own, and the host moves between the two.
|
||||
@@ -533,14 +534,37 @@ export default function PlayerModal({
|
||||
const lost = document.activeElement == null || document.activeElement === document.body;
|
||||
if (!refocusClip.current && !lost) return;
|
||||
refocusClip.current = false;
|
||||
const big = maximized || isFullscreen;
|
||||
const target = big ? clipDockButtonRef.current : clipButtonRef.current;
|
||||
const target = bigView ? clipDockButtonRef.current : clipButtonRef.current;
|
||||
if (target) target.focus();
|
||||
else focusModal();
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!clipOpen) refocusAfterClip();
|
||||
}, [clipOpen]);
|
||||
// The big view covers the card, so the card's part with the controls goes inert under it (see the
|
||||
// markup) and the focus cannot stay there. A key's focus moves to the way out and comes back to
|
||||
// the same control when the view ends. A mouse's goes to the card: on the way out, the next Space
|
||||
// would leave the big view instead of pausing. A focus the view's end took with it (the bar's
|
||||
// controls go with the bar) goes back to the card. After the clip panel's move above, which keeps
|
||||
// its own focus.
|
||||
const cardPanelRef = useRef<HTMLDivElement>(null);
|
||||
const bigExitRef = useRef<HTMLButtonElement>(null);
|
||||
const focusBeforeBig = useRef<HTMLElement | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (bigView) {
|
||||
const active = document.activeElement;
|
||||
if (!(active instanceof HTMLElement) || !cardPanelRef.current?.contains(active)) return;
|
||||
const byKey = active.matches(":focus-visible");
|
||||
focusBeforeBig.current = byKey ? active : null;
|
||||
if (byKey && bigExitRef.current) bigExitRef.current.focus();
|
||||
else focusModal();
|
||||
return;
|
||||
}
|
||||
const back = focusBeforeBig.current;
|
||||
focusBeforeBig.current = null;
|
||||
if (back?.isConnected) back.focus();
|
||||
else reclaimFocus();
|
||||
}, [bigView, focusModal, reclaimFocus]);
|
||||
/** Mark where the player is now (`mark` gets the range so far, or null when the panel is shut). The
|
||||
* panel lives under the video in the card, and in place of the docked bar when maximised; the
|
||||
* browser's fullscreen steps back to the maximised view, which has room for it. */
|
||||
@@ -1151,7 +1175,7 @@ export default function PlayerModal({
|
||||
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
|
||||
<div
|
||||
className={
|
||||
maximized || isFullscreen
|
||||
bigView
|
||||
? `absolute inset-x-0 top-0 ${clipDocked ? "" : "bottom-14"}`
|
||||
: "relative w-full h-full"
|
||||
}
|
||||
@@ -1209,7 +1233,7 @@ export default function PlayerModal({
|
||||
{clipDocked && (
|
||||
<div ref={clipDockRef} className="absolute inset-x-0 bottom-0 z-menu" />
|
||||
)}
|
||||
{(maximized || isFullscreen) && !clipDocked && (
|
||||
{bigView && !clipDocked && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center gap-x-3 border-t border-white/10 bg-media-chrome/95 media-marks px-3 text-sm text-white"
|
||||
@@ -1287,6 +1311,7 @@ export default function PlayerModal({
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
ref={bigExitRef}
|
||||
variant="overlay"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1356,6 +1381,8 @@ export default function PlayerModal({
|
||||
{/* Wide: the side panel. Its content scrolls inside a box the video's height, so a long
|
||||
description never makes the card taller than the video. */}
|
||||
<div
|
||||
ref={cardPanelRef}
|
||||
inert={bigView}
|
||||
className={wide ? "relative shrink-0 border-l border-border" : undefined}
|
||||
style={wide ? { width: panelWidth } : undefined}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user