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

+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) {