Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -614,7 +614,8 @@ success was. `sinceLabel(ts)` gives that "3m ago" label on its own.
**Visuals** (self-contained inline SVG/DOM, no extra CSS)

- `sparkline(values, opts?)` returns an `<svg>` area+line chart element.
- `barFill(percent, opts?)` returns a track+fill bar element.
- `barFill(percent, opts?)` returns a track+fill bar element. It skips its width
transition when the browser asks for reduced motion.
- `smoothPath(points)` returns a smoothed SVG path string through `[[x,y], ...]`.

Check the toolbox before building a new visual by hand.
13 changes: 12 additions & 1 deletion ui/js/widget-toolbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,16 @@ export function sparkline(values, opts = {}) {
return svg;
}

/* A widget page loads no shared stylesheet, so a reduced-motion rule cannot
reach a style set here. Ask for the preference instead. */
function reducedMotion() {
try {
return !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
} catch {
return false;
}
}

/* opts: { color='#0a84ff', track='rgba(255,255,255,0.10)', height=6, radius=3 } */
export function barFill(percent, opts = {}) {
const pct = Math.max(0, Math.min(100, Number(percent) || 0));
Expand All @@ -164,7 +174,8 @@ export function barFill(percent, opts = {}) {
const fill = document.createElement('div');
fill.style.cssText =
`position:absolute;left:0;top:0;bottom:0;width:${pct}%;border-radius:${radius}px;` +
`background:${opts.color || '#0a84ff'};transition:width .4s ease`;
`background:${opts.color || '#0a84ff'}` +
(reducedMotion() ? '' : ';transition:width .4s ease');
track.appendChild(fill);
return track;
}
Expand Down
75 changes: 75 additions & 0 deletions ui/test/widget-toolbox-motion.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/* A widget is an iframe that loads no shared stylesheet, so the reduced-motion
rule in tokens.css cannot reach it and a rule in the widget's own <style>
cannot reach a transition set as an inline style. barFill sets one, so it has
to ask for the preference itself.

Every shipped widget draws its own bars today. This is the toolkit helper the
next one is meant to reuse. */

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { register } from 'node:module';

register('./js-root-hooks.mjs', import.meta.url);
globalThis.location = { search: '?id=test' };

/* Enough of a DOM for an element that carries a style string and a child. */
function stubDom() {
globalThis.document = {
createElement: () => ({
style: { cssText: '' },
children: [],
appendChild(child) {
this.children.push(child);
return child;
},
}),
};
}

/** @param {boolean|null} reduce null means the browser answers no matchMedia */
function setMotionPreference(reduce) {
globalThis.window = reduce === null ? {} : { matchMedia: query => ({ matches: reduce && query.includes('reduce') }) };
}

stubDom();
const { barFill } = await import('../js/widget-toolbox.js');

const fillOf = track => track.children[0];

test('the bar animates when no reduced-motion preference is set', () => {
setMotionPreference(false);
assert.match(fillOf(barFill(40)).style.cssText, /transition:width \.4s ease/);
});

test('the bar does not animate when reduced motion is asked for', () => {
setMotionPreference(true);
const css = fillOf(barFill(40)).style.cssText;
assert.doesNotMatch(css, /transition/);
assert.match(css, /width:40%/, 'the bar still reaches its value, it just does not travel there');
});

/* The preference is read per call, so a widget that redraws after the setting
changes picks up the new answer. */
test('the preference is read on each call, not once at load', () => {
setMotionPreference(true);
assert.doesNotMatch(fillOf(barFill(10)).style.cssText, /transition/);
setMotionPreference(false);
assert.match(fillOf(barFill(10)).style.cssText, /transition/);
});

/* A browser without matchMedia must still draw a bar. */
test('a missing matchMedia is treated as no preference', () => {
setMotionPreference(null);
assert.match(fillOf(barFill(55)).style.cssText, /transition:width \.4s ease/);
});

test('a throwing matchMedia does not break the bar', () => {
globalThis.window = {
matchMedia: () => {
throw new Error('unsupported');
},
};
const css = fillOf(barFill(55)).style.cssText;
assert.match(css, /width:55%/);
});
Loading