mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Improve video modal accessibility handling
This commit is contained in:
1 parent
eef0a50f24
commit
16cb69021d
6 files changed
+228
-26
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user