From 3df86a763048592deb1cd2cc677cf3b4cb5664b4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 10 Oct 2025 22:12:05 -0400 Subject: [PATCH] Add accessibility wiring for modals --- docs/design-system.md | 2 + docs/qa.md | 1 + js/app.js | 73 +++-- js/index.js | 104 ++++---- js/ui/components/EditModal.js | 59 ++++- js/ui/components/RevertModal.js | 59 ++++- js/ui/components/UploadModal.js | 55 +++- js/ui/components/staticModalAccessibility.js | 167 ++++++++++++ js/ui/views/VideoListView.js | 35 ++- tests/modal-accessibility.test.mjs | 263 +++++++++++++++++++ 10 files changed, 716 insertions(+), 102 deletions(-) create mode 100644 js/ui/components/staticModalAccessibility.js create mode 100644 tests/modal-accessibility.test.mjs diff --git a/docs/design-system.md b/docs/design-system.md index 2067eaf1..3a4cd884 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -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. diff --git a/docs/qa.md b/docs/qa.md index f6f3eac1..ee124d6c 100644 --- a/docs/qa.md +++ b/docs/qa.md @@ -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. diff --git a/js/app.js b/js/app.js index 7696be25..fea1dd45 100644 --- a/js/app.js +++ b/js/app.js @@ -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."); diff --git a/js/index.js b/js/index.js index f740321a..cb78446e 100644 --- a/js/index.js +++ b/js/index.js @@ -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"); }); } } diff --git a/js/ui/components/EditModal.js b/js/ui/components/EditModal.js index 686fa201..0e698260 100644 --- a/js/ui/components/EditModal.js +++ b/js/ui/components/EditModal.js @@ -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; + } } diff --git a/js/ui/components/RevertModal.js b/js/ui/components/RevertModal.js index 3d9dee6b..17ba2fea 100644 --- a/js/ui/components/RevertModal.js +++ b/js/ui/components/RevertModal.js @@ -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 ?? ""); diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index 0b21a96b..87e4be95 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -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 { diff --git a/js/ui/components/staticModalAccessibility.js b/js/ui/components/staticModalAccessibility.js new file mode 100644 index 00000000..9b43f681 --- /dev/null +++ b/js/ui/components/staticModalAccessibility.js @@ -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; +} diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index 793b5b11..ae45d45d 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -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, + }); } }; diff --git a/tests/modal-accessibility.test.mjs b/tests/modal-accessibility.test.mjs new file mode 100644 index 00000000..53efe00e --- /dev/null +++ b/tests/modal-accessibility.test.mjs @@ -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( + "
", + ), + ); + 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( + "", + ), + ); + 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( + "", + ), + ); + 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(``), + ); + + 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?.()); +});