Files
bitvid/tests/modal-accessibility.test.mjs
T

556 lines
17 KiB
JavaScript

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";
import {
setFeatureDesignSystemEnabled,
resetRuntimeFlags,
} from "../js/constants.js";
import { applyDesignSystemAttributes } from "../js/designSystem.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();
}
for (const designSystemEnabled of [false, true]) {
const modeLabel = designSystemEnabled ? "design-system" : "legacy";
test(
`[${modeLabel}] UploadModal closes on Escape and restores trigger focus`,
async (t) => {
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]])),
);
setFeatureDesignSystemEnabled(designSystemEnabled);
const modal = new UploadModal({
removeTrackingScripts: () => {},
setGlobalModalState: () => {},
});
await modal.load();
applyDesignSystemAttributes(document);
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);
t.after(() => {
modal.destroy();
resetRuntimeFlags();
cleanups.reverse().forEach((fn) => fn?.());
});
},
);
test(
`[${modeLabel}] UploadModal backdrop click closes and restores trigger focus`,
async (t) => {
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]])),
);
setFeatureDesignSystemEnabled(designSystemEnabled);
const modal = new UploadModal({
removeTrackingScripts: () => {},
setGlobalModalState: () => {},
});
await modal.load();
applyDesignSystemAttributes(document);
const trigger = document.getElementById("trigger");
assert.ok(trigger);
modal.open({ triggerElement: trigger });
await flushMicrotasks();
const backdrop = modal.getRoot()?.querySelector?.("[data-dismiss]");
assert.ok(backdrop, "expected upload modal backdrop");
backdrop.dispatchEvent(
new window.MouseEvent("click", { bubbles: true, cancelable: true }),
);
await flushMicrotasks();
const root = modal.getRoot();
assert.ok(root?.classList.contains("hidden"));
assert.strictEqual(document.activeElement, trigger);
t.after(() => {
modal.destroy();
resetRuntimeFlags();
cleanups.reverse().forEach((fn) => fn?.());
});
},
);
test(
`[${modeLabel}] UploadModal mode toggle updates button states`,
async (t) => {
const markup = await uploadMarkupPromise;
const cleanups = [];
cleanups.push(
installDom(
"<!DOCTYPE html><html><body><div id=\"modalContainer\"></div></body></html>",
),
);
cleanups.push(
stubFetch(new Map([["components/upload-modal.html", markup]])),
);
setFeatureDesignSystemEnabled(designSystemEnabled);
const modal = new UploadModal({
removeTrackingScripts: () => {},
setGlobalModalState: () => {},
});
await modal.load();
applyDesignSystemAttributes(document);
const customButton = document.querySelector(
".upload-mode-toggle[data-upload-mode=\"custom\"]",
);
const cloudflareButton = document.querySelector(
".upload-mode-toggle[data-upload-mode=\"cloudflare\"]",
);
const customSection = document.getElementById("customUploadSection");
const cloudflareSection = document.getElementById("cloudflareUploadSection");
assert.ok(customButton && cloudflareButton);
assert.ok(customSection && cloudflareSection);
modal.setMode("cloudflare");
assert.equal(cloudflareButton.getAttribute("aria-pressed"), "true");
assert.equal(customButton.getAttribute("aria-pressed"), "false");
assert.equal(cloudflareSection.classList.contains("hidden"), false);
assert.equal(customSection.classList.contains("hidden"), true);
modal.setMode("custom");
assert.equal(customButton.getAttribute("aria-pressed"), "true");
assert.equal(cloudflareButton.getAttribute("aria-pressed"), "false");
assert.equal(customSection.classList.contains("hidden"), false);
assert.equal(cloudflareSection.classList.contains("hidden"), true);
t.after(() => {
modal.destroy();
resetRuntimeFlags();
cleanups.reverse().forEach((fn) => fn?.());
});
},
);
}
for (const designSystemEnabled of [false, true]) {
const modeLabel = designSystemEnabled ? "design-system" : "legacy";
test(
`[${modeLabel}] EditModal Escape closes and restores trigger focus`,
async (t) => {
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]]),
),
);
setFeatureDesignSystemEnabled(designSystemEnabled);
const modal = new EditModal({
removeTrackingScripts: () => {},
setGlobalModalState: () => {},
sanitizers: {},
escapeHtml: (value) => String(value ?? ""),
showError: () => {},
getMode: () => "live",
});
await modal.load();
applyDesignSystemAttributes(document);
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);
t.after(() => {
modal.destroy();
resetRuntimeFlags();
cleanups.reverse().forEach((fn) => fn?.());
});
},
);
test(
`[${modeLabel}] EditModal backdrop click closes and restores trigger focus`,
async (t) => {
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]]),
),
);
setFeatureDesignSystemEnabled(designSystemEnabled);
const modal = new EditModal({
removeTrackingScripts: () => {},
setGlobalModalState: () => {},
sanitizers: {},
escapeHtml: (value) => String(value ?? ""),
showError: () => {},
getMode: () => "live",
});
await modal.load();
applyDesignSystemAttributes(document);
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();
const overlay = modal.overlay;
assert.ok(overlay, "expected edit modal overlay");
overlay.dispatchEvent(
new window.MouseEvent("click", { bubbles: true, cancelable: true }),
);
await flushMicrotasks();
const root = modal.getRoot();
assert.ok(root?.classList.contains("hidden"));
assert.strictEqual(document.activeElement, trigger);
t.after(() => {
modal.destroy();
resetRuntimeFlags();
cleanups.reverse().forEach((fn) => fn?.());
});
},
);
test(
`[${modeLabel}] EditModal visibility toggle updates button state`,
async (t) => {
const markup = await editMarkupPromise;
const cleanups = [];
cleanups.push(
installDom(
"<!DOCTYPE html><html><body><div id=\"modalContainer\"></div></body></html>",
),
);
cleanups.push(
stubFetch(
new Map([["components/edit-video-modal.html", markup]]),
),
);
setFeatureDesignSystemEnabled(designSystemEnabled);
const modal = new EditModal({
removeTrackingScripts: () => {},
setGlobalModalState: () => {},
sanitizers: {},
escapeHtml: (value) => String(value ?? ""),
showError: () => {},
getMode: () => "live",
});
await modal.load();
applyDesignSystemAttributes(document);
await modal.open(
{
id: "event1",
title: "Example",
url: "https://example.com/video.mp4",
magnet: "",
ws: "",
xs: "",
enableComments: true,
isPrivate: false,
isNsfw: false,
isForKids: false,
},
{},
);
await flushMicrotasks();
const privateButton = modal.visibility.buttons.find((button) =>
button?.dataset?.visibilityOption === "private",
);
const publicButton = modal.visibility.buttons.find((button) =>
button?.dataset?.visibilityOption === "public",
);
assert.ok(privateButton && publicButton);
const unlockVisibilityButton =
modal.root?.querySelector("[data-edit-target=\"editVideoIsPrivate\"]");
assert.ok(unlockVisibilityButton);
unlockVisibilityButton.dispatchEvent(
new window.Event("click", { bubbles: true, cancelable: true }),
);
modal.setVisibility("private");
assert.equal(privateButton.getAttribute("aria-pressed"), "true");
assert.equal(publicButton.getAttribute("aria-pressed"), "false");
assert.equal(modal.visibility.container?.dataset.state, "private");
modal.setVisibility("public");
assert.equal(publicButton.getAttribute("aria-pressed"), "true");
assert.equal(privateButton.getAttribute("aria-pressed"), "false");
assert.equal(modal.visibility.container?.dataset.state, "public");
t.after(() => {
modal.destroy();
resetRuntimeFlags();
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?.());
});