200 lines
8.6 KiB
JavaScript
200 lines
8.6 KiB
JavaScript
// Generated, do not edit. sha256:15b2f4e3c6894b2e23f02bc74d0bb5d5cabbc5c333e56f9b6ed448d6ac275271
|
|||
|
|
import { describe, it } from 'node:test';
|
||
|
|
import assert from 'node:assert/strict';
|
||
|
|
import { installDom } from '../helpers/mini-dom.mjs';
|
||
|
|
|
||
|
|
const dom = installDom();
|
||
|
|
const { create, configureTabs, rowLayout } = await import('../../client/ui/tabs.js');
|
||
|
|
|
||
|
|
const items = () => [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }, { id: 'c', label: 'C', when: () => false }];
|
||
|
|
const tabIds = t => t.bar.querySelectorAll('[data-tab]').map(b => b.dataset.tab);
|
||
|
|
const tick = () => new Promise(r => setImmediate(r));
|
||
|
|
|
||
|
|
describe('tabs: building and switching', () => {
|
||
|
|
it('a bar and a panel, the active tab marked, a filtered tab left out', () => {
|
||
|
|
const rendered = [];
|
||
|
|
const t = create(dom.host(), { items: items(), active: 'b', render: (id, p) => { rendered.push(id); p.textContent = id; } });
|
||
|
|
assert.deepEqual(tabIds(t), ['a', 'b']);
|
||
|
|
assert.equal(t.bar.querySelector('.is-on').dataset.tab, 'b');
|
||
|
|
assert.equal(t.bar.querySelector('[data-tab="b"]').getAttribute('aria-selected'), 'true');
|
||
|
|
assert.equal(t.panel.textContent, 'b');
|
||
|
|
assert.deepEqual(rendered, ['b']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('an unknown or filtered active tab falls back on the first', () => {
|
||
|
|
assert.equal(create(dom.host(), { items: items(), active: 'c' }).active, 'a');
|
||
|
|
assert.equal(create(dom.host(), { items: items(), active: 'zz' }).active, 'a');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('a click switches, redraws the panel and reports; the same tab again does nothing', () => {
|
||
|
|
const changes = [];
|
||
|
|
let renders = 0;
|
||
|
|
const t = create(dom.host(), { items: items(), render: () => { renders++; }, onChange: id => changes.push(id) });
|
||
|
|
dom.click(t.bar.querySelector('[data-tab="b"]'));
|
||
|
|
dom.click(t.bar.querySelector('[data-tab="b"]'));
|
||
|
|
assert.deepEqual(changes, ['b']);
|
||
|
|
assert.equal(renders, 2);
|
||
|
|
t.select('zz');
|
||
|
|
assert.equal(t.active, 'b', 'an unknown id is ignored');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('size lg, and the bar in another host when asked', () => {
|
||
|
|
const host = dom.host();
|
||
|
|
const side = dom.host();
|
||
|
|
const t = create(host, { items: items(), size: 'lg', barHost: side });
|
||
|
|
assert.equal(t.bar.className, 'tabset tabset--lg');
|
||
|
|
assert.equal(t.bar.parentNode, side);
|
||
|
|
assert.equal(t.panel.parentNode, host);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('tabs: the live dots', () => {
|
||
|
|
it('a dot function runs on every bar draw; a sync event redraws the bar and not the panel', () => {
|
||
|
|
let n = 0;
|
||
|
|
let renders = 0;
|
||
|
|
const t = create(dom.host(), {
|
||
|
|
items: [{ id: 'a', label: 'A', dot: () => `<i>${++n}</i>` }],
|
||
|
|
render: () => { renders++; }, syncOn: ['attention-changed'],
|
||
|
|
});
|
||
|
|
dom.fire(dom.document, 'attention-changed');
|
||
|
|
assert.equal(t.bar.querySelector('i').textContent, '2');
|
||
|
|
assert.equal(renders, 1);
|
||
|
|
t.refresh();
|
||
|
|
assert.equal(t.bar.querySelector('i').textContent, '3');
|
||
|
|
t.destroy();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('a bar that left the document unbinds itself on the next event', () => {
|
||
|
|
const host = dom.host();
|
||
|
|
const t = create(host, { items: items(), syncOn: ['attention-changed'] });
|
||
|
|
const before = dom.document.listeners.length;
|
||
|
|
host.remove();
|
||
|
|
dom.fire(dom.document, 'attention-changed');
|
||
|
|
assert.equal(dom.document.listeners.length, before - 1);
|
||
|
|
assert.equal(t.bar.isConnected, false);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('a second create on the same host removes the listeners of the first', () => {
|
||
|
|
const host = dom.host();
|
||
|
|
const before = dom.document.listeners.length;
|
||
|
|
create(host, { items: items(), syncOn: ['x-event'] });
|
||
|
|
create(host, { items: items(), syncOn: ['x-event'] });
|
||
|
|
assert.equal(dom.document.listeners.length, before + 1);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('tabs: two rows for a large bar that overflows', () => {
|
||
|
|
it('one row while the tabs fit, up to and including the last pixel', () => {
|
||
|
|
assert.deepEqual(rowLayout(11, 900, 1000), { rows: 1, cols: 11 });
|
||
|
|
assert.deepEqual(rowLayout(11, 1000, 1000), { rows: 1, cols: 11 });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('two rows from one pixel over, the first row the longer one', () => {
|
||
|
|
assert.deepEqual(rowLayout(11, 1001, 1000), { rows: 2, cols: 6 });
|
||
|
|
assert.deepEqual(rowLayout(10, 1500, 1000), { rows: 2, cols: 5 });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('never more than two rows, and a single tab is not split', () => {
|
||
|
|
assert.deepEqual(rowLayout(11, 5000, 1000), { rows: 2, cols: 6 });
|
||
|
|
assert.deepEqual(rowLayout(1, 1500, 1000), { rows: 1, cols: 1 });
|
||
|
|
});
|
||
|
|
|
||
|
|
class FakeResizeObserver {
|
||
|
|
constructor(cb) { this.cb = cb; this.targets = []; FakeResizeObserver.last = this; }
|
||
|
|
observe(el) { this.targets.push(el); }
|
||
|
|
disconnect() { this.targets = []; }
|
||
|
|
fire(width) { this.cb([{ contentRect: { width } }]); }
|
||
|
|
}
|
||
|
|
let layout = { reach: 0, room: 0 };
|
||
|
|
const sized = (reach, room) => { layout = { reach, room }; };
|
||
|
|
const withObserver = fn => {
|
||
|
|
const prev = globalThis.ResizeObserver;
|
||
|
|
globalThis.ResizeObserver = FakeResizeObserver;
|
||
|
|
FakeResizeObserver.last = null;
|
||
|
|
let proto = Object.getPrototypeOf(document.createElement('div'));
|
||
|
|
while (!Object.hasOwn(proto, 'getBoundingClientRect')) proto = Object.getPrototypeOf(proto);
|
||
|
|
const rect = proto.getBoundingClientRect;
|
||
|
|
proto.getBoundingClientRect = function () {
|
||
|
|
const right = this.classList.contains('tabset') ? layout.room : layout.reach;
|
||
|
|
return { left: 0, top: 0, right, bottom: 0, width: right, height: 0 };
|
||
|
|
};
|
||
|
|
try { return fn(); } finally {
|
||
|
|
globalThis.ResizeObserver = prev;
|
||
|
|
proto.getBoundingClientRect = rect;
|
||
|
|
layout = { reach: 0, room: 0 };
|
||
|
|
}
|
||
|
|
};
|
||
|
|
const eleven = () => Array.from({ length: 11 }, (_, i) => ({ id: `t${i}`, label: `T${i}` }));
|
||
|
|
|
||
|
|
it('a resize that makes the bar overflow switches it to rows with its column count, and back', () => withObserver(() => {
|
||
|
|
const t = create(dom.host(), { items: eleven(), size: 'lg' });
|
||
|
|
const ro = FakeResizeObserver.last;
|
||
|
|
assert.deepEqual(ro.targets, [t.bar]);
|
||
|
|
sized(1200, 1000);
|
||
|
|
ro.fire(1000);
|
||
|
|
assert.ok(t.bar.classList.contains('tabset--rows'));
|
||
|
|
assert.equal(t.bar.style.getPropertyValue('--tabset-cols'), '6');
|
||
|
|
sized(1200, 1600);
|
||
|
|
ro.fire(1600);
|
||
|
|
assert.equal(t.bar.classList.contains('tabset--rows'), false);
|
||
|
|
}));
|
||
|
|
|
||
|
|
it('a height change alone does not re-measure: that is the switch to two rows itself', () => withObserver(() => {
|
||
|
|
const t = create(dom.host(), { items: eleven(), size: 'lg' });
|
||
|
|
const ro = FakeResizeObserver.last;
|
||
|
|
sized(1200, 1000);
|
||
|
|
ro.fire(1000);
|
||
|
|
sized(900, 1000);
|
||
|
|
ro.fire(1000);
|
||
|
|
assert.ok(t.bar.classList.contains('tabset--rows'));
|
||
|
|
}));
|
||
|
|
|
||
|
|
it('a redraw of the bar re-measures, since a dot changes the width of a tab', () => withObserver(() => {
|
||
|
|
const t = create(dom.host(), { items: eleven(), size: 'lg' });
|
||
|
|
sized(1200, 1000);
|
||
|
|
t.refresh();
|
||
|
|
assert.ok(t.bar.classList.contains('tabset--rows'));
|
||
|
|
}));
|
||
|
|
|
||
|
|
it('a compact bar is never observed, and destroy stops observing', () => withObserver(() => {
|
||
|
|
create(dom.host(), { items: eleven() });
|
||
|
|
assert.equal(FakeResizeObserver.last, null);
|
||
|
|
const t = create(dom.host(), { items: eleven(), size: 'lg' });
|
||
|
|
t.destroy();
|
||
|
|
assert.deepEqual(FakeResizeObserver.last.targets, []);
|
||
|
|
}));
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('tabs: the error net', () => {
|
||
|
|
const quiet = fn => { const e = console.error; console.error = () => {}; try { return fn(); } finally { console.error = e; } };
|
||
|
|
|
||
|
|
it('a throwing render shows the message, escaped, by default', () => {
|
||
|
|
const t = quiet(() => create(dom.host(), { items: items(), render: () => { throw new Error('<boom>'); } }));
|
||
|
|
assert.equal(t.panel.querySelector('.alert-error').textContent, '<boom>');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('an async render that rejects is caught too', async () => {
|
||
|
|
const e = console.error;
|
||
|
|
console.error = () => {};
|
||
|
|
try {
|
||
|
|
const t = create(dom.host(), { items: items(), render: async () => { throw new Error('later'); } });
|
||
|
|
await tick();
|
||
|
|
assert.equal(t.panel.querySelector('.alert-error')?.textContent, 'later');
|
||
|
|
} finally { console.error = e; }
|
||
|
|
});
|
||
|
|
|
||
|
|
it('configureTabs sets how a failed panel shows, with the tab id; null restores the default', () => {
|
||
|
|
const seen = [];
|
||
|
|
configureTabs({ onError: (panel, err, id) => { seen.push([err.message, id]); panel.textContent = 'own'; } });
|
||
|
|
const t = create(dom.host(), { items: items(), active: 'b', render: () => { throw new Error('x'); } });
|
||
|
|
assert.deepEqual(seen, [['x', 'b']]);
|
||
|
|
assert.equal(t.panel.textContent, 'own');
|
||
|
|
configureTabs({ onError: null });
|
||
|
|
const u = quiet(() => create(dom.host(), { items: items(), render: () => { throw new Error('y'); } }));
|
||
|
|
assert.ok(u.panel.querySelector('.alert-error'));
|
||
|
|
});
|
||
|
|
|
||
|
|
it('without a host there is no control', () => {
|
||
|
|
assert.equal(create(null, { items: items() }), null);
|
||
|
|
});
|
||
|
|
});
|