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('jio-modal-runtime__content')) { this.dialog = new Element(this.ownerDocument); const content = new Element(this.ownerDocument); content.className = 'jio-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 === '.jio-modal-runtime__dialog') return this.dialog || null; if (selector === '.jio-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, JioModalTelemetry: { start: () => '', mark() {} }, JioModalState: { create: () => ({}) }, JioModalCache: { acquire(key, request) { const promise = request(); return { promise, release() {} }; }, invalidate() {}, }, JioRequest: { 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.JioModalRuntime, 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: '
overview
' }); 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('jio-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('jio-modal-runtime-open'), true); requests[0].pending.resolve({ text: '
details
' }); await childLoad; runtime.close('control'); flushTimers(timers); assert.equal(runtime.getActive(), parent); assert.equal(parent.classList.contains('jio-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('jio-modal-runtime-open'), true); runtime.close('control'); flushTimers(timers); assert.equal(runtime.getActive(), null); assert.equal(document.documentElement.classList.contains('jio-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: '
overview
' }); const firstLoad = runtime.openRemote({ url: '/badge/one', cache: false, newModal: true }); const firstChild = runtime.getActive(); requests[0].pending.resolve({ text: '
one
' }); await firstLoad; const secondLoad = runtime.openRemote({ url: '/badge/two', cache: false }); const secondChild = runtime.getActive(); requests[1].pending.resolve({ text: '
two
' }); await secondLoad; assert.notEqual(secondChild, firstChild); assert.equal(runtime.getActive(), secondChild); assert.equal(parent.classList.contains('jio-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: '
loaded overview
' }); await parentLoad; assert.equal(parent.content.innerHTML, '
loaded overview
'); assert.equal(runtime.getActive(), child); requests[1].pending.resolve({ text: '
details
' }); await childLoad; runtime.close('control'); flushTimers(timers); assert.equal(runtime.getActive(), parent); assert.equal(parent.content.innerHTML, '
loaded overview
'); 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: '
top
' }); 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: '
overview
' }); const parentTrigger = document.createElement('a'); parent.content.appendChild(parentTrigger); parentTrigger.focus(); runtime.open({ html: '
first child
', newModal: true }); const firstChild = runtime.getActive(); runtime.open({ html: '
replacement child
' }); 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: '
overview
' }); 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: '
late details
' }); 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-jio-modal-url': '/badge', 'data-jio-modal-new': 'true', 'data-jio-modal-loading': 'spinner', 'data-jio-modal-swipe-close': 'true', }; const element = { getAttribute: (name) => attrs[name] || null }; const document = { addEventListener(type, listener) { this.listener = listener; }, }; let options; const window = { JioModalRuntime: { 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="jio-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, '.jio-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: '
overview
' }); 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); });