777 lines
33 KiB
JavaScript
777 lines
33 KiB
JavaScript
'use strict';
|
|
|
|
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 MiniElement {
|
|
constructor(tagName = 'div') {
|
|
this.tagName = String(tagName).toUpperCase();
|
|
this.parentNode = null;
|
|
this.children = [];
|
|
this.attributes = {};
|
|
this.dataset = {};
|
|
this.listeners = {};
|
|
this.offsetLeft = 0;
|
|
this.offsetWidth = 0;
|
|
this.scrollWidth = 0;
|
|
this.clientWidth = 0;
|
|
this.scrollLeft = 0;
|
|
this.rectLeft = 0;
|
|
this.rectWidth = 0;
|
|
this.scrollCalls = [];
|
|
this._text = '';
|
|
this._classes = new Set();
|
|
this.classList = {
|
|
add: (...tokens) => tokens.forEach((token) => this._classes.add(token)),
|
|
remove: (...tokens) => tokens.forEach((token) => this._classes.delete(token)),
|
|
contains: (token) => this._classes.has(token),
|
|
toggle: (token, force) => {
|
|
const shouldAdd = force === undefined ? !this._classes.has(token) : Boolean(force);
|
|
if (shouldAdd) this._classes.add(token);
|
|
else this._classes.delete(token);
|
|
return shouldAdd;
|
|
},
|
|
};
|
|
}
|
|
|
|
get className() { return Array.from(this._classes).join(' '); }
|
|
set className(value) { this._classes = new Set(String(value || '').split(/\s+/).filter(Boolean)); }
|
|
get id() { return this.getAttribute('id') || ''; }
|
|
set id(value) { this.setAttribute('id', value); }
|
|
get max() { return this.getAttribute('max') || ''; }
|
|
set max(value) { this.setAttribute('max', value); }
|
|
get value() { return this._value || ''; }
|
|
set value(value) { this._value = String(value); }
|
|
get parentElement() { return this.parentNode && this.parentNode.tagName !== '#DOCUMENT' ? this.parentNode : null; }
|
|
get firstChild() { return this.children[0] || null; }
|
|
get textContent() { return this._text + this.children.map((child) => child.textContent).join(''); }
|
|
set textContent(value) { this._text = String(value); this.replaceChildren(); }
|
|
get innerHTML() { return this.children.map((child) => child.outerHTML || child.textContent).join(''); }
|
|
set innerHTML(markup) {
|
|
this.replaceChildren();
|
|
parseMarkup(String(markup || ''), this);
|
|
}
|
|
|
|
setAttribute(name, value) {
|
|
const text = String(value);
|
|
this.attributes[name] = text;
|
|
if (name === 'class') this.className = text;
|
|
if (name.startsWith('data-')) this.dataset[toDatasetKey(name.slice(5))] = text;
|
|
}
|
|
|
|
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
|
|
hasAttribute(name) { return Object.hasOwn(this.attributes, name); }
|
|
|
|
appendChild(child) {
|
|
if (child.parentNode) child.remove();
|
|
child.parentNode = this;
|
|
this.children.push(child);
|
|
return child;
|
|
}
|
|
|
|
insertBefore(child, anchor) {
|
|
if (!anchor || anchor.parentNode !== this) return this.appendChild(child);
|
|
if (child.parentNode) child.remove();
|
|
child.parentNode = this;
|
|
this.children.splice(this.children.indexOf(anchor), 0, child);
|
|
return child;
|
|
}
|
|
|
|
replaceChildren(...nodes) {
|
|
this.children.forEach((child) => { child.parentNode = null; });
|
|
this.children = [];
|
|
nodes.forEach((node) => this.appendChild(node));
|
|
}
|
|
|
|
remove() {
|
|
if (!this.parentNode) return;
|
|
const siblings = this.parentNode.children;
|
|
const index = siblings.indexOf(this);
|
|
if (index !== -1) siblings.splice(index, 1);
|
|
this.parentNode = null;
|
|
}
|
|
|
|
addEventListener(type, callback) {
|
|
(this.listeners[type] ||= []).push(callback);
|
|
}
|
|
|
|
dispatch(type, properties = {}) {
|
|
const event = {
|
|
type,
|
|
target: this,
|
|
defaultPrevented: false,
|
|
preventDefault() { this.defaultPrevented = true; },
|
|
stopPropagation() { this.propagationStopped = true; },
|
|
...properties,
|
|
};
|
|
let node = this;
|
|
while (node) {
|
|
for (const listener of node.listeners[type] || []) listener(event);
|
|
if (event.propagationStopped) break;
|
|
node = node.parentNode;
|
|
}
|
|
return event;
|
|
}
|
|
|
|
click() { return this.dispatch('click'); }
|
|
focus() { this.ownerDocument.activeElement = this; }
|
|
|
|
closest(selector) {
|
|
for (let node = this; node; node = node.parentNode) {
|
|
if (matchesCompound(node, selector)) return node;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
matches(selector) { return matchesCompound(this, selector); }
|
|
|
|
querySelectorAll(selector) {
|
|
const directOnly = selector.startsWith(':scope > ');
|
|
const query = directOnly ? selector.slice(9) : selector;
|
|
const nodes = directOnly ? this.children.slice() : descendants(this);
|
|
return nodes.filter((node) => matchesSelector(node, query));
|
|
}
|
|
|
|
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
|
|
|
insertAdjacentHTML(position, markup) {
|
|
const fragment = this.ownerDocument.createElement('div');
|
|
fragment.innerHTML = markup;
|
|
const nodes = fragment.children.slice();
|
|
if (position === 'afterbegin') nodes.reverse().forEach((node) => this.insertBefore(node, this.firstChild));
|
|
else if (position === 'beforebegin' && this.parentNode) nodes.forEach((node) => this.parentNode.insertBefore(node, this));
|
|
else nodes.forEach((node) => this.appendChild(node));
|
|
}
|
|
|
|
scrollTo(options) { this.scrollCalls.push(options); }
|
|
getBoundingClientRect() { return { left: this.rectLeft, width: this.rectWidth || this.offsetWidth }; }
|
|
}
|
|
|
|
class MiniDocument extends MiniElement {
|
|
constructor() {
|
|
super('#document');
|
|
this.ownerDocument = this;
|
|
this.readyState = 'complete';
|
|
this.body = this.createElement('body');
|
|
this.appendChild(this.body);
|
|
this.activeElement = null;
|
|
}
|
|
|
|
createElement(tagName) {
|
|
const element = new MiniElement(tagName);
|
|
element.ownerDocument = this;
|
|
return element;
|
|
}
|
|
}
|
|
|
|
function toDatasetKey(value) { return value.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); }
|
|
|
|
function descendants(node) {
|
|
return node.children.flatMap((child) => [child, ...descendants(child)]);
|
|
}
|
|
|
|
function matchesCompound(node, selector) {
|
|
if (!selector || selector === '*') return true;
|
|
const tag = selector.match(/^[a-z][a-z0-9-]*/i);
|
|
if (tag && node.tagName.toLowerCase() !== tag[0].toLowerCase()) return false;
|
|
const id = selector.match(/#([a-zA-Z_][\w-]*)/);
|
|
if (id && node.getAttribute('id') !== id[1]) return false;
|
|
for (const [, className] of selector.matchAll(/\.([a-zA-Z_][\w-]*)/g)) {
|
|
if (!node.classList.contains(className)) return false;
|
|
}
|
|
for (const [, name, expected] of selector.matchAll(/\[([\w-]+)(?:=["']?([^\]"']+)["']?)?\]/g)) {
|
|
if (!node.hasAttribute(name)) return false;
|
|
if (expected !== undefined && node.getAttribute(name) !== expected) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function matchesSelector(node, selector) {
|
|
const tokens = selector.replace(/>/g, ' > ').trim().split(/\s+/);
|
|
let cursor = node;
|
|
if (!matchesCompound(cursor, tokens.pop())) return false;
|
|
while (tokens.length) {
|
|
const combinator = tokens.pop();
|
|
const previous = combinator === '>' ? tokens.pop() : combinator;
|
|
if (combinator === '>') {
|
|
cursor = cursor.parentNode;
|
|
if (!cursor || !matchesCompound(cursor, previous)) return false;
|
|
} else {
|
|
cursor = cursor.parentNode;
|
|
while (cursor && !matchesCompound(cursor, previous)) cursor = cursor.parentNode;
|
|
if (!cursor) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function parseMarkup(markup, parent) {
|
|
const tokens = markup.match(/<!--[\s\S]*?-->|<[^>]+>|[^<]+/g) || [];
|
|
const stack = [parent];
|
|
const voidTags = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
for (const token of tokens) {
|
|
if (token.startsWith('<!--') || token.trim() === '') continue;
|
|
if (token.startsWith('</')) {
|
|
if (stack.length > 1) stack.pop();
|
|
continue;
|
|
}
|
|
if (!token.startsWith('<')) {
|
|
const textNode = parent.ownerDocument.createElement('#text');
|
|
textNode._text = token;
|
|
stack[stack.length - 1].appendChild(textNode);
|
|
continue;
|
|
}
|
|
const open = token.match(/^<([a-z][\w-]*)([\s\S]*?)\/?\s*>$/i);
|
|
if (!open) continue;
|
|
const node = parent.ownerDocument.createElement(open[1]);
|
|
for (const [, name, quoted, bare] of open[2].matchAll(/([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
|
|
node.setAttribute(name, quoted ?? bare ?? '');
|
|
}
|
|
stack[stack.length - 1].appendChild(node);
|
|
if (!voidTags.has(node.tagName.toLowerCase()) && !token.endsWith('/>')) stack.push(node);
|
|
}
|
|
}
|
|
|
|
function tabMarkup(document, { firstPage = 1, firstSegmentLoaded = true } = {}) {
|
|
const root = document.createElement('div');
|
|
root.className = 'widget-tab-post style-mini posts-mini-lists zib-widget';
|
|
root.setAttribute('data-widget', 'tab_posts');
|
|
root.setAttribute('data-instance', 'widget-7');
|
|
const nav = document.createElement('ul');
|
|
nav.className = 'list-inline scroll-x no-scrollbar';
|
|
nav.setAttribute('role', 'tablist');
|
|
nav.clientWidth = 260;
|
|
nav.scrollWidth = 420;
|
|
const tabOne = addTab(document, nav, 0, 'tab-one', 'panel-one', true);
|
|
const tabTwo = addTab(document, nav, 1, 'tab-two', 'panel-two', false);
|
|
nav.rectLeft = 20;
|
|
tabOne.rectLeft = 100;
|
|
tabOne.rectWidth = 100;
|
|
tabTwo.rectLeft = 380;
|
|
tabTwo.rectWidth = 100;
|
|
root.appendChild(nav);
|
|
const content = document.createElement('div');
|
|
content.className = 'tab-content';
|
|
content.appendChild(addPane(document, 'panel-one', 'tab-one', 0, firstPage, firstSegmentLoaded, true));
|
|
content.appendChild(addPane(document, 'panel-two', 'tab-two', 1, 1, false, false));
|
|
root.appendChild(content);
|
|
return { root, nav, tabOne, tabTwo };
|
|
}
|
|
|
|
function addTab(document, nav, segment, id, panelId, selected) {
|
|
const item = document.createElement('li');
|
|
if (selected) item.classList.add('active');
|
|
const tab = document.createElement('a');
|
|
tab.setAttribute('role', 'tab');
|
|
tab.setAttribute('id', id);
|
|
tab.setAttribute('aria-controls', panelId);
|
|
tab.setAttribute('aria-selected', selected ? 'true' : 'false');
|
|
tab.setAttribute('tabindex', selected ? '0' : '-1');
|
|
tab.setAttribute('data-segment', String(segment));
|
|
item.appendChild(tab);
|
|
nav.appendChild(item);
|
|
return tab;
|
|
}
|
|
|
|
function addPane(document, id, tabId, segment, page, loaded, active) {
|
|
const pane = document.createElement('div');
|
|
pane.className = 'tab-pane fade' + (active ? ' active in' : '');
|
|
pane.setAttribute('id', id);
|
|
pane.setAttribute('role', 'tabpanel');
|
|
pane.setAttribute('aria-labelledby', tabId);
|
|
const pager = document.createElement('div');
|
|
pager.className = 'widget-ajaxpager';
|
|
pager.setAttribute('data-widget-items', '');
|
|
pager.setAttribute('data-widget', 'tab_posts');
|
|
pager.setAttribute('data-instance', 'widget-7');
|
|
pager.setAttribute('data-segment', String(segment));
|
|
pager.setAttribute('data-page', String(page));
|
|
if (loaded) pager.setAttribute('data-loaded', '1');
|
|
if (active && loaded) {
|
|
pager.innerHTML = '<posts class="posts-mini ajax-item" data-post-id="post-one"><div class="posts-mini-con"><h2 class="item-heading"><a>Page one</a></h2></div></posts>';
|
|
pager.appendChild(nextPage(document, 2));
|
|
} else {
|
|
const loader = document.createElement('div');
|
|
loader.className = 'post_ajax_loader';
|
|
pager.appendChild(loader);
|
|
}
|
|
pane.appendChild(pager);
|
|
return pane;
|
|
}
|
|
|
|
function nextPage(document, page) {
|
|
const pagination = document.createElement('div');
|
|
pagination.className = 'ajax-pag';
|
|
const link = document.createElement('a');
|
|
link.setAttribute('data-next-page', String(page));
|
|
pagination.appendChild(link);
|
|
return pagination;
|
|
}
|
|
|
|
function addJumpControl(document, pager, { max = 20, value = '4' } = {}) {
|
|
const pagination = document.createElement('div');
|
|
pagination.className = 'ajax-pag';
|
|
const wrapper = document.createElement('span');
|
|
wrapper.className = 'pag-jump';
|
|
const input = document.createElement('input');
|
|
input.setAttribute('data-widget-jump', '');
|
|
input.setAttribute('type', 'text');
|
|
input.max = max;
|
|
input.value = value;
|
|
const button = document.createElement('button');
|
|
button.setAttribute('data-widget-jump-go', '');
|
|
wrapper.appendChild(input);
|
|
wrapper.appendChild(button);
|
|
pagination.appendChild(wrapper);
|
|
pager.appendChild(pagination);
|
|
return { input, button };
|
|
}
|
|
|
|
function postsMarkup(document, unveiled = false) {
|
|
const root = document.createElement('div');
|
|
root.className = 'widget-main-post style-list';
|
|
const pager = document.createElement('div');
|
|
pager.className = 'widget-ajaxpager ' + (unveiled ? 'lazyloaded' : 'lazyload');
|
|
pager.setAttribute('data-widget-items', '');
|
|
pager.setAttribute('data-widget-lazy', '');
|
|
pager.setAttribute('data-widget', 'posts');
|
|
pager.setAttribute('data-instance', 'aksrt_widget_posts-2');
|
|
pager.setAttribute('data-page', '1');
|
|
pager.setAttribute('aria-busy', 'true');
|
|
pager.innerHTML = '<div class="post_ajax_loader is-loading"><div class="posts-item placeholder">Loading</div></div>';
|
|
root.appendChild(pager);
|
|
return { root };
|
|
}
|
|
|
|
function createRuntime({ fetch, pages, posts = false, unveiled = false, restUrl = '/wp-json/aksrt/v1/widgets/', observe = false } = {}) {
|
|
const document = new MiniDocument();
|
|
const { root: widget, nav, tabOne, tabTwo } = posts ? postsMarkup(document, unveiled) : tabMarkup(document);
|
|
document.body.appendChild(widget);
|
|
const autoFun = [];
|
|
function jQuery() {
|
|
return {
|
|
on(event, callback) { if (event === 'auto_fun') autoFun.push(callback); return this; },
|
|
trigger(event) { if (event === 'auto_fun') autoFun.forEach((callback) => callback()); return this; },
|
|
};
|
|
}
|
|
const requests = [];
|
|
const config = { restUrl, i18n: { error: 'error', retry: 'retry' } };
|
|
const fetchStub = (url) => {
|
|
requests.push(url);
|
|
if (fetch) return fetch(url);
|
|
const page = Number(new URL(url, 'https://example.test').searchParams.get('page'));
|
|
const segment = new URL(url, 'https://example.test').searchParams.get('segment');
|
|
const response = (pages && pages[`${segment}:${page}`]) || {
|
|
html: `<posts class="posts-mini ajax-item" data-post-id="post-${segment}-${page}"><div class="posts-mini-con"><h2 class="item-heading"><a>Returned ${segment}/${page}</a></h2></div></posts>`,
|
|
page,
|
|
paginate_html: page < 3 ? `<div class="ajax-pag"><a data-next-page="${page + 1}">more</a></div>` : '',
|
|
};
|
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(response) });
|
|
};
|
|
let notifyMutation;
|
|
const Observer = observe ? class {
|
|
constructor(callback) { notifyMutation = callback; }
|
|
observe() {}
|
|
} : undefined;
|
|
const window = {
|
|
aksrtWidgets: config,
|
|
location: { href: 'https://example.test/' },
|
|
matchMedia: () => ({ matches: false }),
|
|
jQuery,
|
|
};
|
|
if (observe) window.MutationObserver = Observer;
|
|
vm.runInNewContext(fs.readFileSync(path.join(root, 'assets/widgets/frontend.js'), 'utf8'), {
|
|
window,
|
|
document,
|
|
fetch: fetchStub,
|
|
Promise,
|
|
Array,
|
|
Number,
|
|
String,
|
|
Object,
|
|
Math,
|
|
encodeURIComponent,
|
|
URL,
|
|
MutationObserver: Observer,
|
|
});
|
|
return { document, widget, nav, tabOne, tabTwo, requests, autoFun, notifyMutation };
|
|
}
|
|
|
|
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
const pageRequests = (requests) => requests.map((url) => Number(new URL(url, 'https://example.test').searchParams.get('page')));
|
|
|
|
function pendingFetch() {
|
|
const pending = [];
|
|
return {
|
|
pending,
|
|
fetch(url) {
|
|
return new Promise((resolve, reject) => pending.push({
|
|
url,
|
|
resolve(data) { resolve({ ok: true, json: () => Promise.resolve(data) }); },
|
|
reject,
|
|
}));
|
|
},
|
|
};
|
|
}
|
|
|
|
function addPageLink(document, pager, page) {
|
|
const link = document.createElement('a');
|
|
link.className = 'page-numbers';
|
|
link.setAttribute('data-widget-page', String(page));
|
|
pager.querySelector('.ajax-pag').appendChild(link);
|
|
return link;
|
|
}
|
|
|
|
function assertOnlyLoader(pager, loader, message) {
|
|
assert.equal(pager.children.length, 1, message);
|
|
assert.equal(pager.firstChild, loader);
|
|
}
|
|
|
|
test('numbered pagination replaces the old page with its skeleton before the response arrives', async () => {
|
|
const transport = pendingFetch();
|
|
const runtime = createRuntime({ fetch: transport.fetch });
|
|
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
|
|
const originalPost = pager.querySelector('[data-post-id="post-one"]');
|
|
const loader = runtime.document.createElement('div');
|
|
loader.className = 'post_ajax_loader';
|
|
pager.appendChild(loader);
|
|
|
|
addPageLink(runtime.document, pager, 2).click();
|
|
|
|
assert.equal(pager.querySelectorAll('[data-post-id]').length, 0, 'old rows must stop occupying space while the replacement is pending');
|
|
assert.equal(originalPost.parentNode, null);
|
|
assert.equal(pager.querySelector('.ajax-pag'), null, 'old page controls leave the loading page');
|
|
assertOnlyLoader(pager, loader);
|
|
assert.equal(loader.classList.contains('is-loading'), true);
|
|
assert.equal(pager.getAttribute('aria-busy'), 'true');
|
|
assert.equal(pager.dataset.page, '1', 'the current page changes only after a successful response');
|
|
|
|
transport.pending[0].resolve({
|
|
html: '<posts class="posts-mini ajax-item" data-post-id="post-two">Page two</posts>',
|
|
page: 2,
|
|
paginate_html: '<div class="ajax-pag"><a class="page-numbers" data-widget-page="3">3</a></div>',
|
|
});
|
|
await tick();
|
|
|
|
assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-two']);
|
|
assert.equal(pager.querySelector('.post_ajax_loader'), loader);
|
|
assert.equal(loader.classList.contains('is-loading'), false);
|
|
assert.equal(pager.getAttribute('aria-busy'), 'false');
|
|
assert.equal(pager.dataset.page, '2');
|
|
|
|
pager.querySelector('[data-widget-page="3"]').click();
|
|
assertOnlyLoader(pager, loader, 'the next replacement reuses the same skeleton in the cleared page');
|
|
assert.equal(loader.classList.contains('is-loading'), true);
|
|
transport.pending[1].resolve({ html: '', page: 3, paginate_html: '' });
|
|
await tick();
|
|
assert.equal(pager.dataset.page, '3');
|
|
assert.equal(loader.classList.contains('is-loading'), false);
|
|
});
|
|
|
|
test('a failed numbered page restores the old content and retries the requested replacement', async () => {
|
|
const transport = pendingFetch();
|
|
const runtime = createRuntime({ fetch: transport.fetch });
|
|
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
|
|
const originalPost = pager.querySelector('[data-post-id="post-one"]');
|
|
const originalPagination = pager.querySelector('.ajax-pag');
|
|
const link = addPageLink(runtime.document, pager, 2);
|
|
link.click();
|
|
const loader = pager.querySelector('.post_ajax_loader');
|
|
link.click();
|
|
assert.deepEqual(pageRequests(runtime.requests), [2], 'a detached trigger cannot start a duplicate request');
|
|
transport.pending[0].reject(new Error('temporary failure'));
|
|
await tick();
|
|
|
|
assert.equal(pager.querySelector('[data-post-id="post-one"]'), originalPost, 'failure restores the original row node');
|
|
assert.equal(pager.querySelector('.ajax-pag'), originalPagination, 'failure restores the original pagination controls');
|
|
assert.equal(pager.dataset.page, '1');
|
|
assert.equal(pager.getAttribute('aria-busy'), 'false');
|
|
assert.equal(loader.classList.contains('is-loading'), false);
|
|
const retry = pager.querySelector('[data-widget-retry]');
|
|
assert.equal(retry.dataset.retryPage, '2');
|
|
assert.equal(retry.dataset.retryAppend, '0');
|
|
|
|
retry.click();
|
|
assert.deepEqual(pageRequests(runtime.requests), [2, 2]);
|
|
assertOnlyLoader(pager, loader, 'retry clears the restored page before showing its replacement skeleton');
|
|
transport.pending[1].resolve({
|
|
html: '<posts class="posts-mini ajax-item" data-post-id="post-two">Page two</posts>',
|
|
page: 2,
|
|
paginate_html: '',
|
|
});
|
|
await tick();
|
|
assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-two']);
|
|
assert.equal(pager.querySelector('[data-widget-retry]'), null);
|
|
assert.equal(pager.dataset.page, '2');
|
|
});
|
|
|
|
test('load-more keeps existing rows and places its pending skeleton after them', async () => {
|
|
const transport = pendingFetch();
|
|
const runtime = createRuntime({ fetch: transport.fetch });
|
|
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
|
|
const originalPost = pager.querySelector('[data-post-id="post-one"]');
|
|
pager.querySelector('[data-next-page="2"]').click();
|
|
const loader = pager.querySelector('.post_ajax_loader');
|
|
|
|
assert.equal(pager.querySelector('[data-post-id="post-one"]'), originalPost);
|
|
assert.equal(pager.children.indexOf(loader), pager.children.indexOf(originalPost) + 1);
|
|
assert.equal(loader.classList.contains('is-loading'), true);
|
|
transport.pending[0].resolve({
|
|
html: '<posts class="posts-mini ajax-item" data-post-id="post-two">Page two</posts>',
|
|
page: 2,
|
|
paginate_html: '',
|
|
});
|
|
await tick();
|
|
assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-one', 'post-two']);
|
|
assert.equal(pager.querySelector('.post_ajax_loader'), loader);
|
|
assert.equal(pager.children.indexOf(loader), 2);
|
|
assert.equal(loader.classList.contains('is-loading'), false);
|
|
});
|
|
|
|
test('jump pagination clears the page during loading and keeps the skeleton scoped to that tab', async () => {
|
|
const transport = pendingFetch();
|
|
const runtime = createRuntime({ fetch: transport.fetch });
|
|
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
|
|
const otherPager = runtime.widget.querySelector('#panel-two [data-widget-items]');
|
|
const otherChildren = otherPager.children.slice();
|
|
const { button } = addJumpControl(runtime.document, pager, { value: '4' });
|
|
button.click();
|
|
const loader = pager.querySelector('.post_ajax_loader');
|
|
|
|
assertOnlyLoader(pager, loader);
|
|
assert.equal(loader.classList.contains('is-loading'), true);
|
|
assert.equal(otherPager.children.length, otherChildren.length);
|
|
otherChildren.forEach((child, index) => assert.equal(otherPager.children[index], child, 'another tab keeps its existing DOM'));
|
|
assert.equal(otherPager.querySelector('.is-loading'), null);
|
|
transport.pending[0].resolve({ html: '<posts class="posts-mini ajax-item" data-post-id="post-four">Page four</posts>', page: 4, paginate_html: '' });
|
|
await tick();
|
|
assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-four']);
|
|
assert.equal(pager.dataset.page, '4');
|
|
});
|
|
|
|
test('tab panes cache results and keep their current page after switching away and back', async () => {
|
|
const runtime = createRuntime();
|
|
const paneOne = runtime.widget.querySelector('#panel-one');
|
|
const paneTwo = runtime.widget.querySelector('#panel-two');
|
|
const pagerOne = paneOne.querySelector('[data-widget-items]');
|
|
const pagerTwo = paneTwo.querySelector('[data-widget-items]');
|
|
runtime.tabTwo.click();
|
|
await tick();
|
|
assert.deepEqual(pageRequests(runtime.requests), [1]);
|
|
const pageTwoMore = pagerTwo.querySelector('[data-next-page="2"]');
|
|
assert.ok(pageTwoMore, 'the returned REST markup is parsed and inserted into the selected pane');
|
|
pageTwoMore.click();
|
|
await tick();
|
|
assert.deepEqual(pageRequests(runtime.requests), [1, 2]);
|
|
assert.equal(pagerTwo.dataset.page, '2');
|
|
|
|
runtime.tabOne.click();
|
|
runtime.tabTwo.click();
|
|
await tick();
|
|
assert.deepEqual(pageRequests(runtime.requests), [1, 2], 'a cached pane does not issue another first-page request');
|
|
assert.equal(pagerOne.dataset.page, '1', 'switching tabs preserves the initial pane page');
|
|
assert.equal(pagerTwo.dataset.page, '2', 'switching back preserves the loaded pane page');
|
|
assert.equal(pagerTwo.querySelectorAll('[data-post-id]').length, 2, 'returned page markup remains in the cached pane');
|
|
});
|
|
|
|
test('arrow keys move tab focus, activate the adjacent pane, and center an overflowing nav item', async () => {
|
|
const runtime = createRuntime();
|
|
const keyEvent = runtime.tabOne.dispatch('keydown', { key: 'ArrowRight' });
|
|
await tick();
|
|
|
|
assert.equal(keyEvent.defaultPrevented, true);
|
|
assert.equal(runtime.document.activeElement, runtime.tabTwo);
|
|
assert.equal(runtime.tabTwo.getAttribute('aria-selected'), 'true');
|
|
assert.equal(runtime.widget.querySelector('#panel-two').classList.contains('active'), true);
|
|
assert.equal(runtime.nav.scrollCalls.length, 1);
|
|
assert.equal(runtime.nav.scrollCalls[0].left, 280);
|
|
assert.equal(runtime.nav.scrollCalls[0].behavior, 'smooth');
|
|
assert.deepEqual(pageRequests(runtime.requests), [1]);
|
|
});
|
|
|
|
test('overflowing tab centering includes the nav current scroll offset', () => {
|
|
const runtime = createRuntime();
|
|
runtime.nav.scrollLeft = 35;
|
|
runtime.tabTwo.click();
|
|
|
|
assert.equal(runtime.nav.scrollCalls.length, 1);
|
|
assert.equal(runtime.nav.scrollCalls[0].left, 315, 'the target uses viewport bounds plus current nav scrollLeft');
|
|
});
|
|
|
|
test('a failed load-more can retry page two and append it without replacing page one', async () => {
|
|
let pageTwoAttempts = 0;
|
|
const runtime = createRuntime({ fetch: (url) => {
|
|
const page = Number(new URL(url, 'https://example.test').searchParams.get('page'));
|
|
if (page === 2 && pageTwoAttempts++ === 0) return Promise.reject(new Error('temporary failure'));
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
html: '<posts class="posts-mini ajax-item" data-post-id="post-two"><h2 class="item-heading"><a>Page two</a></h2></posts>',
|
|
page,
|
|
paginate_html: '',
|
|
}),
|
|
});
|
|
} });
|
|
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
|
|
|
|
pager.querySelector('[data-next-page="2"]').click();
|
|
await tick();
|
|
const retry = pager.querySelector('[data-widget-retry]');
|
|
assert.ok(retry, 'a failed append exposes the runtime retry control');
|
|
assert.equal(retry.dataset.retryPage, '2');
|
|
assert.equal(retry.dataset.retryAppend, '1');
|
|
|
|
retry.click();
|
|
await tick();
|
|
assert.deepEqual(pageRequests(runtime.requests), [2, 2], 'retry repeats the failed page rather than resetting to page one');
|
|
assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-one', 'post-two']);
|
|
assert.equal(pager.dataset.page, '2');
|
|
assert.equal(runtime.autoFun.length, 1, 'successful markup insertion initializes widgets through auto_fun');
|
|
});
|
|
|
|
test('jump button replaces the selected pane, keeps its loader, and stops the legacy click handler', async () => {
|
|
const runtime = createRuntime();
|
|
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
|
|
const loader = runtime.document.createElement('div');
|
|
loader.className = 'post_ajax_loader';
|
|
pager.insertBefore(loader, pager.querySelector('.ajax-pag'));
|
|
const { button } = addJumpControl(runtime.document, pager, { value: '4' });
|
|
let legacyClicks = 0;
|
|
runtime.document.body.addEventListener('click', () => { legacyClicks += 1; });
|
|
|
|
const click = button.dispatch('click');
|
|
await tick();
|
|
|
|
assert.equal(click.defaultPrevented, true);
|
|
assert.equal(click.propagationStopped, true);
|
|
assert.equal(legacyClicks, 0, 'the legacy pag_jump_go document handler cannot process the same click');
|
|
assert.deepEqual(pageRequests(runtime.requests), [4]);
|
|
assert.equal(pager.dataset.page, '4');
|
|
assert.equal(pager.querySelector('[data-post-id="post-0-4"]') !== null, true, 'the returned page markup replaces the active pane items');
|
|
assert.equal(pager.querySelector('[data-post-id="post-one"]'), null);
|
|
assert.equal(pager.querySelector('.post_ajax_loader'), loader, 'replacement preserves the loader node for subsequent requests');
|
|
});
|
|
|
|
test('Enter submits a valid jump and stops the legacy key handler', async () => {
|
|
const runtime = createRuntime();
|
|
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
|
|
const loader = runtime.document.createElement('div');
|
|
loader.className = 'post_ajax_loader';
|
|
pager.insertBefore(loader, pager.querySelector('.ajax-pag'));
|
|
const { input } = addJumpControl(runtime.document, pager, { value: '5' });
|
|
let legacyKeydowns = 0;
|
|
runtime.document.body.addEventListener('keydown', () => { legacyKeydowns += 1; });
|
|
|
|
const keydown = input.dispatch('keydown', { key: 'Enter' });
|
|
await tick();
|
|
|
|
assert.equal(keydown.defaultPrevented, true);
|
|
assert.equal(keydown.propagationStopped, true);
|
|
assert.equal(legacyKeydowns, 0, 'the legacy pag_jump_go key handler cannot submit twice');
|
|
assert.deepEqual(pageRequests(runtime.requests), [5]);
|
|
assert.equal(pager.dataset.page, '5');
|
|
assert.ok(pager.querySelector('[data-post-id="post-0-5"]'));
|
|
assert.equal(pager.querySelector('.post_ajax_loader'), loader);
|
|
});
|
|
|
|
test('jump input clamps pages to max and rejects invalid or current pages without fetching', async () => {
|
|
const runtime = createRuntime();
|
|
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
|
|
const { input } = addJumpControl(runtime.document, pager, { max: 8, value: '99' });
|
|
|
|
const inputEvent = input.dispatch('input');
|
|
assert.equal(input.value, '8');
|
|
assert.equal(inputEvent.propagationStopped, true);
|
|
|
|
input.value = 'not-a-page';
|
|
input.dispatch('input');
|
|
assert.equal(input.value, '');
|
|
input.dispatch('keydown', { key: 'Enter' });
|
|
assert.equal(runtime.requests.length, 0, 'invalid input does not request a page');
|
|
|
|
input.value = '1';
|
|
const currentPage = input.dispatch('keydown', { key: 'Enter' });
|
|
await tick();
|
|
assert.equal(currentPage.defaultPrevented, true);
|
|
assert.equal(runtime.requests.length, 0, 'submitting the already selected page does not request again');
|
|
assert.equal(runtime.document.activeElement, input, 'invalid and current page values return focus to the input');
|
|
});
|
|
|
|
test('article widget waits for its own lazy reveal and fetches the first page once', async () => {
|
|
const transport = pendingFetch();
|
|
const runtime = createRuntime({ posts: true, fetch: transport.fetch });
|
|
const pager = runtime.widget.querySelector('[data-widget-items]');
|
|
assert.equal(runtime.requests.length, 0, 'offscreen widgets keep their placeholder without fetching');
|
|
assert.ok(pager.querySelector('.post_ajax_loader.is-loading'));
|
|
pager.querySelector('.placeholder').dispatch('lazybeforeunveil');
|
|
assert.equal(runtime.requests.length, 0, 'a descendant image reveal does not load the pager');
|
|
pager.dispatch('lazybeforeunveil');
|
|
pager.dispatch('lazybeforeunveil');
|
|
assert.deepEqual(pageRequests(runtime.requests), [1]);
|
|
const url = new URL(runtime.requests[0]);
|
|
assert.equal(url.pathname, '/wp-json/aksrt/v1/widgets/posts/items');
|
|
assert.equal(url.searchParams.get('instance'), 'aksrt_widget_posts-2');
|
|
transport.pending[0].resolve({ html: '<posts class="posts-item list ajax-item" data-post-id="first">First article</posts>', page: 1, paginate_html: '' });
|
|
await tick();
|
|
assert.ok(pager.querySelector('[data-post-id="first"]'));
|
|
assert.equal(pager.dataset.loaded, '1');
|
|
assert.equal(pager.getAttribute('aria-busy'), 'false');
|
|
assert.equal(pager.querySelector('.post_ajax_loader').classList.contains('is-loading'), false);
|
|
pager.dispatch('lazybeforeunveil');
|
|
assert.equal(runtime.requests.length, 1, 'a completed first page is never fetched again by lazySizes');
|
|
});
|
|
|
|
test('article widget loads when lazySizes unveiled it before the widget client initialized', async () => {
|
|
const runtime = createRuntime({ posts: true, unveiled: true });
|
|
assert.deepEqual(pageRequests(runtime.requests), [1]);
|
|
await tick();
|
|
const pager = runtime.widget.querySelector('[data-widget-items]');
|
|
assert.equal(pager.dataset.loaded, '1');
|
|
assert.ok(pager.querySelector('[data-post-id]'));
|
|
});
|
|
|
|
test('article widget failure exposes a retry that replaces the initial skeleton', async () => {
|
|
const transport = pendingFetch();
|
|
const runtime = createRuntime({ posts: true, fetch: transport.fetch });
|
|
const pager = runtime.widget.querySelector('[data-widget-items]');
|
|
pager.dispatch('lazybeforeunveil');
|
|
transport.pending[0].reject(new Error('offline'));
|
|
await tick();
|
|
assert.equal(pager.getAttribute('aria-busy'), 'false');
|
|
assert.equal(pager.querySelector('.post_ajax_loader').classList.contains('is-loading'), false);
|
|
pager.querySelector('[data-widget-retry]').click();
|
|
assert.deepEqual(pageRequests(runtime.requests), [1, 1]);
|
|
transport.pending[1].resolve({ html: '<posts class="posts-item card ajax-item" data-post-id="retried">Retried</posts>', page: 1, paginate_html: '' });
|
|
await tick();
|
|
assert.ok(pager.querySelector('[data-post-id="retried"]'));
|
|
assert.equal(pager.querySelector('[data-widget-retry]'), null);
|
|
});
|
|
|
|
test('article REST addresses preserve the route for plain WordPress permalinks', async () => {
|
|
const runtime = createRuntime({ posts: true, unveiled: true, restUrl: 'https://example.test/blog/?rest_route=/aksrt/v1/widgets/' });
|
|
const url = new URL(runtime.requests[0]);
|
|
assert.equal(url.pathname, '/blog/');
|
|
assert.equal(url.searchParams.get('rest_route'), '/aksrt/v1/widgets/posts/items');
|
|
assert.equal(url.searchParams.get('instance'), 'aksrt_widget_posts-2');
|
|
assert.equal(url.searchParams.get('page'), '1');
|
|
await tick();
|
|
assert.equal(runtime.widget.querySelector('[data-widget-items]').dataset.loaded, '1');
|
|
});
|
|
|
|
test('a pager inserted as the mutation root still receives its lazy listener', async () => {
|
|
const runtime = createRuntime({ observe: true });
|
|
const pager = postsMarkup(runtime.document).root.querySelector('[data-widget-items]');
|
|
pager.nodeType = 1;
|
|
runtime.document.body.appendChild(pager);
|
|
runtime.notifyMutation([{ addedNodes: [pager] }]);
|
|
pager.dispatch('lazybeforeunveil');
|
|
await tick();
|
|
assert.deepEqual(pageRequests(runtime.requests), [1]);
|
|
assert.equal(pager.dataset.loaded, '1');
|
|
});
|