feat(ui): render the generated style parameter rows through Vue

This commit is contained in:
Sameh Khamis committed 2026-10-30 23:23:58 -07:00
1 parent 926253e8a3
commit bca7495cbf
9 files changed
+223 -202

No files matched your search

+3 -2
View File
@@ -28,6 +28,7 @@ test('the first-run checkbox keeps its native visible label', () => {
});
test('generated style sliders use the visible parameter label as their name', () => {
assert.match(parameters, /label\.textContent\s*=\s*t\(metadata\.label\);/);
assert.match(parameters, /ariaLabel:\s*t\(metadata\.label\)/);
assert.match(parameters, /label:\s*t\(metadata\.label\),/);
assert.match(parameters, /h\('span', \{ class: 'param-label' \}, row\.label\)/);
assert.match(parameters, /'aria-label': row\.label,/);
});
+7 -7
View File
@@ -1,3 +1,4 @@
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -18,6 +19,9 @@ import {
} from '../data/detection.js';
const pause = async () => {};
// The page fixture swaps the globals for stand-ins; generated rows render
// into a real element, so keep the registered document.
const realDocument = globalThis.document;
class Element extends EventTarget {
constructor() {
super();
@@ -67,8 +71,7 @@ function pageFixture(t) {
const label = selector.match(/aria-label="([^"]+)"/)[1];
return elements
.get('param-sliders')
.children.flatMap((row) => row.children)
.find((el) => el.getAttribute('aria-label') === label);
.querySelector(`input.param-slider[aria-label="${label}"]`);
}
return elements.get(selector.slice(1));
};
@@ -128,8 +131,7 @@ test('live visual cities follow local operating hours in summer and winter', ()
test('thermal controls wait for activation and use the real generated parameter rows', async (t) => {
const { page, document, elements } = pageFixture(t);
const container = new Element();
container.ownerDocument = document;
const container = realDocument.createElement('div');
elements.set('param-sliders', container);
const parameters = createStyleParameters({ container });
t.after(() => parameters.destroy());
@@ -159,9 +161,7 @@ test('thermal controls wait for activation and use the real generated parameter
page.$eval = async (selector, fn, ...args) => {
if (selector.startsWith('.style-btn')) return fn(new Element(), ...args);
const label = selector.match(/aria-label="([^"]+)"/)[1];
const slider = container.children
.flatMap((row) => row.children)
.find((el) => el.getAttribute('aria-label') === label);
const slider = container.querySelector(`[aria-label="${label}"]`);
assert.ok(slider);
return fn(slider, ...args);
};
-32
View File
@@ -1,32 +0,0 @@
/**
* Build the shared native slider. Feature owners retain their input/change
* listeners, formatting and teardown; this component installs no handlers.
* Static sliders use the same gev-quantitative-slider class in their template.
* @param {object} options
* @param {Document} [options.documentRef] Document that owns the input.
* @param {string} [options.className] Additional feature/layout hooks.
* @param {number|string} [options.min]
* @param {number|string} [options.max]
* @param {number|string} [options.step]
* @param {number|string} [options.value]
* @param {string} [options.ariaLabel] Already translated accessible name.
* @returns {HTMLInputElement}
*/
export function createSlider({
documentRef = globalThis.document,
className = '',
min,
max,
step,
value,
ariaLabel,
} = {}) {
const input = documentRef.createElement('input');
input.type = 'range';
input.className = `gev-quantitative-slider${className ? ` ${className}` : ''}`;
for (const [key, setting] of Object.entries({ min, max, step, value })) {
if (setting !== undefined) input[key] = setting;
}
if (ariaLabel !== undefined) input.setAttribute('aria-label', ariaLabel);
return input;
}
+78 -44
View File
@@ -1,5 +1,6 @@
import { h, render, withDirectives } from 'vue';
import { formatNumber, t } from '../i18n/index.js';
import { createSlider } from './slider.js';
import { vRangeValue } from './rail/rangeValue.js';
/** A slider value in the UI language: 0.85 in English, 0,85 in Spanish. */
const formatParam = (value, digits) =>
@@ -10,65 +11,98 @@ const formatParam = (value, digits) =>
});
/**
* Own generated parameter rows and their input listeners.
* The generated rows: a label, the shared native slider and its value in the
* UI language. A render function, so the portable input package needs no
* single-file-component compiler.
*/
const StyleParameterRows = {
props: {
rows: { type: Array, required: true },
onInput: { type: Function, required: true },
},
render() {
return this.rows.map((row) =>
h('div', { key: row.name, class: 'param-slider-row' }, [
h('span', { class: 'param-label' }, row.label),
withDirectives(
h('input', {
type: 'range',
class: 'gev-quantitative-slider param-slider',
min: row.min,
max: row.max,
step: row.step,
'aria-label': row.label,
onInput: (event) => this.onInput(row.name, event.target.value),
}),
[[vRangeValue, row.value]],
),
h('span', { class: 'param-value' }, row.text),
]),
);
},
};
/**
* Own generated parameter rows and their input handling. Renders
* StyleParameterRows into `container` and patches it synchronously.
* The caller supplies uniform metadata/value access and rendering/persistence
* callbacks. Replacing or destroying rows revokes all old input handlers.
* @param {object} options
* @param {HTMLElement} options.container Container owned by this component.
* @param {Document} [options.documentRef] Document used to construct rows.
* @returns {{render: Function, clear: Function, destroy: Function}}
*/
export function createStyleParameters({
container,
documentRef = container.ownerDocument,
}) {
let listeners = [];
export function createStyleParameters({ container }) {
let rows = [];
let onInput = () => {};
// Each render's handlers act only while that render is current.
let generation = 0;
let destroyed = false;
const paint = () =>
render(h(StyleParameterRows, { rows, onInput }), container);
const clear = () => {
if (destroyed) return;
for (const [slider, listener] of listeners)
slider.removeEventListener('input', listener);
listeners = [];
container.replaceChildren();
generation++;
rows = [];
onInput = () => {};
render(null, container);
};
return {
/** Render uniform metadata with readValue, writeValue and onChange callbacks. */
render({ uniforms, readValue, writeValue, onChange }) {
if (destroyed) return;
clear();
for (const [name, metadata] of Object.entries(uniforms)) {
const row = documentRef.createElement('div');
row.className = 'param-slider-row';
const label = documentRef.createElement('span');
label.className = 'param-label';
label.textContent = t(metadata.label);
const slider = createSlider({
documentRef,
className: 'param-slider',
ariaLabel: t(metadata.label),
min: metadata.min,
max: metadata.max,
step: metadata.max <= 1 ? '0.01' : '0.1',
value: readValue(name),
});
const digits = metadata.max <= 1 ? 2 : 1;
const valueDisplay = documentRef.createElement('span');
valueDisplay.className = 'param-value';
valueDisplay.textContent = formatParam(
parseFloat(slider.value),
digits,
const current = generation;
rows = Object.entries(uniforms).map(([name, metadata]) => ({
name,
label: t(metadata.label),
min: metadata.min,
max: metadata.max,
step: metadata.max <= 1 ? '0.01' : '0.1',
digits: metadata.max <= 1 ? 2 : 1,
value: readValue(name),
text: '',
}));
onInput = (name, raw) => {
if (destroyed || current !== generation) return;
const value = parseFloat(raw);
writeValue(name, value);
rows = rows.map((row) =>
row.name === name
? { ...row, value, text: formatParam(value, row.digits) }
: row,
);
const onInput = () => {
const value = parseFloat(slider.value);
writeValue(name, value);
valueDisplay.textContent = formatParam(value, digits);
onChange();
};
slider.addEventListener('input', onInput);
listeners.push([slider, onInput]);
row.append(label, slider, valueDisplay);
container.appendChild(row);
}
paint();
onChange();
};
paint();
// The readout shows the value the slider holds once the browser has
// clamped and snapped it to its step.
const sliders = container.querySelectorAll('input.param-slider');
rows = rows.map((row, index) => ({
...row,
text: formatParam(parseFloat(sliders[index].value), row.digits),
}));
paint();
},
clear,
destroy() {
+122
View File
@@ -0,0 +1,122 @@
// src/ui/styleParameters.test.mjs — generated style-parameter rows render
// through Vue behind createStyleParameters and hand input back to the owner.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createStyleParameters } from './styleParameters.js';
function parameters(t, values = { amount: 0.25, scale: 2 }) {
document.body.replaceChildren();
const container = document.body.appendChild(document.createElement('div'));
const controller = createStyleParameters({ container });
t.after(() => controller.destroy());
const uniforms = {
amount: { label: '<Amount>', min: 0, max: 1 },
scale: { label: 'Scale', min: 0, max: 10 },
};
const order = [];
const rowsOf = () => [...container.querySelectorAll('.param-slider-row')];
const options = {
uniforms,
readValue: (name) => values[name],
writeValue: (name, value) => {
order.push(['write', name, value]);
values[name] = value;
},
onChange: () => {
order.push(['change', rowsOf()[0]?.children[2].textContent]);
},
};
controller.render(options);
return { container, controller, values, options, order, rowsOf };
}
const input = (slider, value) => {
slider.value = value;
slider.dispatchEvent(new Event('input', { bubbles: true }));
};
test('parameter rows preserve labels, bounds, precision and initial values', (t) => {
const f = parameters(t);
const rows = f.rowsOf();
assert.equal(rows.length, 2);
assert.equal(rows[0].childNodes.length, 3, 'no text between the parts');
const [label, slider, value] = rows[0].children;
assert.equal(label.className, 'param-label');
assert.equal(label.textContent, '<Amount>');
assert.equal(slider.getAttribute('aria-label'), '<Amount>');
assert.equal(slider.type, 'range');
assert.equal(slider.className, 'gev-quantitative-slider param-slider');
assert.equal(slider.getAttribute('min'), '0');
assert.equal(slider.getAttribute('max'), '1');
assert.equal(slider.getAttribute('step'), '0.01');
assert.equal(slider.hasAttribute('value'), false, 'the value is live only');
assert.equal(slider.value, '0.25');
assert.equal(value.className, 'param-value');
assert.equal(value.textContent, '0.25');
assert.equal(rows[1].children[1].getAttribute('step'), '0.1');
assert.equal(rows[1].children[2].textContent, '2.0');
assert.deepEqual(
f.order,
[],
'rendering must not write shader values or publish changes',
);
});
test('the readout shows the value the slider holds after clamping', (t) => {
const f = parameters(t, { amount: 1.7, scale: -3 });
const [amount, scale] = f.rowsOf();
const held = (row, digits) => Number(row.children[1].value).toFixed(digits);
assert.equal(amount.children[2].textContent, held(amount, 2));
assert.equal(scale.children[2].textContent, held(scale, 1));
assert.notEqual(amount.children[2].textContent, '1.70', 'not the raw value');
});
test('input writes numeric values, updates the readout, then requests a change', (t) => {
const f = parameters(t);
input(f.rowsOf()[0].children[1], '0.75');
assert.equal(f.values.amount, 0.75);
assert.deepEqual(f.order, [
['write', 'amount', 0.75],
['change', '0.75'],
]);
});
test('rebuilding the parameter panel revokes detached slider listeners', (t) => {
const f = parameters(t);
const oldSlider = f.rowsOf()[0].children[1];
f.controller.render({
...f.options,
uniforms: { scale: f.options.uniforms.scale },
});
input(oldSlider, '0.8');
assert.equal(f.values.amount, 0.25);
assert.equal(f.rowsOf().length, 1);
input(f.rowsOf()[0].children[1], '3.5');
assert.equal(f.values.scale, 3.5);
});
test('clearing removes rows and listeners but allows another style', (t) => {
const f = parameters(t);
const oldSlider = f.rowsOf()[0].children[1];
f.controller.clear();
input(oldSlider, '0.5');
assert.equal(f.container.children.length, 0);
assert.deepEqual(f.order, []);
f.controller.render(f.options);
assert.equal(f.rowsOf().length, 2);
});
test('destroy is final and cannot clear a subsequent owner’s DOM', (t) => {
const f = parameters(t);
const oldSlider = f.rowsOf()[0].children[1];
f.controller.destroy();
const nextOwner = f.container.appendChild(document.createElement('p'));
f.controller.render(f.options);
f.controller.clear();
f.controller.destroy();
input(oldSlider, '0.5');
assert.equal(f.container.children.length, 1);
assert.equal(f.container.firstElementChild === nextOwner, true);
assert.deepEqual(f.order, []);
});
+2 -106
View File
@@ -1,9 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
bindApplicationShortcuts,
createStyleParameters,
} from './visualInput.js';
// Generated parameter rows render through Vue: styleParameters.test.mjs.
import { bindApplicationShortcuts } from './visualInput.js';
class Element {
constructor(tagName = 'DIV', ownerDocument) {
@@ -141,105 +139,3 @@ test('destroy synchronously removes shortcuts and a replacement binds once', ()
assert.deepEqual(f.calls, ['new']);
replacement.destroy();
});
function parameters() {
const documentRef = {
createElement: (tag) => new Element(tag.toUpperCase(), documentRef),
};
const container = new Element('DIV', documentRef);
const controller = createStyleParameters({ container });
const values = { amount: 0.25, scale: 2 };
const uniforms = {
amount: { label: '<Amount>', min: 0, max: 1 },
scale: { label: 'Scale', min: 0, max: 10 },
};
const order = [];
const options = {
uniforms,
readValue: (name) => values[name],
writeValue: (name, value) => {
order.push(['write', name, value]);
values[name] = value;
},
onChange: () => {
order.push(['change', container.children[0]?.children[2].textContent]);
},
};
controller.render(options);
return { container, controller, values, options, order };
}
test('parameter rows preserve labels, bounds, precision and initial values', () => {
const f = parameters();
assert.equal(f.container.children.length, 2);
const [label, slider, value] = f.container.children[0].children;
assert.equal(label.textContent, '<Amount>');
assert.equal(slider.attributes.get('aria-label'), '<Amount>');
assert.equal(slider.type, 'range');
assert.equal(slider.className, 'gev-quantitative-slider param-slider');
assert.equal(slider.min, 0);
assert.equal(slider.max, 1);
assert.equal(slider.step, '0.01');
assert.equal(value.textContent, '0.25');
assert.equal(f.container.children[1].children[1].step, '0.1');
assert.equal(f.container.children[1].children[2].textContent, '2.0');
assert.deepEqual(
f.order,
[],
'rendering must not write shader values or publish changes',
);
});
test('input writes numeric values, updates the readout, then requests a change', () => {
const f = parameters();
const slider = f.container.children[0].children[1];
slider.value = '0.75';
slider.emit('input');
assert.equal(f.values.amount, 0.75);
assert.deepEqual(f.order, [
['write', 'amount', 0.75],
['change', '0.75'],
]);
});
test('rebuilding the parameter panel revokes detached slider listeners', () => {
const f = parameters();
const oldSlider = f.container.children[0].children[1];
f.controller.render({
...f.options,
uniforms: { scale: f.options.uniforms.scale },
});
oldSlider.value = '0.8';
oldSlider.emit('input');
assert.equal(f.values.amount, 0.25);
assert.equal(oldSlider.listeners.get('input').size, 0);
assert.equal(f.container.children.length, 1);
f.container.children[0].children[1].value = '3.5';
f.container.children[0].children[1].emit('input');
assert.equal(f.values.scale, 3.5);
});
test('clearing removes rows and listeners but allows another style', () => {
const f = parameters();
const oldSlider = f.container.children[0].children[1];
f.controller.clear();
oldSlider.emit('input');
assert.equal(f.container.children.length, 0);
assert.deepEqual(f.order, []);
f.controller.render(f.options);
assert.equal(f.container.children.length, 2);
});
test('destroy is final and cannot clear a subsequent owner’s DOM', () => {
const f = parameters();
const oldSlider = f.container.children[0].children[1];
f.controller.destroy();
const nextOwner = new Element();
f.container.appendChild(nextOwner);
f.controller.render(f.options);
f.controller.clear();
f.controller.destroy();
oldSlider.emit('input');
assert.deepEqual(f.container.children, [nextOwner]);
assert.deepEqual(f.order, []);
});