Add accessibility wiring for modals

This commit is contained in:
thePR0M3TH3AN committed 2025-10-10 22:12:05 -04:00
1 parent 817ae355aa
commit 3df86a7630
10 files changed
+716 -102

No files matched your search

+2
View File
@@ -75,6 +75,8 @@ For toggles:
Compose them together when mounting new modal portals. If you need a drawer or sheet, start from these primitives and adjust spacing/positioning via utilities.
Static modal partials (login, application, feedback, etc.) should call the helpers exported from `js/ui/components/staticModalAccessibility.js`—`prepareStaticModal`, `openStaticModal`, and `closeStaticModal`—to attach focus trapping, Escape/backdrop dismissal, and the shared `data-open`/`html.modal-open` toggles without duplicating controller logic.
### Popovers
Use `.popover` as the relative anchor for floating content. Nest `.popover__panel` for the floating surface—it’s positioned absolutely below the trigger, inherits the shared focus ring, and animates with opacity/scale tokens. Toggle `[data-state="open"]` on the panel to switch between the collapsed (`opacity-0 scale-95`) and expanded (`opacity-100 scale-100`) states.
+1
View File
@@ -8,6 +8,7 @@ Before starting, open `docs/kitchen-sink.html` in a browser to review the modal
* 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.
* Repeat the accessibility spot check for the Upload, Edit, and Revert modals along with static dialogs (Login, Application form, Content appeals, Feedback, Feature request, Bug report, and Disclaimer). Ensure `Tab`/`Shift+Tab` stay within each modal and that pressing `Escape` or clicking a dismissible backdrop closes the dialog and restores focus to the opener.
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.
+46 -27
View File
@@ -65,6 +65,11 @@ import { VideoModal } from "./ui/components/VideoModal.js";
import { UploadModal } from "./ui/components/UploadModal.js";
import { EditModal } from "./ui/components/EditModal.js";
import { RevertModal } from "./ui/components/RevertModal.js";
import {
prepareStaticModal,
openStaticModal,
closeStaticModal,
} from "./ui/components/staticModalAccessibility.js";
import { VideoListView } from "./ui/views/VideoListView.js";
import MoreMenuController from "./ui/moreMenuController.js";
import ProfileModalController from "./ui/profileModalController.js";
@@ -883,24 +888,26 @@ class Application {
};
this.videoListView.setPlaybackHandler(this.videoListViewPlaybackHandler);
this.videoListViewEditHandler = ({ video, index }) => {
this.videoListViewEditHandler = ({ video, index, trigger }) => {
if (!video?.id) {
return;
}
this.handleEditVideo({
eventId: video.id,
index: Number.isFinite(index) ? index : null,
triggerElement: trigger,
});
};
this.videoListView.setEditHandler(this.videoListViewEditHandler);
this.videoListViewRevertHandler = ({ video, index }) => {
this.videoListViewRevertHandler = ({ video, index, trigger }) => {
if (!video?.id) {
return;
}
this.handleRevertVideo({
eventId: video.id,
index: Number.isFinite(index) ? index : null,
triggerElement: trigger,
});
};
this.videoListView.setRevertHandler(this.videoListViewRevertHandler);
@@ -1879,20 +1886,22 @@ class Application {
// 3) Upload button => show upload modal
if (this.uploadButton) {
this.uploadButton.addEventListener("click", () => {
this.uploadButton.addEventListener("click", (event) => {
if (this.uploadModal) {
this.uploadModal.open();
const trigger = event?.currentTarget || event?.target || null;
this.uploadModal.open({ triggerElement: trigger });
}
});
}
// 4) Login button => show the login modal
if (this.loginButton) {
this.loginButton.addEventListener("click", () => {
this.loginButton.addEventListener("click", (event) => {
console.log("Login button clicked!");
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.remove("hidden");
const loginModal =
prepareStaticModal({ id: "loginModal" }) || document.getElementById("loginModal");
const trigger = event?.currentTarget || event?.target || null;
if (loginModal && openStaticModal(loginModal, { triggerElement: trigger })) {
setGlobalModalState("login", true);
}
});
@@ -1902,9 +1911,7 @@ class Application {
if (this.closeLoginModalBtn) {
this.closeLoginModalBtn.addEventListener("click", () => {
console.log("[app.js] closeLoginModal button clicked!");
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.add("hidden");
if (closeStaticModal("loginModal")) {
setGlobalModalState("login", false);
}
});
@@ -1975,9 +1982,7 @@ class Application {
}
}
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.add("hidden");
if (closeStaticModal("loginModal")) {
setGlobalModalState("login", false);
}
}
@@ -2031,15 +2036,15 @@ class Application {
document.addEventListener("click", (event) => {
if (event.target && event.target.id === "openApplicationModal") {
// Hide the login modal
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.add("hidden");
if (closeStaticModal("loginModal")) {
setGlobalModalState("login", false);
}
// Show the application modal
const appModal = document.getElementById("nostrFormModal");
const appModal =
prepareStaticModal({ id: "nostrFormModal" }) ||
document.getElementById("nostrFormModal");
if (appModal) {
appModal.classList.remove("hidden");
openStaticModal(appModal, { triggerElement: event.target });
}
}
});
@@ -4997,6 +5002,9 @@ class Application {
}
normalizeActionTarget(target) {
const isElement = (value) =>
typeof Element !== "undefined" && value instanceof Element;
let triggerElement = null;
if (target && typeof target === "object") {
const eventId =
typeof target.eventId === "string" ? target.eventId.trim() : "";
@@ -5009,26 +5017,35 @@ class Application {
const parsed = Number.parseInt(target.index.trim(), 10);
index = Number.isNaN(parsed) ? null : parsed;
}
return { eventId, index };
if (isElement(target.triggerElement)) {
triggerElement = target.triggerElement;
} else if (isElement(target.trigger)) {
triggerElement = target.trigger;
}
return { eventId, index, triggerElement };
}
if (typeof target === "string") {
const trimmed = target.trim();
if (!trimmed) {
return { eventId: "", index: null };
return { eventId: "", index: null, triggerElement };
}
if (/^-?\d+$/.test(trimmed)) {
const parsed = Number.parseInt(trimmed, 10);
return { eventId: "", index: Number.isNaN(parsed) ? null : parsed };
return {
eventId: "",
index: Number.isNaN(parsed) ? null : parsed,
triggerElement,
};
}
return { eventId: trimmed, index: null };
return { eventId: trimmed, index: null, triggerElement };
}
if (typeof target === "number" && Number.isInteger(target)) {
return { eventId: "", index: target };
return { eventId: "", index: target, triggerElement };
}
return { eventId: "", index: null };
return { eventId: "", index: null, triggerElement };
}
async resolveVideoActionTarget({
@@ -5151,6 +5168,7 @@ class Application {
async handleEditVideo(target) {
try {
const normalizedTarget = this.normalizeActionTarget(target);
const { triggerElement } = normalizedTarget;
const latestVideos = await this.nostrService.fetchVideos({
blacklistedEventIds: this.blacklistedEventIds,
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
@@ -5181,7 +5199,7 @@ class Application {
}
try {
await this.editModal.open(video);
await this.editModal.open(video, { triggerElement });
} catch (error) {
console.error("Failed to open edit modal:", error);
this.showError("Edit modal is not available right now.");
@@ -5195,6 +5213,7 @@ class Application {
async handleRevertVideo(target) {
try {
const normalizedTarget = this.normalizeActionTarget(target);
const { triggerElement } = normalizedTarget;
const activeVideos = await this.nostrService.fetchVideos({
blacklistedEventIds: this.blacklistedEventIds,
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
@@ -5229,7 +5248,7 @@ class Application {
const history = await nostrClient.hydrateVideoHistory(video);
this.revertModal.setHistory(video, history);
this.revertModal.open({ video });
this.revertModal.open({ video }, { triggerElement });
} catch (err) {
console.error("Failed to revert video:", err);
this.showError("Failed to load revision history. Please try again.");
+57 -47
View File
@@ -8,6 +8,11 @@ import { setApplication, setApplicationReady } from "./applicationContext.js";
import nostrService from "./services/nostrService.js";
import r2Service from "./services/r2Service.js";
import { loadView, viewInitRegistry } from "./viewManager.js";
import {
prepareStaticModal,
openStaticModal,
closeStaticModal,
} from "./ui/components/staticModalAccessibility.js";
validateInstanceConfig();
@@ -171,6 +176,17 @@ async function bootstrapInterface() {
console.log("Modals loaded.");
[
"loginModal",
"nostrFormModal",
"contentAppealsModal",
"generalFeedbackModal",
"featureRequestModal",
"bugFixModal",
].forEach((id) => {
prepareStaticModal({ id });
});
await loadSidebar("components/sidebar.html", "sidebarContainer");
console.log("Sidebar loaded.");
@@ -445,13 +461,16 @@ async function bootstrapInterface() {
await loadDisclaimer("components/disclaimer.html", "modalContainer");
console.log("Disclaimer loaded.");
prepareStaticModal({ id: "disclaimerModal" });
const loginNavBtn = document.getElementById("loginButton");
if (loginNavBtn) {
loginNavBtn.addEventListener("click", () => {
const loginModal = document.getElementById("loginModal");
loginNavBtn.addEventListener("click", (event) => {
const loginModal =
prepareStaticModal({ id: "loginModal" }) ||
document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.remove("hidden");
openStaticModal(loginModal, { triggerElement: event.currentTarget });
}
});
}
@@ -459,23 +478,19 @@ async function bootstrapInterface() {
const closeLoginBtn = document.getElementById("closeLoginModal");
if (closeLoginBtn) {
closeLoginBtn.addEventListener("click", () => {
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.add("hidden");
}
closeStaticModal("loginModal");
});
}
const openAppFormBtn = document.getElementById("openApplicationModal");
if (openAppFormBtn) {
openAppFormBtn.addEventListener("click", () => {
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.add("hidden");
}
const appModal = document.getElementById("nostrFormModal");
openAppFormBtn.addEventListener("click", (event) => {
closeStaticModal("loginModal");
const appModal =
prepareStaticModal({ id: "nostrFormModal" }) ||
document.getElementById("nostrFormModal");
if (appModal) {
appModal.classList.remove("hidden");
openStaticModal(appModal, { triggerElement: event.currentTarget });
}
});
}
@@ -483,14 +498,11 @@ async function bootstrapInterface() {
const closeNostrFormBtn = document.getElementById("closeNostrFormModal");
if (closeNostrFormBtn) {
closeNostrFormBtn.addEventListener("click", () => {
const appModal = document.getElementById("nostrFormModal");
if (appModal) {
appModal.classList.add("hidden");
}
closeStaticModal("nostrFormModal");
if (!localStorage.getItem("hasSeenDisclaimer")) {
const disclaimerModal = document.getElementById("disclaimerModal");
if (disclaimerModal) {
disclaimerModal.classList.remove("hidden");
openStaticModal(disclaimerModal);
}
}
});
@@ -572,82 +584,80 @@ function handleQueryParams() {
const modalParam = urlParams.get("modal");
if (modalParam === "appeals") {
const appealsModal = document.getElementById("contentAppealsModal");
const appealsModal =
prepareStaticModal({ id: "contentAppealsModal" }) ||
document.getElementById("contentAppealsModal");
if (appealsModal) {
appealsModal.classList.remove("hidden");
openStaticModal(appealsModal);
}
const closeAppealsBtn = document.getElementById("closeContentAppealsModal");
if (closeAppealsBtn) {
closeAppealsBtn.addEventListener("click", () => {
const appealsModal = document.getElementById("contentAppealsModal");
if (appealsModal) {
appealsModal.classList.add("hidden");
}
closeStaticModal("contentAppealsModal");
if (!localStorage.getItem("hasSeenDisclaimer")) {
const disclaimerModal = document.getElementById("disclaimerModal");
if (disclaimerModal) {
disclaimerModal.classList.remove("hidden");
openStaticModal(disclaimerModal);
}
}
});
}
} else if (modalParam === "application") {
const appModal = document.getElementById("nostrFormModal");
const appModal =
prepareStaticModal({ id: "nostrFormModal" }) ||
document.getElementById("nostrFormModal");
if (appModal) {
appModal.classList.remove("hidden");
openStaticModal(appModal);
}
} else {
const hasSeenDisclaimer = localStorage.getItem("hasSeenDisclaimer");
if (!hasSeenDisclaimer) {
const disclaimerModal = document.getElementById("disclaimerModal");
if (disclaimerModal) {
disclaimerModal.classList.remove("hidden");
openStaticModal(disclaimerModal);
}
}
}
if (modalParam === "feedback") {
const feedbackModal = document.getElementById("generalFeedbackModal");
const feedbackModal =
prepareStaticModal({ id: "generalFeedbackModal" }) ||
document.getElementById("generalFeedbackModal");
if (feedbackModal) {
feedbackModal.classList.remove("hidden");
openStaticModal(feedbackModal);
}
} else if (modalParam === "feature") {
const featureModal = document.getElementById("featureRequestModal");
const featureModal =
prepareStaticModal({ id: "featureRequestModal" }) ||
document.getElementById("featureRequestModal");
if (featureModal) {
featureModal.classList.remove("hidden");
openStaticModal(featureModal);
}
} else if (modalParam === "bug") {
const bugModal = document.getElementById("bugFixModal");
const bugModal =
prepareStaticModal({ id: "bugFixModal" }) ||
document.getElementById("bugFixModal");
if (bugModal) {
bugModal.classList.remove("hidden");
openStaticModal(bugModal);
}
}
const closeFeedbackBtn = document.getElementById("closeGeneralFeedbackModal");
if (closeFeedbackBtn) {
closeFeedbackBtn.addEventListener("click", () => {
const feedbackModal = document.getElementById("generalFeedbackModal");
if (feedbackModal) {
feedbackModal.classList.add("hidden");
}
closeStaticModal("generalFeedbackModal");
});
}
const closeFeatureBtn = document.getElementById("closeFeatureRequestModal");
if (closeFeatureBtn) {
closeFeatureBtn.addEventListener("click", () => {
const featureModal = document.getElementById("featureRequestModal");
if (featureModal) {
featureModal.classList.add("hidden");
}
closeStaticModal("featureRequestModal");
});
}
const closeBugBtn = document.getElementById("closeBugFixModal");
if (closeBugBtn) {
closeBugBtn.addEventListener("click", () => {
const bugModal = document.getElementById("bugFixModal");
if (bugModal) {
bugModal.classList.add("hidden");
}
closeStaticModal("bugFixModal");
});
}
}
+45 -14
View File
@@ -1,4 +1,5 @@
import { extractMagnetHints, normalizeAndAugmentMagnet } from "../../magnet.js";
import { createModalAccessibility } from "./modalAccessibility.js";
import { Nip71FormManager } from "./nip71FormManager.js";
export class EditModal {
@@ -54,6 +55,7 @@ export class EditModal {
container: null,
buttons: [],
};
this.modalAccessibility = null;
this.activeVideo = null;
this.isVisible = false;
@@ -124,6 +126,7 @@ export class EditModal {
this.root = modal;
this.cacheElements(modal);
this.setupModalAccessibility();
if (!this.eventsBound) {
this.bindEvents();
this.eventsBound = true;
@@ -189,6 +192,23 @@ export class EditModal {
this.nip71FormManager.registerSection(this.nip71SectionKey, nip71Context);
}
setupModalAccessibility() {
if (!this.root) {
return;
}
if (this.modalAccessibility?.destroy) {
this.modalAccessibility.destroy();
}
this.modalAccessibility = createModalAccessibility({
root: this.root,
panel: this.panel || this.root,
backdrop: this.overlay || this.root,
onRequestClose: () => this.close({ emitCancel: true }),
});
}
bindEvents() {
if (this.form) {
this.form.addEventListener("submit", (event) => {
@@ -349,7 +369,7 @@ export class EditModal {
this.setSubmitState({ pending: false });
}
async open(video) {
async open(video, { triggerElement } = {}) {
await this.load();
if (!video) {
@@ -395,20 +415,23 @@ export class EditModal {
this.activeVideo = editContext;
if (this.root) {
this.root.classList.remove("hidden");
this.root.setAttribute("aria-hidden", "false");
const wasHidden = this.root.classList.contains("hidden");
if (wasHidden) {
this.root.classList.remove("hidden");
this.root.setAttribute("aria-hidden", "false");
this.setGlobalModalState("editVideo", true);
const focusTarget =
this.form?.querySelector("[data-initial-focus]") ||
this.panel ||
this.root;
window.requestAnimationFrame(() => {
if (typeof focusTarget?.focus === "function") {
focusTarget.focus();
}
});
}
this.isVisible = true;
this.setGlobalModalState("editVideo", true);
const focusTarget =
this.form?.querySelector("[data-initial-focus]") ||
this.panel ||
this.root;
window.requestAnimationFrame(() => {
if (typeof focusTarget?.focus === "function") {
focusTarget.focus();
}
});
this.modalAccessibility?.activate({ triggerElement });
}
return true;
@@ -554,6 +577,7 @@ export class EditModal {
this.root.setAttribute("aria-hidden", "true");
}
this.isVisible = false;
this.modalAccessibility?.deactivate();
this.setGlobalModalState("editVideo", false);
const cancelledVideo = this.activeVideo;
this.reset();
@@ -992,4 +1016,11 @@ export class EditModal {
this.submitButton.removeAttribute("disabled");
}
}
destroy() {
if (this.modalAccessibility?.destroy) {
this.modalAccessibility.destroy();
}
this.modalAccessibility = null;
}
}
+58 -1
View File
@@ -1,4 +1,5 @@
import { Nip71FormManager } from "./nip71FormManager.js";
import { createModalAccessibility } from "./modalAccessibility.js";
// NOTE: Any metadata field added to the Upload or Edit modals must also be
// rendered inside the Revert modal to keep the experiences aligned.
@@ -39,6 +40,8 @@ export class RevertModal {
this.closeButton = null;
this.cancelButton = null;
this.confirmButton = null;
this.modalPanel = null;
this.modalAccessibility = null;
this.activeVideo = null;
this.revisions = [];
@@ -110,6 +113,7 @@ export class RevertModal {
}
this.cacheElements(modal);
this.setupModalAccessibility();
this.bindEvents();
this.reset();
@@ -119,6 +123,7 @@ export class RevertModal {
cacheElements(modal) {
this.modal = modal;
this.overlay = modal.querySelector("#revertVideoModalOverlay") || null;
this.modalPanel = modal.querySelector(".bv-modal__panel") || modal;
this.list = modal.querySelector("#revertVersionsList") || null;
this.details = modal.querySelector("#revertVersionDetails") || null;
this.placeholder = modal.querySelector("#revertVersionPlaceholder") || null;
@@ -220,7 +225,24 @@ export class RevertModal {
toggleDisabledStyles(this.closeButton);
}
open(context = {}) {
setupModalAccessibility() {
if (!this.modal) {
return;
}
if (this.modalAccessibility?.destroy) {
this.modalAccessibility.destroy();
}
this.modalAccessibility = createModalAccessibility({
root: this.modal,
panel: this.modalPanel || this.modal,
backdrop: this.overlay || this.modal,
onRequestClose: () => this.handleCancelInteraction(),
});
}
open(context = {}, { triggerElement } = {}) {
if (!this.modal) {
throw new Error("Revert modal has not been loaded.");
}
@@ -230,6 +252,7 @@ export class RevertModal {
if (typeof this.setGlobalModalState === "function") {
this.setGlobalModalState("revertVideo", true);
}
this.modalAccessibility?.activate({ triggerElement });
this.dispatch("video:revert-open", {
context: this.context,
video: this.activeVideo,
@@ -247,6 +270,7 @@ export class RevertModal {
};
this.modal.classList.add("hidden");
this.modalAccessibility?.deactivate();
if (typeof this.setGlobalModalState === "function") {
this.setGlobalModalState("revertVideo", false);
}
@@ -1062,6 +1086,39 @@ export class RevertModal {
this.close();
}
destroy() {
if (this.bound) {
if (this.list) {
this.list.removeEventListener("click", this.boundHandlers.listClick);
}
if (this.confirmButton) {
this.confirmButton.removeEventListener(
"click",
this.boundHandlers.confirm
);
}
if (this.cancelButton) {
this.cancelButton.removeEventListener(
"click",
this.boundHandlers.cancel
);
}
if (this.overlay) {
this.overlay.removeEventListener("click", this.boundHandlers.overlay);
}
if (this.closeButton) {
this.closeButton.removeEventListener("click", this.boundHandlers.close);
}
this.bound = false;
}
if (this.modalAccessibility?.destroy) {
this.modalAccessibility.destroy();
}
this.modalAccessibility = null;
this.modalPanel = null;
}
getEscapeFn() {
return (value) =>
this.escapeHTML ? this.escapeHTML(String(value ?? "")) : String(value ?? "");
+48 -7
View File
@@ -1,5 +1,6 @@
// NOTE: Keep the Upload, Edit, and Revert modals in lockstep when updating NIP-71 form features.
import { createModalAccessibility } from "./modalAccessibility.js";
import { Nip71FormManager } from "./nip71FormManager.js";
export class UploadModal {
@@ -77,6 +78,9 @@ export class UploadModal {
this.nip71FormManager = new Nip71FormManager();
this.cleanupHandlers = [];
this.modalAccessibility = null;
this.modalBackdrop = null;
this.modalPanel = null;
}
addEventListener(type, listener, options) {
@@ -133,6 +137,7 @@ export class UploadModal {
}
this.cacheElements(wrapper);
this.setupModalAccessibility();
this.bindEvents();
this.registerR2Subscriptions();
@@ -154,6 +159,10 @@ export class UploadModal {
}
cacheElements(context) {
const scope = this.root || context;
this.modalBackdrop = scope?.querySelector?.(".bv-modal-backdrop") || null;
this.modalPanel = scope?.querySelector?.(".bv-modal__panel") || scope || null;
this.uploadModeButtons = Array.from(
context.querySelectorAll(".upload-mode-toggle[data-upload-mode]")
);
@@ -370,6 +379,30 @@ export class UploadModal {
this.nip71FormManager.bindSection("cloudflare");
}
setupModalAccessibility() {
if (!this.root) {
return;
}
if (this.modalAccessibility?.destroy) {
this.modalAccessibility.destroy();
}
this.modalAccessibility = createModalAccessibility({
root: this.root,
backdrop: this.modalBackdrop || this.root,
panel: this.modalPanel || this.root,
onRequestClose: () => this.close(),
});
this.cleanupHandlers.push(() => {
if (this.modalAccessibility?.destroy) {
this.modalAccessibility.destroy();
}
this.modalAccessibility = null;
});
}
registerR2Subscriptions() {
if (!this.r2Service?.on) {
return;
@@ -443,24 +476,25 @@ export class UploadModal {
);
}
open() {
open({ triggerElement } = {}) {
if (!this.root) {
return;
}
if (!this.root.classList.contains("hidden")) {
this.isVisible = true;
return;
const wasHidden = this.root.classList.contains("hidden");
if (wasHidden) {
this.root.classList.remove("hidden");
this.setGlobalModalState("upload", true);
this.emit("upload:open", { mode: this.activeMode });
}
this.root.classList.remove("hidden");
this.isVisible = true;
this.setGlobalModalState("upload", true);
this.emit("upload:open", { mode: this.activeMode });
this.modalAccessibility?.activate({ triggerElement });
}
close() {
if (!this.root) {
return;
}
this.modalAccessibility?.deactivate();
if (this.root.classList.contains("hidden")) {
this.isVisible = false;
return;
@@ -847,6 +881,13 @@ export class UploadModal {
}
destroy() {
if (this.modalAccessibility?.destroy) {
this.modalAccessibility.destroy();
}
this.modalAccessibility = null;
this.modalBackdrop = null;
this.modalPanel = null;
if (Array.isArray(this.cleanupHandlers)) {
this.cleanupHandlers.forEach((cleanup) => {
try {
@@ -0,0 +1,167 @@
import { createModalAccessibility } from "./modalAccessibility.js";
const accessibilityByModal = new WeakMap();
function resolveDocument(preferred) {
if (preferred) {
return preferred;
}
if (typeof document !== "undefined") {
return document;
}
return null;
}
function isElement(value) {
if (typeof Element === "undefined") {
return false;
}
return value instanceof Element;
}
function resolveRoot(target, doc = resolveDocument()) {
if (!target) {
return null;
}
if (isElement(target)) {
return target;
}
if (typeof target === "string") {
const context = resolveDocument(doc);
if (!context) {
return null;
}
return context.getElementById(target) || null;
}
return null;
}
function getOrCreateAccessibility(root) {
if (!root) {
return null;
}
let record = accessibilityByModal.get(root);
if (record) {
return record;
}
const doc = resolveDocument(root.ownerDocument);
if (!doc) {
return null;
}
const panel =
root.querySelector(".bv-modal__panel") ||
root.querySelector("[role='dialog']") ||
root;
const backdrop = root.querySelector(".bv-modal-backdrop") || root;
const modalAccessibility = createModalAccessibility({
root,
panel,
backdrop,
document: doc,
onRequestClose: () => {
closeStaticModal(root);
},
});
record = { document: doc, accessibility: modalAccessibility };
accessibilityByModal.set(root, record);
return record;
}
function synchronizeDocumentModalState(doc) {
if (!doc) {
return;
}
const openModals = doc.querySelectorAll(".bv-modal:not(.hidden)");
if (openModals.length > 0) {
doc.documentElement?.classList.add("modal-open");
doc.body?.classList.add("modal-open");
} else {
doc.documentElement?.classList.remove("modal-open");
doc.body?.classList.remove("modal-open");
}
}
export function prepareStaticModal({ id, root, document: providedDocument } = {}) {
const doc = resolveDocument(providedDocument);
const target = root || (typeof id === "string" ? doc?.getElementById(id) : null);
if (!target) {
return null;
}
const record = getOrCreateAccessibility(target);
if (!record) {
return null;
}
if (!target.hasAttribute("data-open")) {
target.setAttribute(
"data-open",
target.classList.contains("hidden") ? "false" : "true"
);
}
return target;
}
export function openStaticModal(target, { triggerElement, document: providedDocument } = {}) {
const doc = resolveDocument(providedDocument);
const root = resolveRoot(target, doc);
if (!root) {
return false;
}
const record = getOrCreateAccessibility(root);
if (!record) {
return false;
}
root.classList.remove("hidden");
root.setAttribute("data-open", "true");
synchronizeDocumentModalState(record.document);
record.accessibility.activate({ triggerElement });
return true;
}
export function closeStaticModal(target, { document: providedDocument } = {}) {
const doc = resolveDocument(providedDocument);
const root = resolveRoot(target, doc);
if (!root) {
return false;
}
const record = getOrCreateAccessibility(root);
if (!record) {
return false;
}
root.classList.add("hidden");
root.setAttribute("data-open", "false");
record.accessibility.deactivate();
synchronizeDocumentModalState(record.document);
return true;
}
export function toggleStaticModal(target, shouldOpen, options = {}) {
if (shouldOpen) {
return openStaticModal(target, options);
}
return closeStaticModal(target, options);
}
export function destroyStaticModal(target, { document: providedDocument } = {}) {
const doc = resolveDocument(providedDocument);
const root = resolveRoot(target, doc);
if (!root) {
return false;
}
const record = accessibilityByModal.get(root);
if (!record) {
return false;
}
try {
record.accessibility.destroy?.();
} finally {
accessibilityByModal.delete(root);
}
synchronizeDocumentModalState(record.document);
return true;
}
+29 -6
View File
@@ -434,21 +434,44 @@ export class VideoListView {
this.emitSelected(detail);
};
videoCard.onEdit = ({ video: editVideo, index: editIndex }) => {
videoCard.onEdit = ({ event: editEvent, video: editVideo, index: editIndex }) => {
if (this.handlers.edit) {
this.handlers.edit({ video: editVideo, index: editIndex });
const trigger = editEvent?.currentTarget || editEvent?.target || null;
this.handlers.edit({
video: editVideo,
index: editIndex,
trigger,
});
}
};
videoCard.onRevert = ({ video: revertVideo, index: revertIndex }) => {
videoCard.onRevert = ({
event: revertEvent,
video: revertVideo,
index: revertIndex,
}) => {
if (this.handlers.revert) {
this.handlers.revert({ video: revertVideo, index: revertIndex });
const trigger = revertEvent?.currentTarget || revertEvent?.target || null;
this.handlers.revert({
video: revertVideo,
index: revertIndex,
trigger,
});
}
};
videoCard.onDelete = ({ video: deleteVideo, index: deleteIndex }) => {
videoCard.onDelete = ({
event: deleteEvent,
video: deleteVideo,
index: deleteIndex,
}) => {
if (this.handlers.delete) {
this.handlers.delete({ video: deleteVideo, index: deleteIndex });
const trigger = deleteEvent?.currentTarget || deleteEvent?.target || null;
this.handlers.delete({
video: deleteVideo,
index: deleteIndex,
trigger,
});
}
};
+263
View File
@@ -0,0 +1,263 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { JSDOM } from "jsdom";
import { UploadModal } from "../js/ui/components/UploadModal.js";
import { EditModal } from "../js/ui/components/EditModal.js";
import { RevertModal } from "../js/ui/components/RevertModal.js";
import {
prepareStaticModal,
openStaticModal,
closeStaticModal,
} from "../js/ui/components/staticModalAccessibility.js";
const uploadMarkupPromise = readFile(
new URL("../components/upload-modal.html", import.meta.url),
"utf8",
);
const editMarkupPromise = readFile(
new URL("../components/edit-video-modal.html", import.meta.url),
"utf8",
);
const revertMarkupPromise = readFile(
new URL("../components/revert-video-modal.html", import.meta.url),
"utf8",
);
function installDom(html) {
const dom = new JSDOM(html, { url: "https://example.com" });
const { window } = dom;
globalThis.window = window;
globalThis.document = window.document;
globalThis.HTMLElement = window.HTMLElement;
globalThis.Element = window.Element;
globalThis.EventTarget = window.EventTarget;
globalThis.CustomEvent = window.CustomEvent;
globalThis.Event = window.Event;
globalThis.Node = window.Node;
globalThis.navigator = window.navigator;
globalThis.location = window.location;
globalThis.KeyboardEvent = window.KeyboardEvent;
globalThis.MouseEvent = window.MouseEvent;
if (typeof window.requestAnimationFrame !== "function") {
window.requestAnimationFrame = (callback) =>
setTimeout(() => callback(Date.now()), 0);
}
if (typeof window.cancelAnimationFrame !== "function") {
window.cancelAnimationFrame = (id) => clearTimeout(id);
}
globalThis.requestAnimationFrame = window.requestAnimationFrame.bind(window);
globalThis.cancelAnimationFrame = window.cancelAnimationFrame.bind(window);
return () => {
delete globalThis.window;
delete globalThis.document;
delete globalThis.HTMLElement;
delete globalThis.Element;
delete globalThis.EventTarget;
delete globalThis.CustomEvent;
delete globalThis.Event;
delete globalThis.Node;
delete globalThis.navigator;
delete globalThis.location;
delete globalThis.KeyboardEvent;
delete globalThis.MouseEvent;
delete globalThis.requestAnimationFrame;
delete globalThis.cancelAnimationFrame;
};
}
function stubFetch(responses) {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (resource) => {
const key = typeof resource === "string" ? resource : resource?.toString?.();
if (!key || !responses.has(key)) {
throw new Error(`Unexpected fetch request: ${key}`);
}
const body = responses.get(key);
return {
ok: true,
text: async () => body,
};
};
return () => {
globalThis.fetch = originalFetch;
};
}
async function flushMicrotasks() {
await Promise.resolve();
await Promise.resolve();
}
test("UploadModal closes on Escape and restores trigger focus", async () => {
const markup = await uploadMarkupPromise;
const cleanups = [];
cleanups.push(
installDom(
"<!DOCTYPE html><html><body><button id=\"trigger\">Upload</button><div id=\"modalContainer\"></div></body></html>",
),
);
cleanups.push(
stubFetch(
new Map([["components/upload-modal.html", markup]]),
),
);
const modal = new UploadModal({
removeTrackingScripts: () => {},
setGlobalModalState: () => {},
});
await modal.load();
const trigger = document.getElementById("trigger");
assert.ok(trigger);
modal.open({ triggerElement: trigger });
await flushMicrotasks();
document.dispatchEvent(
new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
);
await flushMicrotasks();
const root = modal.getRoot();
assert.ok(root?.classList.contains("hidden"));
assert.strictEqual(document.activeElement, trigger);
modal.destroy();
cleanups.reverse().forEach((fn) => fn?.());
});
test("EditModal Escape closes and restores trigger focus", async () => {
const markup = await editMarkupPromise;
const cleanups = [];
cleanups.push(
installDom(
"<!DOCTYPE html><html><body><button id=\"trigger\">Edit</button><div id=\"modalContainer\"></div></body></html>",
),
);
cleanups.push(
stubFetch(
new Map([["components/edit-video-modal.html", markup]]),
),
);
const modal = new EditModal({
removeTrackingScripts: () => {},
setGlobalModalState: () => {},
sanitizers: {},
escapeHtml: (value) => String(value ?? ""),
showError: () => {},
getMode: () => "live",
});
await modal.load();
const trigger = document.getElementById("trigger");
assert.ok(trigger);
await modal.open(
{
id: "event1",
title: "Example",
url: "https://example.com/video.mp4",
magnet: "",
ws: "",
xs: "",
enableComments: true,
isPrivate: false,
isNsfw: false,
isForKids: false,
},
{ triggerElement: trigger },
);
await flushMicrotasks();
document.dispatchEvent(
new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
);
await flushMicrotasks();
const root = modal.getRoot();
assert.ok(root?.classList.contains("hidden"));
assert.strictEqual(document.activeElement, trigger);
modal.destroy();
cleanups.reverse().forEach((fn) => fn?.());
});
test("RevertModal Escape closes and restores trigger focus", async () => {
const markup = await revertMarkupPromise;
const cleanups = [];
cleanups.push(
installDom(
"<!DOCTYPE html><html><body><button id=\"trigger\">Revert</button><div id=\"modalContainer\"></div></body></html>",
),
);
cleanups.push(
stubFetch(
new Map([["components/revert-video-modal.html", markup]]),
),
);
const modal = new RevertModal({
removeTrackingScripts: () => {},
setGlobalModalState: () => {},
formatAbsoluteTimestamp: () => "",
formatTimeAgo: () => "",
escapeHTML: (value) => String(value ?? ""),
truncateMiddle: (value) => value,
fallbackThumbnailSrc: "",
});
await modal.load();
const trigger = document.getElementById("trigger");
assert.ok(trigger);
modal.setHistory({ id: "video1", title: "Video" }, []);
modal.open({ video: { id: "video1" } }, { triggerElement: trigger });
await flushMicrotasks();
document.dispatchEvent(
new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
);
await flushMicrotasks();
const root = modal.modal;
assert.ok(root?.classList.contains("hidden"));
assert.strictEqual(document.activeElement, trigger);
modal.destroy();
cleanups.reverse().forEach((fn) => fn?.());
});
test("static modal helper toggles accessibility hooks", async () => {
const cleanups = [];
cleanups.push(
installDom(`<!DOCTYPE html><html><body><button id="trigger">Open</button><div id="testModal" class="bv-modal hidden" data-open="false"><div class="bv-modal-backdrop" data-dismiss></div><div class="bv-modal__panel" tabindex="-1"><button type="button" data-dismiss>Close</button></div></div></body></html>`),
);
const modal = prepareStaticModal({ id: "testModal" });
assert.ok(modal);
const trigger = document.getElementById("trigger");
assert.ok(trigger);
openStaticModal(modal, { triggerElement: trigger });
await flushMicrotasks();
assert.strictEqual(modal.getAttribute("data-open"), "true");
assert.ok(document.documentElement.classList.contains("modal-open"));
document.dispatchEvent(
new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
);
await flushMicrotasks();
assert.strictEqual(modal.getAttribute("data-open"), "false");
assert.strictEqual(document.documentElement.classList.contains("modal-open"), false);
assert.strictEqual(document.activeElement, trigger);
closeStaticModal(modal);
cleanups.reverse().forEach((fn) => fn?.());
});