295 lines
10 KiB
JavaScript
295 lines
10 KiB
JavaScript
"use strict";
|
|
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const test = require("node:test");
|
|
const vm = require("node:vm");
|
|
|
|
const repositoryRoot = path.resolve(__dirname, "..", "..");
|
|
const modulePath = path.join(repositoryRoot, "src",
|
|
"MBN_STOCK_WEBVIEW.LegacyParityApp", "Web", "legacy-modal-focus.js");
|
|
const modalFocus = require(modulePath);
|
|
|
|
class FakeElement {
|
|
constructor(documentObject, tagName, attributes) {
|
|
this.ownerDocument = documentObject;
|
|
this.tagName = String(tagName || "div").toUpperCase();
|
|
this.parentElement = null;
|
|
this.children = [];
|
|
this.hidden = false;
|
|
this.inert = false;
|
|
this.disabled = false;
|
|
this.style = { display: "block", visibility: "visible", contentVisibility: "visible" };
|
|
this.attributes = new Map();
|
|
Object.entries(attributes || {}).forEach(([name, value]) => {
|
|
this.attributes.set(name, String(value));
|
|
});
|
|
}
|
|
|
|
append(...elements) {
|
|
elements.forEach(element => {
|
|
element.parentElement = this;
|
|
this.children.push(element);
|
|
});
|
|
}
|
|
|
|
contains(element) {
|
|
return element === this || this.children.some(child => child.contains(element));
|
|
}
|
|
|
|
focus() {
|
|
this.ownerDocument.activeElement = this;
|
|
}
|
|
|
|
getAttribute(name) {
|
|
return this.attributes.has(name) ? this.attributes.get(name) : null;
|
|
}
|
|
|
|
hasAttribute(name) {
|
|
return this.attributes.has(name);
|
|
}
|
|
|
|
setAttribute(name, value) {
|
|
this.attributes.set(name, String(value));
|
|
}
|
|
|
|
querySelector(selector) {
|
|
const descendants = this.querySelectorAll("*");
|
|
if (selector.includes("data-modal-initial-focus")) {
|
|
return descendants.find(element =>
|
|
element.hasAttribute("data-modal-initial-focus") || element.hasAttribute("autofocus")) || null;
|
|
}
|
|
if (selector[0] === "#") {
|
|
return descendants.find(element => element.getAttribute("id") === selector.slice(1)) || null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
querySelectorAll() {
|
|
return this.children.flatMap(child => [child, ...child.querySelectorAll("*")]);
|
|
}
|
|
}
|
|
|
|
class FakeDocument {
|
|
constructor() {
|
|
this.body = new FakeElement(this, "body");
|
|
this.activeElement = this.body;
|
|
this.listeners = new Map();
|
|
this.defaultView = {
|
|
getComputedStyle(element) { return element.style; }
|
|
};
|
|
}
|
|
|
|
addEventListener(type, listener) {
|
|
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
|
this.listeners.get(type).add(listener);
|
|
}
|
|
|
|
removeEventListener(type, listener) {
|
|
if (this.listeners.has(type)) this.listeners.get(type).delete(listener);
|
|
}
|
|
|
|
contains(element) {
|
|
return this.body.contains(element);
|
|
}
|
|
|
|
querySelectorAll() {
|
|
return this.body.querySelectorAll("*").filter(element => {
|
|
const role = element.getAttribute("role");
|
|
return role === "dialog" || role === "alertdialog";
|
|
});
|
|
}
|
|
}
|
|
|
|
function element(documentObject, tagName, attributes) {
|
|
return new FakeElement(documentObject, tagName, attributes);
|
|
}
|
|
|
|
function modalFixture() {
|
|
const documentObject = new FakeDocument();
|
|
const opener = element(documentObject, "button", { id: "opener" });
|
|
const backdrop = element(documentObject, "div");
|
|
const dialog = element(documentObject, "section", { role: "dialog" });
|
|
const first = element(documentObject, "button", { id: "first" });
|
|
const preferred = element(documentObject, "input", { id: "preferred" });
|
|
const last = element(documentObject, "button", { id: "last" });
|
|
dialog.append(first, preferred, last);
|
|
backdrop.append(dialog);
|
|
documentObject.body.append(opener, backdrop);
|
|
backdrop.hidden = true;
|
|
opener.focus();
|
|
return { documentObject, opener, backdrop, dialog, first, preferred, last };
|
|
}
|
|
|
|
function keyEvent(key, shiftKey) {
|
|
return {
|
|
key,
|
|
shiftKey: !!shiftKey,
|
|
defaultPrevented: false,
|
|
preventDefault() { this.defaultPrevented = true; }
|
|
};
|
|
}
|
|
|
|
test("exports the same API as a CommonJS module and a browser global", () => {
|
|
assert.equal(typeof modalFocus.create, "function");
|
|
const source = fs.readFileSync(modulePath, "utf8");
|
|
const context = { globalThis: {} };
|
|
vm.runInNewContext(source, context);
|
|
assert.equal(typeof context.globalThis.LegacyModalFocus.create, "function");
|
|
});
|
|
|
|
test("selects the last visible modal and ignores hidden modal ancestors", () => {
|
|
const fixture = modalFixture();
|
|
const alertBackdrop = element(fixture.documentObject, "div");
|
|
const alert = element(fixture.documentObject, "div", { role: "alertdialog" });
|
|
alertBackdrop.append(alert);
|
|
fixture.documentObject.body.append(alertBackdrop);
|
|
|
|
assert.equal(modalFocus.findVisibleModal(fixture.documentObject), alert);
|
|
alertBackdrop.hidden = true;
|
|
assert.equal(modalFocus.findVisibleModal(fixture.documentObject), null);
|
|
fixture.backdrop.hidden = false;
|
|
assert.equal(modalFocus.findVisibleModal(fixture.documentObject), fixture.dialog);
|
|
});
|
|
|
|
test("focusable discovery excludes disabled, hidden, inert, and negative-tab controls", () => {
|
|
const fixture = modalFixture();
|
|
fixture.backdrop.hidden = false;
|
|
fixture.first.disabled = true;
|
|
fixture.preferred.setAttribute("tabindex", "-1");
|
|
const hiddenParent = element(fixture.documentObject, "div");
|
|
hiddenParent.hidden = true;
|
|
const hiddenButton = element(fixture.documentObject, "button");
|
|
hiddenParent.append(hiddenButton);
|
|
const inertParent = element(fixture.documentObject, "div", { inert: "" });
|
|
const inertButton = element(fixture.documentObject, "button");
|
|
inertParent.append(inertButton);
|
|
fixture.dialog.append(hiddenParent, inertParent);
|
|
|
|
assert.deepEqual(modalFocus.getFocusableElements(
|
|
fixture.dialog, fixture.documentObject), [fixture.last]);
|
|
});
|
|
|
|
test("open focuses the preferred control and close restores the opener", () => {
|
|
const fixture = modalFixture();
|
|
const manager = modalFocus.create({ document: fixture.documentObject });
|
|
fixture.backdrop.hidden = false;
|
|
manager.sync("#preferred");
|
|
assert.equal(fixture.documentObject.activeElement, fixture.preferred);
|
|
assert.equal(manager.getActiveModal(), fixture.dialog);
|
|
|
|
fixture.backdrop.hidden = true;
|
|
manager.sync();
|
|
assert.equal(fixture.documentObject.activeElement, fixture.opener);
|
|
assert.equal(manager.getActiveModal(), null);
|
|
});
|
|
|
|
test("open falls back to the first enabled visible control", () => {
|
|
const fixture = modalFixture();
|
|
const manager = modalFocus.create({ document: fixture.documentObject });
|
|
fixture.backdrop.hidden = false;
|
|
manager.sync("#missing");
|
|
assert.equal(fixture.documentObject.activeElement, fixture.first);
|
|
});
|
|
|
|
test("Tab and Shift+Tab wrap inside the active modal without handling Escape", () => {
|
|
const fixture = modalFixture();
|
|
const manager = modalFocus.create({ document: fixture.documentObject });
|
|
fixture.backdrop.hidden = false;
|
|
manager.sync();
|
|
|
|
fixture.last.focus();
|
|
const forward = keyEvent("Tab", false);
|
|
assert.equal(manager.handleKeydown(forward), true);
|
|
assert.equal(forward.defaultPrevented, true);
|
|
assert.equal(fixture.documentObject.activeElement, fixture.first);
|
|
|
|
const backward = keyEvent("Tab", true);
|
|
assert.equal(manager.handleKeydown(backward), true);
|
|
assert.equal(backward.defaultPrevented, true);
|
|
assert.equal(fixture.documentObject.activeElement, fixture.last);
|
|
|
|
fixture.preferred.focus();
|
|
const middle = keyEvent("Tab", false);
|
|
assert.equal(manager.handleKeydown(middle), false);
|
|
assert.equal(middle.defaultPrevented, false);
|
|
assert.equal(fixture.documentObject.activeElement, fixture.preferred);
|
|
|
|
const escape = keyEvent("Escape", false);
|
|
assert.equal(manager.handleKeydown(escape), false);
|
|
assert.equal(escape.defaultPrevented, false);
|
|
assert.equal(manager.getActiveModal(), fixture.dialog);
|
|
});
|
|
|
|
test("a dialog without controls retains Tab focus on its container", () => {
|
|
const documentObject = new FakeDocument();
|
|
const dialog = element(documentObject, "section", { role: "dialog" });
|
|
documentObject.body.append(dialog);
|
|
const manager = modalFocus.create({ document: documentObject });
|
|
manager.sync();
|
|
const tab = keyEvent("Tab", false);
|
|
assert.equal(manager.handleKeydown(tab), true);
|
|
assert.equal(tab.defaultPrevented, true);
|
|
assert.equal(dialog.getAttribute("tabindex"), "-1");
|
|
assert.equal(documentObject.activeElement, dialog);
|
|
});
|
|
|
|
test("closing a nested alert restores its modal opener before the root opener", () => {
|
|
const fixture = modalFixture();
|
|
const manager = modalFocus.create({ document: fixture.documentObject });
|
|
fixture.backdrop.hidden = false;
|
|
manager.sync("#preferred");
|
|
|
|
const alertBackdrop = element(fixture.documentObject, "div");
|
|
const alert = element(fixture.documentObject, "div", { role: "alertdialog" });
|
|
const alertOk = element(fixture.documentObject, "button", { id: "alert-ok" });
|
|
alert.append(alertOk);
|
|
alertBackdrop.append(alert);
|
|
fixture.documentObject.body.append(alertBackdrop);
|
|
manager.sync("#alert-ok");
|
|
assert.equal(fixture.documentObject.activeElement, alertOk);
|
|
|
|
alertBackdrop.hidden = true;
|
|
manager.sync();
|
|
assert.equal(manager.getActiveModal(), fixture.dialog);
|
|
assert.equal(fixture.documentObject.activeElement, fixture.preferred);
|
|
|
|
fixture.backdrop.hidden = true;
|
|
manager.sync();
|
|
assert.equal(fixture.documentObject.activeElement, fixture.opener);
|
|
});
|
|
|
|
test("rememberOpener preserves the owner when application code changes focus during open", () => {
|
|
const fixture = modalFixture();
|
|
const manager = modalFocus.create({ document: fixture.documentObject });
|
|
manager.rememberOpener(fixture.dialog);
|
|
fixture.backdrop.hidden = false;
|
|
fixture.first.focus();
|
|
manager.sync("#preferred");
|
|
fixture.backdrop.hidden = true;
|
|
manager.sync();
|
|
assert.equal(fixture.documentObject.activeElement, fixture.opener);
|
|
});
|
|
|
|
test("an opener resolver restores a logically equivalent control after DOM replacement", () => {
|
|
const fixture = modalFixture();
|
|
const manager = modalFocus.create({ document: fixture.documentObject });
|
|
let currentOpener = fixture.opener;
|
|
manager.rememberOpener(fixture.dialog, () => currentOpener);
|
|
fixture.backdrop.hidden = false;
|
|
manager.sync("#preferred");
|
|
|
|
const replacement = element(fixture.documentObject, "button", { id: "opener" });
|
|
const openerIndex = fixture.documentObject.body.children.indexOf(fixture.opener);
|
|
fixture.opener.parentElement = null;
|
|
replacement.parentElement = fixture.documentObject.body;
|
|
fixture.documentObject.body.children[openerIndex] = replacement;
|
|
currentOpener = replacement;
|
|
|
|
fixture.backdrop.hidden = true;
|
|
manager.sync();
|
|
assert.equal(fixture.documentObject.activeElement, replacement);
|
|
assert.notEqual(fixture.documentObject.activeElement, fixture.opener);
|
|
});
|