fix(street-level): re-check an unknown key, Esc leaves full screen, say why a view is empty

- A failed or missing key status is asked again on the next switch-on, and
  the check gives up after 10 s instead of holding LOADING forever.
- Waiting for the key status reads as LOADING while the layer is on.
- Esc shrinks the photo in full screen too, before the panel's own Esc.
- Destroying the panel with the iPhone popover open leaves no popover behind.
- An empty view says whether the filter emptied it.
- A freed upstream tile slot passes straight to the oldest waiter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-08 18:25:16 +04:00
1 parent d313b6e62c
commit 8e5e3cdd3b
10 files changed
+201 -30

No files matched your search

+13 -9
View File
@@ -118,18 +118,18 @@ export class StreetLevelControls {
this.listen(el.viewerExpand, 'click', () => this._toggleExpanded());
if (this._expandMode === 'fullscreen')
this.listen(document, 'fullscreenchange', () => this._syncExpanded());
else {
else
this.listen(el.viewerWrap, 'toggle', (event) =>
this._onPopoverToggle(event),
);
// Esc shrinks the photo before the panel's own Esc can collapse it.
this.listen(el.viewerWrap, 'keydown', (event) => {
if (event.key !== 'Escape' || !this.isViewerExpanded()) return;
event.preventDefault();
event.stopPropagation();
this._exitExpanded();
});
}
// Esc shrinks the photo before the panel's own Esc can collapse it, in
// a browser that hands the key to the page while full screen too.
this.listen(el.viewerWrap, 'keydown', (event) => {
if (event.key !== 'Escape' || !this.isViewerExpanded()) return;
event.preventDefault();
event.stopPropagation();
this._exitExpanded();
});
} else setProp(el.viewerExpand, 'hidden', true);
for (const button of el.renderButtons) {
this.listen(button, 'click', () => {
@@ -430,6 +430,10 @@ export class StreetLevelControls {
if (this.destroyed) return;
this.destroyed = true;
this._exitExpanded();
// Its `toggle` is not heard once destroyed: a popover left on the wrap
// 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();
+47
View File
@@ -185,6 +185,11 @@ function panelDom({ fullscreen = true, popover = false } = {}) {
const wrap = add(root, 'div', { id: 'sl-viewer-wrap' });
if (fullscreen) wrap.requestFullscreen = async () => fullscreenTo(wrap);
if (popover) {
// `popover` reflects its attribute, as in the DOM.
Object.defineProperty(wrap, 'popover', {
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);
@@ -559,6 +564,28 @@ test('EXPAND puts the viewer full screen in place, and the button shrinks it aga
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 expand = dom.root.querySelector('#sl-viewer-expand');
expand.click();
await settle();
await settle();
assert.equal(dom.document.fullscreenElement, dom.wrap);
let panelEsc = 0;
dom.root.addEventListener('keydown', () => panelEsc++);
expand.dispatchEvent({ type: 'keydown', key: 'Escape', bubbles: true });
await settle();
await settle();
assert.equal(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 });
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 }));
@@ -622,6 +649,26 @@ test('on iPhone (no element fullscreen) EXPAND opens the viewer as a popover ove
{ fullscreen: false, popover: true },
));
test('destroying the panel while the iPhone popover is open leaves no popover behind', () =>
withDom(
async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
dom.root.querySelector('#sl-viewer-expand').click();
await settle();
await settle();
assert.equal(dom.wrap.getAttribute('popover'), 'auto');
controls.destroy();
await settle();
assert.equal(dom.wrap.popoverOpen, false);
assert.equal(
dom.wrap.getAttribute('popover'),
null,
'its toggle is not heard any more',
);
},
{ fullscreen: false, popover: true },
));
/** A MutationObserver stand-in whose callback the test fires. */
function fakeMutationObserver() {
const observers = new Set();
+8 -1
View File
@@ -103,7 +103,14 @@ function presentMeta(state) {
: `${state.sequence.images.toLocaleString()} images in this sequence · Esc clears`;
if (state.coverage.count > 0)
return `${state.coverage.count.toLocaleString()} sequences in view · click a line for its photos`;
return state.coverage.hint || '';
if (state.coverage.hint) return state.coverage.hint;
// Still loading, or the error line already says why.
if (state.coverage.loading || state.keyRequired || state.coverage.error)
return '';
const filter = state.filter || {};
return (filter.pano || 'all') !== 'all' || Number(filter.sinceDays) > 0
? 'No sequences in view match the filter'
: 'No Mapillary coverage in view';
}
/** @param {{now?: number}} [options] Clock for the SINCE readout. */
+18
View File
@@ -201,6 +201,24 @@ test('the meta line never mixes the visible-sequence count with the selected seq
assert.equal(hinted.meta, 'Point the camera at the globe');
});
test('an empty view says so, and whether the filter emptied it', () => {
const meta = (overrides) =>
presentStreetLevelPanel(snapshot({ enabled: true, ...overrides })).meta;
assert.equal(meta({}), 'No Mapillary coverage in view');
assert.equal(
meta({ filter: { pano: 'flat', sinceDays: 0 } }),
'No sequences in view match the filter',
);
assert.equal(
meta({ filter: { pano: 'all', sinceDays: 365 } }),
'No sequences in view match the filter',
);
// Nothing to claim while it loads, or when the error line explains it.
assert.equal(meta({ coverage: { loading: true } }), '');
assert.equal(meta({ keyRequired: true }), '');
assert.equal(meta({ coverage: { error: 'Tile HTTP 502' } }), '');
});
test('viewer caption reads "Image by" left, date right, and links to Mapillary', () => {
const view = presentStreetLevelPanel(
snapshot({