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 -3
View File
@@ -1437,11 +1437,11 @@ The application supplies actions and retains effect settings, restore claims and
rendering. Native keyboard editing, model modes and current defaults are preserved.
Destroying the UI removes these listeners before asynchronous teardown.
Ordinary sliders share the native range component in `ui/slider.js` and the
`gev-quantitative-slider` surface in `ui/styles/slider.css`. Display, shader
Ordinary sliders share the native `gev-quantitative-slider` surface in
`ui/styles/slider.css`. Display, shader
parameters, volume, replay speed, weather settings/history, imagery opacity and
Street View's date filter use the same rail, thumb, value styling, keyboard focus and disabled state.
Generated controls use `createSlider`; static templates and components apply the same class.
Templates, components and generated controls apply the same class.
Feature owners retain bounds, steps, formatted values, input/change timing and
listener cleanup. The radio tuning dial, imagery comparison divider and event
comparison/progression controls keep their specialized presentation and behavior.
+5 -2
View File
@@ -96,8 +96,11 @@ They contain no application state.
descriptor before acting), Display (in progress: `DisplayPanel` renders
the HUD, Sonar, Scope, Celestial, detection, bloom and sharpen from the
`gev-display` store, which VisualSettings publishes to, and the 3D
aircraft group, which AircraftDisplay publishes beside it; the other
groups follow one owner at a time), Context and Radio, the command dock,
aircraft group, which AircraftDisplay publishes beside it; the style
parameter rows render through Vue behind `createStyleParameters()`, as a
render function so the portable `./ui/input` entry needs no component
compiler; the other groups follow one owner at a time),
Context and Radio, the command dock,
the phone shell, then Cockpit. Layers that write into the right rail
directly gain a slot instead.
+3 -6
View File
@@ -479,7 +479,7 @@
"src/ui/visualInput.js",
"src/ui/applicationShortcuts.js",
"src/ui/styleParameters.js",
"src/ui/slider.js",
"src/ui/rail/rangeValue.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/de.json",
@@ -493,7 +493,7 @@
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
"external": []
"external": ["vue"]
},
"display-controls": {
"exports": ["./ui/display"],
@@ -550,7 +550,6 @@
"src/ui/weatherPanel.js",
"src/ui/railCards.js",
"src/ui/railCardBlocks.js",
"src/ui/slider.js",
"src/ui/layerRowText.js",
"src/ui/layerPanel/LayerRows.vue",
"src/ui/layerPanel/RowText.vue",
@@ -896,7 +895,6 @@
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
"src/ui/styleParameters.js",
"src/ui/slider.js",
"src/ui/surfaceKeyboard.js",
"src/ui/uiLifetime.js",
"src/ui/visualEffects.js",
@@ -2591,7 +2589,6 @@
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
"src/ui/styleParameters.js",
"src/ui/slider.js",
"src/ui/surfaceKeyboard.js",
"src/ui/uiLifetime.js",
"src/ui/visualEffects.js",
@@ -3669,7 +3666,7 @@
"src/ui/applicationShortcuts.js",
"src/ui/effects.js",
"src/ui/styleParameters.js",
"src/ui/slider.js",
"src/ui/rail/rangeValue.js",
"src/ui/uiLifetime.js",
"src/ui/visualEffects.js",
"src/ui/visualInput.js",
+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, []);
});