From bca7495cbfd07f555fd9fe03c51b911f557b9c22 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 30 Oct 2026 23:23:58 -0700 Subject: [PATCH] feat(ui): render the generated style parameter rows through Vue --- docs/CURRENT-STATE.md | 6 +- docs/VUE-MIGRATION.md | 7 +- scripts/package-boundaries.json | 9 +-- src/hudA11y.test.mjs | 5 +- src/tooling/transitQa.test.mjs | 14 ++-- src/ui/slider.js | 32 --------- src/ui/styleParameters.js | 122 ++++++++++++++++++++------------ src/ui/styleParameters.test.mjs | 122 ++++++++++++++++++++++++++++++++ src/ui/visualInput.test.mjs | 108 +--------------------------- 9 files changed, 223 insertions(+), 202 deletions(-) delete mode 100644 src/ui/slider.js create mode 100644 src/ui/styleParameters.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index df360c0c1..1c7d3a347 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index afef85b13..64a2ba93d 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -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. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b967e8b4a..98e7a716b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/hudA11y.test.mjs b/src/hudA11y.test.mjs index 2d660b0a0..a453cea63 100644 --- a/src/hudA11y.test.mjs +++ b/src/hudA11y.test.mjs @@ -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,/); }); diff --git a/src/tooling/transitQa.test.mjs b/src/tooling/transitQa.test.mjs index adbf9875d..46af726ec 100644 --- a/src/tooling/transitQa.test.mjs +++ b/src/tooling/transitQa.test.mjs @@ -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); }; diff --git a/src/ui/slider.js b/src/ui/slider.js deleted file mode 100644 index 1bb179b75..000000000 --- a/src/ui/slider.js +++ /dev/null @@ -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; -} diff --git a/src/ui/styleParameters.js b/src/ui/styleParameters.js index 2ce1c32a1..bf00ee0ba 100644 --- a/src/ui/styleParameters.js +++ b/src/ui/styleParameters.js @@ -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() { diff --git a/src/ui/styleParameters.test.mjs b/src/ui/styleParameters.test.mjs new file mode 100644 index 000000000..65be80268 --- /dev/null +++ b/src/ui/styleParameters.test.mjs @@ -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: '', 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, ''); + assert.equal(slider.getAttribute('aria-label'), ''); + 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, []); +}); diff --git a/src/ui/visualInput.test.mjs b/src/ui/visualInput.test.mjs index a87c9c815..d49a19bda 100644 --- a/src/ui/visualInput.test.mjs +++ b/src/ui/visualInput.test.mjs @@ -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: '', 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, ''); - assert.equal(slider.attributes.get('aria-label'), ''); - 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, []); -});