From 40394ff76e408c8eb0bd49adf3aed16904265272 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Wed, 21 Oct 2026 13:07:51 +0200 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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);