{
- 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
deleted file mode 100644
index ad194ddd8..000000000
--- a/src/ui/segmentedControl.test.mjs
+++ /dev/null
@@ -1,174 +0,0 @@
-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 365b543ff..4c0efeb5d 100644
--- a/src/ui/streetLevelControls.js
+++ b/src/ui/streetLevelControls.js
@@ -1,5 +1,4 @@
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
-import { createSegmentedControl } from './segmentedControl.js';
import {
presentStreetLevelPanel,
SINCE_STOPS,
@@ -33,6 +32,15 @@ export class StreetLevelControls {
setSinceStop: (stop) =>
this._setParams({ sinceDays: SINCE_STOPS[stop]?.days ?? 0 }),
describeSinceStop: (stop) => t(SINCE_STOPS[stop]?.label ?? ''),
+ setPano: (pano) => this._setParams({ pano }),
+ setRenderMode: (mode) => {
+ this.layer.setViewerRenderMode?.(mode);
+ try {
+ localStorage.setItem(RENDER_MODE_KEY, mode);
+ } catch {
+ /* storage unavailable */
+ }
+ },
});
this.destroyed = false;
this.listeners = new AbortController();
@@ -51,9 +59,6 @@ export class StreetLevelControls {
_collect() {
const byId = (id) => this.root?.querySelector(`#${id}`) || null;
- const all = (selector) => [
- ...(this.root?.querySelectorAll(selector) || []),
- ];
return {
status: byId('sl-status'),
providerChips: byId('sl-provider-chips'),
@@ -62,8 +67,6 @@ export class StreetLevelControls {
viewerExpand: byId('sl-viewer-expand'),
viewerClose: byId('sl-viewer-close'),
viewer: byId('sl-viewer'),
- panoButtons: all('[data-sl-pano]'),
- renderButtons: all('[data-sl-render]'),
};
}
@@ -93,13 +96,6 @@ export class StreetLevelControls {
}
this._toggleProvider(button.dataset.chipId);
});
- 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),
- });
this.listen(el.nearestBtn, 'click', () => {
const nearest = this._view?.nearest;
if (!nearest?.visible || nearest.disabled) return;
@@ -137,20 +133,6 @@ export class StreetLevelControls {
});
}
this.store.setExpandAvailable(Boolean(this._expandMode));
- 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')
diff --git a/src/ui/views/panels/StreetLevelPanel.vue b/src/ui/views/panels/StreetLevelPanel.vue
index c3a9bd554..5259d3815 100644
--- a/src/ui/views/panels/StreetLevelPanel.vue
+++ b/src/ui/views/panels/StreetLevelPanel.vue
@@ -3,6 +3,7 @@ import { storeToRefs } from 'pinia';
import { computed, shallowRef, watch } from 'vue';
import { t } from '../../../i18n/index.js';
import UiButton from '../../kit/UiButton.vue';
+import UiSegmented from '../../kit/UiSegmented.vue';
import RailPanel from '../../rail/RailPanel.vue';
import RailChips from '../../rail/RailChips.vue';
import { useStreetLevelStore } from '../../stores/streetLevel.js';
@@ -36,12 +37,29 @@ const shows = computed(
);
const pano = computed(() => view.value?.filter.pano ?? 'all');
-/** A radio segment; the chosen one is the group's tab stop once rendered. */
-const segment = (value, option) => ({
- class: `gev-segment sl-seg-btn${value === option ? ' is-active' : ''}`,
- 'aria-checked': String(value === option),
- tabindex: value === option ? (view.value ? '0' : undefined) : '-1',
-});
+const renderModeOptions = [
+ {
+ value: 'letterbox',
+ label: t('FIT'),
+ attrs: {
+ 'data-sl-render': 'letterbox',
+ title: t('Fit: show the whole photo, with dark bars where its shape differs from the frame'),
+ },
+ },
+ {
+ value: 'fill',
+ label: t('FILL'),
+ attrs: {
+ 'data-sl-render': 'fill',
+ title: t('Fill: fill the frame edge to edge, cropping the photo'),
+ },
+ },
+];
+const panoOptions = [
+ { value: 'all', label: t('ALL'), attrs: { 'data-sl-pano': 'all' } },
+ { value: 'pano', label: '360°', attrs: { 'data-sl-pano': 'pano' } },
+ { value: 'flat', label: t('FLAT'), attrs: { 'data-sl-pano': 'flat' } },
+];
// The image line and OPEN keep what they last showed while hidden.
const image = shallowRef({
@@ -109,10 +127,7 @@ const vThumb = { mounted: placeThumb, updated: placeThumb };