319 lines
13 KiB
JavaScript
319 lines
13 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const test = require('node:test');
|
|
|
|
const root = path.resolve(__dirname, '..', '..');
|
|
|
|
class ClassList {
|
|
constructor(owner) { this.owner = owner; this.values = new Set(); }
|
|
add(value) { this.values.add(value); }
|
|
remove(value) { this.values.delete(value); }
|
|
contains(value) { return this.values.has(value); }
|
|
}
|
|
|
|
class Element {
|
|
constructor(document) {
|
|
this.ownerDocument = document;
|
|
this.parentNode = null;
|
|
this.children = [];
|
|
this.attributes = {};
|
|
this.style = { paddingRight: '' };
|
|
this.classList = new ClassList(this);
|
|
this.listeners = {};
|
|
this.className = '';
|
|
this.id = '';
|
|
this._innerHTML = '';
|
|
}
|
|
set innerHTML(value) {
|
|
this._innerHTML = value;
|
|
if (value.includes('aksrt-modal-runtime__content')) {
|
|
this.dialog = new Element(this.ownerDocument);
|
|
const content = new Element(this.ownerDocument);
|
|
content.className = 'aksrt-modal-runtime__content';
|
|
content.parentNode = this;
|
|
this.content = content;
|
|
}
|
|
}
|
|
get innerHTML() { return this._innerHTML; }
|
|
setAttribute(name, value) { this.attributes[name] = String(value); }
|
|
removeAttribute(name) { delete this.attributes[name]; }
|
|
getAttribute(name) { return this.attributes[name] || null; }
|
|
addEventListener(type, listener) { (this.listeners[type] ||= []).push(listener); }
|
|
querySelector(selector) {
|
|
if (selector === '.aksrt-modal-runtime__dialog') return this.dialog || null;
|
|
if (selector === '.aksrt-modal-runtime__content') return this.content || null;
|
|
return null;
|
|
}
|
|
querySelectorAll() { return []; }
|
|
appendChild(child) { child.parentNode = this; this.children.push(child); return child; }
|
|
removeChild(child) {
|
|
this.children = this.children.filter((item) => item !== child);
|
|
child.parentNode = null;
|
|
return child;
|
|
}
|
|
contains(node) {
|
|
return node === this || this.children.some((child) => child.contains(node)) || this.content?.contains(node) || false;
|
|
}
|
|
focus() { this.ownerDocument.activeElement = this; }
|
|
dispatch(type, event) { (this.listeners[type] || []).forEach((listener) => listener(event)); }
|
|
}
|
|
|
|
function createRuntime() {
|
|
const timers = [];
|
|
const document = {
|
|
activeElement: null,
|
|
listeners: {},
|
|
documentElement: new Element(null),
|
|
body: new Element(null),
|
|
createElement() { return new Element(this); },
|
|
getElementById(id) { return this.body.children.find((item) => item.id === id) || null; },
|
|
querySelectorAll() { return []; },
|
|
contains(node) { return this.body.contains(node); },
|
|
addEventListener(type, listener) { (this.listeners[type] ||= []).push(listener); },
|
|
dispatchEvent() {},
|
|
};
|
|
document.documentElement.ownerDocument = document;
|
|
document.body.ownerDocument = document;
|
|
const requests = [];
|
|
let swipeConfig = null;
|
|
const jquery = () => ({
|
|
trigger() {},
|
|
on() { return this; },
|
|
minitouch(options) { swipeConfig = options; return this; },
|
|
});
|
|
jquery.fn = { minitouch() {} };
|
|
const window = {
|
|
document,
|
|
innerWidth: 1200,
|
|
matchMedia: () => ({ matches: false }),
|
|
getComputedStyle: (element) => ({ paddingRight: '5px', transitionDuration: element.transitionDuration || '' }),
|
|
setTimeout(callback, delay) { callback.delay = delay; timers.push(callback); return timers.length; },
|
|
AbortController,
|
|
jQuery: jquery,
|
|
AksrtModalTelemetry: { start: () => '', mark() {} },
|
|
AksrtModalState: { create: () => ({}) },
|
|
AksrtModalCache: {
|
|
acquire(key, request) {
|
|
const promise = request();
|
|
return { promise, release() {} };
|
|
},
|
|
invalidate() {},
|
|
},
|
|
AksrtRequest: {
|
|
url: (url) => url,
|
|
request(url, options) {
|
|
const pending = {};
|
|
pending.promise = new Promise((resolve, reject) => { pending.resolve = resolve; pending.reject = reject; });
|
|
requests.push({ url, options, pending });
|
|
return pending.promise;
|
|
},
|
|
},
|
|
};
|
|
const context = { window, document, CustomEvent: class { constructor(type, options) { this.type = type; this.detail = options?.detail; } }, Date, Promise, String, Number, Object, Array, Math, Error, AbortController };
|
|
vm.runInNewContext(fs.readFileSync(path.join(root, 'assets/js/core/modal-runtime.js'), 'utf8'), context);
|
|
return { runtime: window.AksrtModalRuntime, document, window, requests, timers, getSwipeConfig: () => swipeConfig };
|
|
}
|
|
|
|
function flushTimers(timers) {
|
|
while (timers.length) timers.shift()();
|
|
}
|
|
|
|
test('nested remote dialogs preserve the parent, restore focus, and hold one page scroll lock', async () => {
|
|
const { runtime, document, requests, timers } = createRuntime();
|
|
const parent = runtime.open({ html: '<div>overview</div>' });
|
|
const trigger = document.createElement('a');
|
|
parent.content.appendChild(trigger);
|
|
trigger.focus();
|
|
|
|
const childLoad = runtime.openRemote({ url: '/badge', cache: false, newModal: true });
|
|
const child = runtime.getActive();
|
|
assert.equal(parent.classList.contains('is-open'), true);
|
|
assert.equal(parent.classList.contains('aksrt-modal-runtime--parent'), true);
|
|
assert.equal(parent.getAttribute('aria-modal'), 'false');
|
|
assert.equal(parent.getAttribute('aria-hidden'), 'true');
|
|
assert.equal(child.getAttribute('aria-modal'), 'true');
|
|
assert.equal(document.documentElement.classList.contains('aksrt-modal-runtime-open'), true);
|
|
|
|
requests[0].pending.resolve({ text: '<div>details</div>' });
|
|
await childLoad;
|
|
runtime.close('control');
|
|
flushTimers(timers);
|
|
|
|
assert.equal(runtime.getActive(), parent);
|
|
assert.equal(parent.classList.contains('aksrt-modal-runtime--parent'), false);
|
|
assert.equal(parent.getAttribute('aria-modal'), 'true');
|
|
assert.equal(parent.getAttribute('aria-hidden'), null);
|
|
assert.equal(parent.getAttribute('inert'), null);
|
|
assert.equal(document.activeElement, trigger);
|
|
assert.equal(document.documentElement.classList.contains('aksrt-modal-runtime-open'), true);
|
|
|
|
runtime.close('control');
|
|
flushTimers(timers);
|
|
assert.equal(runtime.getActive(), null);
|
|
assert.equal(document.documentElement.classList.contains('aksrt-modal-runtime-open'), false);
|
|
});
|
|
|
|
test('replacing a nested top dialog keeps the original parent and does not reload it', async () => {
|
|
const { runtime, requests, timers } = createRuntime();
|
|
const parent = runtime.open({ html: '<div>overview</div>' });
|
|
const firstLoad = runtime.openRemote({ url: '/badge/one', cache: false, newModal: true });
|
|
const firstChild = runtime.getActive();
|
|
requests[0].pending.resolve({ text: '<div>one</div>' });
|
|
await firstLoad;
|
|
|
|
const secondLoad = runtime.openRemote({ url: '/badge/two', cache: false });
|
|
const secondChild = runtime.getActive();
|
|
requests[1].pending.resolve({ text: '<div>two</div>' });
|
|
await secondLoad;
|
|
|
|
assert.notEqual(secondChild, firstChild);
|
|
assert.equal(runtime.getActive(), secondChild);
|
|
assert.equal(parent.classList.contains('aksrt-modal-runtime--parent'), true);
|
|
assert.equal(requests.length, 2);
|
|
runtime.close('escape');
|
|
flushTimers(timers);
|
|
assert.equal(runtime.getActive(), parent);
|
|
});
|
|
|
|
test('an unfinished parent request can update its hidden content or error while a child is active', async () => {
|
|
const { runtime, requests, timers } = createRuntime();
|
|
const parentLoad = runtime.openRemote({ url: '/overview', cache: false });
|
|
const parent = runtime.getActive();
|
|
const childLoad = runtime.openRemote({ url: '/badge', cache: false, newModal: true });
|
|
const child = runtime.getActive();
|
|
|
|
requests[0].pending.resolve({ text: '<div>loaded overview</div>' });
|
|
await parentLoad;
|
|
assert.equal(parent.content.innerHTML, '<div>loaded overview</div>');
|
|
assert.equal(runtime.getActive(), child);
|
|
|
|
requests[1].pending.resolve({ text: '<div>details</div>' });
|
|
await childLoad;
|
|
runtime.close('control');
|
|
flushTimers(timers);
|
|
assert.equal(runtime.getActive(), parent);
|
|
assert.equal(parent.content.innerHTML, '<div>loaded overview</div>');
|
|
|
|
const errorLoad = runtime.openRemote({ url: '/overview-error', cache: false });
|
|
const errorParent = runtime.getActive();
|
|
const anotherLoad = runtime.openRemote({ url: '/still-loading', cache: false, newModal: true });
|
|
const top = runtime.getActive();
|
|
requests[2].pending.reject(new Error('overview unavailable'));
|
|
await errorLoad;
|
|
assert.match(errorParent.content.innerHTML, /overview unavailable/);
|
|
assert.equal(runtime.getActive(), top);
|
|
requests[3].pending.resolve({ text: '<div>top</div>' });
|
|
await anotherLoad;
|
|
runtime.close('control');
|
|
flushTimers(timers);
|
|
assert.equal(runtime.getActive(), errorParent);
|
|
});
|
|
|
|
test('replacing a nested child inherits its parent focus target and only the top layer handles keyboard or clicks', () => {
|
|
const { runtime, document, timers } = createRuntime();
|
|
const outsideTrigger = document.createElement('a');
|
|
document.body.appendChild(outsideTrigger);
|
|
outsideTrigger.focus();
|
|
const parent = runtime.open({ html: '<div>overview</div>' });
|
|
const parentTrigger = document.createElement('a');
|
|
parent.content.appendChild(parentTrigger);
|
|
parentTrigger.focus();
|
|
runtime.open({ html: '<div>first child</div>', newModal: true });
|
|
const firstChild = runtime.getActive();
|
|
runtime.open({ html: '<div>replacement child</div>' });
|
|
const replacement = runtime.getActive();
|
|
let parentPrevented = false;
|
|
parent.dispatch('keydown', { key: 'Escape', preventDefault() { parentPrevented = true; } });
|
|
parent.dispatch('click', { target: parent, preventDefault() {} });
|
|
assert.equal(runtime.getActive(), replacement);
|
|
assert.equal(parentPrevented, false);
|
|
|
|
const first = document.createElement('button');
|
|
const last = document.createElement('button');
|
|
replacement.querySelectorAll = () => [first, last];
|
|
last.focus();
|
|
let childPrevented = false;
|
|
replacement.dispatch('keydown', { key: 'Tab', shiftKey: false, preventDefault() { childPrevented = true; } });
|
|
assert.equal(childPrevented, true);
|
|
assert.equal(document.activeElement, first);
|
|
assert.notEqual(firstChild, replacement);
|
|
|
|
runtime.close('control');
|
|
flushTimers(timers);
|
|
assert.equal(runtime.getActive(), parent);
|
|
assert.equal(document.activeElement, parentTrigger);
|
|
});
|
|
|
|
test('Escape from the child closes only that layer and aborts its request', async () => {
|
|
const { runtime, requests, timers } = createRuntime();
|
|
const parent = runtime.open({ html: '<div>overview</div>' });
|
|
const childLoad = runtime.openRemote({ url: '/badge', cache: false, newModal: true });
|
|
const child = runtime.getActive();
|
|
let prevented = false;
|
|
child.dispatch('keydown', { key: 'Escape', preventDefault() { prevented = true; } });
|
|
flushTimers(timers);
|
|
requests[0].pending.resolve({ text: '<div>late details</div>' });
|
|
await childLoad;
|
|
|
|
assert.equal(prevented, true);
|
|
assert.equal(requests[0].options.signal.aborted, true);
|
|
assert.equal(runtime.getActive(), parent);
|
|
assert.equal(requests.length, 1);
|
|
});
|
|
|
|
test('remote trigger forwards the explicit new modal flag', () => {
|
|
const source = fs.readFileSync(path.join(root, 'assets/js/core/modal-triggers.js'), 'utf8');
|
|
const attrs = {
|
|
'data-aksrt-modal-url': '/badge',
|
|
'data-aksrt-modal-new': 'true',
|
|
'data-aksrt-modal-loading': 'spinner',
|
|
'data-aksrt-modal-swipe-close': 'true',
|
|
};
|
|
const element = { getAttribute: (name) => attrs[name] || null };
|
|
const document = {
|
|
addEventListener(type, listener) { this.listener = listener; },
|
|
};
|
|
let options;
|
|
const window = { AksrtModalRuntime: { openRemote(value) { options = value; } } };
|
|
vm.runInNewContext(source, { window, document, parseInt, Object });
|
|
document.listener({
|
|
target: { closest: () => element },
|
|
preventDefault() {},
|
|
});
|
|
|
|
assert.equal(options.newModal, true);
|
|
assert.equal(options.loading, 'spinner');
|
|
assert.equal(options.swipeClose, true);
|
|
});
|
|
|
|
test('bottom dialogs use the shared spinner and minitouch swipe handle', () => {
|
|
const { runtime, getSwipeConfig, timers } = createRuntime();
|
|
const shell = runtime.open({ bottom: true, swipeClose: true, loading: 'spinner' });
|
|
const dialog = shell.innerHTML;
|
|
|
|
assert.match(dialog, /class="aksrt-modal-runtime__loading flex jc"/);
|
|
assert.match(dialog, /class="loading-circle" viewBox="0 0 50 50"/);
|
|
assert.match(dialog, /class="touch-close" aria-hidden="true"/);
|
|
assert.equal(getSwipeConfig().direction, 'bottom');
|
|
assert.equal(getSwipeConfig().selector, '.aksrt-modal-runtime__dialog');
|
|
assert.equal(getSwipeConfig().start_selector, '.modal-colorful-header,.touch-close,.touch');
|
|
assert.equal(getSwipeConfig().depreciation, 50);
|
|
|
|
getSwipeConfig().onEnd();
|
|
flushTimers(timers);
|
|
assert.equal(runtime.getActive(), null);
|
|
});
|
|
|
|
test('closing a dialog preserves its exit animation before removing its DOM', () => {
|
|
const { runtime, timers } = createRuntime();
|
|
const shell = runtime.open({ html: '<div>overview</div>' });
|
|
shell.dialog.transitionDuration = '0.4s, 50ms';
|
|
runtime.close('control');
|
|
assert.equal(timers.at(-1).delay, 400);
|
|
assert.ok(shell.parentNode);
|
|
flushTimers(timers);
|
|
assert.equal(shell.parentNode, null);
|
|
});
|