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. rendering. Native keyboard editing, model modes and current defaults are preserved.
Destroying the UI removes these listeners before asynchronous teardown. Destroying the UI removes these listeners before asynchronous teardown.
Ordinary sliders share the native range component in `ui/slider.js` and the Ordinary sliders share the native `gev-quantitative-slider` surface in
`gev-quantitative-slider` surface in `ui/styles/slider.css`. Display, shader `ui/styles/slider.css`. Display, shader
parameters, volume, replay speed, weather settings/history, imagery opacity and 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. 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 Feature owners retain bounds, steps, formatted values, input/change timing and
listener cleanup. The radio tuning dial, imagery comparison divider and event listener cleanup. The radio tuning dial, imagery comparison divider and event
comparison/progression controls keep their specialized presentation and behavior. 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 descriptor before acting), Display (in progress: `DisplayPanel` renders
the HUD, Sonar, Scope, Celestial, detection, bloom and sharpen from the the HUD, Sonar, Scope, Celestial, detection, bloom and sharpen from the
`gev-display` store, which VisualSettings publishes to, and the 3D `gev-display` store, which VisualSettings publishes to, and the 3D
aircraft group, which AircraftDisplay publishes beside it; the other aircraft group, which AircraftDisplay publishes beside it; the style
groups follow one owner at a time), Context and Radio, the command dock, 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 the phone shell, then Cockpit. Layers that write into the right rail
directly gain a slot instead. directly gain a slot instead.
+3 -6
View File
@@ -479,7 +479,7 @@
"src/ui/visualInput.js", "src/ui/visualInput.js",
"src/ui/applicationShortcuts.js", "src/ui/applicationShortcuts.js",
"src/ui/styleParameters.js", "src/ui/styleParameters.js",
"src/ui/slider.js", "src/ui/rail/rangeValue.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/de.json", "src/i18n/locales/de.json",
@@ -493,7 +493,7 @@
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
"external": [] "external": ["vue"]
}, },
"display-controls": { "display-controls": {
"exports": ["./ui/display"], "exports": ["./ui/display"],
@@ -550,7 +550,6 @@
"src/ui/weatherPanel.js", "src/ui/weatherPanel.js",
"src/ui/railCards.js", "src/ui/railCards.js",
"src/ui/railCardBlocks.js", "src/ui/railCardBlocks.js",
"src/ui/slider.js",
"src/ui/layerRowText.js", "src/ui/layerRowText.js",
"src/ui/layerPanel/LayerRows.vue", "src/ui/layerPanel/LayerRows.vue",
"src/ui/layerPanel/RowText.vue", "src/ui/layerPanel/RowText.vue",
@@ -896,7 +895,6 @@
"src/ui/shellElements.js", "src/ui/shellElements.js",
"src/ui/shellFeedback.js", "src/ui/shellFeedback.js",
"src/ui/styleParameters.js", "src/ui/styleParameters.js",
"src/ui/slider.js",
"src/ui/surfaceKeyboard.js", "src/ui/surfaceKeyboard.js",
"src/ui/uiLifetime.js", "src/ui/uiLifetime.js",
"src/ui/visualEffects.js", "src/ui/visualEffects.js",
@@ -2591,7 +2589,6 @@
"src/ui/shellElements.js", "src/ui/shellElements.js",
"src/ui/shellFeedback.js", "src/ui/shellFeedback.js",
"src/ui/styleParameters.js", "src/ui/styleParameters.js",
"src/ui/slider.js",
"src/ui/surfaceKeyboard.js", "src/ui/surfaceKeyboard.js",
"src/ui/uiLifetime.js", "src/ui/uiLifetime.js",
"src/ui/visualEffects.js", "src/ui/visualEffects.js",
@@ -3669,7 +3666,7 @@
"src/ui/applicationShortcuts.js", "src/ui/applicationShortcuts.js",
"src/ui/effects.js", "src/ui/effects.js",
"src/ui/styleParameters.js", "src/ui/styleParameters.js",
"src/ui/slider.js", "src/ui/rail/rangeValue.js",
"src/ui/uiLifetime.js", "src/ui/uiLifetime.js",
"src/ui/visualEffects.js", "src/ui/visualEffects.js",
"src/ui/visualInput.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', () => { 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, /label:\s*t\(metadata\.label\),/);
assert.match(parameters, /ariaLabel:\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 test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
@@ -18,6 +19,9 @@ import {
} from '../data/detection.js'; } from '../data/detection.js';
const pause = async () => {}; 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 { class Element extends EventTarget {
constructor() { constructor() {
super(); super();
@@ -67,8 +71,7 @@ function pageFixture(t) {
const label = selector.match(/aria-label="([^"]+)"/)[1]; const label = selector.match(/aria-label="([^"]+)"/)[1];
return elements return elements
.get('param-sliders') .get('param-sliders')
.children.flatMap((row) => row.children) .querySelector(`input.param-slider[aria-label="${label}"]`);
.find((el) => el.getAttribute('aria-label') === label);
} }
return elements.get(selector.slice(1)); 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) => { test('thermal controls wait for activation and use the real generated parameter rows', async (t) => {
const { page, document, elements } = pageFixture(t); const { page, document, elements } = pageFixture(t);
const container = new Element(); const container = realDocument.createElement('div');
container.ownerDocument = document;
elements.set('param-sliders', container); elements.set('param-sliders', container);
const parameters = createStyleParameters({ container }); const parameters = createStyleParameters({ container });
t.after(() => parameters.destroy()); 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) => { page.$eval = async (selector, fn, ...args) => {
if (selector.startsWith('.style-btn')) return fn(new Element(), ...args); if (selector.startsWith('.style-btn')) return fn(new Element(), ...args);
const label = selector.match(/aria-label="([^"]+)"/)[1]; const label = selector.match(/aria-label="([^"]+)"/)[1];
const slider = container.children const slider = container.querySelector(`[aria-label="${label}"]`);
.flatMap((row) => row.children)
.find((el) => el.getAttribute('aria-label') === label);
assert.ok(slider); assert.ok(slider);
return fn(slider, ...args); 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 { 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. */ /** A slider value in the UI language: 0.85 in English, 0,85 in Spanish. */
const formatParam = (value, digits) => 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 * The caller supplies uniform metadata/value access and rendering/persistence
* callbacks. Replacing or destroying rows revokes all old input handlers. * callbacks. Replacing or destroying rows revokes all old input handlers.
* @param {object} options * @param {object} options
* @param {HTMLElement} options.container Container owned by this component. * @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}} * @returns {{render: Function, clear: Function, destroy: Function}}
*/ */
export function createStyleParameters({ export function createStyleParameters({ container }) {
container, let rows = [];
documentRef = container.ownerDocument, let onInput = () => {};
}) { // Each render's handlers act only while that render is current.
let listeners = []; let generation = 0;
let destroyed = false; let destroyed = false;
const paint = () =>
render(h(StyleParameterRows, { rows, onInput }), container);
const clear = () => { const clear = () => {
if (destroyed) return; if (destroyed) return;
for (const [slider, listener] of listeners) generation++;
slider.removeEventListener('input', listener); rows = [];
listeners = []; onInput = () => {};
container.replaceChildren(); render(null, container);
}; };
return { return {
/** Render uniform metadata with readValue, writeValue and onChange callbacks. */ /** Render uniform metadata with readValue, writeValue and onChange callbacks. */
render({ uniforms, readValue, writeValue, onChange }) { render({ uniforms, readValue, writeValue, onChange }) {
if (destroyed) return; if (destroyed) return;
clear(); clear();
for (const [name, metadata] of Object.entries(uniforms)) { const current = generation;
const row = documentRef.createElement('div'); rows = Object.entries(uniforms).map(([name, metadata]) => ({
row.className = 'param-slider-row'; name,
const label = documentRef.createElement('span'); label: t(metadata.label),
label.className = 'param-label'; min: metadata.min,
label.textContent = t(metadata.label); max: metadata.max,
const slider = createSlider({ step: metadata.max <= 1 ? '0.01' : '0.1',
documentRef, digits: metadata.max <= 1 ? 2 : 1,
className: 'param-slider', value: readValue(name),
ariaLabel: t(metadata.label), text: '',
min: metadata.min, }));
max: metadata.max, onInput = (name, raw) => {
step: metadata.max <= 1 ? '0.01' : '0.1', if (destroyed || current !== generation) return;
value: readValue(name), const value = parseFloat(raw);
}); writeValue(name, value);
const digits = metadata.max <= 1 ? 2 : 1; rows = rows.map((row) =>
const valueDisplay = documentRef.createElement('span'); row.name === name
valueDisplay.className = 'param-value'; ? { ...row, value, text: formatParam(value, row.digits) }
valueDisplay.textContent = formatParam( : row,
parseFloat(slider.value),
digits,
); );
const onInput = () => { paint();
const value = parseFloat(slider.value); onChange();
writeValue(name, value); };
valueDisplay.textContent = formatParam(value, digits); paint();
onChange(); // The readout shows the value the slider holds once the browser has
}; // clamped and snapped it to its step.
slider.addEventListener('input', onInput); const sliders = container.querySelectorAll('input.param-slider');
listeners.push([slider, onInput]); rows = rows.map((row, index) => ({
row.append(label, slider, valueDisplay); ...row,
container.appendChild(row); text: formatParam(parseFloat(sliders[index].value), row.digits),
} }));
paint();
}, },
clear, clear,
destroy() { 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 assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { // Generated parameter rows render through Vue: styleParameters.test.mjs.
bindApplicationShortcuts, import { bindApplicationShortcuts } from './visualInput.js';
createStyleParameters,
} from './visualInput.js';
class Element { class Element {
constructor(tagName = 'DIV', ownerDocument) { constructor(tagName = 'DIV', ownerDocument) {
@@ -141,105 +139,3 @@ test('destroy synchronously removes shortcuts and a replacement binds once', ()
assert.deepEqual(f.calls, ['new']); assert.deepEqual(f.calls, ['new']);
replacement.destroy(); 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, []);
});