Improve video modal accessibility handling

This commit is contained in:
thePR0M3TH3AN committed 2025-10-10 11:08:21 -04:00
1 parent eef0a50f24
commit 16cb69021d
6 files changed
+228 -26

No files matched your search

+7
View File
@@ -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.
+92 -21
View File
@@ -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,
});
}
+4 -2
View File
@@ -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;
}
+20 -2
View File
@@ -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) {
+1 -1
View File
@@ -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) {
+104
View File
@@ -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(
`<!DOCTYPE html><html><body><button id="trigger" type="button">Open modal</button><div id="modalContainer">${markup}</div></body></html>`,
{ 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);
});