From f6006358edc417781338de15803668928ca03f35 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 20 Oct 2026 13:23:44 +0100 Subject: [PATCH 1/6] fix(street-level): SINCE note on the value's line: ANY DATE (by upload date) The slim-down in #974 dropped --sl-text-small, which .sl-since-note still used, so with KartaView lit "KartaView: by upload date" rendered on its own line larger than the value. The note now sits beside the value in the same mono type and letter spacing, a size smaller (0.5rem), as "(by upload date)". One source is lit at a time, so the provider name is dropped. es/ru catalogs, tests, README, CURRENT-STATE and the registry JSDoc follow. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- changelog.d/1286-since-note-inline.md | 4 ++++ docs/CURRENT-STATE.md | 4 ++-- src/i18n/locales/es.json | 2 +- src/i18n/locales/ru.json | 2 +- .../streetLevel/providers/kartaview/policy.js | 2 +- src/layers/streetLevel/registry.js | 2 +- src/layers/streetLevel/uiState.js | 12 +++++------- src/layers/streetLevel/uiState.test.mjs | 4 ++-- src/ui/streetLevelControls.test.mjs | 4 ++-- src/ui/styles/street-level.css | 15 ++++++++++++--- src/ui/templates/layer-panels.html | 6 ++++-- 12 files changed, 36 insertions(+), 23 deletions(-) create mode 100644 changelog.d/1286-since-note-inline.md diff --git a/README.md b/README.md index affe39fa3..a38fc893b 100644 --- a/README.md +++ b/README.md @@ -425,7 +425,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED **Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless. -**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. +**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. diff --git a/changelog.d/1286-since-note-inline.md b/changelog.d/1286-since-note-inline.md new file mode 100644 index 000000000..f66467d67 --- /dev/null +++ b/changelog.d/1286-since-note-inline.md @@ -0,0 +1,4 @@ +- The Street Level SINCE note sits on the value's line, in its type and a + size smaller: with KartaView lit it reads ANY DATE (by upload date). It + had lost its size variable in the Street Level slim-down and rendered + larger than the value, on a line of its own (asturksever, #1286). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 42f338ce5..83645c1fa 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3648,8 +3648,8 @@ lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it ignores `fieldOfView`) and upload date and checks each answer the same way (when photos are near but none passes, the panel says so), and the cones pass on each photo's projection and upload date. The provider declares -`sinceBasis: 'upload'`, so while it is lit the panel shows "KartaView: by -upload date" under the SINCE slider (`sinceNote` in the UI state). Tiles come uncompressed and +`sinceBasis: 'upload'`, so while it is lit the panel shows "(by +upload date)" beside the SINCE value, as ANY DATE (by upload date) (`sinceNote` in the UI state). Tiles come uncompressed and uncacheable, so the last 24 decoded tiles are kept for 10 minutes while KartaView is lit (`createTileCache`, dropped when it is switched off); one that does not answer in 20 s is asked once more, and one that is not a line diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d86ab843d..76ed72b3d 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -945,7 +945,7 @@ "Image by {creator}": "Imagen de {creator}", "zoom in to see KartaView coverage": "acércate para ver la cobertura de KartaView", "no KartaView photos in view": "no hay fotos de KartaView a la vista", - "{names}: by upload date": "{names}: por fecha de subida", + "(by upload date)": "(por fecha de subida)", "{n} streets in view · click a line for its nearest photo": { "one": "{n} calle a la vista · haz clic en una línea para su foto más cercana", "many": "{n} calles a la vista · haz clic en una línea para su foto más cercana", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7ee31fd1c..81cd13b55 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -954,7 +954,7 @@ "Image by {creator}": "Автор снимка: {creator}", "zoom in to see KartaView coverage": "приблизьтесь, чтобы увидеть покрытие KartaView", "no KartaView photos in view": "фотографий KartaView в зоне видимости нет", - "{names}: by upload date": "{names}: по дате загрузки", + "(by upload date)": "(по дате загрузки)", "{n} streets in view · click a line for its nearest photo": { "one": "{n} улица в кадре · щёлкните линию, чтобы открыть ближайшую фотографию", "few": "{n} улицы в кадре · щёлкните линию, чтобы открыть ближайшую фотографию", diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index 184d1ceb8..939e0ec1c 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -141,7 +141,7 @@ export const FILTERED_OUT_MESSAGE = msg( 'None of KartaView’s photos here pass the imagery filter', ); /** Under the SINCE slider while KartaView is lit. */ -export const SINCE_NOTE = 'KartaView: by upload date'; +export const SINCE_NOTE = '(by upload date)'; /** The UTC calendar day of `ms`, as KartaView's startDate reads it. */ function utcDay(ms) { diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index ba0f986f2..6770cd133 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -23,7 +23,7 @@ export function filtersOf(provider) { * @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin. * @property {Array<'pano'|'since'>} [filters] Filters it honours (default both). * @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos"). - * @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' puts a note under the slider while the provider is lit. + * @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' notes "(by upload date)" beside the SINCE value while the provider is lit. * @property {(context: ProviderContext) => ProviderInstance} create * * @typedef {object} ProviderContext Handed to `create()` once by the core. diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index efb0dd750..577bb8ae1 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -73,16 +73,14 @@ export function filterControls(providers) { } /** - * The note under SINCE when a lit provider dates its imagery by upload, - * e.g. "KartaView: by upload date", or null. + * The note beside the SINCE value when a lit provider dates its imagery by + * upload, "(by upload date)", or null. One source is lit at a time, so the + * note needs no provider name. * @param {Array} providers */ export function sinceNote(providers) { - const names = providers - .filter((p) => p.on && p.sinceBasis === 'upload') - .map((p) => p.name); - return names.length - ? t('{names}: by upload date', { names: names.join(', ') }) + return providers.some((p) => p.on && p.sinceBasis === 'upload') + ? t('(by upload date)') : null; } diff --git a/src/layers/streetLevel/uiState.test.mjs b/src/layers/streetLevel/uiState.test.mjs index 5b9b0bf41..009600026 100644 --- a/src/layers/streetLevel/uiState.test.mjs +++ b/src/layers/streetLevel/uiState.test.mjs @@ -67,7 +67,7 @@ test('the filter controls show only for a lit provider that honours them', () => ); }); -test('a lit provider that dates by upload puts a note under SINCE', () => { +test('a lit provider that dates by upload notes it beside the SINCE value', () => { const kartaView = provider({ id: 'kartaview', name: 'KartaView', @@ -75,7 +75,7 @@ test('a lit provider that dates by upload puts a note under SINCE', () => { }); const note = (providers) => composeUIState({ ...base, providers }).sinceNote; assert.equal(note([provider()]), null, 'Mapillary: capture date'); - assert.equal(note([kartaView]), 'KartaView: by upload date'); + assert.equal(note([kartaView]), '(by upload date)'); assert.equal(note([{ ...kartaView, on: false }]), null, 'only while lit'); }); diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index adeaf2ae4..6f7d35688 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -981,9 +981,9 @@ test('a note under SINCE says what date it compares, only while one is given', ( const { layer } = stubPanel(dom, state); const note = () => dom.root.querySelector('#sl-since-note'); assert.equal(note().hidden, true, 'none for Mapillary'); - layer.publish({ ...state, sinceNote: 'KartaView: by upload date' }); + layer.publish({ ...state, sinceNote: '(by upload date)' }); assert.equal(note().hidden, false); - assert.equal(note().textContent, 'KartaView: by upload date'); + assert.equal(note().textContent, '(by upload date)'); layer.publish(state); assert.equal(note().hidden, true); // The panel's own markup has it, read out with the slider. diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index c218765d9..e14f492de 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -291,10 +291,20 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn { outline-offset: 3px; } -.sl-since-note { +/* The value and its note share a line: ANY DATE (by upload date). */ +.sl-since-value { grid-column: 2; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0 6px; + min-width: 0; +} + +.sl-since-note { font-family: var(--font-mono); - font-size: var(--sl-text-small); + font-size: 0.5rem; + letter-spacing: 0.08em; color: var(--text-secondary); } @@ -303,7 +313,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn { } .sl-since-label { - grid-column: 2; font-family: var(--font-mono); font-size: var(--sl-text); letter-spacing: 0.08em; diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index b170b01e3..cbc8f6e67 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -127,8 +127,10 @@
- ANY DATE - + + ANY DATE + +
From f1c617ad275cae6a0337fff77637491a933c6e84 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Tue, 20 Oct 2026 14:48:42 +0200 Subject: [PATCH 2/6] refactor(ui): share ordinary slider components --- docs/CURRENT-STATE.md | 9 ++ scripts/format-scope.json | 1 + scripts/package-boundaries.json | 5 + scripts/qa-visual-input.mjs | 32 ++++++ src/hudA11y.test.mjs | 2 +- src/ui/railCardBlocks.js | 12 +- src/ui/railTimeline.js | 18 +-- src/ui/recentImagery.js | 12 +- src/ui/slider.js | 32 ++++++ src/ui/styleParameters.js | 18 +-- src/ui/styles/controls.css | 152 ------------------------- src/ui/styles/recent-imagery.css | 10 -- src/ui/styles/slider.css | 70 ++++++++++++ src/ui/styles/street-level.css | 13 --- src/ui/styles/weather.css | 13 --- src/ui/templates/display-controls.html | 22 ++-- src/ui/templates/layer-panels.html | 2 +- src/ui/visualInput.test.mjs | 2 +- style.css | 1 + 19 files changed, 197 insertions(+), 229 deletions(-) create mode 100644 src/ui/slider.js create mode 100644 src/ui/styles/slider.css diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 42f338ce5..0b9b3de0d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1187,6 +1187,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. + ## Application shortcuts and shader parameter controls `ui/input` supplies the bubbling application shortcut listener and generated diff --git a/scripts/format-scope.json b/scripts/format-scope.json index fa5ebc3b2..3a4bba831 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -299,6 +299,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 5e3f3eb69..0c50be306 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -421,6 +421,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", @@ -479,6 +480,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", @@ -738,6 +740,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", @@ -2213,6 +2216,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", @@ -3206,6 +3210,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 857271c8b..3c305a3da 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 { PRODUCTS, hhmm, @@ -318,16 +319,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 f8a7906fe..36d3fb4f6 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 { @@ -1028,12 +925,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); } @@ -1240,47 +1131,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 e04955e2f..72bb407c7 100644 --- a/src/ui/styles/recent-imagery.css +++ b/src/ui/styles/recent-imagery.css @@ -423,16 +423,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 c218765d9..c0623203d 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -278,19 +278,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 220dc373b..aabba14ff 100644 --- a/src/ui/templates/display-controls.html +++ b/src/ui/templates/display-controls.html @@ -27,27 +27,27 @@
- + 8
- + 100%
- + 70%
- + 84%
- + 24°
@@ -58,7 +58,7 @@
- + 50%
@@ -70,12 +70,12 @@
- + 7%
- + 1%
@@ -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. --> - + 11% @@ -172,7 +172,7 @@ Bloom
- + 0%
@@ -182,7 +182,7 @@ Sharpen
- + 49%
diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index b170b01e3..9869ea40c 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -126,7 +126,7 @@
- + ANY DATE
diff --git a/src/ui/visualInput.test.mjs b/src/ui/visualInput.test.mjs index 586c3086e..c69c935ba 100644 --- a/src/ui/visualInput.test.mjs +++ b/src/ui/visualInput.test.mjs @@ -176,7 +176,7 @@ test('parameter rows preserve labels, bounds, precision and initial values', () assert.equal(label.textContent, ''); assert.equal(slider.attributes.get('aria-label'), ''); assert.equal(slider.type, 'range'); - assert.equal(slider.className, 'param-slider'); + 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'); diff --git a/style.css b/style.css index 44b7e07bc..2cebd0e27 100644 --- a/style.css +++ b/style.css @@ -1,5 +1,6 @@ /* Preserve component and responsive rule order in one stylesheet entry. */ @import './src/ui/styles/foundation.css'; +@import './src/ui/styles/slider.css'; @import './src/ui/styles/controls.css'; @import './src/ui/styles/location.css'; @import './src/ui/styles/status.css'; From 64fd9ba1c23161e8535542b5d677eb18c85f4850 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Tue, 20 Oct 2026 14:49:06 +0200 Subject: [PATCH 3/6] docs: add shared slider changelog fragment --- changelog.d/1287-shared-sliders.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1287-shared-sliders.md 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). From 40394ff76e408c8eb0bd49adf3aed16904265272 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Wed, 21 Oct 2026 13:07:51 +0200 Subject: [PATCH 4/6] Unify segmented selectors and fix compact panel spacing --- docs/CURRENT-STATE.md | 15 ++ scripts/format-scope.json | 3 + scripts/package-boundaries.json | 5 +- scripts/qa-segmented-controls.mjs | 286 +++++++++++++++++++++++++ scripts/qa-street-level.mjs | 41 ++++ src/annotations/drawTool.js | 20 +- src/ui/aircraftDisplay.js | 20 +- src/ui/displayControls.js | 27 ++- src/ui/recentImagery.js | 41 ++-- src/ui/segmentedControl.js | 109 ++++++++++ src/ui/segmentedControl.test.mjs | 174 +++++++++++++++ src/ui/streetLevelControls.js | 82 +++---- src/ui/styles/controls.css | 24 +-- src/ui/styles/cyber.css | 2 + src/ui/styles/layers.css | 4 +- src/ui/styles/recent-imagery.css | 1 - src/ui/styles/segmented-control.css | 51 +++++ src/ui/styles/street-level.css | 42 ---- src/ui/templates/display-controls.html | 20 +- src/ui/templates/layer-panels.html | 14 +- src/ui/visualSettings.js | 9 +- style.css | 1 + 22 files changed, 794 insertions(+), 197 deletions(-) create mode 100644 scripts/qa-segmented-controls.mjs create mode 100644 src/ui/segmentedControl.js create mode 100644 src/ui/segmentedControl.test.mjs create mode 100644 src/ui/styles/segmented-control.css diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index eacfa1147..419e3efb6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1224,6 +1224,21 @@ 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. +## Segmented selectors + +Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js` +and the shared `gev-segmented` / `gev-segment` surface. Model coverage, label +allocation, drawing shape, Street View fit/panorama filters and imagery mode keep +their feature-owned values, actions and persistence. Painting a restored value +does not emit a change or move focus. Existing keyboard policies remain explicit: +Street View uses arrows/Home/End with one tab stop, imagery mode uses arrows from +its controlled selection, and Display keeps its existing native button behavior. +Pure value navigation is separate from DOM rendering for reuse by a future view. +Segments keep their word minimum width: compact inline groups such as FIT/FILL +and ALL/360°/FLAT must not break short labels mid-word. Collapsed Weather follows +the same exclusive-rail hiding rule as the other readouts. +Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4173 node scripts/qa-segmented-controls.mjs`. + ## Application shortcuts and shader parameter controls `ui/input` supplies the bubbling application shortcut listener and generated diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 83e961d8b..b365c85c3 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -190,7 +190,10 @@ "src/ui/visualInput.js", "src/ui/visualInput.test.mjs", "scripts/qa-visual-input.mjs", + "scripts/qa-segmented-controls.mjs", "src/ui/displayControls.js", + "src/ui/segmentedControl.test.mjs", + "src/ui/styles/segmented-control.css", "src/ui/displayControls.test.mjs", "src/ui/cameraOrientationControls.js", "src/ui/cameraOrientationControls.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index bf33bebc7..b4fd6ace2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -431,7 +431,7 @@ }, "display-controls": { "exports": ["./ui/display"], - "modules": ["src/ui/displayControls.js"], + "modules": ["src/ui/displayControls.js", "src/ui/segmentedControl.js"], "external": [] }, "visual-effects": { @@ -701,6 +701,7 @@ "src/ui/contextSubscriptions.js", "src/ui/contextTransactions.js", "src/ui/displayControls.js", + "src/ui/segmentedControl.js", "src/ui/effects.js", "src/ui/frameRateMonitor.js", "src/ui/layerPanel.js", @@ -2176,6 +2177,7 @@ "src/ui/contextSubscriptions.js", "src/ui/contextTransactions.js", "src/ui/displayControls.js", + "src/ui/segmentedControl.js", "src/ui/effects.js", "src/ui/frameRateMonitor.js", "src/ui/layerPanel.js", @@ -3212,6 +3214,7 @@ "src/ui/visualInput.js", "src/ui/visualPresets.js", "src/ui/visualSettings.js", + "src/ui/segmentedControl.js", "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", diff --git a/scripts/qa-segmented-controls.mjs b/scripts/qa-segmented-controls.mjs new file mode 100644 index 000000000..cfa8e51d6 --- /dev/null +++ b/scripts/qa-segmented-controls.mjs @@ -0,0 +1,286 @@ +#!/usr/bin/env node +/** Run against a dev server or built preview with QA_BASE_URL (default :4179). */ +import fs from 'node:fs'; +import puppeteer from 'puppeteer'; + +const browser = await puppeteer.launch({ + executablePath: + process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()), + headless: true, + args: ['--use-gl=angle', '--use-angle=swiftshader'], +}); +const page = await browser.newPage(); +const errors = []; +const failures = []; +page.on('pageerror', (error) => errors.push(error.message)); +const check = (name, passed, detail = '') => { + console.log( + `[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`, + ); + if (!passed) failures.push(name); +}; +const base = process.env.QA_BASE_URL || 'http://localhost:4179'; +const shots = process.env.QA_SHOTS_DIR || 'qa-shots/segmented-controls'; +fs.mkdirSync(shots, { recursive: true }); + +try { + await page.setViewport({ width: 1440, height: 900 }); + await page.goto(`${base}/?welcome=0`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => window.__godsEyeView?.styleManager?._applicationShortcuts, + { timeout: 60000 }, + ); + await page.waitForFunction( + () => + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + await page.evaluate(() => + window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, { + persist: false, + syncShare: false, + }), + ); + + const surface = await page.evaluate(() => { + const groups = [...document.querySelectorAll('[role="radiogroup"]')]; + const buttons = groups.flatMap((group) => [ + ...group.querySelectorAll('[role="radio"]'), + ]); + return { + groups: groups.length, + buttons: buttons.length, + shared: + groups.every((g) => g.classList.contains('gev-segmented')) && + buttons.every((b) => { + const style = getComputedStyle(b); + return ( + b.classList.contains('gev-segment') && + style.fontSize === '9px' && + style.minHeight === '24px' && + style.borderRadius === '0px' + ); + }), + }; + }); + check( + 'all six one-of-many groups use the shared surface', + surface.groups === 6 && surface.buttons === 15 && surface.shared, + JSON.stringify(surface), + ); + + await page.evaluate(() => + document.getElementById('models3d-mode-all').click(), + ); + check( + 'model selection reaches both aircraft layers', + await page.evaluate(() => { + const dm = window.__godsEyeView.dataManager; + return ( + ['flights', 'military'].every( + (id) => dm.layers.get(id).module.getParams().models3dMode === 'all', + ) && + document + .getElementById('models3d-mode-all') + .getAttribute('aria-checked') === 'true' + ); + }), + ); + await page.focus('#models3d-mode-all'); + await page.keyboard.press('ArrowLeft'); + check( + 'Display retains its click-only arrow policy', + await page.evaluate( + () => + document + .getElementById('models3d-mode-all') + .getAttribute('aria-checked') === 'true', + ), + ); + await page.focus('#models3d-mode-proximity'); + await page.keyboard.press('Space'); + check( + 'native Space selects model coverage', + await page.evaluate( + () => + document + .getElementById('models3d-mode-proximity') + .getAttribute('aria-checked') === 'true', + ), + ); + await page.evaluate(() => + document.getElementById('detection-allocation-weighted').click(), + ); + check( + 'allocation selection still updates application tuning', + await page.evaluate( + () => + window.__godsEyeView.styleManager._detectionAllocationPreference === + 'WEIGHTED' && + document + .getElementById('detection-allocation-weighted') + .getAttribute('aria-checked') === 'true', + ), + ); + + await page.evaluate(() => + document.querySelector('[data-shape="pin"]').click(), + ); + check( + 'drawing shape still uses its existing feature action', + await page.evaluate( + () => + window.__gevDrawTool?.shape === 'pin' && + document + .querySelector('[data-shape="pin"]') + .getAttribute('aria-checked') === 'true', + ), + ); + + // Off-layer controls still own params; no catalog/provider request is needed. + await page.evaluate(() => + document.querySelector('[data-sl-pano="flat"]').click(), + ); + check( + 'panorama choice reaches the Street Level layer', + await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getParams().pano === 'flat', + ), + ); + await page.evaluate(() => + document.querySelector('[data-sl-pano="flat"]').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Home', + bubbles: true, + cancelable: true, + }), + ), + ); + check( + 'Street Level Home retains selection and roving tab state', + await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getParams().pano === 'all' && + document + .querySelector('[data-sl-pano="all"]') + .getAttribute('tabindex') === '0', + ), + ); + await page.evaluate(() => + document.querySelector('[data-sl-render="fill"]').click(), + ); + check( + 'fit/fill keeps its stored render preference', + await page.evaluate( + () => + localStorage.getItem('gev:street-level:render-mode') === 'fill' && + document + .querySelector('[data-sl-render="fill"]') + .getAttribute('aria-checked') === 'true', + ), + ); + + await page.evaluate(() => + document.querySelector('.ri-mode-btn[data-mode="ab"]').click(), + ); + check( + 'imagery mode reaches its controlled layer state', + await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('recent-imagery') + .module.getSnapshot().mode === 'ab', + ), + ); + await page.evaluate(() => + document.getElementById('ri-mode').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + cancelable: true, + }), + ), + ); + check( + 'imagery arrows wrap from the current selected value', + await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('recent-imagery') + .module.getSnapshot().mode === 'image' && + document + .querySelector('.ri-mode-btn[data-mode="image"]') + .getAttribute('aria-checked') === 'true', + ), + ); + + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager._setDetectionAllocation('ELASTIC', { + syncShare: false, + persist: false, + }); + document + .getElementById('models3d-mode-proximity') + .scrollIntoView({ block: 'center' }); + }); + await page.screenshot({ path: `${shots}/desktop.png` }); + await page.setViewport({ width: 620, height: 900 }); + await page.evaluate(() => + document + .getElementById('models3d-mode-proximity') + .scrollIntoView({ block: 'center' }), + ); + await page.screenshot({ path: `${shots}/narrow.png` }); + check( + 'narrow selectors stay within their panel', + await page.evaluate(() => + [...document.querySelectorAll('.pp-mode-seg')] + .filter((g) => g.getClientRects().length) + .every((group) => group.scrollWidth <= group.clientWidth + 1), + ), + ); + const cyber = await page.evaluate(() => { + const root = document.documentElement; + const previous = root.getAttribute('data-ui-theme'); + root.setAttribute('data-ui-theme', 'cyber'); + const selected = [ + ...document.querySelectorAll('.gev-segment[aria-checked="true"]'), + ]; + // Check the final theme colors, independent of software-GL frame cadence. + for (const button of selected) + for (const animation of button.getAnimations()) animation.finish(); + const backgrounds = selected.map( + (button) => getComputedStyle(button).backgroundColor, + ); + const radii = [...document.querySelectorAll('.gev-segmented')].map( + (group) => getComputedStyle(group).borderRadius, + ); + const consistent = + selected.length === 6 && + backgrounds.every((color) => color === backgrounds[0]) && + radii.every((radius) => radius === '1px'); + if (previous === null) root.removeAttribute('data-ui-theme'); + else root.setAttribute('data-ui-theme', previous); + return { consistent, count: selected.length, backgrounds, radii }; + }); + check( + 'Cyber theme styles every selected segment consistently', + cyber.consistent, + JSON.stringify(cyber), + ); + check('no uncaught browser errors', errors.length === 0, errors.join(' | ')); +} catch (error) { + console.error('Browser errors:', errors); + await page.screenshot({ path: `${shots}/failure.png` }).catch(() => {}); + throw error; +} finally { + await browser.close(); +} +console.log(`RESULT: ${failures.length} failures`); +process.exitCode = failures.length ? 1 : 0; diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index d099ed9b5..1be1a80fa 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -661,6 +661,32 @@ async function main() { }, ); + await step( + 'FIT/FILL keeps native selection, keyboard focus and persistence with a photo open', + async () => { + await page.click('[data-sl-render="fill"]'); + await uiUntil(page, (u) => u.street.renderMode === 'fill'); + assert.equal( + await page.evaluate(() => + localStorage.getItem('gev:street-level:render-mode'), + ), + 'fill', + ); + await page.keyboard.press('Home'); + await uiUntil(page, (u) => u.street.renderMode === 'letterbox'); + assert.deepEqual( + await page.$eval('[data-sl-render="letterbox"]', (node) => ({ + selected: node.getAttribute('aria-checked'), + tabIndex: node.tabIndex, + focused: document.activeElement === node, + })), + { selected: 'true', tabIndex: 0, focused: true }, + ); + await page.keyboard.press('End'); + await uiUntil(page, (u) => u.street.renderMode === 'fill'); + }, + ); + await step('× closes the photo and deselects its sequence', async () => { await press(page, '#sl-viewer-close'); await uiUntil( @@ -673,6 +699,21 @@ async function main() { ); }); + await step( + 'panorama segments keep native arrow and Home navigation', + async () => { + await page.click('[data-sl-pano="flat"]'); + await uiUntil(page, (u) => u.filter.pano === 'flat'); + await page.keyboard.press('ArrowRight'); + await uiUntil(page, (u) => u.filter.pano === 'all'); + await page.keyboard.press('End'); + await uiUntil(page, (u) => u.filter.pano === 'flat'); + await page.keyboard.press('Home'); + await uiUntil(page, (u) => u.filter.pano === 'all'); + await waitForCoverage(page); + }, + ); + await keylessProviderStep({ id: 'kartaview', name: 'KartaView', diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index 91adb4d0d..0f4e0fc76 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -30,6 +30,7 @@ * at finish, deliberately and in one place — see `finishSpec` in drawMode.js. */ import * as Cesium from 'cesium'; +import { createSegmentedControl } from '../ui/segmentedControl.js'; import { pickWorldFromScreen } from './annotationResolver.js'; import { claimPointer, @@ -432,22 +433,21 @@ export function initDrawTool({ viewer, annotations }) { const shapeButtons = [ ...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []), ]; - for (const btn of shapeButtons) { - listen(btn, 'click', () => { - shape = normalizeShape(btn.dataset.shape); - for (const other of shapeButtons) { - const on = other === btn; - other.classList.toggle('active', on); - other.setAttribute('aria-checked', String(on)); - } + const shapeControl = createSegmentedControl({ + buttons: shapeButtons, + valueOf: (button) => button.dataset.shape, + listen, + onChange: (value) => { + shape = normalizeShape(value); + shapeControl.update(shape); if (active) { generation += 1; session = createDrawSession(shape); cursor = null; syncPreview(); } - }); - } + }, + }); listen(colorSelect, 'change', () => { color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary'; syncPreview(); diff --git a/src/ui/aircraftDisplay.js b/src/ui/aircraftDisplay.js index 2afc77579..ba325f6e9 100644 --- a/src/ui/aircraftDisplay.js +++ b/src/ui/aircraftDisplay.js @@ -1,3 +1,5 @@ +import { syncSegmentedControl } from './segmentedControl.js'; + /** Own the shared aircraft display preference and its controls. */ export class AircraftDisplay { constructor({ elements, readDataManager, layout }) { @@ -28,12 +30,9 @@ export class AircraftDisplay { this._models3dMode = options.models3dMode === 'all' ? 'all' : 'proximity'; this._syncModels3dButtonState(); this._models3dModeRow?.classList.toggle('visible', this._models3dEnabled); - for (const button of this._models3dModeBtns || []) { - if (!button) continue; - const active = button.dataset.mode === this._models3dMode; - button.classList.toggle('active', active); - button.setAttribute('aria-checked', String(active)); - } + syncSegmentedControl(this._models3dModeBtns, this._models3dMode, { + valueOf: (button) => button.dataset.mode, + }); this._layoutRightPanels(); } @@ -59,12 +58,9 @@ export class AircraftDisplay { const normalized = mode === 'all' ? 'all' : 'proximity'; this._models3dMode = normalized; this._setModels3dParams({ models3dMode: normalized }); - for (const button of this._models3dModeBtns) { - if (!button) continue; - const active = button.dataset.mode === normalized; - button.classList.toggle('active', active); - button.setAttribute('aria-checked', String(active)); - } + syncSegmentedControl(this._models3dModeBtns, normalized, { + valueOf: (button) => button.dataset.mode, + }); this._syncModels3dButtonState(); } diff --git a/src/ui/displayControls.js b/src/ui/displayControls.js index 09276db31..e0d76d338 100644 --- a/src/ui/displayControls.js +++ b/src/ui/displayControls.js @@ -1,3 +1,5 @@ +import { createSegmentedControl } from './segmentedControl.js'; + /** * Bind Display controls to application actions. Inputs remain native DOM controls; * the caller owns settings and effect state. Destroy before replacing the view. @@ -44,12 +46,25 @@ export function bindDisplayControls({ elements, actions }) { listen(elements[name], 'input', action, integer); for (const el of elements.styleButtons || []) listen(el, 'click', 'setStyle', (el) => el.dataset.style); - for (const el of elements.allocationButtons || []) - listen(el, 'click', 'setAllocation', (el) => el.dataset.allocation); - for (const el of elements.modelModeButtons || []) - listen(el, 'click', 'setModelsMode', (el) => - el.dataset.mode === 'all' ? 'all' : 'proximity', - ); + for (const [buttons, valueOf, action] of [ + [ + elements.allocationButtons || [], + (el) => el.dataset.allocation, + 'setAllocation', + ], + [ + elements.modelModeButtons || [], + (el) => (el.dataset.mode === 'all' ? 'all' : 'proximity'), + 'setModelsMode', + ], + ]) { + const control = createSegmentedControl({ + buttons, + valueOf, + onChange: (value) => actions[action](value), + }); + removers.push(() => control.destroy()); + } for (const el of elements.fadeSliders || []) listen(el, 'input', 'setFade'); return { destroy() { diff --git a/src/ui/recentImagery.js b/src/ui/recentImagery.js index 857271c8b..0706ad17d 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 { createSegmentedControl } from './segmentedControl.js'; import { PRODUCTS, hhmm, @@ -286,13 +287,13 @@ export function createRecentImageryPanel({ const selection = el('section', 'ri-block ri-selection', root); selection.id = 'ri-selection'; selection.setAttribute('aria-label', t('On the map')); - const modeGroup = el('div', 'ri-mode', selection); + const modeGroup = el('div', 'gev-segmented ri-mode', selection); modeGroup.id = 'ri-mode'; modeGroup.setAttribute('role', 'radiogroup'); modeGroup.setAttribute('aria-label', t('Mode')); const modeButtons = new Map( MODE_LABELS.map(([mode, label]) => { - const node = el('button', 'data-toggle-chip ri-mode-btn', modeGroup); + const node = el('button', 'gev-segment ri-mode-btn', modeGroup); node.type = 'button'; node.textContent = t(label); node.dataset.mode = mode; @@ -300,6 +301,15 @@ export function createRecentImageryPanel({ return [mode, node]; }), ); + const modeControl = createSegmentedControl({ + buttons: modeButtons.values(), + valueOf: (button) => button.dataset.mode, + onChange: (mode) => layer.setMode(mode), + keyboard: 'arrows', + keyTarget: modeGroup, + readValue: () => snapshot.mode, + listen, + }); const slotRows = {}; for (const slotId of ['a', 'b']) { const row = el('div', 'ri-slot', selection); @@ -668,12 +678,7 @@ export function createRecentImageryPanel({ function renderSelection() { const { mode } = snapshot; set(selection.dataset, 'mode', mode); - for (const [id, node] of modeButtons) { - const on = id === mode; - attribute(node, 'aria-checked', String(on)); - toggle(node, 'active', on); - set(node, 'tabIndex', on ? 0 : -1); - } + modeControl.update(mode, { rovingFocus: true }); const ab = mode === 'ab'; for (const slotId of ['a', 'b']) { const { row, tag, value, unpin } = slotRows[slotId]; @@ -1065,11 +1070,6 @@ export function createRecentImageryPanel({ strip.focus({ preventScroll: true }); return; } - const mode = target?.closest?.('.ri-mode-btn'); - if (mode) { - layer.setMode(mode.dataset.mode); - return; - } if (target?.closest?.('.ri-swap') && !swap.disabled) { layer.swapSides?.(); return; @@ -1086,21 +1086,6 @@ export function createRecentImageryPanel({ if (event.target === opacity) layer.setAlpha(Number(opacity.value) / 100); }); - listen(modeGroup, 'keydown', (event) => { - const order = MODE_LABELS.map(([mode]) => mode); - const step = - { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[event.key] || - 0; - if (!step) return; - event.preventDefault(); - const next = - order[ - (order.indexOf(snapshot.mode) + step + order.length) % order.length - ]; - layer.setMode(next); - modeButtons.get(next)?.focus?.(); - }); - listen(strip, 'keydown', (event) => { const total = snapshot.candidates.length; const index = snapshot.focusIndex || 0; diff --git a/src/ui/segmentedControl.js b/src/ui/segmentedControl.js new file mode 100644 index 000000000..0ad785986 --- /dev/null +++ b/src/ui/segmentedControl.js @@ -0,0 +1,109 @@ +const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }; + +/** Pure value navigation, also usable by a future template-based view. */ +export function nextSegmentValue(values, value, key, { homeEnd = true } = {}) { + if (!values.length) return undefined; + if (homeEnd && key === 'Home') return values[0]; + if (homeEnd && key === 'End') return values.at(-1); + const step = STEPS[key]; + if (!step) return undefined; + return values[(values.indexOf(value) + step + values.length) % values.length]; +} + +/** Paint controlled selection without emitting changes or moving focus. */ +export function syncSegmentedControl( + buttons, + value, + { valueOf, activeClass = 'active', rovingFocus = false } = {}, +) { + for (const button of buttons || []) { + if (!button) continue; + const selected = valueOf(button) === value; + if (button.classList.contains(activeClass) !== selected) + button.classList.toggle(activeClass, selected); + const set = (name, content) => { + if (button.getAttribute?.(name) !== content) + button.setAttribute(name, content); + }; + set('aria-checked', String(selected)); + if (rovingFocus) set('tabindex', selected ? '0' : '-1'); + } +} + +/** + * Controlled segmented-selector DOM adapter. Feature owners supply values and + * receive changes; rendering a restored value never triggers the callback. + * `keyboard` retains each existing group's policy: none, arrows, or radio + * (arrows plus Home/End). Pointer and native Enter/Space share the click path. + * An injected listener owner keeps the feature's existing teardown boundary. + * @param {object} options + * @param {Iterable} options.buttons + * @param {Function} options.valueOf Maps a button to the feature's value. + * @param {Function} options.onChange Receives value and originating button. + * @param {'none'|'arrows'|'radio'} [options.keyboard] + * @param {HTMLElement} [options.keyTarget] Delegated keyboard target, if any. + * @param {Function} [options.readValue] Current controlled value for delegation. + * @param {Function} [options.listen] Feature-owned event registration. + * @returns {{ update: Function, destroy: Function }} + */ +export function createSegmentedControl({ + buttons, + valueOf, + onChange, + keyboard = 'none', + keyTarget, + readValue, + listen, +}) { + const options = [...buttons].filter(Boolean); + const removers = []; + let destroyed = false; + const bind = (target, type, handler) => { + const owned = (event) => { + if (!destroyed) handler(event); + }; + if (listen) listen(target, type, owned); + else { + target.addEventListener(type, owned); + removers.push(() => target.removeEventListener(type, owned)); + } + }; + for (const button of options) + bind(button, 'click', () => { + if (!button.disabled) onChange(valueOf(button), button); + }); + const navigate = (event, from) => { + const enabled = options.filter((button) => !button.disabled); + const values = enabled.map(valueOf); + const next = nextSegmentValue(values, from, event.key, { + homeEnd: keyboard === 'radio', + }); + if (next === undefined) return; + const button = enabled.find((option) => valueOf(option) === next); + event.preventDefault(); + if (keyTarget) { + button.click(); + button.focus(); + } else { + button.focus(); + if (next !== from) button.click(); + } + }; + if (keyboard !== 'none') { + if (keyTarget) + bind(keyTarget, 'keydown', (event) => navigate(event, readValue())); + else + for (const button of options) + bind(button, 'keydown', (event) => navigate(event, valueOf(button))); + } + return { + update(value, presentation) { + if (!destroyed) + syncSegmentedControl(options, value, { valueOf, ...presentation }); + }, + destroy() { + destroyed = true; + for (const remove of removers.splice(0)) remove(); + }, + }; +} diff --git a/src/ui/segmentedControl.test.mjs b/src/ui/segmentedControl.test.mjs new file mode 100644 index 000000000..ad194ddd8 --- /dev/null +++ b/src/ui/segmentedControl.test.mjs @@ -0,0 +1,174 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { railFixture } from './railTestFixture.mjs'; +import { + createSegmentedControl, + nextSegmentValue, +} from './segmentedControl.js'; + +function fixture(options = {}) { + let writes = 0; + const f = railFixture(() => writes++); + const buttons = ['a', 'b', 'c'].map((value) => { + const button = f.document.createElement('button'); + button.dataset.value = value; + f.container.appendChild(button); + return button; + }); + const calls = []; + const control = createSegmentedControl({ + buttons, + valueOf: (button) => button.dataset.value, + onChange: (value) => calls.push(value), + ...options, + }); + const key = (target, value) => { + const event = new Event('keydown', { cancelable: true }); + Object.defineProperty(event, 'key', { value }); + target.dispatchEvent(event); + return event.defaultPrevented; + }; + return { ...f, buttons, calls, control, key, writes: () => writes }; +} + +test('controlled restoration updates selection without emitting or stealing focus', () => { + const f = fixture(); + f.buttons[0].focus(); + f.control.update('b', { rovingFocus: true }); + assert.deepEqual(f.calls, []); + assert.equal(f.document.activeElement, f.buttons[0]); + assert.deepEqual( + f.buttons.map((b) => b.getAttribute('aria-checked')), + ['false', 'true', 'false'], + ); + assert.deepEqual( + f.buttons.map((b) => b.getAttribute('tabindex')), + ['-1', '0', '-1'], + ); + const before = f.writes(); + f.control.update('b', { rovingFocus: true }); + assert.equal( + f.writes(), + before, + 'unchanged renders must not retrigger rail observers', + ); + f.control.destroy(); +}); + +test('clicks emit current values once, including reselection; disabled controls do not emit', () => { + const f = fixture(); + f.control.update('a'); + f.buttons[0].click(); + f.buttons[0].click(); + f.buttons[1].dataset.value = 'renamed'; + f.buttons[1].click(); + f.buttons[2].disabled = true; + f.buttons[2].dispatchEvent(new Event('click')); + assert.deepEqual(f.calls, ['a', 'a', 'renamed']); + assert.equal( + f.buttons[0].getAttribute('aria-checked'), + 'true', + 'the owner controls selection', + ); + f.control.destroy(); +}); + +test('radio navigation wraps, skips disabled choices, and retains Home/End semantics', () => { + const f = fixture({ keyboard: 'radio' }); + f.buttons[1].disabled = true; + assert.equal(f.key(f.buttons[0], 'ArrowRight'), true); + assert.equal(f.document.activeElement, f.buttons[2]); + assert.deepEqual(f.calls, ['c']); + f.key(f.buttons[2], 'ArrowDown'); + assert.equal(f.document.activeElement, f.buttons[0]); + f.key(f.buttons[0], 'Home'); + assert.deepEqual( + f.calls, + ['c', 'a'], + 'Home on the first button does not reselect it', + ); + f.key(f.buttons[0], 'End'); + assert.equal(f.document.activeElement, f.buttons[2]); + assert.deepEqual(f.calls, ['c', 'a', 'c']); + f.control.destroy(); +}); + +test('legacy click-only groups leave arrows and tab order to their existing owner', () => { + const f = fixture(); + f.control.update('b'); + assert.equal(f.key(f.buttons[0], 'ArrowRight'), false); + assert.equal(f.key(f.buttons[0], 'Home'), false); + assert.deepEqual(f.calls, []); + assert.ok( + f.buttons.every((button) => button.getAttribute('tabindex') === null), + ); + f.control.destroy(); +}); + +test('delegated imagery keys navigate from the controlled value, not stale focus', () => { + const f = railFixture(); + const buttons = ['a', 'b', 'c'].map((value) => { + const button = f.document.createElement('button'); + button.dataset.value = value; + f.container.appendChild(button); + return button; + }); + let selected = 'c'; + const calls = []; + const control = createSegmentedControl({ + buttons, + valueOf: (b) => b.dataset.value, + onChange: (value) => { + calls.push(value); + selected = value; + }, + keyboard: 'arrows', + keyTarget: f.container, + readValue: () => selected, + }); + const event = new Event('keydown', { cancelable: true }); + Object.defineProperty(event, 'key', { value: 'ArrowRight' }); + f.container.dispatchEvent(event); + assert.deepEqual(calls, ['a']); + assert.equal(f.document.activeElement, buttons[0]); + const home = new Event('keydown', { cancelable: true }); + Object.defineProperty(home, 'key', { value: 'Home' }); + f.container.dispatchEvent(home); + assert.equal(home.defaultPrevented, false); + assert.deepEqual(calls, ['a']); + control.destroy(); +}); + +test('disposal revokes detached controls and replacement subscriptions fire once', () => { + const f = fixture({ keyboard: 'radio' }); + f.control.destroy(); + const next = []; + const replacement = createSegmentedControl({ + buttons: f.buttons, + valueOf: (b) => b.dataset.value, + onChange: (value) => next.push(value), + keyboard: 'radio', + }); + f.buttons[0].click(); + f.key(f.buttons[0], 'End'); + assert.deepEqual(f.calls, []); + assert.deepEqual(next, ['a', 'c']); + replacement.destroy(); + replacement.destroy(); + f.buttons[1].click(); + f.key(f.buttons[0], 'End'); + assert.deepEqual(next, ['a', 'c']); +}); + +test('pure value navigation can be reused without a DOM', () => { + assert.equal(nextSegmentValue(['fit', 'fill'], 'fill', 'ArrowRight'), 'fit'); + assert.equal(nextSegmentValue(['fit', 'fill'], 'fit', 'ArrowUp'), 'fill'); + assert.equal(nextSegmentValue(['fit', 'fill'], 'fill', 'Home'), 'fit'); + assert.equal(nextSegmentValue(['fit', 'fill'], 'fit', 'End'), 'fill'); + assert.equal( + nextSegmentValue(['fit', 'fill'], 'fit', 'Home', { homeEnd: false }), + undefined, + ); + assert.equal(nextSegmentValue([], 'fit', 'ArrowRight'), undefined); + assert.equal(nextSegmentValue(['fit'], 'fit', 'Escape'), undefined); +}); diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index 44bd18488..2c6b7e94d 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -1,5 +1,9 @@ import { isExplicitLayerStateOrigin } from '../data/layerState.js'; import { syncChipGroup } from './chipGroup.js'; +import { + createSegmentedControl, + syncSegmentedControl, +} from './segmentedControl.js'; import { presentStreetLevelPanel, SINCE_STOPS, @@ -9,12 +13,6 @@ import { t, translateKnown } from '../i18n/index.js'; const RENDER_MODE_KEY = 'gev:street-level:render-mode'; /** classes of the modes that hide every panel; an expanded photo leaves with them. */ const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode']; -const RADIO_STEPS = Object.freeze({ - ArrowRight: 1, - ArrowDown: 1, - ArrowLeft: -1, - ArrowUp: -1, -}); // Renders run on every layer notification, and the rail's MutationObserver // relays out on each attribute write: write only what changed. @@ -106,13 +104,13 @@ export class StreetLevelControls { } this._toggleProvider(button.dataset.chipId); }); - for (const button of el.panoButtons) { - this.listen(button, 'click', () => - this._setParams({ pano: button.dataset.slPano }), - ); - } - this._bindRadioGroup(el.panoButtons); - this._bindRadioGroup(el.renderButtons); + createSegmentedControl({ + buttons: el.panoButtons, + valueOf: (button) => button.dataset.slPano, + onChange: (pano) => this._setParams({ pano }), + keyboard: 'radio', + listen: (target, type, handler) => this.listen(target, type, handler), + }); // The readout follows the thumb; coverage is rebuilt once, on release. const sinceDays = () => SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0; @@ -160,17 +158,20 @@ export class StreetLevelControls { this._exitExpanded(); }); } else setProp(el.viewerExpand, 'hidden', true); - for (const button of el.renderButtons) { - this.listen(button, 'click', () => { - const mode = button.dataset.slRender; + createSegmentedControl({ + buttons: el.renderButtons, + valueOf: (button) => button.dataset.slRender, + keyboard: 'radio', + listen: (target, type, handler) => this.listen(target, type, handler), + onChange: (mode) => { this.layer.setViewerRenderMode?.(mode); try { localStorage.setItem(RENDER_MODE_KEY, mode); } catch { /* storage unavailable */ } - }); - } + }, + }); try { const stored = localStorage.getItem(RENDER_MODE_KEY); if (stored === 'fill' || stored === 'letterbox') @@ -185,24 +186,6 @@ export class StreetLevelControls { } } - /** ARIA radio keys. Selecting clicks the radio, so keys and pointer share a path. */ - _bindRadioGroup(buttons) { - buttons.forEach((button, index) => { - this.listen(button, 'keydown', (event) => { - let next = null; - if (event.key in RADIO_STEPS) { - const count = buttons.length; - next = buttons[(index + RADIO_STEPS[event.key] + count) % count]; - } else if (event.key === 'Home') next = buttons[0]; - else if (event.key === 'End') next = buttons[buttons.length - 1]; - if (!next) return; - event.preventDefault(); - next.focus(); - if (next !== button) next.click(); - }); - }); - } - /** * Refit the viewer once per frame. Skips a hidden (0×0) viewer, which would * ask for tiles at z=NaN; the observer fires again once it has a size. @@ -416,15 +399,6 @@ export class StreetLevelControls { setProp(el.errorText, 'textContent', view.error || ''); } - _renderRadios(buttons, isChecked) { - for (const button of buttons) { - const checked = isChecked(button); - button.classList.toggle('is-active', checked); - setAttr(button, 'aria-checked', String(checked)); - setAttr(button, 'tabindex', checked ? '0' : '-1'); - } - } - _renderFilters(view) { const el = this._elements; const shows = view.filterControls || { pano: true, since: true }; @@ -434,10 +408,11 @@ export class StreetLevelControls { setProp(el.sinceNote, 'textContent', view.sinceNote || ''); setProp(el.sinceNote, 'hidden', !view.sinceNote); setProp(el.controls, 'hidden', !shows.pano && !shows.since); - this._renderRadios( - el.panoButtons, - (button) => button.dataset.slPano === view.filter.pano, - ); + syncSegmentedControl(el.panoButtons, view.filter.pano, { + valueOf: (button) => button.dataset.slPano, + activeClass: 'is-active', + rovingFocus: true, + }); if (el.sinceRange) { // Never move the thumb under the user's hand; once the committed value // matches the thumb, show the full readout with its cut-off date. @@ -467,10 +442,11 @@ export class StreetLevelControls { setProp(wrap, 'hidden', !viewer.open); } setProp(el.viewerPlaceholder, 'hidden', !viewer.loading); - this._renderRadios( - el.renderButtons, - (button) => button.dataset.slRender === viewer.renderMode, - ); + syncSegmentedControl(el.renderButtons, viewer.renderMode, { + valueOf: (button) => button.dataset.slRender, + activeClass: 'is-active', + rovingFocus: true, + }); // Google's viewer always fills its frame: no FIT/FILL to offer. const renderGroup = el.renderButtons[0]?.parentElement; if (renderGroup) setProp(renderGroup, 'hidden', !viewer.renderModes); diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index f8a7906fe..49d6a1418 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -677,11 +677,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label { padding: 5px 8px 6px; } -.detection-allocation-seg .pp-mode-btn { - font-size: 8px; - letter-spacing: 0.35px; -} - #hud-layout-row { min-width: 200px; } @@ -699,7 +694,7 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label { min-width: 200px; } -.cyber-sonar-toggle-row .pp-mode-btn { +.cyber-sonar-toggle-row .pp-mode-btn:not(.gev-segment) { margin-left: auto; min-width: 48px; } @@ -851,11 +846,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label { #draw-mode-row { min-width: 184px; } -/* Equal thirds with room around the label, so a long translated shape name - never runs edge to edge in its segment. */ -html:not([lang='en']) #draw-mode-row .pp-mode-btn { - padding: 0 4px; -} #draw-label-row { flex-wrap: wrap; row-gap: 6px; @@ -984,11 +974,8 @@ body.gev-geo-import-drag #cesiumContainer { .pp-mode-seg { display: flex; flex: 1; - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 6px; - overflow: hidden; } -.pp-mode-btn { +.pp-mode-btn:not(.gev-segment) { flex: 1; height: 22px; background: rgba(255, 255, 255, 0.04); @@ -1003,14 +990,11 @@ body.gev-geo-import-drag #cesiumContainer { background-color var(--transition-fast), color var(--transition-fast); } -.pp-mode-btn + .pp-mode-btn { - border-left: 1px solid rgba(255, 255, 255, 0.14); -} -.pp-mode-btn:hover { +.pp-mode-btn:not(.gev-segment):hover { background: rgba(255, 255, 255, 0.08); color: var(--text-primary); } -.pp-mode-btn.active { +.pp-mode-btn:not(.gev-segment).active { background: var(--accent); color: #000; } diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index 04a1efa77..fc93abcb6 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -506,11 +506,13 @@ 0 0 12px rgba(196, 84, 84, 0.14); } +:root[data-ui-theme='cyber'] .gev-segmented, :root[data-ui-theme='cyber'] .pp-mode-seg { border-color: rgba(206, 222, 226, 0.24); border-radius: 1px; } +:root[data-ui-theme='cyber'] .gev-segment[aria-checked='true'], :root[data-ui-theme='cyber'] .pp-mode-btn.active { background: var(--cyber-red); color: #f7f8f7; diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 4043751d4..ea64eefc5 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -184,8 +184,8 @@ max-height: 100%; pointer-events: auto; } -#right-context-rail > #weather-panel, -/* Collapsed, it must stay hideable by the rail's exclusive layout. */ +/* Collapsed readouts must stay hideable by the rail's exclusive layout. */ +#right-context-rail > #weather-panel:not(.collapsed), #right-context-rail > #recent-imagery-panel:not(.collapsed) { display: flex; flex-direction: column; diff --git a/src/ui/styles/recent-imagery.css b/src/ui/styles/recent-imagery.css index e04955e2f..95c149a3c 100644 --- a/src/ui/styles/recent-imagery.css +++ b/src/ui/styles/recent-imagery.css @@ -321,7 +321,6 @@ html:not([lang='en']) .recent-imagery-readout .ri-mode { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 4px; } .recent-imagery-readout .ri-selection .data-toggle-chip { height: 20px; diff --git a/src/ui/styles/segmented-control.css b/src/ui/styles/segmented-control.css new file mode 100644 index 000000000..bcf083e71 --- /dev/null +++ b/src/ui/styles/segmented-control.css @@ -0,0 +1,51 @@ +/* Shared one-of-many control. Feature classes retain surrounding placement. */ +.gev-segmented { + display: inline-flex; + min-width: 0; + border: 1px solid var(--glass-border-hover); + border-radius: 6px; + overflow: hidden; +} +.gev-segmented[hidden] { + display: none; +} +.gev-segment { + flex: 1; + min-width: min-content; + min-height: 24px; + margin: 0; + padding: 4px 8px; + border: 0; + border-radius: 0; + background: rgba(255, 255, 255, 0.04); + color: var(--text-secondary); + font: 9px/1.25 var(--font-mono); + letter-spacing: 0.6px; + text-transform: uppercase; + overflow-wrap: normal; + cursor: pointer; + transition: + background-color var(--transition-fast), + color var(--transition-fast); +} +.gev-segment + .gev-segment { + border-left: 1px solid var(--glass-border); +} +.gev-segment:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary); +} +.gev-segment[aria-checked='true'] { + background: var(--accent); + color: #000; +} +.gev-segment:focus-visible { + position: relative; + outline: 2px solid var(--text-primary); + outline-offset: -3px; + box-shadow: inset 0 0 0 4px #061014; +} +.gev-segment:disabled { + opacity: 0.42; + cursor: not-allowed; +} diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index c218765d9..14b47524f 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -222,14 +222,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn { flex-wrap: wrap; } -/* No overflow clipping: it would cut off the buttons' focus ring. The end - buttons round themselves instead. */ -.sl-seg { - display: inline-flex; - border: 1px solid var(--glass-border-hover); - border-radius: 999px; -} - .sl-seg[hidden], .sl-controls[hidden], .sl-filters[hidden], @@ -237,40 +229,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn { display: none; } -.sl-seg-btn { - font-family: var(--font-mono); - font-size: var(--sl-text); - letter-spacing: 0.08em; - padding: 4px 9px; - border: 0; - background: transparent; - color: var(--text-secondary); - cursor: pointer; -} - -.sl-seg-btn:first-child { - border-radius: 999px 0 0 999px; -} - -.sl-seg-btn:last-child { - border-radius: 0 999px 999px 0; -} - -.sl-seg-btn + .sl-seg-btn { - border-left: 1px solid var(--glass-border); -} - -.sl-seg-btn:focus-visible { - position: relative; - outline: 1px solid var(--accent); - outline-offset: 2px; -} - -.sl-seg-btn.is-active { - background: rgba(5, 203, 99, 0.22); - color: var(--text-primary); -} - .sl-since { display: grid; grid-template-columns: auto 1fr; diff --git a/src/ui/templates/display-controls.html b/src/ui/templates/display-controls.html index 220dc373b..e067f68bd 100644 --- a/src/ui/templates/display-controls.html +++ b/src/ui/templates/display-controls.html @@ -63,9 +63,9 @@
Allocation -
- - +
+ +
@@ -99,9 +99,9 @@
Models -
- - +
+ +
@@ -128,10 +128,10 @@
Shape -
- - - +
+ + +
diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index b170b01e3..6f581ae91 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -87,9 +87,9 @@
-
- - +
+ +
@@ -118,10 +118,10 @@
IMAGERY -
- - - +
+ + +
diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index 2aab58f10..db074dda8 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -1,4 +1,5 @@ import { STYLE_STATUS_LABELS } from './visualPresets.js'; +import { syncSegmentedControl } from './segmentedControl.js'; import { t } from '../i18n/index.js'; import { UiLifetime } from './uiLifetime.js'; import { @@ -557,11 +558,9 @@ export class VisualSettings { const normalized = normalizeAllocationStrategy(raw); this._detectionAllocationPreference = normalized; setDetectionTuning({ allocationStrategy: normalized }); - for (const button of this._detectionAllocationBtns) { - const active = button.dataset.allocation === normalized; - button.classList.toggle('active', active); - button.setAttribute('aria-checked', String(active)); - } + syncSegmentedControl(this._detectionAllocationBtns, normalized, { + valueOf: (button) => button.dataset.allocation, + }); if (persist) { try { localStorage.setItem(DETECTION_ALLOCATION_STORAGE_KEY, normalized); diff --git a/style.css b/style.css index 44b7e07bc..693cccb67 100644 --- a/style.css +++ b/style.css @@ -27,4 +27,5 @@ @import './src/ui/styles/weather.css'; @import './src/ui/styles/panel-surfaces.css'; +@import './src/ui/styles/segmented-control.css'; @import './src/ui/styles/cyber.css'; From 7df8b369e4cd05db0a1104482859751ecf3a9d4e Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Wed, 21 Oct 2026 13:08:35 +0200 Subject: [PATCH 5/6] Add changelog for shared segmented selectors --- changelog.d/1301-shared-segmented-selectors.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1301-shared-segmented-selectors.md diff --git a/changelog.d/1301-shared-segmented-selectors.md b/changelog.d/1301-shared-segmented-selectors.md new file mode 100644 index 000000000..96c1ee20d --- /dev/null +++ b/changelog.d/1301-shared-segmented-selectors.md @@ -0,0 +1,4 @@ +- Unify segmented selectors for model coverage, detection allocation, drawing + shape, Street View and imagery comparison while preserving their existing + behavior. Keep compact labels such as FILL and FLAT on one line, and let + collapsed Weather release space to the open rail panel (mazy1998, #1301). From 4bfee31fdb2ecdabed61fa73e291e6ff576e4ca7 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Wed, 21 Oct 2026 13:21:23 +0200 Subject: [PATCH 6/6] Reserve full height for imagery segmented controls --- .../1301-shared-segmented-selectors.md | 3 +- docs/CURRENT-STATE.md | 2 ++ scripts/qa-segmented-controls.mjs | 36 +++++++++++++++++++ src/ui/recentImagery.test.mjs | 6 ++-- src/ui/styles/recent-imagery.css | 4 +-- 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/changelog.d/1301-shared-segmented-selectors.md b/changelog.d/1301-shared-segmented-selectors.md index 96c1ee20d..379b8c716 100644 --- a/changelog.d/1301-shared-segmented-selectors.md +++ b/changelog.d/1301-shared-segmented-selectors.md @@ -1,4 +1,5 @@ - Unify segmented selectors for model coverage, detection allocation, drawing shape, Street View and imagery comparison while preserving their existing behavior. Keep compact labels such as FILL and FLAT on one line, and let - collapsed Weather release space to the open rail panel (mazy1998, #1301). + collapsed Weather release space to the open rail panel. Reserve the full mode + button height in Recent Imagery so keyboard focus is not clipped (mazy1998, #1301). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 419e3efb6..224c9a02a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1237,6 +1237,8 @@ Pure value navigation is separate from DOM rendering for reuse by a future view. Segments keep their word minimum width: compact inline groups such as FIT/FILL and ALL/360°/FLAT must not break short labels mid-word. Collapsed Weather follows the same exclusive-rail hiding rule as the other readouts. +The imagery mode row reserves 26px for the 24px buttons and group borders so +the complete button and inset keyboard-focus outline remain visible. Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4173 node scripts/qa-segmented-controls.mjs`. ## Application shortcuts and shader parameter controls diff --git a/scripts/qa-segmented-controls.mjs b/scripts/qa-segmented-controls.mjs index cfa8e51d6..8612c2f23 100644 --- a/scripts/qa-segmented-controls.mjs +++ b/scripts/qa-segmented-controls.mjs @@ -274,6 +274,42 @@ try { cyber.consistent, JSON.stringify(cyber), ); + await page.evaluate(async () => { + const { dataManager, styleManager } = window.__godsEyeView; + await dataManager.setEnabled('recent-imagery', true); + styleManager.setPanelCollapsed('recent-imagery-panel', false, { + persist: false, + syncShare: false, + }); + }); + for (const width of [1440, 620, 390]) { + await page.setViewport({ width, height: 900 }); + await page.focus('.ri-mode-btn[aria-checked="true"]'); + const modeLayout = await page.evaluate(() => { + const group = document.getElementById('ri-mode'); + const bounds = group.getBoundingClientRect(); + const style = getComputedStyle(group); + const top = bounds.top + parseFloat(style.borderTopWidth); + const bottom = bounds.bottom - parseFloat(style.borderBottomWidth); + const buttons = [...group.querySelectorAll('[role="radio"]')]; + return { + visible: bounds.width > 0 && bounds.height > 0, + contained: buttons.every((button) => { + const rect = button.getBoundingClientRect(); + return rect.top >= top - 0.5 && rect.bottom <= bottom + 0.5; + }), + groupHeight: bounds.height, + buttonHeights: buttons.map( + (button) => button.getBoundingClientRect().height, + ), + }; + }); + check( + `imagery mode buttons and focus outline fit their row at ${width}px`, + modeLayout.visible && modeLayout.contained, + JSON.stringify(modeLayout), + ); + } check('no uncaught browser errors', errors.length === 0, errors.join(' | ')); } catch (error) { console.error('Browser errors:', errors); diff --git a/src/ui/recentImagery.test.mjs b/src/ui/recentImagery.test.mjs index a77556797..fe9496688 100644 --- a/src/ui/recentImagery.test.mjs +++ b/src/ui/recentImagery.test.mjs @@ -412,7 +412,7 @@ test('the body is a fixed stack of seven blocks in a fixed order, each with a fi assert.match(rule('.recent-imagery-readout .ri-card'), /height: 112px/); assert.match( rule('.recent-imagery-readout .ri-selection'), - /grid-template-rows: 20px 20px 20px/, + /grid-template-rows: 26px 20px 20px/, ); assert.match(rule('.recent-imagery-readout .ri-slot'), /height: 20px/); assert.match(rule('.recent-imagery-readout .ri-controls'), /height: 22px/); @@ -445,7 +445,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h /** * Every block that must keep its height: the node carrying the class, that * class, and the rule it gets its height from. The mode row is the first - * 20 px track of the selection grid. The details header has no height of + * 26 px track of the selection grid. The details header has no height of * its own: it is the last block, so it moves nothing below it, and its * title is held to one line. */ @@ -491,7 +491,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h mode.parentNode.children[0] === mode ? mode.parentNode : null, 'ri-selection', readout('.ri-selection'), - /grid-template-rows: 20px 20px 20px/, + /grid-template-rows: 26px 20px 20px/, ], 'ri-slot-a': [ f.byId('ri-slot-a'), diff --git a/src/ui/styles/recent-imagery.css b/src/ui/styles/recent-imagery.css index 95c149a3c..66b351d26 100644 --- a/src/ui/styles/recent-imagery.css +++ b/src/ui/styles/recent-imagery.css @@ -309,10 +309,10 @@ html:not([lang='en']) display: none; } -/* 4. The selection bar: mode, then two rows that are always there. */ +/* 4. The selection bar: 24px mode buttons plus borders, then two stable rows. */ .recent-imagery-readout .ri-selection { display: grid; - grid-template-rows: 20px 20px 20px; + grid-template-rows: 26px 20px 20px; row-gap: 4px; padding: 5px; border: 1px solid var(--weather-card-border);