diff --git a/changelog.d/1287-shared-sliders.md b/changelog.d/1287-shared-sliders.md new file mode 100644 index 000000000..b5e5fb215 --- /dev/null +++ b/changelog.d/1287-shared-sliders.md @@ -0,0 +1,3 @@ +- Give ordinary sliders a shared native control and consistent track, thumb, + value, focus and disabled styling while preserving feature behavior and + specialized controls (mazy1998, #1287). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 224c9a02a..b3930264c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1224,6 +1224,15 @@ 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 +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 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. + ## Segmented selectors Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js` diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b365c85c3..a941b1d8f 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -304,6 +304,7 @@ "src/ui/styles/status.css", "src/ui/styles/layers.css", "src/ui/styles/controls.css", + "src/ui/styles/slider.css", "src/ui/styles/cctv.css", "src/ui/styles/foundation.css", "src/ui/styles/first-run.css", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b4fd6ace2..3a9950f39 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -422,6 +422,7 @@ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", "src/ui/styleParameters.js", + "src/ui/slider.js", "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", @@ -480,6 +481,7 @@ "src/ui/weatherPanel.js", "src/ui/railCards.js", "src/ui/railCardBlocks.js", + "src/ui/slider.js", "src/ui/chipGroup.js", "src/ui/rowList.js", "src/ui/railTimeline.js", @@ -740,6 +742,7 @@ "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", @@ -2216,6 +2219,7 @@ "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", @@ -3209,6 +3213,7 @@ "src/ui/applicationShortcuts.js", "src/ui/effects.js", "src/ui/styleParameters.js", + "src/ui/slider.js", "src/ui/uiLifetime.js", "src/ui/visualEffects.js", "src/ui/visualInput.js", diff --git a/scripts/qa-visual-input.mjs b/scripts/qa-visual-input.mjs index 3117e1414..5e3991806 100644 --- a/scripts/qa-visual-input.mjs +++ b/scripts/qa-visual-input.mjs @@ -183,6 +183,38 @@ try { return manager.bloomEnabled !== before; }), ); + const sliders = await page.evaluate(() => { + const ordinary = [ + ...document.querySelectorAll('input[type="range"]'), + ].filter( + (input) => !input.matches('#radio-tuner-slider, .bhote-event-range'), + ); + return { + count: ordinary.length, + shared: ordinary.every((input) => + input.classList.contains('gev-quantitative-slider'), + ), + surface: ordinary.every((input) => { + const style = getComputedStyle(input); + return ( + style.height === '18px' && + style.appearance === 'none' && + style.padding === '0px' && + style.borderWidth === '0px' && + (!input.disabled || + (style.opacity === '0.42' && style.cursor === 'not-allowed')) + ); + }), + tunerSeparate: !document + .getElementById('radio-tuner-slider') + .classList.contains('gev-quantitative-slider'), + }; + }); + check( + 'ordinary sliders share one native surface', + sliders.count > 10 && sliders.shared && sliders.surface, + ); + check('radio tuning keeps its specialized control', sliders.tunerSeparate); fs.mkdirSync('qa-shots/visual-input', { recursive: true }); await page.screenshot({ path: 'qa-shots/visual-input/desktop.png' }); await page.setViewport({ width: 620, height: 900 }); diff --git a/src/hudA11y.test.mjs b/src/hudA11y.test.mjs index 0c0fd6fe2..a854b5964 100644 --- a/src/hudA11y.test.mjs +++ b/src/hudA11y.test.mjs @@ -29,5 +29,5 @@ 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, /slider\.setAttribute\(['"]aria-label['"],\s*t\(metadata\.label\)\)/); + assert.match(parameters, /ariaLabel:\s*t\(metadata\.label\)/); }); diff --git a/src/ui/railCardBlocks.js b/src/ui/railCardBlocks.js index e1d37356c..b63d0a6a0 100644 --- a/src/ui/railCardBlocks.js +++ b/src/ui/railCardBlocks.js @@ -2,6 +2,7 @@ import { t } from '../i18n/index.js'; import { syncChipGroup } from './chipGroup.js'; import { syncRowList } from './rowList.js'; import { formatScaleLabel, unitLabel } from './unitLabels.js'; +import { createSlider } from './slider.js'; const set = (node, key, value) => { if (node[key] !== value) node[key] = value; @@ -90,12 +91,11 @@ export function createRailCardBlocks({ container, cardId, onParams }) { 'rail-card-setting-range-label', block.rangeRow, ); - block.rangeInput = make( - 'input', - 'rail-card-setting-range', - block.rangeRow, - ); - block.rangeInput.type = 'range'; + block.rangeInput = createSlider({ + documentRef: document, + className: 'rail-card-setting-range', + }); + block.rangeRow.appendChild(block.rangeInput); block.rangeValue = make( 'span', 'rail-card-setting-range-value', diff --git a/src/ui/railTimeline.js b/src/ui/railTimeline.js index b96ded34e..181f9a73f 100644 --- a/src/ui/railTimeline.js +++ b/src/ui/railTimeline.js @@ -1,4 +1,5 @@ import { formatUtcDayMonth, t } from '../i18n/index.js'; +import { createSlider } from './slider.js'; const set = (node, key, value) => { if (node[key] !== value) node[key] = value; }; @@ -43,14 +44,15 @@ export function createRailTimeline({ return node; }; const previous = makeButton('‹', t('Earlier observation'), row); - const slider = document.createElement('input'); - slider.type = 'range'; - slider.className = `gev-quantitative-slider${sliderClassName ? ` ${sliderClassName}` : ''}`; - slider.min = '0'; - slider.max = '0'; - slider.step = '1'; - slider.value = '0'; - slider.setAttribute('aria-label', t('Observed history')); + const slider = createSlider({ + documentRef: document, + className: sliderClassName, + min: '0', + max: '0', + step: '1', + value: '0', + ariaLabel: t('Observed history'), + }); row.appendChild(slider); const next = makeButton('›', t('Later observation'), row); const controls = document.createElement('div'); diff --git a/src/ui/recentImagery.js b/src/ui/recentImagery.js index 0706ad17d..2531ecb9a 100644 --- a/src/ui/recentImagery.js +++ b/src/ui/recentImagery.js @@ -12,6 +12,7 @@ import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js'; import { createRailCardBlocks } from './railCardBlocks.js'; import { createRailCards } from './railCards.js'; import { createImagerySplit } from './imagerySplit.js'; +import { createSlider } from './slider.js'; import { createSegmentedControl } from './segmentedControl.js'; import { PRODUCTS, @@ -328,16 +329,17 @@ export function createRecentImageryPanel({ const opacityLabel = el('label', 'ri-opacity-label', controls); el('span', '', opacityLabel, t('OPACITY')); const opacityValue = el('span', 'ri-opacity-value', opacityLabel, '100%'); - const opacity = el('input', 'ri-opacity', controls); - opacity.id = 'ri-opacity'; - Object.assign(opacity, { - type: 'range', + const opacity = createSlider({ + documentRef: document, + className: 'ri-opacity', min: '0', max: '100', step: '1', value: '100', + ariaLabel: t('Imagery opacity'), }); - opacity.setAttribute('aria-label', t('Imagery opacity')); + opacity.id = 'ri-opacity'; + controls.appendChild(opacity); opacityLabel.htmlFor = 'ri-opacity'; // Its slot is always there; it is only usable while a swipe is live. const swap = el('button', 'data-toggle-chip ri-swap', controls, t('SWAP')); diff --git a/src/ui/slider.js b/src/ui/slider.js new file mode 100644 index 000000000..1bb179b75 --- /dev/null +++ b/src/ui/slider.js @@ -0,0 +1,32 @@ +/** + * 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 c88872d69..2ce1c32a1 100644 --- a/src/ui/styleParameters.js +++ b/src/ui/styleParameters.js @@ -1,4 +1,5 @@ import { formatNumber, t } from '../i18n/index.js'; +import { createSlider } from './slider.js'; /** A slider value in the UI language: 0.85 in English, 0,85 in Spanish. */ const formatParam = (value, digits) => @@ -41,14 +42,15 @@ export function createStyleParameters({ const label = documentRef.createElement('span'); label.className = 'param-label'; label.textContent = t(metadata.label); - const slider = documentRef.createElement('input'); - slider.type = 'range'; - slider.className = 'param-slider'; - slider.setAttribute('aria-label', t(metadata.label)); - slider.min = metadata.min; - slider.max = metadata.max; - slider.step = metadata.max <= 1 ? '0.01' : '0.1'; - slider.value = readValue(name); + 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'; diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 49d6a1418..7fdb51276 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -532,110 +532,7 @@ html:not([lang='en']) .panel-collapse-btn { .pp-slider { flex: 1; - -webkit-appearance: none; - appearance: none; - height: 3px; - background: rgba(255, 255, 255, 0.08); - border-radius: 2px; - outline: none; - cursor: pointer; - min-width: 60px; -} - -.pp-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 6px var(--accent-glow); - cursor: pointer; -} - -.pp-slider::-moz-range-thumb { - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--accent); - border: none; - box-shadow: 0 0 6px var(--accent-glow); - cursor: pointer; -} - -.pp-slider-value { - font-family: var(--font-mono); - font-size: 9px; - letter-spacing: 0.5px; - color: var(--accent); - min-width: 28px; - text-align: right; - flex-shrink: 0; -} - -/* Quantitative companion sliders share Display / Sharpen's visible rail, - * circular thumb, glow, and value treatment while retaining a taller hit area. */ -.gev-quantitative-slider { - width: 100%; min-width: 0; - height: 18px; - margin: 0; - -webkit-appearance: none; - appearance: none; - background: transparent; - outline: none; - cursor: pointer; -} -.gev-quantitative-slider::-webkit-slider-runnable-track { - height: 3px; - border-radius: 2px; - background: rgba(255, 255, 255, 0.08); -} -.gev-quantitative-slider::-webkit-slider-thumb { - width: 10px; - height: 10px; - margin-top: -3.5px; - border: 0; - border-radius: 50%; - -webkit-appearance: none; - appearance: none; - background: var(--accent); - box-shadow: 0 0 6px var(--accent-glow); - cursor: pointer; -} -.gev-quantitative-slider::-moz-range-track, -.gev-quantitative-slider::-moz-range-progress { - height: 3px; - border: 0; - border-radius: 2px; - background: rgba(255, 255, 255, 0.08); -} -.gev-quantitative-slider::-moz-range-thumb { - width: 10px; - height: 10px; - border: 0; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 6px var(--accent-glow); - cursor: pointer; -} -.gev-quantitative-slider:focus-visible { - border-radius: 4px; - outline: 1px solid color-mix(in srgb, var(--accent) 80%, transparent); - outline-offset: 2px; -} -.gev-quantitative-slider:disabled { - opacity: 0.42; - cursor: not-allowed; -} -.gev-slider-value { - min-width: 28px; - color: var(--accent); - font-family: var(--font-mono); - font-size: 9px; - letter-spacing: 0.5px; - text-align: right; - flex-shrink: 0; } .pp-slider-mini-label { @@ -1012,12 +909,6 @@ body.gev-geo-import-drag #cesiumContainer { box-shadow: inset 0 0 0 4px #061014; } -.pp-slider:focus-visible, -.param-slider:focus-visible { - outline: 2px solid var(--text-primary); - outline-offset: 4px; -} - #clean-view-toggle { width: var(--pp-expanded-width); } @@ -1224,47 +1115,4 @@ html:not([lang='en']) .param-slider-row { .param-slider { flex: 1; min-width: 0; - -webkit-appearance: none; - appearance: none; - height: 3px; - background: rgba(255, 255, 255, 0.08); - border-radius: 2px; - outline: none; - cursor: pointer; -} - -.param-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 12px; - height: 12px; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 6px var(--accent-glow); - cursor: pointer; - transition: box-shadow var(--transition-fast); -} - -.param-slider::-webkit-slider-thumb:hover { - box-shadow: 0 0 12px var(--accent-glow); -} - -.param-slider::-moz-range-thumb { - width: 12px; - height: 12px; - border-radius: 50%; - background: var(--accent); - border: none; - box-shadow: 0 0 6px var(--accent-glow); - cursor: pointer; -} - -.param-value { - font-family: var(--font-mono); - font-size: 10px; - letter-spacing: 0.5px; - color: var(--accent); - min-width: 32px; - text-align: right; - flex-shrink: 0; } diff --git a/src/ui/styles/recent-imagery.css b/src/ui/styles/recent-imagery.css index 66b351d26..e13e17b95 100644 --- a/src/ui/styles/recent-imagery.css +++ b/src/ui/styles/recent-imagery.css @@ -422,16 +422,6 @@ html:not([lang='en']) .recent-imagery-readout .ri-opacity-label { } .recent-imagery-readout .ri-opacity-value { font-variant-numeric: tabular-nums; - color: var(--text-secondary); -} -.recent-imagery-readout .ri-opacity { - width: 100%; - min-width: 0; - margin: 0; - accent-color: var(--accent); -} -.recent-imagery-readout .ri-opacity:disabled { - opacity: 0.35; } .recent-imagery-readout .ri-swap { width: 40px; diff --git a/src/ui/styles/slider.css b/src/ui/styles/slider.css new file mode 100644 index 000000000..4a5ee2ba2 --- /dev/null +++ b/src/ui/styles/slider.css @@ -0,0 +1,70 @@ +/* Shared ordinary slider surface; feature classes only own surrounding layout. + * Native range inputs retain keyboard, pointer, bounds and step behavior. */ +.gev-quantitative-slider { + width: 100%; + min-width: 0; + height: 18px; + margin: 0; + padding: 0; + border: 0; + -webkit-appearance: none; + appearance: none; + background: transparent; + outline: none; + cursor: pointer; +} +.gev-quantitative-slider::-webkit-slider-runnable-track { + height: 3px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.08); +} +.gev-quantitative-slider::-webkit-slider-thumb { + width: 10px; + height: 10px; + margin-top: -3.5px; + border: 0; + border-radius: 50%; + -webkit-appearance: none; + appearance: none; + background: var(--accent); + box-shadow: 0 0 6px var(--accent-glow); + cursor: pointer; +} +.gev-quantitative-slider::-moz-range-track, +.gev-quantitative-slider::-moz-range-progress { + height: 3px; + border: 0; + border-radius: 2px; + background: rgba(255, 255, 255, 0.08); +} +.gev-quantitative-slider::-moz-range-thumb { + width: 10px; + height: 10px; + border: 0; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 6px var(--accent-glow); + cursor: pointer; +} +.gev-quantitative-slider:focus-visible { + border-radius: 4px; + outline: 1px solid color-mix(in srgb, var(--accent) 80%, transparent); + outline-offset: 2px; +} +.gev-quantitative-slider:disabled { + opacity: 0.42; + cursor: not-allowed; +} +.pp-slider-value, +.param-value, +.ri-opacity-value, +.rail-card-setting-range-value, +.gev-slider-value { + min-width: 28px; + color: var(--accent); + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.5px; + text-align: right; + flex-shrink: 0; +} diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index 14b47524f..5d34ca642 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -236,19 +236,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn { gap: 4px 10px; } -.sl-range { - width: 100%; - min-width: 0; - margin: 0; - accent-color: var(--sl-green); - cursor: pointer; -} - -.sl-range:focus-visible { - outline: 1px solid var(--accent); - outline-offset: 3px; -} - .sl-since-note { grid-column: 2; font-family: var(--font-mono); diff --git a/src/ui/styles/weather.css b/src/ui/styles/weather.css index c8b2d544c..a47fa87b1 100644 --- a/src/ui/styles/weather.css +++ b/src/ui/styles/weather.css @@ -362,22 +362,9 @@ html:not([lang='en']) .rail-card-setting { font: 8px var(--font-mono); white-space: nowrap; } -.rail-card-setting-range { - width: 100%; - min-width: 0; - accent-color: var(--accent); - cursor: pointer; -} -.rail-card-setting-range:focus-visible { - outline: 2px solid var(--text-primary); - outline-offset: 3px; -} .rail-card-setting-range-value { flex: 0 0 4ch; white-space: nowrap; - color: var(--text-primary); - font: 9px var(--font-mono); - text-align: right; } .rail-card-result { display: grid; diff --git a/src/ui/templates/display-controls.html b/src/ui/templates/display-controls.html index e067f68bd..bcd54f572 100644 --- a/src/ui/templates/display-controls.html +++ b/src/ui/templates/display-controls.html @@ -27,27 +27,27 @@
@@ -58,7 +58,7 @@ @@ -117,7 +117,7 @@ mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather softens the black mask edge; label fading is the separate Fade slider. --> - + @@ -172,7 +172,7 @@ Bloom @@ -182,7 +182,7 @@ Sharpen diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index 6f581ae91..78ecfd777 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -126,7 +126,7 @@