diff --git a/docs/qa.md b/docs/qa.md index b7736575..38afd04c 100644 --- a/docs/qa.md +++ b/docs/qa.md @@ -2,6 +2,13 @@ Run this checklist before publishing releases or merging changes that touch upload or playback flows. Copy/paste the steps into QA tickets when requesting validation. +Before starting, open `docs/kitchen-sink.html` in a browser to review the modal surface reference. With that page as your visual baseline, perform this quick accessibility spot check in the running app: + +* Launch the site, trigger any video card or thumbnail to open the playback modal, and keep track of the element you used to open it. +* Scroll the modal content down and back up; confirm the sticky navigation bar hides on downward scroll and reappears when you reverse direction. +* Activate the zap button to display the popover and tab through its fields to confirm focus stays within the dialog; press `Escape` to close it and ensure focus returns to the zap trigger. +* Close the video modal first with `Escape` and again by clicking the dimmed backdrop; in both cases, focus should move back to the original trigger element you used to open the modal. + 1. **Upload Modal Smoke Test** - Open the Upload modal, verify title is required, and ensure either a hosted URL or a magnet (or both) must be supplied. - Submit three variants: URL-only, magnet-only, and both URL + magnet. Confirm success messaging or event publication in each case. diff --git a/js/app.js b/js/app.js index 0d766190..2e631ab3 100644 --- a/js/app.js +++ b/js/app.js @@ -194,6 +194,7 @@ class Application { this.videoListViewDeleteHandler = null; this.moreMenuController = null; this.latestFeedMetadata = null; + this.lastModalTrigger = null; this.nostrService = services.nostrService || nostrService; this.r2Service = services.r2Service || r2Service; @@ -858,16 +859,23 @@ class Application { : ui.videoListView) || new VideoListView(videoListViewConfig); - this.videoListViewPlaybackHandler = ({ videoId, url, magnet }) => { + this.videoListViewPlaybackHandler = ({ + videoId, + url, + magnet, + trigger, + }) => { if (videoId) { Promise.resolve( - this.playVideoByEventId(videoId, { url, magnet }) + this.playVideoByEventId(videoId, { url, magnet, trigger }) ).catch((error) => { console.error("[VideoListView] Failed to play by event id:", error); }); return; } - Promise.resolve(this.playVideoWithFallback({ url, magnet })).catch( + Promise.resolve( + this.playVideoWithFallback({ url, magnet, trigger }) + ).catch( (error) => { console.error("[VideoListView] Failed to start playback:", error); } @@ -1260,14 +1268,45 @@ class Application { } } + normalizeModalTrigger(candidate) { + if (!candidate) { + return null; + } + const doc = + (this.videoModal && this.videoModal.document) || + (typeof document !== "undefined" ? document : null); + const isElement = + typeof candidate === "object" && + candidate !== null && + typeof candidate.nodeType === "number" && + candidate.nodeType === 1 && + typeof candidate.focus === "function"; + if (!isElement) { + return null; + } + if (doc && typeof doc.contains === "function" && !doc.contains(candidate)) { + return null; + } + return candidate; + } + + setLastModalTrigger(candidate) { + this.lastModalTrigger = this.normalizeModalTrigger(candidate); + return this.lastModalTrigger; + } + /** * Show the modal and set the "Please stand by" poster on the video. */ - async showModalWithPoster(video = this.currentVideo) { + async showModalWithPoster(video = this.currentVideo, options = {}) { if (!this.videoModal) { return null; } + if (Object.prototype.hasOwnProperty.call(options || {}, "trigger")) { + this.setLastModalTrigger(options.trigger); + } + const targetVideo = video || this.currentVideo; try { @@ -1279,7 +1318,9 @@ class Application { return root || null; } - this.videoModal.open(targetVideo); + this.videoModal.open(targetVideo, { + triggerElement: this.lastModalTrigger, + }); this.applyModalLoadingPoster(); return ( @@ -3730,6 +3771,8 @@ class Application { } } + this.lastModalTrigger = null; + this.currentMagnetUri = null; // 3) Kick off heavy cleanup work asynchronously. We still await it so @@ -5675,7 +5718,15 @@ class Application { * Unified playback helper that prefers HTTP URL sources * and falls back to WebTorrent when needed. */ - async playVideoWithFallback({ url = "", magnet = "" } = {}) { + async playVideoWithFallback(options = {}) { + const { url = "", magnet = "", trigger } = options || {}; + const hasTrigger = Object.prototype.hasOwnProperty.call( + options || {}, + "trigger" + ); + if (hasTrigger) { + this.setLastModalTrigger(trigger); + } const sanitizedUrl = typeof url === "string" ? url.trim() : ""; const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : ""; const requestSignature = JSON.stringify({ @@ -5877,24 +5928,30 @@ class Application { return; } + const hint = playbackHint && typeof playbackHint === "object" + ? playbackHint + : {}; const fallbackUrl = - typeof playbackHint?.url === "string" ? playbackHint.url.trim() : ""; + typeof hint.url === "string" ? hint.url.trim() : ""; const fallbackTitle = - typeof playbackHint?.title === "string" ? playbackHint.title : ""; + typeof hint.title === "string" ? hint.title : ""; const fallbackDescription = - typeof playbackHint?.description === "string" - ? playbackHint.description - : ""; + typeof hint.description === "string" ? hint.description : ""; const fallbackMagnetRaw = - typeof playbackHint?.magnet === "string" - ? playbackHint.magnet.trim() - : ""; + typeof hint.magnet === "string" ? hint.magnet.trim() : ""; let fallbackMagnetCandidate = ""; if (fallbackMagnetRaw) { const decoded = safeDecodeMagnet(fallbackMagnetRaw); fallbackMagnetCandidate = decoded || fallbackMagnetRaw; } + const hasTrigger = Object.prototype.hasOwnProperty.call(hint, "trigger"); + if (hasTrigger) { + this.setLastModalTrigger(hint.trigger); + } else { + this.setLastModalTrigger(null); + } + this.currentVideoPointer = null; this.currentVideoPointerKey = null; @@ -5914,6 +5971,7 @@ class Application { magnet: fallbackMagnetCandidate, title: fallbackTitle || "Untitled", description: fallbackDescription || "", + trigger: hasTrigger ? hint.trigger : null, }); } this.showError("Video not found or has been removed."); @@ -6054,6 +6112,7 @@ class Application { const playbackPromise = this.playVideoWithFallback({ url: trimmedUrl, magnet: magnetInput, + trigger: hasTrigger ? this.lastModalTrigger : null, }); let lightningAddress = ""; @@ -6311,12 +6370,23 @@ class Application { this.videoModal.updateMetadata({ timestamps: payload }); } - async playVideoWithoutEvent({ - url = "", - magnet = "", - title = "Untitled", - description = "", - } = {}) { + async playVideoWithoutEvent(options = {}) { + const { + url = "", + magnet = "", + title = "Untitled", + description = "", + trigger, + } = options || {}; + const hasTrigger = Object.prototype.hasOwnProperty.call( + options || {}, + "trigger" + ); + if (hasTrigger) { + this.setLastModalTrigger(trigger); + } else { + this.setLastModalTrigger(null); + } this.currentVideoPointer = null; this.currentVideoPointerKey = null; this.subscribeModalViewCount(null, null); @@ -6383,7 +6453,7 @@ class Application { }); } - await this.showModalWithPoster(this.currentVideo); + await this.showModalWithPoster(this.currentVideo, hasTrigger ? { trigger } : {}); const urlObj = new URL(window.location.href); urlObj.searchParams.delete("v"); @@ -6393,6 +6463,7 @@ class Application { return this.playVideoWithFallback({ url: sanitizedUrl, magnet: usableMagnet, + trigger: hasTrigger ? this.lastModalTrigger : null, }); } diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index 1998d76c..210b5a82 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -461,7 +461,7 @@ export class VideoModal { this.dispatch("creator:navigate", { video: this.activeVideo }); } - open(video) { + open(video, options = {}) { this.activeVideo = video || null; if (!this.playerModal) { return; @@ -471,7 +471,9 @@ export class VideoModal { this.playerModal.classList.remove("hidden"); this.document.body.classList.add("modal-open"); this.document.documentElement.classList.add("modal-open"); - this.modalAccessibility?.activate(); + const triggerElement = + options && typeof options === "object" ? options.triggerElement : null; + this.modalAccessibility?.activate({ triggerElement }); if (this.scrollRegion) { this.scrollRegion.scrollTop = 0; } diff --git a/js/ui/components/modalAccessibility.js b/js/ui/components/modalAccessibility.js index b434881a..e70cd24f 100644 --- a/js/ui/components/modalAccessibility.js +++ b/js/ui/components/modalAccessibility.js @@ -19,6 +19,15 @@ const scheduleMicrotask = ? queueMicrotask : (callback) => Promise.resolve().then(callback); +function isElement(node) { + return ( + !!node && + typeof node === "object" && + typeof node.nodeType === "number" && + node.nodeType === 1 + ); +} + function isFocusable(element) { if (!element) { return false; @@ -130,13 +139,22 @@ export function createModalAccessibility({ root, panel, backdrop, document: prov onRequestClose?.(event); }; - const activate = () => { + const activate = ({ triggerElement } = {}) => { if (active) { return; } active = true; const activeElement = doc.activeElement; - previousFocus = activeElement instanceof Element ? activeElement : null; + const hasDocContains = typeof doc.contains === "function"; + const normalizedTrigger = + isElement(triggerElement) && (!hasDocContains || doc.contains(triggerElement)) + ? triggerElement + : null; + if (normalizedTrigger) { + previousFocus = normalizedTrigger; + } else { + previousFocus = isElement(activeElement) ? activeElement : null; + } doc.addEventListener("keydown", keydownListener, true); root.addEventListener("click", clickListener); if (backdrop && backdrop !== root) { diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index 5e6f3223..793b5b11 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -796,7 +796,7 @@ export class VideoListView { const videoId = target?.dataset?.videoId || target?.getAttribute?.("data-video-id") || video?.id || ""; - return { videoId, url, magnet, video }; + return { videoId, url, magnet, video, trigger: element }; } emitSelected(detail) { diff --git a/tests/video-modal-accessibility.test.mjs b/tests/video-modal-accessibility.test.mjs new file mode 100644 index 00000000..729eeb1a --- /dev/null +++ b/tests/video-modal-accessibility.test.mjs @@ -0,0 +1,104 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { JSDOM } from "jsdom"; + +import { VideoModal } from "../js/ui/components/VideoModal.js"; + +const modalMarkupPromise = readFile( + new URL("../components/video-modal.html", import.meta.url), + "utf8" +); + +async function setupModal() { + const markup = await modalMarkupPromise; + const dom = new JSDOM( + `
${markup}
`, + { url: "https://example.com" } + ); + + const { window } = dom; + const { document } = window; + + globalThis.window = window; + globalThis.document = document; + globalThis.HTMLElement = window.HTMLElement; + globalThis.HTMLVideoElement = window.HTMLVideoElement; + globalThis.Element = window.Element; + globalThis.CustomEvent = window.CustomEvent; + globalThis.Event = window.Event; + globalThis.Node = window.Node; + globalThis.EventTarget = window.EventTarget; + globalThis.navigator = window.navigator; + globalThis.location = window.location; + + const modal = new VideoModal({ + removeTrackingScripts: () => {}, + setGlobalModalState: () => {}, + document, + logger: console, + }); + + const playerModal = document.querySelector("#playerModal"); + assert.ok(playerModal, "player modal markup should exist"); + modal.hydrate(playerModal); + + const trigger = document.getElementById("trigger"); + assert.ok(trigger, "trigger button should exist"); + + return { window, document, modal, playerModal, trigger }; +} + +test("backdrop data-dismiss closes the modal and restores focus", async () => { + const { window, document, modal, playerModal, trigger } = await setupModal(); + const backdrop = playerModal.querySelector("[data-dismiss]"); + assert.ok(backdrop, "modal backdrop should be present"); + + let closeEvents = 0; + modal.addEventListener("modal:close", () => { + closeEvents += 1; + modal.close(); + }); + + modal.open(null, { triggerElement: trigger }); + await Promise.resolve(); + + backdrop.dispatchEvent( + new window.MouseEvent("click", { bubbles: true, cancelable: true }) + ); + + await Promise.resolve(); + + assert.equal(closeEvents, 1); + assert.strictEqual(document.activeElement, trigger); +}); + +test("Escape key closes the modal and returns focus to the trigger", async () => { + const { window, document, modal, playerModal, trigger } = await setupModal(); + const closeButton = playerModal.querySelector("#closeModal"); + assert.ok(closeButton, "close button should be present"); + + let closeEvents = 0; + modal.addEventListener("modal:close", () => { + closeEvents += 1; + modal.close(); + }); + + modal.open(null, { triggerElement: trigger }); + await Promise.resolve(); + + closeButton.focus(); + + document.dispatchEvent( + new window.KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }) + ); + + await Promise.resolve(); + + assert.equal(closeEvents, 1); + assert.strictEqual(document.activeElement, trigger); +});