fix(street-level): viewer lifecycle, hover cost, keyboard focus, panel lift

From the IC8 review of #768 (P2s), each with a test that fails before:

- Switching the layer off and on during the first photo download no longer
  leaves the viewer stuck on "not mounted": an outdated mount only unmounts
  an adapter nobody else holds and subscribes poses only once current, and
  the MapillaryJS adapter never builds a viewer after it was unmounted.
- Hover picks at most every 120 ms (the CCTV convention), not while the
  camera moves or a button is held, and not after the layer went off.
- Esc shrinks the expanded viewer wherever focus is (document capture), so
  the map keeps its selection, and Tab from outside returns into it.
  Closing a photo moves focus to the panel instead of dropping it on body.
- Portable panels lift only after a mouse or pen moves past the drag
  threshold: a click on a docked panel's edge or a touch swipe on its
  header no longer turns it into a floating window, and pointercancel
  saves nothing and puts the panel back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-05 15:55:18 +01:00
1 parent b1924ebb3e
commit 539d514adb
10 files changed
+793 -101

No files matched your search

+114
View File
@@ -34,8 +34,10 @@ function fixture() {
querySelector: () => null,
querySelectorAll: () => [],
closest: () => null,
setAttribute() {},
removeAttribute() {},
appendChild() {},
remove() {},
getBoundingClientRect: () => ({
left: 100,
top: 80,
@@ -49,6 +51,7 @@ function fixture() {
globalThis.document = Object.assign(new EventTarget(), {
getElementById: () => null,
querySelectorAll: () => [],
createElement: () => element(),
});
globalThis.window = Object.assign(new EventTarget(), {
innerWidth: 1440,
@@ -245,3 +248,114 @@ test('renumbering the z ladder never drops a window below the docked panels', ()
f.restore();
}
});
/** A portable panel still in its rail, with its header and resize handles. */
function dockedPanel(f, id) {
const panel = f.element();
const handle = f.element();
panel.id = id;
const portable = { panel, min: { width: 320, height: 280 } };
f.owner._portablePanels.set(id, portable);
f.owner._makePanelDraggable(id, panel, handle);
f.owner._makePanelResizable(id, panel, portable.min);
const edges = Object.fromEntries(
f.owner._resizeHandles.map((node) => [node.dataset.dir, node]),
);
return { panel, handle, edges };
}
/** A pointer event; `pointerType` defaults to what a mouse reports. */
function pointer(target, type, { x = 200, y = 90, pointerType = 'mouse' }) {
const event = new Event(type, { cancelable: true });
for (const [key, value] of Object.entries({
button: 0,
clientX: x,
clientY: y,
pointerType,
}))
Object.defineProperty(event, key, { value });
target.dispatchEvent(event);
return event;
}
const POSITION_KEY = 'godsEyeView.v8.panelPos.street-level-panel';
test('a click on a docked panel’s resize edge leaves it docked and stores nothing', () => {
const f = fixture();
try {
const { panel, edges } = dockedPanel(f, 'street-level-panel');
pointer(edges.w, 'pointerdown', { x: 101, y: 200 });
pointer(window, 'pointermove', { x: 103, y: 201 }); // a jittery click
pointer(window, 'pointerup', { x: 103, y: 201 });
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(panel.style.width, undefined, 'no size was pinned');
assert.equal(f.store.has(POSITION_KEY), false);
assert.deepEqual(f.calls.resized, []);
// A real resize still lifts it out and remembers the window.
pointer(edges.w, 'pointerdown', { x: 101, y: 200 });
pointer(window, 'pointermove', { x: 61, y: 200 });
pointer(window, 'pointerup', { x: 61, y: 200 });
assert.equal(panel.classList.contains('panel-floating'), true);
assert.equal(JSON.parse(f.store.get(POSITION_KEY)).floating, true);
} finally {
f.restore();
}
});
test('a touch on the header or an edge does not lift the panel, and the rail can still scroll', () => {
const f = fixture();
try {
const { panel, handle, edges } = dockedPanel(f, 'street-level-panel');
const press = pointer(handle, 'pointerdown', { pointerType: 'touch' });
assert.equal(press.defaultPrevented, false, 'the swipe can scroll');
pointer(window, 'pointermove', { y: 190, pointerType: 'touch' });
pointer(window, 'pointerup', { y: 190, pointerType: 'touch' });
pointer(edges.s, 'pointerdown', { y: 500, pointerType: 'touch' });
pointer(window, 'pointermove', { y: 600, pointerType: 'touch' });
pointer(window, 'pointerup', { y: 600, pointerType: 'touch' });
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(f.store.has(POSITION_KEY), false);
} finally {
f.restore();
}
});
test('a pointercancel mid-drag saves nothing and puts the panel back', () => {
const f = fixture();
try {
const { panel, handle, edges } = dockedPanel(f, 'street-level-panel');
pointer(handle, 'pointerdown', {});
pointer(window, 'pointermove', { x: 260, y: 140 });
assert.equal(panel.classList.contains('panel-floating'), true, 'lifted');
pointer(window, 'pointercancel', { x: 260, y: 140 });
assert.equal(panel.classList.contains('panel-floating'), false, 'docked');
assert.equal(f.store.has(POSITION_KEY), false);
pointer(edges.e, 'pointerdown', { x: 459 });
pointer(window, 'pointermove', { x: 520 });
pointer(window, 'pointercancel', { x: 520 });
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(f.store.has(POSITION_KEY), false);
} finally {
f.restore();
}
});
test('a pointercancel while dragging a floating window returns it to where it was', () => {
const f = fixture();
try {
const { panel, handle } = floatingPanel(f, 'cctv-panel');
const stored = f.store.get('godsEyeView.v8.panelPos.cctv-panel');
pointer(handle, 'pointerdown', {});
pointer(window, 'pointermove', { x: 400, y: 300 });
assert.notEqual(panel.style.left, '100px', 'it moved');
pointer(window, 'pointercancel', { x: 400, y: 300 });
assert.equal(panel.style.left, '100px');
assert.equal(panel.style.top, '80px');
assert.equal(panel.classList.contains('panel-floating'), true);
assert.equal(f.store.get('godsEyeView.v8.panelPos.cctv-panel'), stored);
} finally {
f.restore();
}
});
+80 -30
View File
@@ -52,6 +52,21 @@ function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
/**
* Only a mouse or pen lifts and resizes a portable panel. On a phone the
* rail is a scroll container, so a finger on a header is a swipe.
*/
function isPrecisePointer(event) {
const pointerType = event.pointerType || 'mouse';
return pointerType === 'mouse' || pointerType === 'pen';
}
/** Inline geometry a cancelled gesture puts back. */
function geometryOf(panelEl) {
const { left, top, width, height } = panelEl.style;
return { left, top, width, height };
}
export class PanelPositionControls {
/**
* @param {object} options
@@ -548,7 +563,7 @@ export class PanelPositionControls {
});
let lastPress = null;
this.listen(handleEl, 'pointerdown', (event) => {
if (event.button !== 0) return;
if (event.button !== 0 || !isPrecisePointer(event)) return;
const interactive = event.target.closest?.(INTERACTIVE_SELECTOR);
if (interactive && !interactive.matches('.panel-collapse-btn')) return;
@@ -574,6 +589,8 @@ export class PanelPositionControls {
const startX = event.clientX;
const startY = event.clientY;
let dragging = false;
let lifted = false;
let before = null;
let offsetX = 0;
let offsetY = 0;
let width = 0;
@@ -594,7 +611,8 @@ export class PanelPositionControls {
offsetY = startY - rect.top;
width = rect.width;
height = rect.height;
this._liftPanelOut(panelId, panelEl, rect);
before = geometryOf(panelEl);
lifted = this._liftPanelOut(panelId, panelEl, rect);
panelEl.classList.add('panel-dragging');
this._promotePanelZ(panelEl);
}
@@ -615,7 +633,7 @@ export class PanelPositionControls {
panelEl.classList.remove('panel-dragging');
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
window.removeEventListener('pointercancel', onCancel);
this._cancelDrag = null;
};
const onUp = () => {
@@ -628,14 +646,33 @@ export class PanelPositionControls {
this._savePanelPosition(panelId, panelEl);
this._onPanelResized?.(panelId);
};
const onCancel = () => {
cancel();
if (dragging)
this._revertPanelGesture(panelId, panelEl, lifted, before);
};
this._cancelDrag = cancel;
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
window.addEventListener('pointercancel', onCancel);
});
}
/**
* The browser took a gesture back (pointercancel): leave no trace and save
* nothing. A panel the gesture lifted returns to its rail; a window that
* was already floating returns to where it was.
*/
_revertPanelGesture(panelId, panelEl, lifted, before) {
if (lifted) {
this._resetPanelPosition(panelId);
return;
}
Object.assign(panelEl.style, before);
this._onPanelResized?.(panelId);
}
/**
* Append a resize handle for every edge and corner. Seven are invisible
* strips; the bottom-right corner shows a grip. Resizing a docked panel
@@ -661,63 +698,76 @@ export class PanelPositionControls {
}
_startPanelResize(event, panelId, panelEl, dir, limits) {
if (event.button !== 0) return;
if (event.button !== 0 || !isPrecisePointer(event)) return;
event.preventDefault();
this._cancelDrag?.();
const rect = panelEl.getBoundingClientRect();
this._liftPanelOut(panelId, panelEl, rect);
const startBox = {
left: Math.round(rect.left),
top: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
const startX = event.clientX;
const startY = event.clientY;
// Set once the pointer has travelled: a plain click on an edge (or on
// the globe just beside a docked panel) must not lift it out.
let startBox = null;
let lifted = false;
let before = null;
const apply = ({ left, top, width, height }) => {
panelEl.style.left = `${left}px`;
panelEl.style.top = `${top}px`;
panelEl.style.width = `${width}px`;
panelEl.style.height = `${height}px`;
};
panelEl.classList.add('panel-resizing');
this._promotePanelZ(panelEl);
apply(startBox);
const begin = () => {
const rect = panelEl.getBoundingClientRect();
before = geometryOf(panelEl);
lifted = this._liftPanelOut(panelId, panelEl, rect);
startBox = {
left: Math.round(rect.left),
top: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
panelEl.classList.add('panel-resizing');
this._promotePanelZ(panelEl);
apply(startBox);
};
const onMove = (moveEvent) => {
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
if (!startBox) {
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
begin();
}
moveEvent.preventDefault();
apply(
resizeBox(
startBox,
dir,
moveEvent.clientX - startX,
moveEvent.clientY - startY,
{
minWidth: limits.width,
minHeight: limits.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
},
),
resizeBox(startBox, dir, dx, dy, {
minWidth: limits.width,
minHeight: limits.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
}),
);
};
const cancel = () => {
panelEl.classList.remove('panel-resizing');
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
window.removeEventListener('pointercancel', onCancel);
this._cancelDrag = null;
};
const onUp = () => {
cancel();
if (!startBox) return;
this._savePanelPosition(panelId, panelEl);
this._onPanelResized?.(panelId);
};
const onCancel = () => {
cancel();
if (startBox) this._revertPanelGesture(panelId, panelEl, lifted, before);
};
this._cancelDrag = cancel;
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
window.addEventListener('pointercancel', onCancel);
}
destroy() {
+37 -6
View File
@@ -121,8 +121,13 @@ export class StreetLevelControls {
} catch {
/* storage unavailable */
}
// The expanded viewer is a dialog: Esc closes it, Tab stays inside.
this.listen(el.viewerWrap, 'keydown', (event) => this._onDialogKey(event));
// The expanded viewer is a dialog: Esc shrinks it, Tab stays inside.
// Listen on the document, ahead of everything else, so both still hold
// once focus has left it (a click on the globe) and Esc never reaches
// the map's own handler, which would clear the selected sequence.
this.listen(document, 'keydown', (event) => this._onDialogKey(event), {
capture: true,
});
// MapillaryJS only tracks window resizes; the panel resizes on its own.
if (typeof ResizeObserver === 'function' && el.viewer) {
let queued = false;
@@ -258,9 +263,15 @@ export class StreetLevelControls {
this._wrapHome = null;
const target = this._expandReturnFocus;
this._expandReturnFocus = null;
if (target?.isConnected && typeof target.focus === 'function')
target.focus({ preventScroll: true });
else this._elements.viewerExpand?.focus?.({ preventScroll: true });
// A closed image hides the wrap: focus inside it would drop to <body>.
const reachable = (node) =>
node?.isConnected &&
typeof node.focus === 'function' &&
!(wrap.hidden && wrap.contains(node));
if (reachable(target)) target.focus({ preventScroll: true });
else if (reachable(this._elements.viewerExpand))
this._elements.viewerExpand.focus({ preventScroll: true });
else this._focusPanelControl();
}
const button = this._elements.viewerExpand;
if (button) {
@@ -275,6 +286,14 @@ export class StreetLevelControls {
requestAnimationFrame(() => this.layer.resizeViewer?.());
}
/** Park focus on a control that stays on screen while the viewer is hidden. */
_focusPanelControl() {
const el = this._elements;
const target =
el.status || el.providerChips?.querySelector('.data-toggle-chip');
target?.focus?.({ preventScroll: true });
}
_onDialogKey(event) {
if (!this.isViewerExpanded()) return;
if (event.key === 'Escape') {
@@ -391,7 +410,19 @@ export class StreetLevelControls {
_renderViewer(view, state) {
const el = this._elements;
const { viewer } = view;
if (el.viewerWrap) el.viewerWrap.hidden = !viewer.open;
const wrap = el.viewerWrap;
if (wrap) {
// Closing the image (its × button) hides the wrap: move focus out
// first, or it drops to <body>. A shrink below returns it itself.
if (
!viewer.open &&
!wrap.hidden &&
!this.isViewerExpanded() &&
wrap.contains(document.activeElement)
)
this._focusPanelControl();
wrap.hidden = !viewer.open;
}
if (!viewer.open && this.isViewerExpanded()) this.setViewerExpanded(false);
if (el.viewerPlaceholder) el.viewerPlaceholder.hidden = !viewer.loading;
if (el.followBtn) {
+167 -30
View File
@@ -66,6 +66,9 @@ class FakeNode {
getAttribute(key) {
return this.attributes.get(key) ?? null;
}
removeAttribute(key) {
this.attributes.delete(key);
}
appendChild(child) {
return this.insertBefore(child, null);
}
@@ -95,6 +98,12 @@ class FakeNode {
if (current === this) return true;
return false;
}
get isConnected() {
return this.ownerDocument?.contains(this) === true;
}
focus() {
this.ownerDocument.activeElement = this;
}
closest(selector) {
const name = selector.replace(/^\./, '');
for (let node = this; node; node = node.parent)
@@ -110,18 +119,33 @@ class FakeNode {
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
querySelectorAll(selector) {
const byId = /^#(.+)$/.exec(selector);
const byData = /^\[data-([a-z-]+)\]$/.exec(selector);
const key = byData?.[1].replace(/-([a-z])/g, (_, c) => c.toUpperCase());
return [...this.walk()].filter((node) =>
byId ? node.id === byId[1] : key ? key in node.dataset : false,
);
/** `#id`, `[data-x]`, and the tag / attribute forms of a focusable list. */
matches(selector) {
return selector.split(',').some((part) => {
const one = part.trim();
const byId = /^#(.+)$/.exec(one);
if (byId) return this.id === byId[1];
const byData = /^\[data-([a-z-]+)\]$/.exec(one);
if (byData)
return (
byData[1].replace(/-([a-z])/g, (_, c) => c.toUpperCase()) in
this.dataset
);
if (one === '[tabindex]:not([tabindex="-1"])')
return ![null, '-1'].includes(this.getAttribute('tabindex'));
const byTag = /^([a-z]+)(\[href\])?(:not\(\[disabled\]\))?$/.exec(one);
if (!byTag || this.tagName !== byTag[1].toUpperCase()) return false;
if (byTag[2] && this.getAttribute('href') === null) return false;
return !(byTag[3] && this.disabled);
});
}
addEventListener(type, listener, { signal } = {}) {
querySelectorAll(selector) {
return [...this.walk()].filter((node) => node.matches(selector));
}
addEventListener(type, listener, { signal, capture = false } = {}) {
if (signal?.aborted) return;
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type).add(listener);
if (!this.listeners.has(type)) this.listeners.set(type, new Map());
this.listeners.get(type).set(listener, capture);
signal?.addEventListener('abort', () =>
this.listeners.get(type)?.delete(listener),
);
@@ -129,16 +153,45 @@ class FakeNode {
removeEventListener(type, listener) {
this.listeners.get(type)?.delete(listener);
}
/** Deliver to this node, then bubble to its ancestors with the same target. */
/**
* Capture down from the document, then deliver to this node and bubble
* back up with the same target; `stopPropagation` ends the trip.
*/
dispatchEvent(event) {
let stopped = false;
const delivered = {
type: event.type,
target: this,
bubbles: event.bubbles,
key: event.key,
shiftKey: event.shiftKey === true,
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true;
},
stopPropagation() {
stopped = true;
},
};
for (let node = this; node; node = delivered.bubbles ? node.parent : null)
for (const listener of [...(node.listeners.get(event.type) || [])])
listener(delivered);
const path = [];
for (let node = this; node; node = node.parent) path.push(node);
const run = (node, phase) => {
for (const [listener, capture] of [
...(node.listeners.get(event.type) || []),
])
if (phase === 'target' || capture === (phase === 'capture'))
listener(delivered);
};
for (const node of path.slice(1).reverse()) {
run(node, 'capture');
if (stopped) return true;
}
run(this, 'target');
if (!delivered.bubbles) return true;
for (const node of path.slice(1)) {
if (stopped) return true;
run(node, 'bubble');
}
return true;
}
click() {
@@ -148,39 +201,48 @@ class FakeNode {
/** The Street Level panel's body, as layer-panels.html lays it out. */
function panelDom() {
const document = {
const document = new FakeNode(null, '#document');
Object.assign(document, {
ownerDocument: document,
activeElement: null,
body: null,
createElement: (tag) => new FakeNode(document, tag),
};
document.body = new FakeNode(document, 'body');
const root = new FakeNode(document, 'section', { id: 'street-level-panel' });
});
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' });
for (const id of [
'sl-status',
'sl-provider-chips',
'sl-error',
'sl-error-text',
'sl-since',
'sl-since-label',
'sl-legend',
'sl-follow-btn',
'sl-viewer-expand',
'sl-viewer-close',
'sl-viewer-placeholder',
'sl-image-by',
'sl-image-when',
'sl-image-link',
'sl-coverage-meta',
])
add(root, id === 'sl-legend' ? 'ul' : 'div', { id });
const controls = add(root, 'fieldset', { id: 'sl-controls' });
for (const pano of ['all', 'pano', 'flat'])
add(controls, 'button', { dataset: { slPano: pano } });
const wrap = add(root, 'div', { id: 'sl-viewer-wrap' });
add(wrap, 'div', { id: 'sl-viewer' });
return { document, root };
// The viewer's own tool bar lives inside the wrap, as in the markup.
const wrap = add(controls, 'div', { id: 'sl-viewer-wrap' });
for (const id of ['sl-viewer-expand', 'sl-follow-btn', 'sl-viewer-close'])
add(wrap, 'button', { id });
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 });
// The globe's canvas is focusable too (tabindex=0).
const globe = add(document.body, 'canvas');
globe.setAttribute('tabindex', '0');
return { document, root, globe };
}
/** Run `fn` with the fake document and an immediate animation frame. */
@@ -504,3 +566,78 @@ test('the viewer is resized when it opens, not on every render (review #8)', ()
layer.publish(uiState({ open: true }));
assert.equal(layer.calls.resize, 2, 'the next photo resizes again');
}));
/* ── The expanded viewer as a dialog, and where focus goes ─────────────── */
const keydown = (target, key, { shiftKey = false } = {}) =>
target.dispatchEvent({ type: 'keydown', bubbles: true, key, shiftKey });
/** The map's own Esc handler (selection.js) listens on the document. */
function mapEscape(dom) {
const reached = [];
dom.document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') reached.push(event);
});
return reached;
}
test('Esc shrinks the expanded viewer after focus left it, and the map keeps its selection', () =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
const reached = mapEscape(dom);
controls.setViewerExpanded(true);
dom.globe.focus(); // a click on the globe
keydown(dom.globe, 'Escape');
assert.equal(controls.isViewerExpanded(), false);
assert.equal(reached.length, 0, 'the selected sequence was not cleared');
keydown(dom.globe, 'Escape');
assert.equal(reached.length, 1, 'once shrunk, Esc is the map’s again');
controls.destroy();
}));
test('Tab from outside the expanded viewer brings focus back into it', () =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
controls.setViewerExpanded(true);
dom.globe.focus();
keydown(dom.globe, 'Tab');
const wrap = dom.document.body.querySelector('#sl-viewer-wrap');
assert.equal(
dom.document.activeElement,
wrap.querySelector('#sl-viewer-expand'),
);
keydown(dom.document.activeElement, 'Tab', { shiftKey: true });
assert.equal(
dom.document.activeElement,
wrap.querySelector('#sl-viewer-close'),
'Shift+Tab wraps to the last control',
);
controls.destroy();
}));
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 }));
dom.root.querySelector('#sl-viewer-close').focus();
layer.publish(uiState({ open: false }));
assert.equal(dom.root.querySelector('#sl-viewer-wrap').hidden, true);
assert.equal(
dom.document.activeElement,
dom.root.querySelector('#sl-status'),
);
controls.destroy();
}));
test('an image closed while expanded does not return focus into the hidden viewer', () =>
withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
dom.root.querySelector('#sl-viewer-expand').focus();
controls.setViewerExpanded(true); // remembers EXPAND to return to
layer.publish(uiState({ open: false }));
assert.equal(controls.isViewerExpanded(), false);
assert.equal(
dom.document.activeElement,
dom.root.querySelector('#sl-status'),
);
controls.destroy();
}));