diff --git a/desktop/src/features/channels/ui/markdownDocFocus.test.mjs b/desktop/src/features/channels/ui/markdownDocFocus.test.mjs new file mode 100644 index 0000000000..70613f8fcf --- /dev/null +++ b/desktop/src/features/channels/ui/markdownDocFocus.test.mjs @@ -0,0 +1,142 @@ +/** + * Opener-identity tests for the markdown panel focus restore (PR #6731 P2 + * follow-up). The same attachment can appear in several messages — several + * cards sharing one `data-doc-url` — so restoring by URL alone always lands + * on the first DOM match. These tests pin the recorded per-invocation + * identity: the invoking card wins, the record is consumed after one + * restore, a missing record falls back to the first match, and a claimed + * focus target aborts the restore entirely. + */ + +import assert from "node:assert/strict"; +import { after, before, beforeEach, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +// pretendToBeVisual gives the module its requestAnimationFrame loop. +const dom = new JSDOM("
", { + pretendToBeVisual: true, + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + // This jsdom build has no window.CSS; quote-safe escaping is all the + // module's attribute selectors need. + CSS: { escape: (value) => String(value).replace(/["\\]/g, "\\$&") }, + cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + window: dom.window, + }); +}); + +after(() => dom.window.close()); + +const DOC_URL = "http://localhost:3000/media/deadbeef.bin"; + +function addCard(url = DOC_URL) { + const card = dom.window.document.createElement("button"); + card.setAttribute("data-testid", "file-card"); + card.setAttribute("data-doc-url", url); + dom.window.document.body.appendChild(card); + return card; +} + +function settleFrames(count = 3) { + let done = Promise.resolve(); + for (let i = 0; i < count; i += 1) { + done = done.then( + () => new Promise((resolve) => dom.window.requestAnimationFrame(resolve)), + ); + } + return done; +} + +async function loadModule() { + return import("./markdownDocFocus.ts"); +} + +beforeEach(async () => { + dom.window.document.body.innerHTML = ""; + // Park focus on (focus is "free") and clear any leftover record. + const { recordMarkdownDocOpener } = await loadModule(); + recordMarkdownDocOpener(DOC_URL, null); +}); + +test("restores the recorded invoking card, not the first URL match", async () => { + const { recordMarkdownDocOpener, restoreFocusToMarkdownDocOpener } = + await loadModule(); + addCard(); + const second = addCard(); + + recordMarkdownDocOpener(DOC_URL, second); + restoreFocusToMarkdownDocOpener(DOC_URL); + await settleFrames(); + + assert.equal(dom.window.document.activeElement, second); +}); + +test("consumes the record: a later restore without one takes the first match", async () => { + const { recordMarkdownDocOpener, restoreFocusToMarkdownDocOpener } = + await loadModule(); + const first = addCard(); + const second = addCard(); + + recordMarkdownDocOpener(DOC_URL, second); + restoreFocusToMarkdownDocOpener(DOC_URL); + await settleFrames(); + assert.equal(dom.window.document.activeElement, second); + + // Free the focus again, then restore with no fresh record (deep link / + // reload open): the stale index must not survive the first consumption. + second.blur(); + restoreFocusToMarkdownDocOpener(DOC_URL); + await settleFrames(); + assert.equal(dom.window.document.activeElement, first); +}); + +test("clamps to the last remaining card when the recorded one is gone", async () => { + const { recordMarkdownDocOpener, restoreFocusToMarkdownDocOpener } = + await loadModule(); + const first = addCard(); + const second = addCard(); + + recordMarkdownDocOpener(DOC_URL, second); + second.remove(); + restoreFocusToMarkdownDocOpener(DOC_URL); + await settleFrames(); + + assert.equal(dom.window.document.activeElement, first); +}); + +test("ignores a record made for a different document URL", async () => { + const { recordMarkdownDocOpener, restoreFocusToMarkdownDocOpener } = + await loadModule(); + const first = addCard(); + addCard(); + const otherCard = addCard("http://localhost:3000/media/cafe.bin"); + + recordMarkdownDocOpener("http://localhost:3000/media/cafe.bin", otherCard); + restoreFocusToMarkdownDocOpener(DOC_URL); + await settleFrames(); + + assert.equal(dom.window.document.activeElement, first); +}); + +test("aborts when another control already claimed focus", async () => { + const { recordMarkdownDocOpener, restoreFocusToMarkdownDocOpener } = + await loadModule(); + addCard(); + const second = addCard(); + const claimed = dom.window.document.createElement("button"); + dom.window.document.body.appendChild(claimed); + + recordMarkdownDocOpener(DOC_URL, second); + claimed.focus(); + restoreFocusToMarkdownDocOpener(DOC_URL); + await settleFrames(); + + assert.equal(dom.window.document.activeElement, claimed); +}); diff --git a/desktop/src/features/channels/ui/markdownDocFocus.ts b/desktop/src/features/channels/ui/markdownDocFocus.ts index 8615493763..36fb186377 100644 --- a/desktop/src/features/channels/ui/markdownDocFocus.ts +++ b/desktop/src/features/channels/ui/markdownDocFocus.ts @@ -6,13 +6,47 @@ * panel that held focus — in both directions focus falls to `` and * keyboard/screen-reader users lose their place. On open, focus moves to the * panel's close control; on close, it returns to the attachment card that - * opened the document (matched by URL, since the original element was - * unmounted meanwhile). + * opened the document (by recorded identity, since the original element was + * unmounted meanwhile and the URL alone can match several cards). */ const PANEL_CLOSE_SELECTOR = '[data-testid="markdown-doc-panel"] [data-testid="auxiliary-panel-close"]'; +/** + * Identity of the card that invoked the current open, captured while it was + * still mounted. The same attachment can appear in several messages — several + * cards, one URL — so the opener is remembered as its DOM-order index among + * the cards sharing that URL. The index survives the narrow-layout swap (the + * message list remounts in the same order) where an element reference or a + * URL-only selector cannot single out the invoking card. + */ +type OpenerRecord = { index: number; url: string }; + +let lastOpenerRecord: OpenerRecord | null = null; + +function findOpenerCards(url: string): HTMLElement[] { + return Array.from( + document.querySelectorAll