The kit draws in one shadow DOM host in the active theme's colors, corners and font and repaints live; accessible dialogs (focus trap and return, Esc/Enter rules, alertdialog for alerts and confirms), stacking with inert, toasts with actions, reduced motion, text-only content (ui.h), and scope.ui closes a plugin's dialogs on dispose. api.toast routes to it.
200 lines
11 KiB
TypeScript
200 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { buildElement, dialogWidth, toastDuration, trapIndex, validationMessage, UI_CSS, WIDTHS } from '../src/ui.ts';
|
|
import {
|
|
contrastRatio, createThemeApi, normalizeResolved, readableOn, themeFromColors, themeVars, themeVarsCss, toHex,
|
|
type ResolvedTheme, type ThemeSource,
|
|
} from '../src/themeApi.ts';
|
|
import { createCorePluginApi, isTypingTarget, type PluginApiSite } from '../src/pluginApi.ts';
|
|
|
|
test('dialog widths: presets, clamped numbers, md by default', () => {
|
|
assert.equal(dialogWidth(undefined), WIDTHS.md);
|
|
assert.equal(dialogWidth('sm'), 380);
|
|
assert.equal(dialogWidth('lg'), 640);
|
|
assert.equal(dialogWidth(100), 260);
|
|
assert.equal(dialogWidth(5000), 960);
|
|
assert.equal(dialogWidth(520.4), 520);
|
|
assert.equal(dialogWidth(Number.NaN), WIDTHS.md);
|
|
});
|
|
|
|
test('toast duration: defaults, action default, sticky and clamping', () => {
|
|
assert.equal(toastDuration({}), 3200);
|
|
assert.equal(toastDuration({ action: { label: 'Undo', onClick() {} } }), 6000);
|
|
assert.equal(toastDuration({ duration: 0 }), 0, '0 keeps it until closed');
|
|
assert.equal(toastDuration({ duration: Infinity }), 0);
|
|
assert.equal(toastDuration({ duration: 10 }), 800);
|
|
assert.equal(toastDuration({ duration: 9000 }), 9000);
|
|
assert.equal(toastDuration({ duration: 10 ** 9 }), 60_000);
|
|
});
|
|
|
|
test('focus trap wraps at both ends and lets the browser move focus in between', () => {
|
|
assert.equal(trapIndex(0, -1, false), -1, 'nothing to focus');
|
|
assert.equal(trapIndex(3, -1, false), 0, 'focus outside the list enters at the start');
|
|
assert.equal(trapIndex(3, -1, true), 2, 'Shift+Tab from outside enters at the end');
|
|
assert.equal(trapIndex(3, 2, false), 0, 'Tab on the last wraps to the first');
|
|
assert.equal(trapIndex(3, 0, true), 2, 'Shift+Tab on the first wraps to the last');
|
|
assert.equal(trapIndex(3, 1, false), -1);
|
|
assert.equal(trapIndex(3, 1, true), -1);
|
|
assert.equal(trapIndex(1, 0, false), 0, 'a single element keeps focus');
|
|
});
|
|
|
|
test('validation answers become error messages', () => {
|
|
assert.equal(validationMessage('Too long'), 'Too long');
|
|
assert.equal(validationMessage(' '), null);
|
|
assert.equal(validationMessage(false), 'Please check this value.');
|
|
for (const ok of [true, null, undefined, 0, '']) assert.equal(validationMessage(ok), null);
|
|
});
|
|
|
|
test('the kit stylesheet is scoped and honours reduced motion', () => {
|
|
assert.match(UI_CSS, /prefers-reduced-motion: reduce/);
|
|
assert.match(UI_CSS, /:host \{ all: initial; \}/);
|
|
// Every rule targets the kit's own classes (no bare element selectors that could leak by accident).
|
|
for (const selector of UI_CSS.replace(/\/\*[\s\S]*?\*\//g, '').replace(/@keyframes[^{]+\{(?:[^{}]*\{[^}]*\})*\s*\}/g, '').match(/(^|\})\s*([^{}@]+)\{/g) ?? []) {
|
|
const text = selector.replace(/^\}?\s*/, '').replace(/\{$/, '').trim();
|
|
if (!text || text.startsWith('from') || text.startsWith('to')) continue;
|
|
for (const part of text.split(',')) assert.match(part.trim(), /^(:host|\.wb-)/, part);
|
|
}
|
|
});
|
|
|
|
type FakeNode = {
|
|
tag: string; attrs: Record<string, string>; props: Record<string, unknown>; children: unknown[]; listeners: Record<string, unknown>;
|
|
className: string; textContent: string; style: Record<string, string>; dataset: Record<string, string>;
|
|
setAttribute(k: string, v: string): void; addEventListener(t: string, f: unknown): void; appendChild(c: unknown): unknown;
|
|
};
|
|
function fakeDoc() {
|
|
return {
|
|
createElement(tag: string) {
|
|
const node: FakeNode = {
|
|
tag, attrs: {}, props: {}, children: [], listeners: {}, className: '', textContent: '', style: {}, dataset: {},
|
|
setAttribute(k, v) { node.attrs[k] = v; },
|
|
addEventListener(t, f) { node.listeners[t] = f; },
|
|
appendChild(c) { node.children.push(c); return c; },
|
|
};
|
|
return node;
|
|
},
|
|
createTextNode(text: string) { return { text }; },
|
|
} as unknown as Document;
|
|
}
|
|
|
|
test('ui.h builds from text only: no innerHTML, string handlers or javascript: URLs', () => {
|
|
const doc = fakeDoc();
|
|
const click = () => {};
|
|
const el = buildElement(doc, 'a', {
|
|
class: 'wb-check', href: 'javascript:alert(1)', onclick: 'alert(1)', onClick: click, innerHTML: '<img onerror=x>',
|
|
title: 'hi', hidden: true, skip: false, checked: false, dataset: { id: '7' },
|
|
}, '<b>not html</b>', 3, null, ['a', false]) as unknown as FakeNode;
|
|
assert.equal(el.className, 'wb-check');
|
|
assert.equal(el.attrs.href, undefined, 'javascript: URL dropped');
|
|
assert.equal(el.attrs.onclick, undefined, 'string handler dropped');
|
|
assert.equal(el.listeners.click, click, 'function handler becomes a listener');
|
|
assert.equal((el as unknown as Record<string, unknown>).innerHTML, undefined);
|
|
assert.equal(el.attrs.innerHTML, undefined);
|
|
assert.equal(el.attrs.title, 'hi');
|
|
assert.equal(el.attrs.hidden, '');
|
|
assert.equal(el.attrs.skip, undefined, 'false attributes are left out');
|
|
assert.equal((el as unknown as Record<string, unknown>).checked, false, 'false properties are still set');
|
|
assert.equal(el.dataset.id, '7');
|
|
assert.deepEqual(el.children, [{ text: '<b>not html</b>' }, { text: '3' }, { text: 'a' }]);
|
|
assert.throws(() => buildElement(doc, 'script'), /unsupported tag/);
|
|
assert.throws(() => buildElement(doc, 'img onerror=x'), /unsupported tag/);
|
|
});
|
|
|
|
test('colors: hex parsing, contrast, readable label color', () => {
|
|
assert.equal(toHex('rgb(0, 0, 0)'), '#000000');
|
|
assert.equal(toHex('rgba(21, 32, 43, 1)'), '#15202b');
|
|
assert.equal(toHex('rgba(0, 0, 0, 0)'), null, 'transparent is no color');
|
|
assert.equal(toHex('#FFF'), '#ffffff');
|
|
assert.equal(toHex('red'), null);
|
|
assert.ok(Math.abs(contrastRatio('#000000', '#ffffff') - 21) < 0.01);
|
|
assert.equal(readableOn('#0b5ed7'), '#ffffff', 'white when it reaches AA');
|
|
assert.equal(readableOn('#1d9bf0'), '#000000', "X's blue reads better with black (7:1 vs 3:1)");
|
|
assert.equal(readableOn('#ffd400'), '#000000');
|
|
});
|
|
|
|
test('a resolved theme fills missing roles, clamps geometry and becomes --wb-* variables', () => {
|
|
const partial = { id: 'demo', name: 'Demo', mode: 'dark', siteDefault: false, radius: 99, buttonRadius: 9999, font: '',
|
|
colors: { bg: '#000000', text: '#E7E9EA', accent: '#1d9bf0' } } as unknown as ResolvedTheme;
|
|
const theme = normalizeResolved(partial);
|
|
assert.equal(theme.colors.text, '#e7e9ea');
|
|
assert.ok(theme.colors.surface && theme.colors.border && theme.colors.muted, 'derived roles');
|
|
assert.equal(theme.colors.onAccent, readableOn('#1d9bf0'));
|
|
assert.equal(theme.radius, 28, 'radius clamped');
|
|
assert.ok(theme.font.includes('sans-serif'), 'font falls back to a system stack');
|
|
assert.ok(Object.isFrozen(theme) && Object.isFrozen(theme.colors));
|
|
const vars = themeVars(theme);
|
|
assert.equal(vars['--wb-bg'], '#000000');
|
|
assert.equal(vars['--wb-on-inverse'], theme.colors.onInverse);
|
|
assert.equal(vars['--wb-radius'], '28px');
|
|
assert.equal(vars['--wb-mode'], 'dark');
|
|
const css = themeVarsCss({ ...theme, font: 'Evil;}</style><script>' });
|
|
assert.ok(css.startsWith(':root{') && css.endsWith('}'));
|
|
assert.doesNotMatch(css.slice(6, -1), /[{}<>]/, 'values cannot break out of the rule');
|
|
const light = themeFromColors('#ffffff', '#0f1419', '#1d9bf0');
|
|
assert.equal(light.mode, 'light');
|
|
assert.equal(light.colors.inverse, '#0f1419');
|
|
});
|
|
|
|
test('theme api: reports the source, falls back to page colors, notifies only on real changes', () => {
|
|
let current: ResolvedTheme | null = null;
|
|
const listeners = new Set<() => void>();
|
|
const source: ThemeSource = { current: () => current, onChange: fn => { listeners.add(fn); return () => listeners.delete(fn); } };
|
|
const win = {
|
|
document: { body: null, documentElement: {} },
|
|
getComputedStyle: () => ({ backgroundColor: 'rgb(0, 0, 0)', color: 'rgb(231, 233, 234)', fontFamily: 'Chirp' }),
|
|
matchMedia: () => ({ matches: true }),
|
|
} as unknown as Window;
|
|
const api = createThemeApi(win, source);
|
|
assert.equal(api.current().siteDefault, true, 'no source theme yet: page fallback');
|
|
const seen: string[] = [];
|
|
const off = api.onChange(theme => seen.push(theme.id));
|
|
current = themeFromColors('#101820', '#f0f0f0', '#ff7a00', { id: 'mine', siteDefault: false });
|
|
for (const fn of listeners) fn();
|
|
for (const fn of listeners) fn();
|
|
assert.deepEqual(seen, ['mine'], 'one notification per visible change');
|
|
assert.equal(api.current().colors.accent, '#ff7a00');
|
|
off();
|
|
assert.equal(listeners.size, 0, 'dispose unsubscribes');
|
|
});
|
|
|
|
test('core api exposes ui and theme without touching the DOM until something is shown', () => {
|
|
const site: PluginApiSite = {
|
|
name: 'Demo', product: 'wayBetter for Test', storagePrefix: 'demo:', styleDataKey: 'demo', toastId: 'demo-ui',
|
|
toastBackground: '#123456', itemSelector: 'article', chordKeys: [],
|
|
};
|
|
let created = 0;
|
|
const win = {
|
|
document: { createElement: () => { created += 1; return {}; }, body: null, documentElement: {} },
|
|
location: { href: 'https://example.com/' },
|
|
getComputedStyle: () => ({ backgroundColor: 'rgb(255, 255, 255)', color: 'rgb(15, 20, 25)', fontFamily: '' }),
|
|
} as unknown as Window & typeof globalThis;
|
|
const api = createCorePluginApi(win, site);
|
|
assert.equal(typeof api.ui.confirm, 'function');
|
|
assert.equal(typeof api.ui.dialog, 'function');
|
|
assert.equal(api.theme.current().colors.accent, '#123456', 'site accent is the fallback');
|
|
const scope = api.scope();
|
|
assert.equal(typeof scope.ui.prompt, 'function');
|
|
assert.equal(scope.ui, scope.ui, 'one scoped kit per scope');
|
|
scope.onThemeChange(() => {});
|
|
scope.dispose();
|
|
assert.equal(created, 0);
|
|
});
|
|
|
|
test('typing detection looks into focused shadow roots (dialog fields)', () => {
|
|
const input = { nodeName: 'INPUT', closest: () => null };
|
|
const host = { nodeName: 'WB-UI', closest: () => null, shadowRoot: { activeElement: input } };
|
|
assert.equal(isTypingTarget(host as unknown as EventTarget), true);
|
|
const button = { nodeName: 'BUTTON', closest: () => null };
|
|
assert.equal(isTypingTarget({ nodeName: 'WB-UI', closest: () => null, shadowRoot: { activeElement: button } } as unknown as EventTarget), false);
|
|
});
|
|
|
|
test('URL attributes are judged the way browsers read them', async () => {
|
|
const { isSafeUrl } = await import('../src/ui.ts');
|
|
for (const bad of ['javascript:alert(1)', 'java\tscript:alert(1)', 'java\nscript:x', '\x01javascript:x', ' JavaScript:x', 'data:text/html,x', 'vbscript:x']) {
|
|
assert.equal(isSafeUrl(bad), false, JSON.stringify(bad));
|
|
}
|
|
for (const ok of ['https://x.com/jack', 'http://example.com', 'mailto:a@b.c', '/jack/status/1', '#top', 'relative/path', '?q=1']) {
|
|
assert.equal(isSafeUrl(ok), true, ok);
|
|
}
|
|
const el = buildElement(fakeDoc(), 'a', { href: 'java\tscript:alert(1)', src: '\x01javascript:x', srcset: 'javascript:x 1x' }) as unknown as FakeNode;
|
|
assert.deepEqual(el.attrs, {}, 'no unsafe URL attribute is set');
|
|
});
|