mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add accessibility wiring for modals
This commit is contained in:
1 parent
817ae355aa
commit
3df86a7630
10 files changed
+716
-102
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 ?? "");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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?.());
|
||||
});
|
||||
Reference in new issue
Block a user