diff --git a/ui/js/widget-toolbox.js b/ui/js/widget-toolbox.js index da8c7d42..994a00fe 100644 --- a/ui/js/widget-toolbox.js +++ b/ui/js/widget-toolbox.js @@ -488,6 +488,32 @@ export function errorState(opts = {}) { return _errorState(Object.assign({ t: _t }, opts)); } +const PH_STYLE_ID = 'wt-ph-css'; +const PH_CSS = + '.wt-ph{display:inline-block;vertical-align:middle;flex:none;border-radius:4px;' + + 'background:var(--wt-ph,rgba(235,235,245,0.16))}' + + 'html[data-theme="light"] .wt-ph{background:var(--wt-ph,rgba(60,60,67,0.12))}'; + +/** A soft rounded shape that stands in for content still loading. Set --wt-ph + on an ancestor to recolour it for a light surface. + @param {number|string} width px, or any CSS length @param {number} [height] px + @param {number} [radius] px @returns {HTMLElement} */ +export function placeholder(width, height = 10, radius = 4) { + if (!document.getElementById(PH_STYLE_ID)) { + const s = document.createElement('style'); + s.id = PH_STYLE_ID; + s.textContent = PH_CSS; + document.head.appendChild(s); + } + const el = document.createElement('span'); + el.className = 'wt-ph'; + el.setAttribute('aria-hidden', 'true'); + el.style.width = typeof width === 'number' ? width + 'px' : width; + el.style.height = height + 'px'; + el.style.borderRadius = radius + 'px'; + return el; +} + /** The reader's wording for a failure, for a widget that draws its own state. @param {unknown} err a caught error, or { kind } from a response @returns {string} */ diff --git a/ui/test/widget-placeholder.test.mjs b/ui/test/widget-placeholder.test.mjs new file mode 100644 index 00000000..0af6cd42 --- /dev/null +++ b/ui/test/widget-placeholder.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { register } from 'node:module'; +import { test } from 'node:test'; + +register('./js-root-hooks.mjs', import.meta.url); +globalThis.location = /** @type {any} */ ({ search: '?id=test' }); + +const nodes = new Map(); +const mk = tag => ({ + tagName: tag, + style: {}, + attrs: {}, + setAttribute(k, v) { + this.attrs[k] = v; + }, + appendChild(c) { + if (c.id) nodes.set(c.id, c); + }, +}); +globalThis.document = /** @type {any} */ ({ + documentElement: { getAttribute: () => 'dark' }, + head: mk('head'), + getElementById: id => nodes.get(id) || null, + createElement: mk, +}); +const { placeholder } = await import('../js/widget-toolbox.js'); + +test('a placeholder is a hidden, sized shape, and its style is added once', () => { + const a = placeholder(72, 9); + const b = placeholder('40%', 12, 6); + assert.equal(a.className, 'wt-ph'); + assert.equal(a.attrs['aria-hidden'], 'true'); + assert.deepEqual([a.style.width, a.style.height, a.style.borderRadius], ['72px', '9px', '4px']); + assert.deepEqual([b.style.width, b.style.height, b.style.borderRadius], ['40%', '12px', '6px']); + assert.equal([...nodes.keys()].filter(k => k === 'wt-ph-css').length, 1); +}); + +test('no widget claims a state before its data arrives', () => { + const page = p => fs.readFileSync(new URL(`../widgets/${p}`, import.meta.url), 'utf8'); + assert.doesNotMatch(page('nowplaying/index.html'), /id="title">Nothing playingREADING--/); +}); diff --git a/ui/widgets/backup/backup.html b/ui/widgets/backup/backup.html index 7c02cb63..47b9582d 100644 --- a/ui/widgets/backup/backup.html +++ b/ui/widgets/backup/backup.html @@ -121,7 +121,7 @@