mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(controls): render every segmented group with UiSegmented
Street Level's fit and panorama filters and Recent Imagery's mode render through the kit's UiSegmented, and their owners answer the choice through the Street Level store (setPano, setRenderMode) and the readout's onSetMode. The imperative segmentedControl.js adapter and its test go. UiSegmented moves focus at once on a key, a radio group from the option holding focus and an arrows group from the selection, and asks for a change only when the key lands somewhere new.
This commit is contained in:
1 parent
42c3cc12b1
commit
e5fa31d01e
13 files changed
+119
-380
No files matched your search
+4
-1
@@ -55,7 +55,10 @@
|
|||||||
`initGeoFileImport` takes `connect(actions)` the same way and no longer
|
`initGeoFileImport` takes `connect(actions)` the same way and no longer
|
||||||
listens on the Import button, picker or list, nor restores focus itself:
|
listens on the Import button, picker or list, nor restores focus itself:
|
||||||
the `GeoImport` view does both, and its `publish` need not settle once the
|
the `GeoImport` view does both, and its `publish` need not settle once the
|
||||||
list renders. `syncScopeControls` (`./ui/display`) and
|
list renders. Street Level's fit and panorama filters and Recent Imagery's
|
||||||
|
mode are `UiSegmented` too: the Street Level store's owner answers
|
||||||
|
`setPano` and `setRenderMode`, and `RecentImageryReadout` takes
|
||||||
|
`onSetMode`. `syncScopeControls` (`./ui/display`) and
|
||||||
`shouldHideCollapsedRightPanels` (`./ui/layout`) are deprecated: the Display
|
`shouldHideCollapsedRightPanels` (`./ui/layout`) are deprecated: the Display
|
||||||
view and the rail now own what they did. `./ui/input` imports Vue
|
view and the rail now own what they did. `./ui/input` imports Vue
|
||||||
(samehkhamis).
|
(samehkhamis).
|
||||||
|
|||||||
@@ -1495,14 +1495,15 @@ Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4179 node scripts/qa-action-bu
|
|||||||
|
|
||||||
## Segmented selectors
|
## Segmented selectors
|
||||||
|
|
||||||
Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js`
|
Segmented selectors are the kit's `UiSegmented` (`ui/kit/UiSegmented.vue`) on
|
||||||
and the shared `gev-segmented` / `gev-segment` surface. Model coverage, label
|
the shared `gev-segmented` / `gev-segment` surface. Model coverage, label
|
||||||
allocation, drawing shape, Street View fit/panorama filters and imagery mode keep
|
allocation, drawing shape, Street View fit/panorama filters and imagery mode keep
|
||||||
their feature-owned values, actions and persistence. Painting a restored value
|
their feature-owned values, actions and persistence: the view renders the
|
||||||
does not emit a change or move focus. Existing keyboard policies remain explicit:
|
owner's value and sends a choice back through its store. Rendering a restored
|
||||||
Street View uses arrows/Home/End with one tab stop, imagery mode uses arrows from
|
value does not emit a change or move focus. Each group names its keyboard
|
||||||
its controlled selection, and Display keeps its existing native button behavior.
|
policy: Street View uses `radio` (arrows/Home/End with one tab stop), imagery
|
||||||
Pure value navigation is separate from DOM rendering for reuse by a future view.
|
mode `arrows` from its controlled selection, and Display `none`, its native
|
||||||
|
button behavior.
|
||||||
Segments keep their word minimum width: compact inline groups such as FIT/FILL
|
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
|
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 same exclusive-rail hiding rule as the other readouts.
|
||||||
|
|||||||
@@ -51,7 +51,11 @@ its own classes beside it. They contain no application state.
|
|||||||
owner hides the body by writing the root's `collapsed` class at once, and
|
owner hides the body by writing the root's `collapsed` class at once, and
|
||||||
there is no `closable`, since Core's panels collapse rather than close.
|
there is no `closable`, since Core's panels collapse rather than close.
|
||||||
`UiSegmented` adds `keyboard` (`none`, `arrows` as in ui-vue, or `radio` with
|
`UiSegmented` adds `keyboard` (`none`, `arrows` as in ui-vue, or `radio` with
|
||||||
Home and End), since each of Core's groups keeps its own keyboard policy.
|
Home and End), since each of Core's groups keeps its own keyboard policy, and
|
||||||
|
`itemClass`/`activeClass` for Core's segment classes. A key moves focus at
|
||||||
|
once and asks for a change only when it lands on another option. Every
|
||||||
|
segmented group in Core is a `UiSegmented`; the old `segmentedControl.js`
|
||||||
|
adapter is gone.
|
||||||
|
|
||||||
Controls share one skin, so every panel reads the same. Every scrolling
|
Controls share one skin, so every panel reads the same. Every scrolling
|
||||||
surface carries the kit's `.ui-scroll` (ui-vue's name), which draws the thin
|
surface carries the kit's `.ui-scroll` (ui-vue's name), which draws the thin
|
||||||
|
|||||||
@@ -199,7 +199,6 @@
|
|||||||
"scripts/qa-scope-hud.mjs",
|
"scripts/qa-scope-hud.mjs",
|
||||||
"scripts/qa-segmented-controls.mjs",
|
"scripts/qa-segmented-controls.mjs",
|
||||||
"src/ui/displayControls.js",
|
"src/ui/displayControls.js",
|
||||||
"src/ui/segmentedControl.test.mjs",
|
|
||||||
"src/ui/styles/segmented-control.css",
|
"src/ui/styles/segmented-control.css",
|
||||||
"src/ui/cameraOrientationControls.js",
|
"src/ui/cameraOrientationControls.js",
|
||||||
"src/ui/cameraOrientationControls.test.mjs",
|
"src/ui/cameraOrientationControls.test.mjs",
|
||||||
|
|||||||
@@ -929,7 +929,6 @@
|
|||||||
"src/ui/contextSubscriptions.js",
|
"src/ui/contextSubscriptions.js",
|
||||||
"src/ui/contextTransactions.js",
|
"src/ui/contextTransactions.js",
|
||||||
"src/ui/displayControls.js",
|
"src/ui/displayControls.js",
|
||||||
"src/ui/segmentedControl.js",
|
|
||||||
"src/ui/effects.js",
|
"src/ui/effects.js",
|
||||||
"src/ui/frameRateMonitor.js",
|
"src/ui/frameRateMonitor.js",
|
||||||
"src/ui/layerPanel.js",
|
"src/ui/layerPanel.js",
|
||||||
@@ -2653,7 +2652,6 @@
|
|||||||
"src/ui/contextSubscriptions.js",
|
"src/ui/contextSubscriptions.js",
|
||||||
"src/ui/contextTransactions.js",
|
"src/ui/contextTransactions.js",
|
||||||
"src/ui/displayControls.js",
|
"src/ui/displayControls.js",
|
||||||
"src/ui/segmentedControl.js",
|
|
||||||
"src/ui/effects.js",
|
"src/ui/effects.js",
|
||||||
"src/ui/frameRateMonitor.js",
|
"src/ui/frameRateMonitor.js",
|
||||||
"src/ui/layerPanel.js",
|
"src/ui/layerPanel.js",
|
||||||
@@ -3017,6 +3015,7 @@
|
|||||||
"src/ui/kit/UiTooltip.vue",
|
"src/ui/kit/UiTooltip.vue",
|
||||||
"src/ui/kit/rail.js",
|
"src/ui/kit/rail.js",
|
||||||
"src/ui/kit/UiRail.vue",
|
"src/ui/kit/UiRail.vue",
|
||||||
|
"src/ui/kit/UiSegmented.vue",
|
||||||
"src/ui/PanelRailView.vue",
|
"src/ui/PanelRailView.vue",
|
||||||
"src/ui/panelNavigation.js",
|
"src/ui/panelNavigation.js",
|
||||||
"src/ui/panelAccordion.js",
|
"src/ui/panelAccordion.js",
|
||||||
@@ -3820,7 +3819,6 @@
|
|||||||
"src/ui/visualSettings.js",
|
"src/ui/visualSettings.js",
|
||||||
"src/ui/stores/display.js",
|
"src/ui/stores/display.js",
|
||||||
"src/ui/vueRuntime.js",
|
"src/ui/vueRuntime.js",
|
||||||
"src/ui/segmentedControl.js",
|
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
|
|||||||
@@ -81,12 +81,22 @@ test('keyboard arrows: one roving stop, arrows skip disabled options and wrap',
|
|||||||
|
|
||||||
test('keyboard radio: Home and End reach the ends', async () => {
|
test('keyboard radio: Home and End reach the ends', async () => {
|
||||||
const wrapper = mountGroup({ keyboard: 'radio', modelValue: 'c' });
|
const wrapper = mountGroup({ keyboard: 'radio', modelValue: 'c' });
|
||||||
await wrapper.get('#seg-a').trigger('keydown', { key: 'Home' });
|
const [a, , c] = wrapper.findAll('button');
|
||||||
await wrapper.get('#seg-a').trigger('keydown', { key: 'End' });
|
await c.trigger('keydown', { key: 'Home' });
|
||||||
|
await wrapper.setProps({ modelValue: 'a' });
|
||||||
|
await a.trigger('keydown', { key: 'End' });
|
||||||
assert.deepEqual(wrapper.emitted('update:modelValue'), [['a'], ['c']]);
|
assert.deepEqual(wrapper.emitted('update:modelValue'), [['a'], ['c']]);
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a key that lands where it started moves focus without a change', async () => {
|
||||||
|
const wrapper = mountGroup({ keyboard: 'radio', modelValue: 'a' });
|
||||||
|
await wrapper.get('#seg-a').trigger('keydown', { key: 'Home' });
|
||||||
|
assert.equal(wrapper.emitted('update:modelValue'), undefined);
|
||||||
|
assert.ok(document.activeElement === wrapper.get('#seg-a').element);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
test('a disabled group answers nothing and leaves the tab order', async () => {
|
test('a disabled group answers nothing and leaves the tab order', async () => {
|
||||||
const wrapper = mountGroup({ disabled: true });
|
const wrapper = mountGroup({ disabled: true });
|
||||||
assert.deepEqual(tabStops(wrapper), ['-1', '-1', '-1']);
|
assert.deepEqual(tabStops(wrapper), ['-1', '-1', '-1']);
|
||||||
@@ -95,3 +105,15 @@ test('a disabled group answers nothing and leaves the tab order', async () => {
|
|||||||
assert.equal(wrapper.emitted('update:modelValue'), undefined);
|
assert.equal(wrapper.emitted('update:modelValue'), undefined);
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a radio group moves from the focused option, an arrows group from the selection', async () => {
|
||||||
|
// The selection was restored to c while a keeps focus.
|
||||||
|
const radio = mountGroup({ keyboard: 'radio', modelValue: 'c' });
|
||||||
|
await radio.get('#seg-a').trigger('keydown', { key: 'ArrowLeft' });
|
||||||
|
assert.deepEqual(radio.emitted('update:modelValue'), [['c']]);
|
||||||
|
radio.unmount();
|
||||||
|
const arrows = mountGroup({ keyboard: 'arrows', modelValue: 'c' });
|
||||||
|
await arrows.get('#seg-a').trigger('keydown', { key: 'ArrowLeft' });
|
||||||
|
assert.deepEqual(arrows.emitted('update:modelValue'), [['a']]);
|
||||||
|
arrows.unmount();
|
||||||
|
});
|
||||||
+20
-11
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
// Core's segmented control, with @halfpixel/ui-vue UiSegmented's
|
// Core's segmented control, with @halfpixel/ui-vue UiSegmented's
|
||||||
// v-model, options, label, stacked and disabled so the two kits can merge.
|
// v-model, options, label, stacked and disabled so the two kits can merge.
|
||||||
@@ -50,8 +50,12 @@ function choose(index) {
|
|||||||
if (!option || optionOff(option)) return;
|
if (!option || optionOff(option)) return;
|
||||||
emit('update:modelValue', option.value);
|
emit('update:modelValue', option.value);
|
||||||
}
|
}
|
||||||
/** Select the option `delta` away, wrapping past disabled ones, or `to` an end. */
|
/**
|
||||||
function move({ delta = 0, to = null }) {
|
* Select the option `delta` away from `from`, wrapping past disabled ones, or
|
||||||
|
* `to` an end. A key that lands where it started (Home on the first) only
|
||||||
|
* moves focus.
|
||||||
|
*/
|
||||||
|
function move({ delta = 0, to = null, from }) {
|
||||||
const count = props.options.length;
|
const count = props.options.length;
|
||||||
if (!count || props.disabled) return;
|
if (!count || props.disabled) return;
|
||||||
const enabled = props.options
|
const enabled = props.options
|
||||||
@@ -62,23 +66,28 @@ function move({ delta = 0, to = null }) {
|
|||||||
if (to === 'first') target = enabled[0];
|
if (to === 'first') target = enabled[0];
|
||||||
else if (to === 'last') target = enabled.at(-1);
|
else if (to === 'last') target = enabled.at(-1);
|
||||||
else {
|
else {
|
||||||
target =
|
target = from < 0 ? (delta > 0 ? -1 : count) : from;
|
||||||
selectedIndex.value < 0 ? (delta > 0 ? -1 : count) : selectedIndex.value;
|
|
||||||
do target = (((target + delta) % count) + count) % count;
|
do target = (((target + delta) % count) + count) % count;
|
||||||
while (props.options[target].disabled);
|
while (props.options[target].disabled);
|
||||||
}
|
}
|
||||||
choose(target);
|
if (target !== from) choose(target);
|
||||||
// Focus follows the selection once the options re-render.
|
// Focus moves at once, as a radio group's does; the option is already there.
|
||||||
nextTick(() => items[target]?.focus());
|
items[target]?.focus();
|
||||||
}
|
}
|
||||||
const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
|
const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
|
||||||
function onKey(event) {
|
function onKey(event) {
|
||||||
if (props.keyboard === 'none') return;
|
if (props.keyboard === 'none') return;
|
||||||
if (STEPS[event.key]) move({ delta: STEPS[event.key] });
|
// A radio group moves from the option holding focus, `arrows` from the
|
||||||
|
// selection.
|
||||||
|
const from =
|
||||||
|
props.keyboard === 'radio'
|
||||||
|
? items.indexOf(event.target)
|
||||||
|
: selectedIndex.value;
|
||||||
|
if (STEPS[event.key]) move({ delta: STEPS[event.key], from });
|
||||||
else if (props.keyboard === 'radio' && event.key === 'Home')
|
else if (props.keyboard === 'radio' && event.key === 'Home')
|
||||||
move({ to: 'first' });
|
move({ to: 'first', from });
|
||||||
else if (props.keyboard === 'radio' && event.key === 'End')
|
else if (props.keyboard === 'radio' && event.key === 'End')
|
||||||
move({ to: 'last' });
|
move({ to: 'last', from });
|
||||||
else return;
|
else return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-12
@@ -11,7 +11,6 @@
|
|||||||
import { h, render as renderVue } from 'vue';
|
import { h, render as renderVue } from 'vue';
|
||||||
import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
|
import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
|
||||||
import { createImagerySplit } from './imagerySplit.js';
|
import { createImagerySplit } from './imagerySplit.js';
|
||||||
import { createSegmentedControl } from './segmentedControl.js';
|
|
||||||
import RecentImageryReadout from './recentImagery/RecentImageryReadout.vue';
|
import RecentImageryReadout from './recentImagery/RecentImageryReadout.vue';
|
||||||
import {
|
import {
|
||||||
PRODUCTS,
|
PRODUCTS,
|
||||||
@@ -236,6 +235,7 @@ export function createRecentImageryPanel({
|
|||||||
view: buildView(),
|
view: buildView(),
|
||||||
modes: MODE_LABELS,
|
modes: MODE_LABELS,
|
||||||
onOpenDetails,
|
onOpenDetails,
|
||||||
|
onSetMode: (mode) => layer.setMode(mode),
|
||||||
}),
|
}),
|
||||||
container,
|
container,
|
||||||
);
|
);
|
||||||
@@ -245,16 +245,6 @@ export function createRecentImageryPanel({
|
|||||||
const zoomIn = root.querySelector('#ri-zoom-in');
|
const zoomIn = root.querySelector('#ri-zoom-in');
|
||||||
const swap = root.querySelector('#ri-swap');
|
const swap = root.querySelector('#ri-swap');
|
||||||
const opacity = root.querySelector('#ri-opacity');
|
const opacity = root.querySelector('#ri-opacity');
|
||||||
const modeGroup = root.querySelector('#ri-mode');
|
|
||||||
const modeControl = createSegmentedControl({
|
|
||||||
buttons: modeGroup.querySelectorAll('.ri-mode-btn'),
|
|
||||||
valueOf: (button) => button.dataset.mode,
|
|
||||||
onChange: (mode) => layer.setMode(mode),
|
|
||||||
keyboard: 'arrows',
|
|
||||||
keyTarget: modeGroup,
|
|
||||||
readValue: () => snapshot.mode,
|
|
||||||
listen,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The body is the scroll container and no render may move it: a
|
* The body is the scroll container and no render may move it: a
|
||||||
@@ -758,7 +748,6 @@ export function createRecentImageryPanel({
|
|||||||
preserveScroll(() => {
|
preserveScroll(() => {
|
||||||
renderPanel();
|
renderPanel();
|
||||||
paint();
|
paint();
|
||||||
modeControl.update(snapshot.mode, { rovingFocus: true });
|
|
||||||
renderSplit();
|
renderSplit();
|
||||||
reportVisibleRange();
|
reportVisibleRange();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
|
import UiSegmented from '../kit/UiSegmented.vue';
|
||||||
import RailCardBlocks from '../rail/RailCardBlocks.vue';
|
import RailCardBlocks from '../rail/RailCardBlocks.vue';
|
||||||
import RailCards from '../rail/RailCards.vue';
|
import RailCards from '../rail/RailCards.vue';
|
||||||
import { vRangeValue } from '../rail/rangeValue.js';
|
import { vRangeValue } from '../rail/rangeValue.js';
|
||||||
import RecentImageryCard from './RecentImageryCard.vue';
|
import RecentImageryCard from './RecentImageryCard.vue';
|
||||||
|
|
||||||
// Every block exists from the start, in its final order. The owner computes
|
// Every block exists from the start, in its final order. The owner computes
|
||||||
// the view and listens on the root; the mode buttons are painted by its
|
// the view and listens on the root; a mode choice calls `onSetMode`.
|
||||||
// segmented control.
|
const props = defineProps({
|
||||||
defineProps({
|
|
||||||
view: { type: Object, required: true },
|
view: { type: Object, required: true },
|
||||||
modes: { type: Array, required: true },
|
modes: { type: Array, required: true },
|
||||||
onOpenDetails: { type: Function, default: () => {} },
|
onOpenDetails: { type: Function, default: () => {} },
|
||||||
|
onSetMode: { type: Function, default: () => {} },
|
||||||
});
|
});
|
||||||
|
const modeOptions = computed(() =>
|
||||||
|
props.modes.map(([mode, label]) => ({
|
||||||
|
value: mode,
|
||||||
|
label: t(label),
|
||||||
|
attrs: { 'data-mode': mode },
|
||||||
|
})),
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -81,21 +90,16 @@ defineProps({
|
|||||||
class="ri-block ri-selection"
|
class="ri-block ri-selection"
|
||||||
:aria-label="t('On the map')"
|
:aria-label="t('On the map')"
|
||||||
:data-mode="view.mode"
|
:data-mode="view.mode"
|
||||||
><div
|
><UiSegmented
|
||||||
id="ri-mode"
|
id="ri-mode"
|
||||||
class="gev-segmented ri-mode"
|
class="ri-mode"
|
||||||
role="radiogroup"
|
item-class="ri-mode-btn"
|
||||||
:aria-label="t('Mode')"
|
keyboard="arrows"
|
||||||
><button
|
:label="t('Mode')"
|
||||||
v-for="[mode, label] in modes"
|
:options="modeOptions"
|
||||||
:key="mode"
|
:model-value="view.mode"
|
||||||
class="gev-segment ri-mode-btn"
|
@update:model-value="onSetMode"
|
||||||
type="button"
|
/><div
|
||||||
:data-mode="mode"
|
|
||||||
role="radio"
|
|
||||||
>{{ t(label) }}</button
|
|
||||||
></div
|
|
||||||
><div
|
|
||||||
v-for="slot in view.slots"
|
v-for="slot in view.slots"
|
||||||
:id="`ri-slot-${slot.id}`"
|
:id="`ri-slot-${slot.id}`"
|
||||||
:key="slot.id"
|
:key="slot.id"
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
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<HTMLButtonElement>} 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();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
|
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
|
||||||
import { createSegmentedControl } from './segmentedControl.js';
|
|
||||||
import {
|
import {
|
||||||
presentStreetLevelPanel,
|
presentStreetLevelPanel,
|
||||||
SINCE_STOPS,
|
SINCE_STOPS,
|
||||||
@@ -33,6 +32,15 @@ export class StreetLevelControls {
|
|||||||
setSinceStop: (stop) =>
|
setSinceStop: (stop) =>
|
||||||
this._setParams({ sinceDays: SINCE_STOPS[stop]?.days ?? 0 }),
|
this._setParams({ sinceDays: SINCE_STOPS[stop]?.days ?? 0 }),
|
||||||
describeSinceStop: (stop) => t(SINCE_STOPS[stop]?.label ?? ''),
|
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.destroyed = false;
|
||||||
this.listeners = new AbortController();
|
this.listeners = new AbortController();
|
||||||
@@ -51,9 +59,6 @@ export class StreetLevelControls {
|
|||||||
|
|
||||||
_collect() {
|
_collect() {
|
||||||
const byId = (id) => this.root?.querySelector(`#${id}`) || null;
|
const byId = (id) => this.root?.querySelector(`#${id}`) || null;
|
||||||
const all = (selector) => [
|
|
||||||
...(this.root?.querySelectorAll(selector) || []),
|
|
||||||
];
|
|
||||||
return {
|
return {
|
||||||
status: byId('sl-status'),
|
status: byId('sl-status'),
|
||||||
providerChips: byId('sl-provider-chips'),
|
providerChips: byId('sl-provider-chips'),
|
||||||
@@ -62,8 +67,6 @@ export class StreetLevelControls {
|
|||||||
viewerExpand: byId('sl-viewer-expand'),
|
viewerExpand: byId('sl-viewer-expand'),
|
||||||
viewerClose: byId('sl-viewer-close'),
|
viewerClose: byId('sl-viewer-close'),
|
||||||
viewer: byId('sl-viewer'),
|
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);
|
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', () => {
|
this.listen(el.nearestBtn, 'click', () => {
|
||||||
const nearest = this._view?.nearest;
|
const nearest = this._view?.nearest;
|
||||||
if (!nearest?.visible || nearest.disabled) return;
|
if (!nearest?.visible || nearest.disabled) return;
|
||||||
@@ -137,20 +133,6 @@ export class StreetLevelControls {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.store.setExpandAvailable(Boolean(this._expandMode));
|
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 {
|
try {
|
||||||
const stored = localStorage.getItem(RENDER_MODE_KEY);
|
const stored = localStorage.getItem(RENDER_MODE_KEY);
|
||||||
if (stored === 'fill' || stored === 'letterbox')
|
if (stored === 'fill' || stored === 'letterbox')
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { storeToRefs } from 'pinia';
|
|||||||
import { computed, shallowRef, watch } from 'vue';
|
import { computed, shallowRef, watch } from 'vue';
|
||||||
import { t } from '../../../i18n/index.js';
|
import { t } from '../../../i18n/index.js';
|
||||||
import UiButton from '../../kit/UiButton.vue';
|
import UiButton from '../../kit/UiButton.vue';
|
||||||
|
import UiSegmented from '../../kit/UiSegmented.vue';
|
||||||
import RailPanel from '../../rail/RailPanel.vue';
|
import RailPanel from '../../rail/RailPanel.vue';
|
||||||
import RailChips from '../../rail/RailChips.vue';
|
import RailChips from '../../rail/RailChips.vue';
|
||||||
import { useStreetLevelStore } from '../../stores/streetLevel.js';
|
import { useStreetLevelStore } from '../../stores/streetLevel.js';
|
||||||
@@ -36,12 +37,29 @@ const shows = computed(
|
|||||||
);
|
);
|
||||||
const pano = computed(() => view.value?.filter.pano ?? 'all');
|
const pano = computed(() => view.value?.filter.pano ?? 'all');
|
||||||
|
|
||||||
/** A radio segment; the chosen one is the group's tab stop once rendered. */
|
const renderModeOptions = [
|
||||||
const segment = (value, option) => ({
|
{
|
||||||
class: `gev-segment sl-seg-btn${value === option ? ' is-active' : ''}`,
|
value: 'letterbox',
|
||||||
'aria-checked': String(value === option),
|
label: t('FIT'),
|
||||||
tabindex: value === option ? (view.value ? '0' : undefined) : '-1',
|
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.
|
// The image line and OPEN keep what they last showed while hidden.
|
||||||
const image = shallowRef({
|
const image = shallowRef({
|
||||||
@@ -109,10 +127,7 @@ const vThumb = { mounted: placeThumb, updated: placeThumb };
|
|||||||
<div class="sl-viewer-bar">
|
<div class="sl-viewer-bar">
|
||||||
<div class="sl-viewer-tools">
|
<div class="sl-viewer-tools">
|
||||||
<button id="sl-viewer-expand" class="scene-btn" type="button" :hidden="!expandAvailable" :aria-pressed="String(Boolean(expanded))" :aria-label="expanded === null ? undefined : expanded ? t('Shrink') : t('Expand')" title="Show the street-level view full screen (Esc shrinks it)" data-i18n-title><span class="sl-btn-icon" aria-hidden="true">{{ expanded ? '⤡' : '⤢' }}</span><span class="sl-btn-text">{{ expanded ? t('SHRINK') : t('EXPAND') }}</span></button>
|
<button id="sl-viewer-expand" class="scene-btn" type="button" :hidden="!expandAvailable" :aria-pressed="String(Boolean(expanded))" :aria-label="expanded === null ? undefined : expanded ? t('Shrink') : t('Expand')" title="Show the street-level view full screen (Esc shrinks it)" data-i18n-title><span class="sl-btn-icon" aria-hidden="true">{{ expanded ? '⤡' : '⤢' }}</span><span class="sl-btn-text">{{ expanded ? t('SHRINK') : t('EXPAND') }}</span></button>
|
||||||
<div class="gev-segmented sl-seg" role="radiogroup" aria-label="Image fit" data-i18n-aria-label :hidden="!viewer.renderModes">
|
<UiSegmented class="sl-seg" item-class="sl-seg-btn" active-class="is-active" keyboard="radio" :hidden="!viewer.renderModes" :label="t('Image fit')" :options="renderModeOptions" :model-value="viewer.renderMode" @update:model-value="store.act('setRenderMode', $event)" />
|
||||||
<button type="button" v-bind="segment(viewer.renderMode, 'letterbox')" data-sl-render="letterbox" role="radio" title="Fit: show the whole photo, with dark bars where its shape differs from the frame" data-i18n-title data-i18n>FIT</button>
|
|
||||||
<button type="button" v-bind="segment(viewer.renderMode, 'fill')" data-sl-render="fill" role="radio" title="Fill: fill the frame edge to edge, cropping the photo" data-i18n-title data-i18n>FILL</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="sl-viewer-tools sl-viewer-tools-right">
|
<div class="sl-viewer-tools sl-viewer-tools-right">
|
||||||
<button id="sl-viewer-close" class="sl-viewer-x" type="button" title="Close the image and deselect it on the map" aria-label="Close the street-level image" data-i18n-title data-i18n-aria-label>×</button>
|
<button id="sl-viewer-close" class="sl-viewer-x" type="button" title="Close the image and deselect it on the map" aria-label="Close the street-level image" data-i18n-title data-i18n-aria-label>×</button>
|
||||||
@@ -140,11 +155,7 @@ const vThumb = { mounted: placeThumb, updated: placeThumb };
|
|||||||
<fieldset id="sl-controls" class="sl-controls" :disabled="Boolean(view?.controlsDisabled)" :hidden="!shows.pano && !shows.since">
|
<fieldset id="sl-controls" class="sl-controls" :disabled="Boolean(view?.controlsDisabled)" :hidden="!shows.pano && !shows.since">
|
||||||
<div id="sl-pano-row" class="sl-filters" :hidden="!shows.pano" role="group" aria-label="Imagery filters" data-i18n-aria-label>
|
<div id="sl-pano-row" class="sl-filters" :hidden="!shows.pano" role="group" aria-label="Imagery filters" data-i18n-aria-label>
|
||||||
<span class="cctv-summary-label" data-i18n>IMAGERY</span>
|
<span class="cctv-summary-label" data-i18n>IMAGERY</span>
|
||||||
<div class="gev-segmented sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
|
<UiSegmented class="sl-seg" item-class="sl-seg-btn" active-class="is-active" keyboard="radio" :disabled="Boolean(view?.controlsDisabled)" :label="t('Panorama filter')" :options="panoOptions" :model-value="pano" @update:model-value="store.act('setPano', $event)" />
|
||||||
<button type="button" v-bind="segment(pano, 'all')" data-sl-pano="all" role="radio" data-i18n>ALL</button>
|
|
||||||
<button type="button" v-bind="segment(pano, 'pano')" data-sl-pano="pano" role="radio">360°</button>
|
|
||||||
<button type="button" v-bind="segment(pano, 'flat')" data-sl-pano="flat" role="radio" data-i18n>FLAT</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="sl-since-row" class="sl-since" :hidden="!shows.since">
|
<div id="sl-since-row" class="sl-since" :hidden="!shows.since">
|
||||||
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
|
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
|
||||||
|
|||||||
Reference in new issue
Block a user