diff --git a/web/src/components/journal/JournalLightbox.test.tsx b/web/src/components/journal/JournalLightbox.test.tsx new file mode 100644 index 000000000..91588d62e --- /dev/null +++ b/web/src/components/journal/JournalLightbox.test.tsx @@ -0,0 +1,113 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import JournalLightbox from "./JournalLightbox"; + +// Clicking a picture in a post opens it large in a dialog. The focus stayed +// on the page under it, so one Tab went to a link behind the backdrop. On 27 +// October, on the 50 posts with pictures at 390 and 1440, that Tab left the +// window in 100 of 100 runs and scrolled the post away from the picture in 72. +// In 2 it went into the video above the picture, and Esc no longer closed the +// window. + +function renderPost() { + return render( + <> + +
+ {/* eslint-disable-next-line @next/next/no-img-element -- stands in for the picture JournalImage draws */} + The desk +
The desk in October.
+
+ Older post + , + ); +} + +function open() { + act(() => { + fireEvent.click(screen.getByAltText("The desk")); + }); + return screen.getByRole("dialog", { name: "The desk in October." }); +} + +describe("JournalLightbox", () => { + it("moves the focus to the close button when a picture opens", () => { + renderPost(); + open(); + expect(document.activeElement).toBe(screen.getByRole("button", { name: "Close image" })); + }); + + it("keeps Tab and Shift+Tab on the close button", () => { + renderPost(); + open(); + const close = screen.getByRole("button", { name: "Close image" }); + + expect(fireEvent.keyDown(close, { key: "Tab" })).toBe(false); + expect(document.activeElement).toBe(close); + expect(fireEvent.keyDown(close, { key: "Tab", shiftKey: true })).toBe(false); + expect(document.activeElement).toBe(close); + + // Focus that got out some other way comes back on the next Tab. + screen.getByRole("link", { name: "Older post" }).focus(); + fireEvent.keyDown(document.activeElement ?? document.body, { key: "Tab" }); + expect(document.activeElement).toBe(close); + }); + + it("gives the focus back to the picture on Esc, without a Tab stop", () => { + renderPost(); + open(); + act(() => { + fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" }); + }); + + expect(screen.queryByRole("dialog")).toBeNull(); + const picture = screen.getByAltText("The desk"); + expect(document.activeElement).toBe(picture); + expect(picture.tabIndex).toBe(-1); + }); + + it("gives the focus back to the picture when the close button is pressed", () => { + renderPost(); + open(); + act(() => { + fireEvent.click(screen.getByRole("button", { name: "Close image" })); + }); + + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.activeElement).toBe(screen.getByAltText("The desk")); + }); + + // Chromium rings whatever a script focuses, so a mouse that opened and + // closed the window would leave a ring on the close button and on the + // picture. jsdom draws no ring; the options passed to focus() are the test. + it("asks for a focus ring only once the reader has pressed Tab", () => { + const focus = vi.spyOn(HTMLElement.prototype, "focus"); + const ringFor = (element: HTMLElement) => + focus.mock.contexts.flatMap((context, i) => + context === element ? [(focus.mock.calls[i][0] as { focusVisible?: boolean } | undefined)?.focusVisible] : [], + ); + + renderPost(); + const picture = screen.getByAltText("The desk"); + open(); + const close = screen.getByRole("button", { name: "Close image" }); + act(() => { + fireEvent.click(close); + }); + expect(ringFor(close)).toEqual([false]); + expect(ringFor(picture)).toEqual([false]); + + focus.mockClear(); + open(); + fireEvent.keyDown(screen.getByRole("button", { name: "Close image" }), { key: "Tab" }); + act(() => { + fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" }); + }); + expect(focus.mock.calls.map((call) => (call[0] as { focusVisible?: boolean } | undefined)?.focusVisible)).toEqual([ + false, + true, + true, + ]); + expect(focus.mock.contexts.at(-1)).toBe(picture); + }); +}); diff --git a/web/src/components/journal/JournalLightbox.tsx b/web/src/components/journal/JournalLightbox.tsx index 56e79fe6e..9ff4137be 100644 --- a/web/src/components/journal/JournalLightbox.tsx +++ b/web/src/components/journal/JournalLightbox.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; type LightboxImage = { src: string; @@ -8,8 +8,16 @@ type LightboxImage = { caption: string; }; +// focusVisible is in Chromium and Firefox but not yet in TypeScript's DOM types. +function moveFocus(element: HTMLElement, ring: boolean) { + const options: FocusOptions & { focusVisible: boolean } = { preventScroll: true, focusVisible: ring }; + element.focus(options); +} + export default function JournalLightbox() { const [image, setImage] = useState(null); + const closeRef = useRef(null); + const openerRef = useRef(null); useEffect(() => { const handleClick = (event: MouseEvent) => { @@ -31,6 +39,7 @@ export default function JournalLightbox() { // always shows the full-resolution image, even on mobile where // Next Image's srcset may have selected a smaller variant. const originalSrc = target.getAttribute("src") || target.src; + openerRef.current = target; setImage({ src: originalSrc, alt: target.alt, @@ -47,18 +56,41 @@ export default function JournalLightbox() { return; } + // Chromium draws the focus ring wherever a script puts the focus, even + // after a mouse click. The ring waits for the first Tab. + let tabbed = false; + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setImage(null); + } else if (event.key === "Tab") { + // The close button is the one thing in the window that takes the + // focus. A Tab that left it went to the links under the backdrop and + // scrolled the post away from the picture. + event.preventDefault(); + const close = closeRef.current; + if (!close) return; + // focus() on the focused element changes nothing, ring included. + if (!tabbed) close.blur(); + tabbed = true; + moveFocus(close, true); } }; document.body.style.overflow = "hidden"; document.addEventListener("keydown", handleKeyDown); + if (closeRef.current) moveFocus(closeRef.current, false); return () => { document.body.style.overflow = ""; document.removeEventListener("keydown", handleKeyDown); + // Back to the picture, so the next Tab carries on from there. An img + // takes the focus only with a tabindex. + const opener = openerRef.current; + if (opener?.isConnected) { + if (!opener.hasAttribute("tabindex")) opener.tabIndex = -1; + moveFocus(opener, tabbed); + } }; }, [image]); @@ -75,6 +107,7 @@ export default function JournalLightbox() { onClick={() => setImage(null)} >