mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
refactor(street-level): the panel cuts deferred until the new sources landed
HAL-530 left five panel cuts until Street View, KartaView and Panoramax were in: - SINCE is a select of the nine windows instead of a slider with its own thumb-sync code; a link's day count between them gets its own option and the tooltip names the cut-off date. - IMAGERY and FIT/FILL are toggle buttons (aria-pressed), each a tab stop, instead of ARIA radiogroups with roving tabindex and arrow-key handling. - The panel opens by itself only when a photo opens (a map or line click would load it out of sight); a switch-on no longer opens it, so the enable-origin subscription goes. - Clean View, recording and the cockpit call shrinkViewer() as they hide the panels, instead of a MutationObserver on <body> classes. - FIT/FILL stays (three providers use it) but is no longer kept in localStorage: each session starts at FIT. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d983ef23d8
commit
bdf3848dbc
12 files changed
+203
-238
No files matched your search
@@ -421,7 +421,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
||||
|
||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
||||
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under SINCE; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||
|
||||
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
|
||||
|
||||
|
||||
@@ -3484,7 +3484,12 @@ Mapillary token, Street View on Google 3D with a Google key), the layer
|
||||
reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage
|
||||
line and the layer row's tooltip say to light it. The
|
||||
viewer sits under the header with EXPAND, FIT/FILL and close above the
|
||||
image. SINCE is a stepped slider whose readout names the cut-off date.
|
||||
image; FIT/FILL starts at FIT each session. IMAGERY and FIT/FILL are rows of
|
||||
toggle buttons (`aria-pressed`), each its own tab stop. SINCE is a select of
|
||||
the nine windows; a link's day count between them gets its own option, and
|
||||
the tooltip names the cut-off date. The panel opens by itself only when a
|
||||
photo opens (a click on the map or a line would otherwise load it out of
|
||||
sight), never when the layer is switched on.
|
||||
|
||||
MapillaryJS loads on the first photo. Opening one frames it with a single
|
||||
camera flight through the application's deferred navigation (`begin` /
|
||||
@@ -3493,7 +3498,8 @@ if newer navigation took the camera while the photo loaded; closing the photo
|
||||
cancels it. EXPAND is the browser's Fullscreen API on the viewer element; on
|
||||
iPhone, which has no element fullscreen, the viewer opens as a top-layer
|
||||
popover instead (hidden where neither API exists). Clean View, recording and
|
||||
the cockpit take an expanded photo out of full screen. At phone width the
|
||||
the cockpit take an expanded photo out of full screen: the shell calls the
|
||||
panel's `shrinkViewer()` as it enters them. At phone width the
|
||||
viewer height comes from the rail band so the whole photo fits.
|
||||
|
||||
Providers implement the contract in `src/layers/streetLevel/registry.js`. The
|
||||
@@ -3586,7 +3592,7 @@ lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it ignores
|
||||
photos are near but none passes, the panel says so), and the cones pass on
|
||||
each photo's projection and upload date. The provider declares
|
||||
`sinceBasis: 'upload'`, so while it is lit the panel shows "KartaView: by
|
||||
upload date" under the SINCE slider (`sinceNote` in the UI state). Tiles come uncompressed and
|
||||
upload date" under the SINCE select (`sinceNote` in the UI state). Tiles come uncompressed and
|
||||
uncacheable, so the last 24 decoded tiles are kept for 10 minutes while
|
||||
KartaView is lit (`createTileCache`, dropped when it is switched off); one
|
||||
that does not answer in 20 s is asked once more, and one that is not a line
|
||||
|
||||
@@ -693,7 +693,10 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
|
||||
}
|
||||
|
||||
assert.match(ui, /onEntered: \(\) => this\.enterPanels\(\)/);
|
||||
assert.match(ui, /enterPanels: \(\) => this\._panelChrome\.enterCockpit\(\)/);
|
||||
assert.match(
|
||||
ui,
|
||||
/enterPanels: \(\) => \{[^}]*this\._panelChrome\.enterCockpit\(\);\s*\}/,
|
||||
);
|
||||
const callback = { 1: shellMethod('enterCockpit').toString() };
|
||||
assert.ok(callback, 'Cockpit onEntered callback is missing');
|
||||
assert.match(
|
||||
|
||||
@@ -70,24 +70,26 @@ test('the key gate holds every filter, with the error line outside it', () => {
|
||||
assert.match(html, /id="sl-error"[^>]*role="alert"/);
|
||||
});
|
||||
|
||||
test('each radiogroup is one tab stop, on its checked button', () => {
|
||||
for (const [group, checked] of [
|
||||
test('the segmented filters are toggle buttons, each its own tab stop', () => {
|
||||
for (const [group, chosen] of [
|
||||
['data-sl-render', 'letterbox'],
|
||||
['data-sl-pano', 'all'],
|
||||
]) {
|
||||
const radios = [
|
||||
const buttons = [
|
||||
...html.matchAll(
|
||||
new RegExp(`<button [^>]*${group}="([a-z]+)"[^>]*>`, 'g'),
|
||||
),
|
||||
];
|
||||
assert.ok(radios.length >= 2, group);
|
||||
for (const [tag, value] of radios)
|
||||
assert.equal(
|
||||
/tabindex="-1"/.test(tag),
|
||||
value !== checked,
|
||||
`${group}="${value}" roving tab stop`,
|
||||
);
|
||||
assert.ok(buttons.length >= 2, group);
|
||||
for (const [tag, value] of buttons) {
|
||||
assert.match(tag, new RegExp(`aria-pressed="${value === chosen}"`));
|
||||
assert.doesNotMatch(tag, /role="radio"|tabindex=/, `${group}="${value}"`);
|
||||
}
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/role="radiogroup"[^>]*(Image fit|Panorama filter)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the Cyber theme frames the Street Level panel like its rail peers (P3)', () => {
|
||||
|
||||
@@ -403,7 +403,11 @@ export class StyleManager extends ShellFacade {
|
||||
readDataManager: () => this._dataManager,
|
||||
readContext: () => this.getContextModeState(),
|
||||
readActiveStyle: () => this.activeStyle,
|
||||
enterPanels: () => this._panelChrome.enterCockpit(),
|
||||
enterPanels: () => {
|
||||
// The cockpit hides the panels: an expanded street-level photo too.
|
||||
this._streetLevelControls?.shrinkViewer();
|
||||
this._panelChrome.enterCockpit();
|
||||
},
|
||||
exitPanels: () => this._panelChrome.exitCockpit(),
|
||||
});
|
||||
|
||||
@@ -1109,12 +1113,6 @@ export class StyleManager extends ShellFacade {
|
||||
setPanelCollapsed: (collapsed, options) =>
|
||||
this.setPanelCollapsed('street-level-panel', collapsed, options),
|
||||
showToast: (message) => this._showToast(message),
|
||||
// Lets the panel open for a user, voice or tool switch-on, not a restore.
|
||||
subscribeEnableRequests: (listener) =>
|
||||
this._dataManager?.subscribeVisibilityRequests?.((change) => {
|
||||
if (change?.layerId === 'street-level' && change.enabled)
|
||||
listener(change.origin);
|
||||
}) || null,
|
||||
},
|
||||
});
|
||||
this._streetLevelControls.connect();
|
||||
@@ -1250,6 +1248,8 @@ export class StyleManager extends ShellFacade {
|
||||
? forceEnabled
|
||||
: !document.body.classList.contains('ui-clean-view');
|
||||
document.body.classList.toggle('ui-clean-view', shouldEnable);
|
||||
// Clean View hides the panels: an expanded street-level photo too.
|
||||
if (shouldEnable) this._streetLevelControls?.shrinkViewer();
|
||||
if (this._cleanViewBtn) {
|
||||
this._cleanViewBtn.classList.toggle('active', shouldEnable);
|
||||
}
|
||||
|
||||
@@ -1182,6 +1182,9 @@ export class ShellFacade {
|
||||
* @returns {void}
|
||||
*/
|
||||
setRecordingMode(enabled, options) {
|
||||
// Recording hides the panels: an expanded street-level photo goes too.
|
||||
if (enabled && options?.hidePanels !== false)
|
||||
this._streetLevelControls?.shrinkViewer();
|
||||
return this._recording.setRecordingMode(enabled, options);
|
||||
}
|
||||
|
||||
|
||||
+48
-124
@@ -1,4 +1,3 @@
|
||||
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
|
||||
import { syncChipGroup } from './chipGroup.js';
|
||||
import {
|
||||
presentStreetLevelPanel,
|
||||
@@ -6,16 +5,6 @@ import {
|
||||
} from './streetLevelPresentation.js';
|
||||
import { t, translateKnown } from '../i18n/index.js';
|
||||
|
||||
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
|
||||
/** <body> 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.
|
||||
const setProp = (node, key, value) => {
|
||||
@@ -39,11 +28,9 @@ export class StreetLevelControls {
|
||||
this._unsubscribe = null;
|
||||
this._state = null;
|
||||
this._view = null;
|
||||
this._wasEnabled = null;
|
||||
this._wasOpen = false;
|
||||
// Whether the last switch-on was explicit (user, voice, tool), not a restore.
|
||||
this._explicitEnable = false;
|
||||
this._unsubscribeEnableRequests = null;
|
||||
/** The SINCE option for a day count between the stops, while one is set. */
|
||||
this._customSince = null;
|
||||
this._resizeQueued = false;
|
||||
this._elements = this._collect();
|
||||
this._bind();
|
||||
@@ -63,8 +50,7 @@ export class StreetLevelControls {
|
||||
errorText: byId('sl-error-text'),
|
||||
panoRow: byId('sl-pano-row'),
|
||||
sinceRow: byId('sl-since-row'),
|
||||
sinceRange: byId('sl-since'),
|
||||
sinceLabel: byId('sl-since-label'),
|
||||
since: byId('sl-since'),
|
||||
sinceNote: byId('sl-since-note'),
|
||||
viewerWrap: byId('sl-viewer-wrap'),
|
||||
viewerExpand: byId('sl-viewer-expand'),
|
||||
@@ -111,18 +97,15 @@ export class StreetLevelControls {
|
||||
this._setParams({ pano: button.dataset.slPano }),
|
||||
);
|
||||
}
|
||||
this._bindRadioGroup(el.panoButtons);
|
||||
this._bindRadioGroup(el.renderButtons);
|
||||
// The readout follows the thumb; coverage is rebuilt once, on release.
|
||||
const sinceDays = () =>
|
||||
SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0;
|
||||
this.listen(el.sinceRange, 'input', () => {
|
||||
const label = t(SINCE_STOPS[Number(el.sinceRange.value) || 0].label);
|
||||
if (el.sinceLabel) el.sinceLabel.textContent = label;
|
||||
el.sinceRange.setAttribute('aria-valuetext', label);
|
||||
});
|
||||
this.listen(el.sinceRange, 'change', () =>
|
||||
this._setParams({ sinceDays: sinceDays() }),
|
||||
// The stops, in the UI language (a language change reloads the page).
|
||||
for (const stop of SINCE_STOPS) {
|
||||
const option = document.createElement('option');
|
||||
option.value = String(stop.days);
|
||||
option.textContent = t(stop.label);
|
||||
el.since?.appendChild(option);
|
||||
}
|
||||
this.listen(el.since, 'change', () =>
|
||||
this._setParams({ sinceDays: Number(el.since.value) || 0 }),
|
||||
);
|
||||
this.listen(el.nearestBtn, 'click', () => {
|
||||
const nearest = this._view?.nearest;
|
||||
@@ -160,24 +143,10 @@ 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;
|
||||
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')
|
||||
this.layer.setViewerRenderMode?.(stored);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
for (const button of el.renderButtons)
|
||||
this.listen(button, 'click', () =>
|
||||
this.layer.setViewerRenderMode?.(button.dataset.slRender),
|
||||
);
|
||||
// MapillaryJS only tracks window resizes; the panel resizes on its own.
|
||||
if (typeof ResizeObserver === 'function' && el.viewer) {
|
||||
this._resizeObserver = new ResizeObserver(() => this._requestResize());
|
||||
@@ -185,24 +154,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.
|
||||
@@ -315,32 +266,18 @@ export class StreetLevelControls {
|
||||
this._syncExpanded();
|
||||
}
|
||||
|
||||
/** Clean View, recording and the cockpit hide every panel, so they end EXPAND too. */
|
||||
_watchPanelHidingModes(on) {
|
||||
const body = document.body;
|
||||
if (!on || !body) {
|
||||
this._modesObserver?.disconnect();
|
||||
this._modesObserver = null;
|
||||
return;
|
||||
}
|
||||
const leaveIfHiding = () => {
|
||||
if (PANEL_HIDING_MODES.some((mode) => body.classList.contains(mode)))
|
||||
this._exitExpanded();
|
||||
};
|
||||
if (!this._modesObserver && typeof MutationObserver === 'function') {
|
||||
this._modesObserver = new MutationObserver(leaveIfHiding);
|
||||
this._modesObserver.observe(body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
}
|
||||
leaveIfHiding();
|
||||
/**
|
||||
* Shrink an expanded photo. Clean View, recording and the cockpit hide
|
||||
* every panel and call it, so the photo does not stay full screen (or, on
|
||||
* iPhone, as a popover) over them.
|
||||
*/
|
||||
shrinkViewer() {
|
||||
this._exitExpanded();
|
||||
}
|
||||
|
||||
/** Mirror the expanded state on EXPAND, watch the panel-hiding modes, refit. */
|
||||
/** Mirror the expanded state on EXPAND and refit. */
|
||||
_syncExpanded() {
|
||||
const on = this.isViewerExpanded();
|
||||
this._watchPanelHidingModes(on);
|
||||
const button = this._elements.viewerExpand;
|
||||
if (button) {
|
||||
setProp(
|
||||
@@ -363,12 +300,6 @@ export class StreetLevelControls {
|
||||
this._unsubscribe?.();
|
||||
this._unsubscribe = null;
|
||||
if (this.destroyed || !this.root) return;
|
||||
this._unsubscribeEnableRequests?.();
|
||||
this._unsubscribeEnableRequests = this.actions.subscribeEnableRequests(
|
||||
(origin) => {
|
||||
this._explicitEnable = isExplicitLayerStateOrigin(origin);
|
||||
},
|
||||
);
|
||||
this._unsubscribe = this.layer.subscribe?.((state) => this.render(state));
|
||||
if (this.layer.getUIState) this.render(this.layer.getUIState());
|
||||
}
|
||||
@@ -416,12 +347,12 @@ export class StreetLevelControls {
|
||||
setProp(el.errorText, 'textContent', view.error || '');
|
||||
}
|
||||
|
||||
_renderRadios(buttons, isChecked) {
|
||||
/** A segmented control: the chosen button is pressed. */
|
||||
_renderPressed(buttons, isChosen) {
|
||||
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');
|
||||
const chosen = isChosen(button);
|
||||
button.classList.toggle('is-active', chosen);
|
||||
setAttr(button, 'aria-pressed', String(chosen));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,23 +365,25 @@ 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(
|
||||
this._renderPressed(
|
||||
el.panoButtons,
|
||||
(button) => button.dataset.slPano === view.filter.pano,
|
||||
);
|
||||
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.
|
||||
const index = String(view.since.index);
|
||||
if (
|
||||
document.activeElement !== el.sinceRange &&
|
||||
el.sinceRange.value !== index
|
||||
)
|
||||
el.sinceRange.value = index;
|
||||
if (el.sinceRange.value === index) {
|
||||
setProp(el.sinceLabel, 'textContent', view.since.label);
|
||||
setAttr(el.sinceRange, 'aria-valuetext', view.since.label);
|
||||
if (el.since) {
|
||||
// A link may carry a day count between the stops: it gets an option.
|
||||
if (!view.since.exact) {
|
||||
this._customSince ||= el.since.appendChild(
|
||||
document.createElement('option'),
|
||||
);
|
||||
setProp(this._customSince, 'value', String(view.since.days));
|
||||
setProp(this._customSince, 'textContent', view.since.window);
|
||||
} else if (this._customSince) {
|
||||
this._customSince.remove();
|
||||
this._customSince = null;
|
||||
}
|
||||
setProp(el.since, 'value', String(view.since.days));
|
||||
// The cut-off date, for the pointer and for screen readers.
|
||||
setProp(el.since, 'title', view.since.label);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,7 +400,7 @@ export class StreetLevelControls {
|
||||
setProp(wrap, 'hidden', !viewer.open);
|
||||
}
|
||||
setProp(el.viewerPlaceholder, 'hidden', !viewer.loading);
|
||||
this._renderRadios(
|
||||
this._renderPressed(
|
||||
el.renderButtons,
|
||||
(button) => button.dataset.slRender === viewer.renderMode,
|
||||
);
|
||||
@@ -497,17 +430,11 @@ export class StreetLevelControls {
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the panel on a switch-on or when an image opens. Never persisted:
|
||||
* the stored collapse state stays the user's own choice.
|
||||
* Open the panel when an image opens (a click on the map or a line would
|
||||
* otherwise load it out of sight). Never persisted: the stored collapse
|
||||
* state stays the user's own choice.
|
||||
*/
|
||||
_reactToTransitions(view) {
|
||||
const enabled = view.enabled;
|
||||
if (enabled && this._wasEnabled === false) {
|
||||
// A restore must not reopen a panel the user or a share link kept collapsed.
|
||||
if (this._explicitEnable) this.setCollapsed(false, { persist: false });
|
||||
this._explicitEnable = false;
|
||||
}
|
||||
this._wasEnabled = enabled;
|
||||
// Other size changes reach the viewer through the ResizeObserver; opening
|
||||
// asks for one resize because the element may not have a size yet.
|
||||
const open = view.viewer.open;
|
||||
@@ -523,14 +450,11 @@ export class StreetLevelControls {
|
||||
// would hide the viewer even inside the panel.
|
||||
if (this._expandMode === 'popover')
|
||||
this._elements.viewerWrap?.removeAttribute('popover');
|
||||
this._watchPanelHidingModes(false);
|
||||
this.listeners.abort();
|
||||
this._resizeObserver?.disconnect();
|
||||
this._resizeObserver = null;
|
||||
this._unsubscribe?.();
|
||||
this._unsubscribe = null;
|
||||
this._unsubscribeEnableRequests?.();
|
||||
this._unsubscribeEnableRequests = null;
|
||||
this.layer.attachViewerHost?.(null);
|
||||
}
|
||||
}
|
||||
@@ -247,8 +247,7 @@ function panelDom({ fullscreen = true, popover = false } = {}) {
|
||||
for (const pano of ['all', 'pano', 'flat'])
|
||||
add(panoRow, 'button', { dataset: { slPano: pano } });
|
||||
const sinceRow = add(controls, 'div', { id: 'sl-since-row' });
|
||||
add(sinceRow, 'input', { id: 'sl-since' });
|
||||
add(sinceRow, 'output', { id: 'sl-since-label' });
|
||||
add(sinceRow, 'select', { id: 'sl-since' });
|
||||
add(sinceRow, 'small', { id: 'sl-since-note' });
|
||||
const metaRow = add(root, 'div', { classes: ['sl-meta-row'] });
|
||||
add(metaRow, 'div', { id: 'sl-coverage-meta' });
|
||||
@@ -336,7 +335,6 @@ async function productionPanel(dom) {
|
||||
manager.setLayerParams('street-level', params, options),
|
||||
setPanelCollapsed() {},
|
||||
showToast() {},
|
||||
subscribeEnableRequests: () => () => {},
|
||||
},
|
||||
});
|
||||
controls.connect();
|
||||
@@ -372,11 +370,11 @@ test('a 360° click reaches the share link through the data manager', () =>
|
||||
coordinator.destroy();
|
||||
}));
|
||||
|
||||
test('releasing the SINCE slider records the window in the share link', () =>
|
||||
test('choosing a SINCE window records it in the share link', () =>
|
||||
withDom(async (dom) => {
|
||||
const { coordinator, controls, share } = await productionPanel(dom);
|
||||
const since = dom.root.querySelector('#sl-since');
|
||||
since.value = '5'; // the "last year" stop
|
||||
since.value = '365'; // the "last year" stop
|
||||
since.dispatchEvent(new Event('change'));
|
||||
await settle();
|
||||
const days =
|
||||
@@ -416,6 +414,7 @@ function uiState({
|
||||
on = true,
|
||||
open = false,
|
||||
pano = 'all',
|
||||
sinceDays = 0,
|
||||
renderMode = 'letterbox',
|
||||
keyRequired = false,
|
||||
} = {}) {
|
||||
@@ -424,7 +423,7 @@ function uiState({
|
||||
providers: [providerSnapshot({ on, keyRequired })],
|
||||
keyRequired,
|
||||
keyRejected: false,
|
||||
filter: { pano, sinceDays: 0 },
|
||||
filter: { pano, sinceDays },
|
||||
coverage: { count: 3, loading: false, hint: '', error: null },
|
||||
sequence: { selectedId: null, images: 0, loading: false },
|
||||
street: {
|
||||
@@ -441,8 +440,6 @@ function stubPanel(dom, state, extraActions = {}) {
|
||||
const layer = stubLayer();
|
||||
const calls = { setParams: [], setEnabled: [], collapsed: [], toasts: [] };
|
||||
let enabled = state.enabled;
|
||||
/** Reports a switch-on request's origin, as the data manager does. */
|
||||
let announce = null;
|
||||
layer.publish(state);
|
||||
const controls = new StreetLevelControls({
|
||||
root: dom.root,
|
||||
@@ -457,12 +454,6 @@ function stubPanel(dom, state, extraActions = {}) {
|
||||
setPanelCollapsed: (collapsed, options) =>
|
||||
calls.collapsed.push([collapsed, options]),
|
||||
showToast: (message) => calls.toasts.push(message),
|
||||
subscribeEnableRequests: (listener) => {
|
||||
announce = listener;
|
||||
return () => {
|
||||
announce = null;
|
||||
};
|
||||
},
|
||||
...extraActions,
|
||||
},
|
||||
});
|
||||
@@ -471,8 +462,6 @@ function stubPanel(dom, state, extraActions = {}) {
|
||||
layer,
|
||||
calls,
|
||||
controls,
|
||||
announce: (origin) => announce?.(origin),
|
||||
subscribed: () => announce !== null,
|
||||
chip: () => dom.root.querySelector('#sl-provider-chips').children[0],
|
||||
};
|
||||
}
|
||||
@@ -737,50 +726,57 @@ function fakeMutationObserver() {
|
||||
};
|
||||
}
|
||||
|
||||
for (const mode of ['ui-clean-view', 'recording-mode', 'cockpit-mode'])
|
||||
test(`entering ${mode} takes the photo out of full screen`, () =>
|
||||
withDom(async (dom) => {
|
||||
const saved = globalThis.MutationObserver;
|
||||
const watch = fakeMutationObserver();
|
||||
globalThis.MutationObserver = watch.FakeObserver;
|
||||
try {
|
||||
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||
dom.root.querySelector('#sl-viewer-expand').click();
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(dom.document.fullscreenElement, dom.wrap);
|
||||
assert.equal(watch.observers.size, 1, 'watching while expanded');
|
||||
dom.document.body.classList.toggle(mode, true);
|
||||
watch.fire();
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(dom.document.fullscreenElement, null);
|
||||
assert.equal(watch.observers.size, 0, 'and not after');
|
||||
controls.destroy();
|
||||
} finally {
|
||||
globalThis.MutationObserver = saved;
|
||||
}
|
||||
}));
|
||||
test('shrinkViewer takes the photo out of full screen (Clean View, recording, the cockpit)', () =>
|
||||
withDom(async (dom) => {
|
||||
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||
dom.root.querySelector('#sl-viewer-expand').click();
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(dom.document.fullscreenElement, dom.wrap);
|
||||
controls.shrinkViewer();
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(dom.document.fullscreenElement, null);
|
||||
controls.shrinkViewer(); // nothing expanded: nothing to do
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
test('the shell shrinks the photo when Clean View, recording or the cockpit hide the panels', () => {
|
||||
const shell = fs.readFileSync(
|
||||
new URL('./applicationShell.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const facade = fs.readFileSync(
|
||||
new URL('./shellFacade.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
shell,
|
||||
/toggleCleanView[\s\S]*?if \(shouldEnable\) this\._streetLevelControls\?\.shrinkViewer\(\)/,
|
||||
);
|
||||
assert.match(
|
||||
shell,
|
||||
/enterPanels: \(\) => \{[\s\S]*?this\._streetLevelControls\?\.shrinkViewer\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
facade,
|
||||
/setRecordingMode\(enabled, options\) \{[\s\S]*?this\._streetLevelControls\?\.shrinkViewer\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
/* ── Restores and keyboard rules ───────────────────────────────────────── */
|
||||
|
||||
test('only an explicit switch-on opens the panel, unstored; a restore never does (P2-1)', () =>
|
||||
test('a photo opening opens the panel, unstored; switching the layer on does not', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer, calls, controls, announce, subscribed } = stubPanel(
|
||||
const { layer, calls, controls } = stubPanel(
|
||||
dom,
|
||||
uiState({ enabled: false }),
|
||||
);
|
||||
announce('restore');
|
||||
layer.publish(uiState({ enabled: true }));
|
||||
assert.deepEqual(calls.collapsed, [], 'a restore leaves the panel be');
|
||||
layer.publish(uiState({ enabled: false }));
|
||||
announce('user');
|
||||
layer.publish(uiState({ enabled: true }));
|
||||
assert.deepEqual(calls.collapsed, [[false, { persist: false }]]);
|
||||
assert.deepEqual(calls.collapsed, [], 'a switch-on leaves the panel be');
|
||||
layer.publish(uiState({ enabled: true, open: true }));
|
||||
assert.equal(calls.collapsed.length, 2, 'a photo opening opens it too');
|
||||
assert.deepEqual(calls.collapsed, [[false, { persist: false }]]);
|
||||
controls.destroy();
|
||||
assert.equal(subscribed(), false, 'destroy unsubscribes');
|
||||
}));
|
||||
|
||||
test('FIT / FILL show the selected render mode (P3)', () =>
|
||||
@@ -788,8 +784,8 @@ test('FIT / FILL show the selected render mode (P3)', () =>
|
||||
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
|
||||
layer.publish(uiState({ open: true, renderMode: 'fill' }));
|
||||
const [fit, fill] = dom.wrap.querySelectorAll('[data-sl-render]');
|
||||
assert.equal(fill.getAttribute('aria-checked'), 'true');
|
||||
assert.equal(fit.getAttribute('aria-checked'), 'false');
|
||||
assert.equal(fill.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(fit.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(fill.classList.contains('is-active'), true);
|
||||
controls.destroy();
|
||||
}));
|
||||
@@ -834,35 +830,46 @@ test('identical renders write nothing to the DOM (P3: rail MutationObserver)', (
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
test('arrow keys move the selection within a radiogroup, which has one tab stop (P3)', () =>
|
||||
test('the imagery filter is a row of toggle buttons, the chosen one pressed', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer, calls, controls } = stubPanel(dom, uiState());
|
||||
const keydown = (target, key) =>
|
||||
target.dispatchEvent({ type: 'keydown', bubbles: true, key });
|
||||
const [all, pano, flat] = dom.root.querySelectorAll('[data-sl-pano]');
|
||||
assert.deepEqual(
|
||||
[all, pano, flat].map((button) => button.getAttribute('tabindex')),
|
||||
['0', '-1', '-1'],
|
||||
);
|
||||
all.focus();
|
||||
keydown(all, 'ArrowRight');
|
||||
assert.equal(dom.document.activeElement, pano);
|
||||
const pressed = () =>
|
||||
[all, pano, flat].map((button) => button.getAttribute('aria-pressed'));
|
||||
assert.deepEqual(pressed(), ['true', 'false', 'false']);
|
||||
pano.click();
|
||||
assert.deepEqual(calls.setParams.at(-1), [
|
||||
{ pano: 'pano' },
|
||||
{ origin: 'user' },
|
||||
]);
|
||||
keydown(all, 'ArrowLeft'); // wraps to the end
|
||||
assert.equal(dom.document.activeElement, flat);
|
||||
keydown(flat, 'Home');
|
||||
assert.equal(dom.document.activeElement, all);
|
||||
layer.publish(uiState({ pano: 'flat' }));
|
||||
assert.deepEqual(pressed(), ['false', 'false', 'true']);
|
||||
// No roving tab stop: every button is reachable with Tab.
|
||||
assert.deepEqual(
|
||||
[all, pano, flat].map((button) => button.getAttribute('tabindex')),
|
||||
['-1', '-1', '0'],
|
||||
[null, null, null],
|
||||
);
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
test('SINCE lists the stops, adds a link’s own day count and names the cut-off date', () =>
|
||||
withDom(async (dom) => {
|
||||
const { layer, controls } = stubPanel(dom, uiState());
|
||||
const since = dom.root.querySelector('#sl-since');
|
||||
assert.equal(since.children.length, 9, 'the nine stops');
|
||||
assert.equal(since.children[0].textContent, 'ANY DATE');
|
||||
layer.publish(uiState({ sinceDays: 365 }));
|
||||
assert.equal(since.value, '365');
|
||||
assert.match(since.title, /^LAST YEAR · SINCE \d{4}-\d{2}-\d{2}$/);
|
||||
layer.publish(uiState({ sinceDays: 400 }));
|
||||
assert.equal(since.children.length, 10);
|
||||
assert.equal(since.children[9].textContent, 'LAST 400 DAYS');
|
||||
assert.equal(since.value, '400');
|
||||
layer.publish(uiState({ sinceDays: 30 }));
|
||||
assert.equal(since.children.length, 9, 'gone once a stop is chosen');
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
test('under KEY REQUIRED the provider chip stays live and explains the key instead of toggling (P3)', () =>
|
||||
withDom(async (dom) => {
|
||||
const { calls, chip, controls } = stubPanel(
|
||||
@@ -984,7 +991,10 @@ test('a note under SINCE says what date it compares, only while one is given', (
|
||||
'utf8',
|
||||
);
|
||||
assert.match(template, /<small id="sl-since-note"[^>]* hidden>/);
|
||||
assert.match(template, /aria-describedby="sl-since-label sl-since-note"/);
|
||||
assert.match(
|
||||
template,
|
||||
/<select id="sl-since"[^>]*aria-describedby="sl-since-note"/,
|
||||
);
|
||||
}));
|
||||
|
||||
test('Street View lit hides the imagery filters; Mapillary lit shows them', () =>
|
||||
|
||||
@@ -35,7 +35,14 @@ export function sinceStopIndex(days) {
|
||||
function presentSince(days, now) {
|
||||
const value = Number(days) || 0;
|
||||
const index = sinceStopIndex(value);
|
||||
if (value <= 0) return { index, days: 0, label: t('ANY DATE') };
|
||||
if (value <= 0)
|
||||
return {
|
||||
index,
|
||||
days: 0,
|
||||
exact: true,
|
||||
window: t('ANY DATE'),
|
||||
label: t('ANY DATE'),
|
||||
};
|
||||
const stop = SINCE_STOPS[index];
|
||||
const window =
|
||||
stop.days === value
|
||||
@@ -44,6 +51,9 @@ function presentSince(days, now) {
|
||||
return {
|
||||
index,
|
||||
days: value,
|
||||
/** One of SINCE_STOPS; a link may carry any other day count. */
|
||||
exact: stop.days === value,
|
||||
window,
|
||||
label: t('{window} · SINCE {date}', {
|
||||
window,
|
||||
date: formatDate(now - value * DAY_MS),
|
||||
|
||||
@@ -182,7 +182,13 @@ test('a SINCE window from a link lands on the nearest slider stop', () => {
|
||||
test('the SINCE readout names the window and the cut-off date it means today', () => {
|
||||
const now = Date.UTC(2026, 8, 25);
|
||||
const any = presentStreetLevelPanel(snapshot(), { now });
|
||||
assert.deepEqual(any.since, { index: 0, days: 0, label: 'ANY DATE' });
|
||||
assert.deepEqual(any.since, {
|
||||
index: 0,
|
||||
days: 0,
|
||||
exact: true,
|
||||
window: 'ANY DATE',
|
||||
label: 'ANY DATE',
|
||||
});
|
||||
const year = presentStreetLevelPanel(
|
||||
snapshot({ filter: { pano: 'pano', sinceDays: 365 } }),
|
||||
{ now },
|
||||
@@ -191,6 +197,8 @@ test('the SINCE readout names the window and the cut-off date it means today', (
|
||||
assert.deepEqual(year.since, {
|
||||
index: 5,
|
||||
days: 365,
|
||||
exact: true,
|
||||
window: 'LAST YEAR',
|
||||
label: 'LAST YEAR · SINCE 2025-09-25',
|
||||
});
|
||||
const custom = presentStreetLevelPanel(
|
||||
@@ -198,6 +206,8 @@ test('the SINCE readout names the window and the cut-off date it means today', (
|
||||
{ now },
|
||||
);
|
||||
assert.equal(custom.since.index, 5);
|
||||
assert.equal(custom.since.exact, false);
|
||||
assert.equal(custom.since.window, 'LAST 400 DAYS');
|
||||
assert.equal(custom.since.label, 'LAST 400 DAYS · SINCE 2025-08-21');
|
||||
});
|
||||
|
||||
|
||||
@@ -278,17 +278,23 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
||||
gap: 4px 10px;
|
||||
}
|
||||
|
||||
.sl-range {
|
||||
.sl-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
accent-color: var(--sl-green);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 7px;
|
||||
color: var(--text-primary);
|
||||
padding: 4px 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text);
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sl-range:focus-visible {
|
||||
.sl-select:focus-visible {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sl-since-note {
|
||||
@@ -302,14 +308,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sl-since-label {
|
||||
grid-column: 2;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text);
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Embedded viewer ─────────────────────────────────────────────────────── */
|
||||
.sl-viewer-wrap {
|
||||
position: relative;
|
||||
|
||||
@@ -87,9 +87,9 @@
|
||||
<div class="sl-viewer-bar">
|
||||
<div class="sl-viewer-tools">
|
||||
<button id="sl-viewer-expand" class="scene-btn" type="button" aria-pressed="false" title="Show the street-level view full screen (Esc shrinks it)" data-i18n-title><span class="sl-btn-icon" aria-hidden="true">⤢</span><span class="sl-btn-text" data-i18n>EXPAND</span></button>
|
||||
<div class="sl-seg" role="radiogroup" aria-label="Image fit" data-i18n-aria-label>
|
||||
<button type="button" class="sl-seg-btn is-active" data-sl-render="letterbox" role="radio" aria-checked="true" 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" class="sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" tabindex="-1" title="Fill: fill the frame edge to edge, cropping the photo" data-i18n-title data-i18n>FILL</button>
|
||||
<div class="sl-seg" role="group" aria-label="Image fit" data-i18n-aria-label>
|
||||
<button type="button" class="sl-seg-btn is-active" data-sl-render="letterbox" aria-pressed="true" 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" class="sl-seg-btn" data-sl-render="fill" aria-pressed="false" title="Fill: fill the frame edge to edge, cropping the photo" data-i18n-title data-i18n>FILL</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sl-viewer-tools sl-viewer-tools-right">
|
||||
@@ -118,16 +118,15 @@
|
||||
<fieldset id="sl-controls" class="sl-controls">
|
||||
<div id="sl-pano-row" class="sl-filters" role="group" aria-label="Imagery filters" data-i18n-aria-label>
|
||||
<span class="cctv-summary-label" data-i18n>IMAGERY</span>
|
||||
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
|
||||
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true" data-i18n>ALL</button>
|
||||
<button type="button" class="sl-seg-btn" data-sl-pano="pano" role="radio" aria-checked="false" tabindex="-1">360°</button>
|
||||
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1" data-i18n>FLAT</button>
|
||||
<div class="sl-seg" role="group" aria-label="Panorama filter" data-i18n-aria-label>
|
||||
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" aria-pressed="true" data-i18n>ALL</button>
|
||||
<button type="button" class="sl-seg-btn" data-sl-pano="pano" aria-pressed="false">360°</button>
|
||||
<button type="button" class="sl-seg-btn" data-sl-pano="flat" aria-pressed="false" data-i18n>FLAT</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sl-since-row" class="sl-since">
|
||||
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
|
||||
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label sl-since-note" />
|
||||
<output id="sl-since-label" class="sl-since-label" for="sl-since" data-i18n>ANY DATE</output>
|
||||
<select id="sl-since" class="sl-select" aria-describedby="sl-since-note"></select>
|
||||
<small id="sl-since-note" class="sl-since-note" hidden></small>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
Reference in new issue
Block a user