From a8ee8a292a223428dad85cf62c6ca92f277ecb64 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 18:03:20 +0000 Subject: [PATCH 1/5] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=20pf=20141?= =?UTF-8?q?=EB=B0=94=ED=80=B4=20=EC=A0=80=EB=84=90=20=EB=8B=A4=EB=93=AC?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Gdqi6Mr2cnHMsA4rsUWCSK From 1df6b9d025888b38a2e263f2ae27dfabc53eefdc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 18:13:54 +0000 Subject: [PATCH 2/5] web: keep the focus in the journal picture window Opening a picture left the focus on the page, so Tab went behind the backdrop. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Gdqi6Mr2cnHMsA4rsUWCSK --- .../journal/JournalLightbox.test.tsx | 79 +++++++++++++++++++ .../components/journal/JournalLightbox.tsx | 20 ++++- 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 web/src/components/journal/JournalLightbox.test.tsx diff --git a/web/src/components/journal/JournalLightbox.test.tsx b/web/src/components/journal/JournalLightbox.test.tsx new file mode 100644 index 000000000..9e285256e --- /dev/null +++ b/web/src/components/journal/JournalLightbox.test.tsx @@ -0,0 +1,79 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } 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")); + }); +}); diff --git a/web/src/components/journal/JournalLightbox.tsx b/web/src/components/journal/JournalLightbox.tsx index 56e79fe6e..879c580e2 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; @@ -10,6 +10,8 @@ type LightboxImage = { export default function JournalLightbox() { const [image, setImage] = useState(null); + const closeRef = useRef(null); + const openerRef = useRef(null); useEffect(() => { const handleClick = (event: MouseEvent) => { @@ -31,6 +33,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, @@ -50,15 +53,29 @@ export default function JournalLightbox() { 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(); + closeRef.current?.focus(); } }; document.body.style.overflow = "hidden"; document.addEventListener("keydown", handleKeyDown); + closeRef.current?.focus({ preventScroll: true }); 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; + opener.focus({ preventScroll: true }); + } }; }, [image]); @@ -75,6 +92,7 @@ export default function JournalLightbox() { onClick={() => setImage(null)} >