feat(ui): render the Street Level panel from a store

The panel's markup moves, verbatim, into StreetLevelPanel, which renders
the presentation StreetLevelControls publishes to the gev-street-level
store, with the EXPAND state. The controls keep their listeners, full
screen and popover handling, focus moves, resizing and transitions. The
SINCE readout follows the thumb while dragging and shows the recorded
window once the thumb is there; the thumb never moves under the user's
hand. Text the panel now renders itself drops its data-i18n markers.
Panels lifted out of a view live in src/ui/views/panels/, kept out of
Prettier like the views.
This commit is contained in:
Sameh Khamis committed 2026-10-30 20:43:42 -07:00
1 parent 3a2acf9b0f
commit e6aee52ea0
8 files changed
+500 -528

No files matched your search

+194 -298
View File
@@ -1,7 +1,13 @@
import '../testSupport/registerDom.mjs';
import assert from 'node:assert/strict';
import { assertSameNode } from '../testSupport/domAssert.mjs';
import fs from 'node:fs';
import test from 'node:test';
import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue';
import { StreetLevelControls } from './streetLevelControls.js';
import { useStreetLevelStore } from './stores/streetLevel.js';
import StreetLevelPanel from './views/panels/StreetLevelPanel.vue';
import { createStreetLevelLayer } from '../layers/streetLevel/index.js';
import { createMapillaryProvider } from '../layers/streetLevel/providers/mapillary/index.js';
import { DataLayerManager } from '../data/manager.js';
@@ -16,214 +22,53 @@ import {
providerSnapshot,
} from '../testSupport/streetLevelFakes.mjs';
/* ── A small DOM: just what the panel controls touch ───────────────────── */
/** Every write a page would see as a mutation, even one to the same value. */
const mutations = { count: 0 };
const REFLECTED = ['hidden', 'disabled', 'textContent', 'title', 'value'];
class FakeNode {
constructor(document, tag, { id = null, dataset = {}, classes = [] } = {}) {
this.listeners = new Map();
this.ownerDocument = document;
this.tagName = tag.toUpperCase();
this.id = id;
this.dataset = { ...dataset };
this.children = [];
this.parent = null;
this.props = {
hidden: false,
disabled: false,
textContent: '',
title: '',
value: '',
};
for (const key of REFLECTED)
Object.defineProperty(this, key, {
get: () => this.props[key],
set: (value) => {
mutations.count++;
this.props[key] = value;
},
});
this.attributes = new Map();
const names = new Set(classes);
this.classList = {
contains: (name) => names.has(name),
toggle: (name, force) => {
const on = force ?? !names.has(name);
if (on) names.add(name);
else names.delete(name);
return on;
},
};
const style = {};
this.style = Object.assign(style, {
setProperty: (key, value) => {
style[key] = value;
},
removeProperty: (key) => {
delete style[key];
},
});
Object.defineProperty(this, 'className', {
get: () => [...names].join(' '),
set: (value) => {
mutations.count++;
names.clear();
for (const name of String(value).split(/\s+/).filter(Boolean))
names.add(name);
},
});
}
get childElementCount() {
return this.children.length;
}
setAttribute(key, value) {
mutations.count++;
this.attributes.set(key, String(value));
}
getAttribute(key) {
return this.attributes.get(key) ?? null;
}
removeAttribute(key) {
mutations.count++;
this.attributes.delete(key);
}
appendChild(child) {
child.parent = this;
this.children.push(child);
return child;
}
append(...nodes) {
for (const node of nodes) this.appendChild(node);
}
replaceChildren(...nodes) {
this.children = [];
this.append(...nodes);
}
contains(node) {
for (let current = node; current; current = current.parent)
if (current === this) return true;
return false;
}
closest(selector) {
for (let current = this; current; current = current.parent)
if (current.matches?.(selector)) return current;
return null;
}
insertBefore(child, before) {
child.parent?.children.splice(child.parent.children.indexOf(child), 1);
child.parent = this;
const at = before ? this.children.indexOf(before) : -1;
if (at === -1) this.children.push(child);
else this.children.splice(at, 0, child);
return child;
}
remove() {
this.parent?.children.splice(this.parent.children.indexOf(this), 1);
this.parent = null;
}
focus() {
this.ownerDocument.activeElement = this;
}
*walk() {
for (const child of this.children) {
yield child;
yield* child.walk();
}
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
/** `#id`, `.class` or `[data-x]`. */
matches(selector) {
const byId = /^#(.+)$/.exec(selector);
if (byId) return this.id === byId[1];
const byClass = /^\.(.+)$/.exec(selector);
if (byClass) return this.classList.contains(byClass[1]);
const byData = /^\[data-([a-z-]+)\]$/.exec(selector);
return Boolean(
byData &&
byData[1].replace(/-([a-z])/g, (_, c) => c.toUpperCase()) in this.dataset,
);
}
querySelectorAll(selector) {
return [...this.walk()].filter((node) => node.matches(selector));
}
addEventListener(type, listener, { signal } = {}) {
if (signal?.aborted) return;
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type).add(listener);
signal?.addEventListener('abort', () =>
this.listeners.get(type)?.delete(listener),
);
}
/** Deliver to this node, then bubble up with the same target. */
dispatchEvent(event) {
const delivered = {
type: event.type,
target: this,
key: event.key,
newState: event.newState,
stopped: false,
preventDefault() {},
stopPropagation() {
this.stopped = true;
},
};
for (
let node = this;
node && !delivered.stopped;
node = event.bubbles ? node.parent : null
)
for (const listener of [...(node.listeners.get(event.type) || [])])
listener(delivered);
return true;
}
click() {
this.dispatchEvent({ type: 'click', bubbles: true });
}
}
/* ── The panel view, mounted on a happy-dom page ───────────────────────── */
/**
* The Street Level panel's body, as layer-panels.html lays it out, on a page
* with the Fullscreen API unless `fullscreen` is false, and the Popover API
* if `popover` is true (iPhone Safari: popovers, no element fullscreen).
* The Street Level panel as the page renders it, on a page with the
* Fullscreen API unless `fullscreen` is false, and the Popover API if
* `popover` is true (iPhone Safari: popovers, no element fullscreen).
*/
function panelDom({ fullscreen = true, popover = false } = {}) {
const document = new FakeNode(null, '#document');
Object.assign(document, {
ownerDocument: document,
activeElement: null,
fullscreenElement: null,
createElement: (tag) => new FakeNode(document, tag),
});
document.body.replaceChildren();
document.body.className = '';
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(StreetLevelPanel).use(pinia);
app.mount(host);
const store = useStreetLevelStore(pinia);
const root = document.getElementById('street-level-panel');
const wrap = document.getElementById('sl-viewer-wrap');
// happy-dom lays nothing out: the viewer has a size, as on a page.
sized(document.getElementById('sl-viewer'), 640, 400);
// The browser reports a change after the request settles.
let fullscreenElement = null;
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => fullscreenElement,
});
const fullscreenTo = (element) => {
document.fullscreenElement = element;
setTimeout(() => document.dispatchEvent({ type: 'fullscreenchange' }), 0);
fullscreenElement = element;
setTimeout(() => document.dispatchEvent(new Event('fullscreenchange')), 0);
};
document.exitFullscreen = async () => fullscreenTo(null);
document.body = document.appendChild(new FakeNode(document, 'body'));
const root = document.body.appendChild(
new FakeNode(document, 'section', { id: 'street-level-panel' }),
);
const add = (parent, tag, options) =>
parent.appendChild(new FakeNode(document, tag, options));
add(root, 'button', { id: 'sl-status' });
add(root, 'div', { id: 'sl-provider-chips' });
const wrap = add(root, 'div', { id: 'sl-viewer-wrap' });
if (fullscreen) wrap.requestFullscreen = async () => fullscreenTo(wrap);
wrap.requestFullscreen = fullscreen ? async () => fullscreenTo(wrap) : null;
wrap.showPopover = null;
wrap.hidePopover = null;
if (popover) {
// `popover` reflects its attribute, as in the DOM.
Object.defineProperty(wrap, 'popover', {
configurable: true,
get: () => wrap.getAttribute('popover'),
set: (value) => wrap.setAttribute('popover', value),
});
// The browser fires `toggle` after the popover opens or closes.
const toggleTo = (newState) =>
setTimeout(() => wrap.dispatchEvent({ type: 'toggle', newState }), 0);
setTimeout(() => {
const event = new Event('toggle');
event.newState = newState;
wrap.dispatchEvent(event);
}, 0);
wrap.showPopover = () => {
wrap.popoverOpen = true;
toggleTo('open');
@@ -233,51 +78,47 @@ function panelDom({ fullscreen = true, popover = false } = {}) {
toggleTo('closed');
};
}
const expand = add(wrap, 'button', { id: 'sl-viewer-expand' });
add(expand, 'span', { classes: ['sl-btn-icon'] });
add(expand, 'span', { classes: ['sl-btn-text'] });
const fit = add(wrap, 'div', { classes: ['sl-seg'] });
for (const mode of ['letterbox', 'fill'])
add(fit, 'button', { dataset: { slRender: mode } });
add(wrap, 'button', { id: 'sl-viewer-close' });
for (const id of [
'sl-viewer-placeholder',
'sl-viewer',
'sl-image-by',
'sl-image-when',
'sl-image-link',
])
add(wrap, id === 'sl-image-link' ? 'a' : 'div', { id });
const error = add(root, 'div', { id: 'sl-error' });
add(error, 'span', { id: 'sl-error-text' });
const controls = add(root, 'fieldset', { id: 'sl-controls' });
const panoRow = add(controls, 'div', { id: 'sl-pano-row' });
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, 'small', { id: 'sl-since-note' });
const metaRow = add(root, 'div', { classes: ['sl-meta-row'] });
add(metaRow, 'div', { id: 'sl-coverage-meta' });
add(metaRow, 'button', { id: 'sl-nearest-btn' });
return { document, root, wrap };
// Every DOM write the rail's MutationObserver would see.
const observer = new MutationObserver(() => {});
observer.observe(root, {
subtree: true,
childList: true,
attributes: true,
characterData: true,
});
return {
document,
root,
wrap,
store,
writes: () => observer.takeRecords().length,
unmount() {
observer.disconnect();
app.unmount();
delete document.fullscreenElement;
},
};
}
/** Run `fn` with the fake document and an immediate animation frame. */
/** Give an element the layout size happy-dom never computes. */
function sized(element, width, height) {
for (const [key, value] of [
['clientWidth', width],
['clientHeight', height],
])
Object.defineProperty(element, key, { configurable: true, value });
}
/** Run `fn` on a fresh panel with an immediate animation frame. */
async function withDom(fn, options) {
const saved = {
document: globalThis.document,
requestAnimationFrame: globalThis.requestAnimationFrame,
};
const saved = globalThis.requestAnimationFrame;
const page = panelDom(options);
globalThis.document = page.document;
globalThis.requestAnimationFrame = (task) => setTimeout(task, 0);
try {
return await fn(page);
} finally {
globalThis.document = saved.document;
globalThis.requestAnimationFrame = saved.requestAnimationFrame;
globalThis.requestAnimationFrame = saved;
page.unmount();
}
}
@@ -336,6 +177,7 @@ async function productionPanel(dom) {
const controls = new StreetLevelControls({
root: dom.root,
layer,
store: dom.store,
actions: {
isEnabled: () => manager.isEnabled('street-level'),
setEnabled: (on) =>
@@ -403,9 +245,11 @@ function stubLayer() {
const calls = { resize: 0 };
return {
calls,
/** Notify, then wait for the panel to render. */
publish(next) {
state = next;
for (const listener of listeners) listener(state);
return nextTick();
},
subscribe(listener) {
listeners.add(listener);
@@ -445,16 +289,17 @@ function uiState({
};
}
function stubPanel(dom, state, extraActions = {}) {
async 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);
await layer.publish(state);
const controls = new StreetLevelControls({
root: dom.root,
layer,
store: dom.store,
actions: {
isEnabled: () => enabled,
setEnabled: async (on) => {
@@ -475,6 +320,7 @@ function stubPanel(dom, state, extraActions = {}) {
},
});
controls.connect();
await nextTick();
return {
layer,
calls,
@@ -488,7 +334,7 @@ function stubPanel(dom, state, extraActions = {}) {
test('the header pill switches the layer; provider switches are left alone', () =>
withDom(async (dom) => {
const status = dom.root.querySelector('#sl-status');
const { calls, controls } = stubPanel(dom, uiState());
const { calls, controls } = await stubPanel(dom, uiState());
status.click();
await settle();
assert.deepEqual(calls.setEnabled, [false]);
@@ -498,7 +344,7 @@ test('the header pill switches the layer; provider switches are left alone', ()
test('darkening the only lit chip turns the layer off and keeps the provider switched on', () =>
withDom(async (dom) => {
const { calls, chip, controls } = stubPanel(dom, uiState());
const { calls, chip, controls } = await stubPanel(dom, uiState());
assert.equal(chip().dataset.chipId, 'mapillary');
assert.equal(chip().getAttribute('aria-pressed'), 'true');
chip().click();
@@ -510,7 +356,7 @@ test('darkening the only lit chip turns the layer off and keeps the provider swi
test('lighting a dark chip switches the provider on as a user params request, then the layer', () =>
withDom(async (dom) => {
const { calls, chip, controls } = stubPanel(
const { calls, chip, controls } = await stubPanel(
dom,
uiState({ enabled: false, on: false }),
);
@@ -526,7 +372,7 @@ test('lighting a dark chip switches the provider on as a user params request, th
test('under KEY REQUIRED the provider chip stays live and explains the key instead of toggling', () =>
withDom(async (dom) => {
const { calls, chip, controls } = stubPanel(
const { calls, chip, controls } = await stubPanel(
dom,
uiState({ keyRequired: true }),
);
@@ -546,7 +392,7 @@ test('under KEY REQUIRED the provider chip stays live and explains the key inste
test('under KEY REQUIRED the filters are gated and the error line says how to add the key', () =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ keyRequired: true }));
const { controls } = await stubPanel(dom, uiState({ keyRequired: true }));
assert.equal(dom.root.querySelector('#sl-controls').disabled, true);
assert.equal(dom.root.querySelector('#sl-error').hidden, false);
assert.match(
@@ -558,18 +404,18 @@ test('under KEY REQUIRED the filters are gated and the error line says how to ad
test('the viewer is resized once when it opens, not on every render', () =>
withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState());
const { layer, controls } = await stubPanel(dom, uiState());
await settle();
assert.equal(layer.calls.resize, 0);
layer.publish(uiState({ open: true }));
await layer.publish(uiState({ open: true }));
await settle();
// Opening also opens the panel; the two share one coalesced resize.
assert.equal(layer.calls.resize, 1, 'once on open');
for (let i = 0; i < 5; i++) layer.publish(uiState({ open: true }));
for (let i = 0; i < 5; i++) await layer.publish(uiState({ open: true }));
await settle();
assert.equal(layer.calls.resize, 1, 'not again while it stays open');
layer.publish(uiState({ open: false }));
layer.publish(uiState({ open: true }));
await layer.publish(uiState({ open: false }));
await layer.publish(uiState({ open: true }));
await settle();
assert.equal(layer.calls.resize, 2, 'the next photo resizes again');
controls.destroy();
@@ -577,11 +423,11 @@ test('the viewer is resized once when it opens, not on every render', () =>
test('closing the image from its × button leaves focus on the panel, not <body>', () =>
withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
const { layer, controls } = await stubPanel(dom, uiState({ open: true }));
dom.root.querySelector('#sl-viewer-close').focus();
layer.publish(uiState({ open: false }));
await layer.publish(uiState({ open: false }));
assert.equal(dom.wrap.hidden, true);
assert.equal(
assertSameNode(
dom.document.activeElement,
dom.root.querySelector('#sl-status'),
);
@@ -600,55 +446,59 @@ const expandLabel = (dom) => {
test('EXPAND puts the viewer full screen in place, and the button shrinks it again', () =>
withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
const { layer, controls } = await stubPanel(dom, uiState({ open: true }));
await settle();
const resizes = layer.calls.resize;
const expand = dom.root.querySelector('#sl-viewer-expand');
expand.click();
await settle();
await settle();
assert.equal(dom.document.fullscreenElement, dom.wrap);
assertSameNode(dom.document.fullscreenElement, dom.wrap);
assert.equal(dom.root.contains(dom.wrap), true, 'never leaves the panel');
assert.deepEqual(expandLabel(dom), ['SHRINK', 'true']);
assert.equal(layer.calls.resize, resizes + 1, 'the viewer refits');
expand.click();
await settle();
await settle();
assert.equal(dom.document.fullscreenElement, null);
assertSameNode(dom.document.fullscreenElement, null);
assert.deepEqual(expandLabel(dom), ['EXPAND', 'false']);
controls.destroy();
}));
test('Esc in full screen shrinks the photo; the panel never sees it', () =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
const { controls } = await stubPanel(dom, uiState({ open: true }));
const expand = dom.root.querySelector('#sl-viewer-expand');
expand.click();
await settle();
await settle();
assert.equal(dom.document.fullscreenElement, dom.wrap);
assertSameNode(dom.document.fullscreenElement, dom.wrap);
let panelEsc = 0;
dom.root.addEventListener('keydown', () => panelEsc++);
expand.dispatchEvent({ type: 'keydown', key: 'Escape', bubbles: true });
expand.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
await settle();
await settle();
assert.equal(dom.document.fullscreenElement, null);
assertSameNode(dom.document.fullscreenElement, null);
assert.equal(panelEsc, 0, 'the panel stays open');
assert.deepEqual(expandLabel(dom), ['EXPAND', 'false']);
// Not expanded, Esc is the panel's again.
expand.dispatchEvent({ type: 'keydown', key: 'Escape', bubbles: true });
expand.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
assert.equal(panelEsc, 1);
controls.destroy();
}));
test('an image closed while full screen leaves full screen', () =>
withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
const { layer, controls } = await stubPanel(dom, uiState({ open: true }));
dom.root.querySelector('#sl-viewer-expand').click();
await settle();
layer.publish(uiState({ open: false }));
await layer.publish(uiState({ open: false }));
await settle();
assert.equal(dom.document.fullscreenElement, null);
assertSameNode(dom.document.fullscreenElement, null);
assert.equal(dom.wrap.hidden, true);
assert.deepEqual(expandLabel(dom), ['EXPAND', 'false']);
controls.destroy();
@@ -657,7 +507,7 @@ test('an image closed while full screen leaves full screen', () =>
test('without the Fullscreen or Popover API there is no EXPAND button', () =>
withDom(
async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
const { controls } = await stubPanel(dom, uiState({ open: true }));
assert.equal(dom.root.querySelector('#sl-viewer-expand').hidden, true);
controls.destroy();
},
@@ -667,7 +517,7 @@ test('without the Fullscreen or Popover API there is no EXPAND button', () =>
test('on iPhone (no element fullscreen) EXPAND opens the viewer as a popover over the page', () =>
withDom(
async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
const { controls } = await stubPanel(dom, uiState({ open: true }));
const expand = dom.root.querySelector('#sl-viewer-expand');
assert.equal(expand.hidden, false, 'offered');
expand.click();
@@ -679,7 +529,9 @@ test('on iPhone (no element fullscreen) EXPAND opens the viewer as a popover ove
// Esc shrinks the photo; the panel's own Esc (collapse) never sees it.
let panelEsc = 0;
dom.root.addEventListener('keydown', () => panelEsc++);
expand.dispatchEvent({ type: 'keydown', key: 'Escape', bubbles: true });
expand.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
await settle();
await settle();
assert.equal(dom.wrap.popoverOpen, false);
@@ -707,7 +559,7 @@ test('on iPhone (no element fullscreen) EXPAND opens the viewer as a popover ove
test('destroying the panel while the iPhone popover is open leaves no popover behind', () =>
withDom(
async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
const { controls } = await stubPanel(dom, uiState({ open: true }));
dom.root.querySelector('#sl-viewer-expand').click();
await settle();
await settle();
@@ -752,17 +604,17 @@ for (const mode of ['ui-clean-view', 'recording-mode', 'cockpit-mode'])
const watch = fakeMutationObserver();
globalThis.MutationObserver = watch.FakeObserver;
try {
const { controls } = stubPanel(dom, uiState({ open: true }));
const { controls } = await stubPanel(dom, uiState({ open: true }));
dom.root.querySelector('#sl-viewer-expand').click();
await settle();
await settle();
assert.equal(dom.document.fullscreenElement, dom.wrap);
assertSameNode(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);
assertSameNode(dom.document.fullscreenElement, null);
assert.equal(watch.observers.size, 0, 'and not after');
controls.destroy();
} finally {
@@ -774,18 +626,18 @@ for (const mode of ['ui-clean-view', 'recording-mode', 'cockpit-mode'])
test('only an explicit switch-on opens the panel, unstored; a restore never does (P2-1)', () =>
withDom(async (dom) => {
const { layer, calls, controls, announce, subscribed } = stubPanel(
const { layer, calls, controls, announce, subscribed } = await stubPanel(
dom,
uiState({ enabled: false }),
);
announce('restore');
layer.publish(uiState({ enabled: true }));
await layer.publish(uiState({ enabled: true }));
assert.deepEqual(calls.collapsed, [], 'a restore leaves the panel be');
layer.publish(uiState({ enabled: false }));
await layer.publish(uiState({ enabled: false }));
announce('user');
layer.publish(uiState({ enabled: true }));
await layer.publish(uiState({ enabled: true }));
assert.deepEqual(calls.collapsed, [[false, { persist: false }]]);
layer.publish(uiState({ enabled: true, open: true }));
await layer.publish(uiState({ enabled: true, open: true }));
assert.equal(calls.collapsed.length, 2, 'a photo opening opens it too');
controls.destroy();
assert.equal(subscribed(), false, 'destroy unsubscribes');
@@ -793,8 +645,8 @@ test('only an explicit switch-on opens the panel, unstored; a restore never does
test('FIT / FILL show the selected render mode (P3)', () =>
withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
layer.publish(uiState({ open: true, renderMode: 'fill' }));
const { layer, controls } = await stubPanel(dom, uiState({ open: true }));
await 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');
@@ -814,15 +666,15 @@ test('a hidden (0×0) viewer is not resized, so no z=NaN tile request (P3)', ()
disconnect() {}
};
try {
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
const { layer, controls } = await stubPanel(dom, uiState({ open: true }));
await settle();
const resizes = layer.calls.resize;
const viewer = dom.root.querySelector('#sl-viewer');
Object.assign(viewer, { clientWidth: 0, clientHeight: 0 });
sized(viewer, 0, 0);
observers[0]();
await settle();
assert.equal(layer.calls.resize, resizes, 'skipped while 0×0');
Object.assign(viewer, { clientWidth: 640, clientHeight: 400 });
sized(viewer, 640, 400);
observers[0]();
await settle();
assert.equal(layer.calls.resize, resizes + 1, 'resized once it shows');
@@ -834,19 +686,23 @@ test('a hidden (0×0) viewer is not resized, so no z=NaN tile request (P3)', ()
test('identical renders write nothing to the DOM (P3: rail MutationObserver)', () =>
withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
layer.publish(uiState({ open: true }));
const before = mutations.count;
for (let i = 0; i < 10; i++) layer.publish(uiState({ open: true }));
assert.equal(mutations.count - before, 0);
const { layer, controls } = await stubPanel(dom, uiState({ open: true }));
await layer.publish(uiState({ open: true }));
await settle();
dom.writes();
for (let i = 0; i < 10; i++) await layer.publish(uiState({ open: true }));
await settle();
assert.equal(dom.writes(), 0);
controls.destroy();
}));
test('arrow keys move the selection within a radiogroup, which has one tab stop (P3)', () =>
withDom(async (dom) => {
const { layer, calls, controls } = stubPanel(dom, uiState());
const { layer, calls, controls } = await stubPanel(dom, uiState());
const keydown = (target, key) =>
target.dispatchEvent({ type: 'keydown', bubbles: true, key });
target.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key }),
);
const [all, pano, flat] = dom.root.querySelectorAll('[data-sl-pano]');
assert.deepEqual(
[all, pano, flat].map((button) => button.getAttribute('tabindex')),
@@ -854,16 +710,16 @@ test('arrow keys move the selection within a radiogroup, which has one tab stop
);
all.focus();
keydown(all, 'ArrowRight');
assert.equal(dom.document.activeElement, pano);
assertSameNode(dom.document.activeElement, pano);
assert.deepEqual(calls.setParams.at(-1), [
{ pano: 'pano' },
{ origin: 'user' },
]);
keydown(all, 'ArrowLeft'); // wraps to the end
assert.equal(dom.document.activeElement, flat);
assertSameNode(dom.document.activeElement, flat);
keydown(flat, 'Home');
assert.equal(dom.document.activeElement, all);
layer.publish(uiState({ pano: 'flat' }));
assertSameNode(dom.document.activeElement, all);
await layer.publish(uiState({ pano: 'flat' }));
assert.deepEqual(
[all, pano, flat].map((button) => button.getAttribute('tabindex')),
['-1', '-1', '0'],
@@ -873,7 +729,7 @@ test('arrow keys move the selection within a radiogroup, which has one tab stop
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(
const { calls, chip, controls } = await stubPanel(
dom,
uiState({ keyRequired: true }),
);
@@ -904,7 +760,7 @@ test('OPEN STREET VIEW opens the nearest panorama at the view centre without mov
count: 0,
});
state.groundClickReady = true;
const { layer } = stubPanel(dom, state);
const { layer } = await stubPanel(dom, state);
const asked = [];
layer.openNearest = (point, options) => asked.push([point, options]);
const button = dom.root.querySelector('#sl-nearest-btn');
@@ -918,12 +774,12 @@ test('OPEN STREET VIEW opens the nearest panorama at the view centre without mov
],
]);
// Too high to mean a street: disabled, and a click does nothing.
layer.publish({ ...state, groundClickReady: false });
await layer.publish({ ...state, groundClickReady: false });
assert.equal(button.disabled, true);
button.click();
assert.equal(asked.length, 1);
// Off Google 3D the provider is not listed: no button.
layer.publish({ ...state, providers: [state.providers[0]] });
await layer.publish({ ...state, providers: [state.providers[0]] });
assert.equal(button.hidden, true);
}));
@@ -941,7 +797,7 @@ test('a refused Street View key cannot lock Mapillary out; its chip only explain
error: 'Google refused GOOGLE_MAPS_API_KEY for Street View',
});
state.providers[0].keyRequired = false;
const { calls, layer } = stubPanel(dom, state);
const { calls, layer } = await stubPanel(dom, state);
const chips = () => dom.root.querySelector('#sl-provider-chips').children;
chips()[0].click(); // Mapillary: configured, dark
await settle();
@@ -953,7 +809,7 @@ test('a refused Street View key cannot lock Mapillary out; its chip only explain
state.providers[0].on = true;
state.providers[1].on = false;
state.keyRequired = false;
layer.publish({ ...state });
await layer.publish({ ...state });
chips()[1].click();
await settle();
assert.equal(calls.setParams.length, 1);
@@ -965,7 +821,7 @@ test('a refused Street View key cannot lock Mapillary out; its chip only explain
test('a swap names every registered provider, also those hidden on this map', () =>
withDom(async (dom) => {
const state = uiState({ on: false });
const { calls, layer, chip } = stubPanel(dom, state);
const { calls, layer, chip } = await stubPanel(dom, state);
// Off Google 3D the Street View chip is not listed, but stays registered.
layer.providerIds = ['mapillary', 'google'];
chip().click();
@@ -978,27 +834,27 @@ test('a swap names every registered provider, also those hidden on this map', ()
test('a note under SINCE says what date it compares, only while one is given', () =>
withDom(async (dom) => {
const state = uiState();
const { layer } = stubPanel(dom, state);
const { layer } = await stubPanel(dom, state);
const note = () => dom.root.querySelector('#sl-since-note');
assert.equal(note().hidden, true, 'none for Mapillary');
layer.publish({ ...state, sinceNote: '(by upload date)' });
await layer.publish({ ...state, sinceNote: '(by upload date)' });
assert.equal(note().hidden, false);
assert.equal(note().textContent, '(by upload date)');
layer.publish(state);
await layer.publish(state);
assert.equal(note().hidden, true);
// The panel's own markup has it, read out with the slider.
const template = fs.readFileSync(
new URL('./views/LayerPanelsView.vue', import.meta.url),
new URL('./views/panels/StreetLevelPanel.vue', import.meta.url),
'utf8',
);
assert.match(template, /<small id="sl-since-note"[^>]* hidden>/);
assert.match(template, /<small id="sl-since-note"[^>]* :hidden="!view/);
assert.match(template, /aria-describedby="sl-since-label sl-since-note"/);
}));
test('Street View lit hides the imagery filters; Mapillary lit shows them', () =>
withDom(async (dom) => {
const state = uiState();
const { layer } = stubPanel(dom, state);
const { layer } = await stubPanel(dom, state);
const shown = () => ({
pano: !dom.root.querySelector('#sl-pano-row').hidden,
since: !dom.root.querySelector('#sl-since-row').hidden,
@@ -1009,7 +865,7 @@ test('Street View lit hides the imagery filters; Mapillary lit shows them', () =
since: true,
controls: true,
});
layer.publish({
await layer.publish({
...state,
filterControls: { pano: false, since: false },
});
@@ -1018,10 +874,50 @@ test('Street View lit hides the imagery filters; Mapillary lit shows them', () =
since: false,
controls: false,
});
layer.publish(state);
await layer.publish(state);
assert.deepEqual(shown(), {
pano: true,
since: true,
controls: true,
});
}));
test('dragging SINCE shows the stop under the thumb and leaves the thumb there', () =>
withDom(async (dom) => {
const { calls, controls } = await stubPanel(dom, uiState());
const since = dom.root.querySelector('#sl-since');
since.value = '5';
since.dispatchEvent(new Event('input'));
await settle();
assert.equal(since.value, '5', 'the readout render keeps the thumb');
assert.equal(
dom.root.querySelector('#sl-since-label').textContent,
'LAST YEAR',
);
since.dispatchEvent(new Event('change'));
assert.deepEqual(calls.setParams.at(-1)[0], { sinceDays: 365 });
controls.destroy();
}));
test('a window changed elsewhere under a focused SINCE thumb leaves its readout alone', () =>
withDom(async (dom) => {
const state = uiState();
const { layer, controls } = await stubPanel(dom, {
...state,
filter: { ...state.filter, sinceDays: 365 },
});
const since = dom.root.querySelector('#sl-since');
const label = () => dom.root.querySelector('#sl-since-label').textContent;
assert.equal(since.value, '5');
assert.match(label(), /^LAST YEAR · SINCE /);
since.focus();
await layer.publish({
...state,
filter: { ...state.filter, sinceDays: 30 },
});
await settle();
assert.equal(since.value, '5', 'the thumb stays under the hand');
assert.match(label(), /^LAST YEAR · SINCE /, 'and the readout with it');
assert.equal(since.getAttribute('aria-valuetext'), label());
controls.destroy();
}));