refactor(street-level): simpler panel — native fullscreen, no FOLLOW button, no provider chips

EXPAND now uses the browser's Fullscreen API on the viewer element, which
stays in the panel; the hand-built modal (re-parenting to <body>, inert
siblings, focus trap, document-level Esc/Tab, Clean View/recording/cockpit
hide rules) is gone. The button is hidden where the API is missing.

The FOLLOW button and the PROVIDERS chip row are removed. The header pill is
the only layer switch and re-enables a provider switched off elsewhere; under
KEY REQUIRED the error line says which key to add. Chip colour support in
chipGroup, dead CSS and tests pinning the removed behaviour go with them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-07 21:50:11 +04:00
1 parent fbbc4cda5c
commit 7dec7883aa
17 files changed
+285 -1444

No files matched your search

+3 -3
View File
@@ -4,9 +4,9 @@
- Street Level: a street-level imagery layer modelled on the iD editor's photo - Street Level: a street-level imagery layer modelled on the iD editor's photo
overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0 overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0
imagery). One right-rail panel holds a chip per provider, shared 360°/flat and imagery). One right-rail panel holds an ON/OFF pill, shared 360°/flat and
captured-since filters, and an embedded viewer with EXPAND, FIT/FILL and captured-since filters, a legend, and an embedded viewer with full-screen
FOLLOW (Google 3D only). Coverage lines and image cones are drawn from orbit EXPAND and FIT/FILL. Coverage lines and image cones are drawn from orbit
down to a single street; on Google 3D they sit on the bare earth, refined down to a single street; on Google 3D they sit on the bare earth, refined
against the rendered mesh near the camera, so trees and buildings hide them. against the rendered mesh near the camera, so trees and buildings hide them.
Share links carry the provider switches, the filters and the panel's collapsed Share links carry the provider switches, the filters and the panel's collapsed
+2 -2
View File
@@ -296,7 +296,7 @@ Twenty layers and map sources. **Seventeen have a keyless path.** Some offer add
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
| 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary first; Panoramax, KartaView and Google Street View planned | Mapillary | 🟡 Mapillary client token | | 📷 **Street Level** | Street-level photos behind one panel: coverage on the streets, image cones and an embedded viewer with full-screen **EXPAND**. Mapillary first; Panoramax, KartaView and Google Street View planned | Mapillary | 🟡 Mapillary client token |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | | 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 |
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
@@ -338,7 +338,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**. **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**.
**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). The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to every provider. Click a coverage line for its image cones and a cone to open the photo. Above the photo: **EXPAND**, **FIT / FILL**, and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while Mapillary is on. **Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider draws in its own colour (Mapillary green; see the legend). The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to every provider. Click a coverage line for its image cones and a cone to open the photo. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL**. The caption links to the image on the provider's site, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while Mapillary is on.
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `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`. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `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`.
+15 -14
View File
@@ -2938,15 +2938,16 @@ Street Level is a collapsible right-rail panel (`#street-level-panel`, layer
token `0`, option owner `street-level`, panel `ui` token `t`) that starts token `0`, option owner `street-level`, panel `ui` token `t`) that starts
collapsed. It opens itself only when the user, voice or a tool switches the collapsed. It opens itself only when the user, voice or a tool switches the
layer on or a photo opens, never on a restore, and those opens are not stored. layer on or a photo opens, never on a restore, and those opens are not stored.
Like the iD editor's photo overlay, it has one chip per registered provider, Like the iD editor's photo overlay, it has one switch per registered provider
(share options, no panel chips),
shared 360°/flat and captured-since filters (relative days, so a link keeps its shared 360°/flat and captured-since filters (relative days, so a link keeps its
meaning), one viewer host and one on-globe credit per active provider. Each meaning), one viewer host and one on-globe credit per active provider. Each
provider draws in one colour (`PROVIDER_COLORS` in provider draws in one colour (`PROVIDER_COLORS` in
`src/layers/streetLevel/policy.js`); 360° cones are rings and the selected `src/layers/streetLevel/policy.js`); 360° cones are rings and the selected
sequence is GEV cyan. Share options: `m` (Mapillary), `p` (`a`/`p`/`f` sequence is GEV cyan. Share options: `m` (Mapillary), `p` (`a`/`p`/`f`
panoramas), `s` (since, days). Only Mapillary is registered. Without panoramas), `s` (since, days). Only Mapillary is registered. Without
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are `MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED, its error line says
disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14 which key to add, and the filters are disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14
sequence lines below 60 km; the proxy strips the unused `image` layer from z14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
requests for a tile. requests for a tile.
@@ -2968,16 +2969,16 @@ miss cooldown. Roads on elevated decks are still drawn at
ground level. Framing a photo ignores mesh samples far below the bare earth ground level. Framing a photo ignores mesh samples far below the bare earth
(unloaded tiles). (unloaded tiles).
The header pill is the layer switch. With one provider its chip is a layer The header pill is the only layer switch; switching on also turns back on a
switch too; with several, darkening the last lit chip turns the layer off. The provider a share link or tool switched off. The viewer sits under the header
viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the with EXPAND, FIT/FILL and close above the image. EXPAND is the browser's
image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and Fullscreen API on the viewer element, which stays in the panel (Esc or SHRINK
stops when the stack changes. Camera moves go through the application's leaves; the button is hidden where the API is missing). There is no FOLLOW
navigation (`attachNavigation`), which releases aircraft and satellite tracking button; the layer's `setFollow` has no panel control. Camera moves go through
and is refused in the cockpit: turning FOLLOW on claims the camera at once, and the application's navigation (`attachNavigation`), which releases aircraft and
FOLLOW stops when another feature takes it; a photo takes a deferred ticket satellite tracking and is refused in the cockpit; a photo takes a deferred
when it starts opening and frames only if nothing newer took the camera while ticket when it starts opening and frames only if nothing newer took the camera
it loaded. SINCE is a stepped slider whose readout names the while it loaded. SINCE is a stepped slider whose readout names the
cut-off date. At phone width (≤720 px) the viewer height is derived from the cut-off date. At phone width (≤720 px) the viewer height is derived from the
rail band so the whole photo fits. rail band so the whole photo fits.
@@ -2988,7 +2989,7 @@ add a provider: implement the definition, register it in
`src/app/layers/streetLevel.js`, add its boolean option to the `street-level` `src/app/layers/streetLevel.js`, add its boolean option to the `street-level`
group in `src/data/layerState.js` and its modules to group in `src/data/layerState.js` and its modules to
`scripts/package-boundaries.json`. Once a keyless provider registers, the `scripts/package-boundaries.json`. Once a keyless provider registers, the
layer's `requiresKeyId` becomes null and the key gate moves to the chips. layer's `requiresKeyId` becomes null and the key gate must move per provider.
Street Level has two browser gates. `npm run qa:street-level -- --url <server>` Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
+10 -189
View File
@@ -41,7 +41,7 @@ export const VIEWPORTS = Object.freeze([
{ width: 1280, height: 800 }, { width: 1280, height: 800 },
]); ]);
/** Provider chips the panel must show, in order. */ /** Providers the layer registers: one legend swatch each, plus "Selected". */
export const EXPECTED_PROVIDERS = Object.freeze(['mapillary']); export const EXPECTED_PROVIDERS = Object.freeze(['mapillary']);
/** /**
@@ -448,15 +448,12 @@ async function assertKeylessGate(page) {
const info = await readPanel(page); const info = await readPanel(page);
assert.equal(info.controlsDisabled, true); assert.equal(info.controlsDisabled, true);
assert.equal(info.status, 'KEY REQUIRED'); assert.equal(info.status, 'KEY REQUIRED');
const chip = await page.$eval( const hint = await page.$eval('#sl-error', (node) => ({
'#sl-provider-chips [data-chip-id="mapillary"]', hidden: node.hidden,
(node) => ({ text: node.textContent,
error: node.classList.contains('chip-error'), }));
title: node.title, assert.equal(hint.hidden, false, 'the panel says how to add the key');
}), assert.match(hint.text, /MAPILLARY_CLIENT_TOKEN/);
);
assert.equal(chip.error, true, 'the keyless provider chip reads as an error');
assert.match(chip.title, /MAPILLARY_CLIENT_TOKEN/);
} }
/** /**
@@ -655,32 +652,18 @@ async function main() {
}; };
await expandStrip(); await expandStrip();
await step( await step(
'expanding the strip shows the body, one chip per provider and the legend', 'expanding the strip shows the body and the legend',
async () => { async () => {
const info = await panel(); const info = await panel();
assert.ok(!isCollapsed(info.classes)); assert.ok(!isCollapsed(info.classes));
assert.equal(info.bodyDisplay, 'flex'); assert.equal(info.bodyDisplay, 'flex');
const chips = await page.evaluate(() => // One swatch per source plus "Selected".
[
...document.querySelectorAll(
'#sl-provider-chips .data-toggle-chip',
),
].map((chip) => chip.dataset.chipId),
);
assert.deepEqual(chips, EXPECTED_PROVIDERS);
// One swatch per source plus "Selected"; each chip wears its colour.
assert.equal( assert.equal(
await page.evaluate( await page.evaluate(
() => document.querySelectorAll('#sl-legend li').length, () => document.querySelectorAll('#sl-legend li').length,
), ),
EXPECTED_PROVIDERS.length + 1, EXPECTED_PROVIDERS.length + 1,
); );
assert.equal(
await page.$eval('[data-chip-id="mapillary"]', (chip) =>
chip.style.getPropertyValue('--chip-color'),
),
'#05cb63',
);
}, },
); );
if (!status.configured) { if (!status.configured) {
@@ -779,7 +762,6 @@ async function main() {
undefined, undefined,
options, options,
); );
const chip = '#sl-provider-chips [data-chip-id="mapillary"]';
const stacks = { const stacks = {
active: () => active: () =>
page.evaluate(() => page.evaluate(() =>
@@ -838,42 +820,6 @@ async function main() {
); );
}, },
); );
await step(
'the only lit provider chip switches the whole layer off, credit and all',
async () => {
await clickPanelControl(page, chip);
await page.waitForFunction(
() =>
!window.__godsEyeView.dataManager.isEnabled('street-level') &&
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState().coverage.count === 0,
{ timeout: 15_000 },
);
await page.waitForFunction(
() =>
!document.body.innerHTML.includes('Mapillary</a> contributors'),
{ timeout: 15_000 },
);
assert.equal(
await page.$eval(chip, (node) => node.getAttribute('aria-pressed')),
'false',
);
// The provider stays switched on, so the layer comes back with it.
assert.equal((await ui()).providers[0].on, true);
},
);
await step(
'lighting the chip turns the layer back on with coverage',
async () => {
await clickPanelControl(page, chip);
await waitForCoverage(page);
assert.equal(
await page.$eval(chip, (node) => node.getAttribute('aria-pressed')),
'true',
);
},
);
await step( await step(
'the 360° filter keeps at most the unfiltered sequence count', 'the 360° filter keeps at most the unfiltered sequence count',
async () => { async () => {
@@ -1026,14 +972,10 @@ async function main() {
error: document.getElementById('sl-error').textContent, error: document.getElementById('sl-error').textContent,
errorHidden: document.getElementById('sl-error').hidden, errorHidden: document.getElementById('sl-error').hidden,
controls: document.getElementById('sl-controls').disabled, controls: document.getElementById('sl-controls').disabled,
chip: document
.querySelector('#sl-provider-chips [data-chip-id="mapillary"]')
.classList.contains('chip-error'),
})); }));
assert.match(shown.error, /rejected MAPILLARY_CLIENT_TOKEN/); assert.match(shown.error, /rejected MAPILLARY_CLIENT_TOKEN/);
assert.equal(shown.errorHidden, false); assert.equal(shown.errorHidden, false);
assert.equal(shown.controls, true); assert.equal(shown.controls, true);
assert.equal(shown.chip, true);
// Panning asks for nothing more: the verdict holds for every tile. // Panning asks for nothing more: the verdict holds for every tile.
const rejectedAt = await tileRequestsQuiet(QUIET_MS); const rejectedAt = await tileRequestsQuiet(QUIET_MS);
await panTo(-121.42, 38.63); await panTo(-121.42, 38.63);
@@ -1302,59 +1244,6 @@ async function main() {
'fixtures only', 'fixtures only',
'a click on a neighbouring cone steps the viewer to that photo', 'a click on a neighbouring cone steps the viewer to that photo',
); );
await step(
'FOLLOW is offered only on the Google 3D map',
async ({ skip }) => {
const follow = () =>
page.$eval('#sl-follow-btn', (node) => ({
disabled: node.disabled,
pressed: node.getAttribute('aria-pressed'),
title: node.title,
}));
const followIs = (disabled, pressed = null) =>
settle(
page,
([d, p]) => {
const node = document.getElementById('sl-follow-btn');
return (
node.disabled === d &&
(p === null || node.getAttribute('aria-pressed') === p)
);
},
[disabled, pressed],
);
const original = await stacks.active();
await stacks.set('esri-imagery');
await followIs(true);
let state = await follow();
assert.equal(state.disabled, true, 'disabled on Esri');
assert.match(state.title, /needs the Google 3D map/);
if (await stacks.photoreal()) {
await stacks.set('photoreal');
await followIs(false);
state = await follow();
assert.equal(state.disabled, false, 'enabled on Google 3D');
await clickPanelControl(page, '#sl-follow-btn');
await followIs(false, 'true');
assert.equal((await follow()).pressed, 'true');
await stacks.set('esri-imagery');
await followIs(true, 'false');
state = await follow();
assert.equal(
state.pressed,
'false',
'leaving Google 3D stops following',
);
assert.equal(state.disabled, true);
} else {
console.log(
' (Google 3D unavailable here: only the disabled path ran)',
);
skip('no Google 3D');
}
await stacks.set(original);
},
);
await park(); await park();
await step( await step(
'closing a photo stops the framing flight toward it', 'closing a photo stops the framing flight toward it',
@@ -1449,78 +1338,10 @@ async function main() {
await stacks.set(original); await stacks.set(original);
}, },
); );
await step(
'EXPAND opens a modal dialog (the rest of <body> inert) and Esc returns focus to the button',
async () => {
await openById(firstImageId);
const inertBefore = await page.evaluate(
() =>
[...document.body.children].filter((node) => node.inert).length,
);
await clickPanelControl(page, '#sl-viewer-expand');
await settle(page, () =>
document
.getElementById('sl-viewer-wrap')
.classList.contains('sl-viewer-wrap-expanded'),
);
const dialog = await page.evaluate(() => {
const wrap = document.getElementById('sl-viewer-wrap');
const others = [...document.body.children].filter(
(node) => node !== wrap,
);
return {
role: wrap.getAttribute('role'),
modal: wrap.getAttribute('aria-modal'),
inside: wrap.contains(document.activeElement),
width: Math.round(wrap.getBoundingClientRect().width),
onBody: wrap.parentElement === document.body,
notInert: others
.filter((node) => !node.inert)
.map((node) => node.id || node.tagName.toLowerCase()),
wrapInert: wrap.inert,
};
});
assert.equal(dialog.role, 'dialog');
assert.equal(dialog.modal, 'true');
assert.equal(dialog.inside, true);
assert.ok(dialog.width > 900);
assert.equal(dialog.onBody, true);
assert.deepEqual(
dialog.notInert,
[],
'every other body child is inert',
);
assert.equal(dialog.wrapInert, false);
await page.keyboard.press('Escape');
await settle(
page,
() =>
!document
.getElementById('sl-viewer-wrap')
.classList.contains('sl-viewer-wrap-expanded'),
);
const after = await page.evaluate(() => ({
expanded: document
.getElementById('sl-viewer-wrap')
.classList.contains('sl-viewer-wrap-expanded'),
focus: document.activeElement?.id,
inert: [...document.body.children].filter((node) => node.inert)
.length,
}));
assert.equal(
`${after.expanded}:${after.focus}`,
'false:sl-viewer-expand',
);
assert.equal(
after.inert,
inertBefore,
'the application is live again',
);
},
);
await step( await step(
'× closes the image and deselects it on the globe', '× closes the image and deselects it on the globe',
async () => { async () => {
await openById(firstImageId);
await clickPanelControl(page, '#sl-viewer-close'); await clickPanelControl(page, '#sl-viewer-close');
await uiUntil( await uiUntil(
(u) => !u.street.open && u.sequence.selectedId === null, (u) => !u.street.open && u.sequence.selectedId === null,
+13 -55
View File
@@ -66,12 +66,7 @@ test('the panel is registered with panel chrome, cockpit entry and the right rai
test('panel styles stay inside GEV conventions: no !important, no fixed panel', () => { test('panel styles stay inside GEV conventions: no !important, no fixed panel', () => {
assert.equal((panelCss.match(/!important/g) || []).length, 0); assert.equal((panelCss.match(/!important/g) || []).length, 0);
const fixed = panelCss assert.doesNotMatch(panelCss, /position:\s*fixed/);
.replace(/\/\*[\s\S]*?\*\//g, '')
.split('}')
.filter((block) => /position:\s*fixed/.test(block))
.map((block) => block.slice(0, block.indexOf('{')).trim());
assert.deepEqual(fixed, ['.sl-viewer-wrap-expanded']);
}); });
test('the keyless state gates the controls rather than leaving dead buttons', () => { test('the keyless state gates the controls rather than leaving dead buttons', () => {
@@ -81,13 +76,14 @@ test('the keyless state gates the controls rather than leaving dead buttons', ()
/sl-keyless|sl-query|sl-results|data-sl-suggestion/, /sl-keyless|sl-query|sl-results|data-sl-suggestion/,
); );
assert.match(html, /<fieldset id="sl-controls"/); assert.match(html, /<fieldset id="sl-controls"/);
assert.match(html, /<div id="sl-provider-chips" class="sl-chips"><\/div>/); // The gate holds the filters; the error line above it says how to add the key.
// The gate holds the filters only, so the chips' key tooltip stays reachable.
const gate = html.slice( const gate = html.slice(
html.indexOf('<fieldset id="sl-controls"'), html.indexOf('<fieldset id="sl-controls"'),
html.indexOf('</fieldset>', html.indexOf('<fieldset id="sl-controls"')), html.indexOf('</fieldset>', html.indexOf('<fieldset id="sl-controls"')),
); );
assert.doesNotMatch(gate, /sl-provider-chips/); assert.ok(
html.indexOf('id="sl-error"') < html.indexOf('<fieldset id="sl-controls"'),
);
assert.match(gate, /data-sl-pano="all"/); assert.match(gate, /data-sl-pano="all"/);
assert.match(gate, /id="sl-since"/); assert.match(gate, /id="sl-since"/);
assert.match( assert.match(
@@ -109,26 +105,13 @@ test('the keyless state gates the controls rather than leaving dead buttons', ()
); );
}); });
test('the expanded viewer is a modal dialog that restores focus', () => {
assert.match(controls, /setAttribute\('role', 'dialog'\)/);
assert.match(controls, /setAttribute\('aria-modal', 'true'\)/);
assert.match(controls, /_expandReturnFocus/);
// A real modal: the rest of the application is inert while it is shown.
assert.match(controls, /node\.inert = true/);
assert.match(controls, /node\.inert = false/);
});
test('labels say what the buttons do', () => { test('labels say what the buttons do', () => {
for (const label of [ for (const label of ['>EXPAND<', '>FIT<', '>FILL<', 'SINCE'])
'aria-label="Camera follows view"',
'PROVIDERS',
'SINCE',
])
assert.ok(html.includes(label), label); assert.ok(html.includes(label), label);
// The provider chips are the on/off switch; no separate buttons. // The header pill is the only on/off switch: no provider chips, no FOLLOW.
assert.doesNotMatch( assert.doesNotMatch(
html, html,
/OPEN NEAREST PHOTO|STREET LEVEL OFF|sl-enable-btn|sl-look-btn|LOOK HERE|STREET COCKPIT|>FRAME<|ZOOM TO RESULTS|ASK IN PLAIN ENGLISH/, /sl-provider-chips|sl-follow-btn|OPEN NEAREST PHOTO|STREET LEVEL OFF|sl-enable-btn|sl-look-btn|LOOK HERE|STREET COCKPIT|>FRAME<|ZOOM TO RESULTS|ASK IN PLAIN ENGLISH/,
); );
}); });
@@ -137,14 +120,14 @@ test('on phones the viewer is sized from the rail band, not its aspect ratio', (
panelCss, panelCss,
/@media \(max-width: 720px\) \{\s*\.sl-viewer \{[^}]*height: clamp\(/, /@media \(max-width: 720px\) \{\s*\.sl-viewer \{[^}]*height: clamp\(/,
); );
// The expanded viewer must not inherit the phone height. // The full-screen viewer must not inherit the phone height.
assert.match( assert.match(
panelCss, panelCss,
/\.sl-viewer-wrap-expanded \.sl-viewer \{[^}]*height: auto/, /\.sl-viewer-wrap:fullscreen \.sl-viewer \{[^}]*height: auto/,
); );
}); });
test('the viewer comes first, with follow in its toolbar', () => { test('the viewer comes first, with EXPAND in its toolbar', () => {
const controlsBlock = html.slice(html.indexOf('<div class="sl-main">')); const controlsBlock = html.slice(html.indexOf('<div class="sl-main">'));
assert.ok( assert.ok(
controlsBlock.indexOf('id="sl-viewer-wrap"') < controlsBlock.indexOf('id="sl-viewer-wrap"') <
@@ -153,33 +136,8 @@ test('the viewer comes first, with follow in its toolbar', () => {
); );
assert.match( assert.match(
controlsBlock.slice(0, controlsBlock.indexOf('id="sl-viewer"')), controlsBlock.slice(0, controlsBlock.indexOf('id="sl-viewer"')),
/id="sl-follow-btn"/, /id="sl-viewer-expand"/,
'follow lives in the viewer toolbar', 'EXPAND lives in the viewer toolbar',
);
});
test('Clean View, recording and the cockpit hide the expanded viewer on <body> (P2-5)', () => {
// EXPAND moves the viewer out of every panel, past the panel-hiding rules.
assert.match(
read('src/ui/styles/controls.css'),
/body\.ui-clean-view \.sl-viewer-wrap-expanded \{\s*opacity: 0 !important;\s*visibility: hidden !important;/,
);
assert.match(
read('src/ui/styles/recording.css'),
/body\.recording-mode \.sl-viewer-wrap-expanded \{\s*opacity: 0 !important;\s*visibility: hidden !important;/,
);
assert.match(
read('src/ui/styles/cockpit.css'),
/body\.cockpit-mode\s*:is\([^)]*\.sl-viewer-wrap-expanded\s*\)\s*\{\s*display: none !important;/,
);
// Hidden, it must not keep holding Esc and Tab.
assert.match(controls, /_isDialogShown\(\)/);
});
test('the expanded viewer keeps the panel tokens (P3)', () => {
assert.match(
panelCss,
/#street-level-panel,\s*\.sl-viewer-wrap \{\s*--sl-green:[^}]*--sl-text:[^}]*--sl-text-small:/,
); );
}); });
+1 -1
View File
@@ -25,7 +25,7 @@ import { redact } from '../../scripts/qa-browserEvidence.mjs';
globalThis.requestAnimationFrame ??= (callback) => globalThis.requestAnimationFrame ??= (callback) =>
setTimeout(() => callback(Date.now()), 0); setTimeout(() => callback(Date.now()), 0);
test('the harness expects exactly the providers the app registers, in chip order', () => { test('the harness expects exactly the providers the app registers, in order', () => {
// Any method a provider asks its source for is a no-op: only ids matter. // Any method a provider asks its source for is a no-op: only ids matter.
const source = new Proxy({}, { get: () => () => {} }); const source = new Proxy({}, { get: () => () => {} });
const layer = createApplicationStreetLevel({ const layer = createApplicationStreetLevel({
-11
View File
@@ -28,17 +28,6 @@ export function syncChipGroup(container, chips = [], { before = null } = {}) {
`data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`, `data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`,
); );
set(button, 'textContent', chip.label); set(button, 'textContent', chip.label);
// Optional per-chip colour; CSS decides how it is used.
const color = chip.color || '';
if ((button.dataset.chipColor || '') !== color) {
if (color) {
button.style.setProperty('--chip-color', color);
button.dataset.chipColor = color;
} else {
button.style.removeProperty('--chip-color');
delete button.dataset.chipColor;
}
}
set(button, 'title', chip.title || ''); set(button, 'title', chip.title || '');
set(button, 'disabled', Boolean(chip.disabled)); set(button, 'disabled', Boolean(chip.disabled));
attribute(button, 'aria-pressed', String(Boolean(chip.active))); attribute(button, 'aria-pressed', String(Boolean(chip.active)));
-21
View File
@@ -31,24 +31,3 @@ test('keyed chips retain focus, reflect state, reorder and remove without redund
syncChipGroup(f.container, []); syncChipGroup(f.container, []);
assert.deepEqual(f.container.children, [legend]); assert.deepEqual(f.container.children, [legend]);
}); });
test('a chip colour is set once, changed, and cleared through a CSS variable', () => {
const f = railFixture(() => {});
const calls = [];
syncChipGroup(f.container, [{ id: 'm', label: 'M' }]);
const chip = f.container.children[0];
chip.style.setProperty = (name, value) => calls.push(['set', name, value]);
chip.style.removeProperty = (name) => calls.push(['remove', name]);
syncChipGroup(f.container, [{ id: 'm', label: 'M' }]);
assert.deepEqual(calls, [], 'a chip without a colour never touches style');
syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#05cb63' }]);
syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#05cb63' }]);
syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#a66bff' }]);
syncChipGroup(f.container, [{ id: 'm', label: 'M' }]);
assert.deepEqual(calls, [
['set', '--chip-color', '#05cb63'],
['set', '--chip-color', '#a66bff'],
['remove', '--chip-color'],
]);
assert.equal(chip.dataset.chipColor, undefined);
});
+66 -278
View File
@@ -1,4 +1,3 @@
import { syncChipGroup } from './chipGroup.js';
import { isExplicitLayerStateOrigin } from '../data/layerState.js'; import { isExplicitLayerStateOrigin } from '../data/layerState.js';
import { import {
presentStreetLevelPanel, presentStreetLevelPanel,
@@ -6,8 +5,6 @@ import {
} from './streetLevelPresentation.js'; } from './streetLevelPresentation.js';
const RENDER_MODE_KEY = 'gev:street-level:render-mode'; const RENDER_MODE_KEY = 'gev:street-level:render-mode';
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const RADIO_STEPS = Object.freeze({ const RADIO_STEPS = Object.freeze({
ArrowRight: 1, ArrowRight: 1,
ArrowDown: 1, ArrowDown: 1,
@@ -25,8 +22,8 @@ const setAttr = (node, key, value) => {
}; };
/** /**
* Fills the Street Level panel body: provider chips, filters, legend and * Fills the Street Level panel body: filters, legend and viewer. The panel
* viewer. The panel chrome itself belongs to the application shell. * chrome itself belongs to the application shell.
*/ */
export class StreetLevelControls { export class StreetLevelControls {
constructor({ root, layer, actions }) { constructor({ root, layer, actions }) {
@@ -37,9 +34,8 @@ export class StreetLevelControls {
this.listeners = new AbortController(); this.listeners = new AbortController();
this._unsubscribe = null; this._unsubscribe = null;
this._state = null; this._state = null;
this._view = null;
this._wasEnabled = null; this._wasEnabled = null;
this._wasStreetOpen = false; this._wasOpen = false;
// Until the user first touches the app, an off → on is the saved layer // Until the user first touches the app, an off → on is the saved layer
// state being restored, not a user switching the layer on. // state being restored, not a user switching the layer on.
this._restoreWindow = true; this._restoreWindow = true;
@@ -48,11 +44,6 @@ export class StreetLevelControls {
this._explicitEnable = null; this._explicitEnable = null;
this._unsubscribeEnableRequests = null; this._unsubscribeEnableRequests = null;
this._resizeQueued = false; this._resizeQueued = false;
this._wrapHome = null;
this._expandReturnFocus = null;
// Only the <body> children this class made inert are released again.
this._inerted = new Set();
this._modalObserver = null;
this._elements = this._collect(); this._elements = this._collect();
this._bind(); this._bind();
} }
@@ -65,13 +56,11 @@ export class StreetLevelControls {
return { return {
status: byId('sl-status'), status: byId('sl-status'),
controls: byId('sl-controls'), controls: byId('sl-controls'),
providerChips: byId('sl-provider-chips'),
error: byId('sl-error'), error: byId('sl-error'),
errorText: byId('sl-error-text'), errorText: byId('sl-error-text'),
sinceRange: byId('sl-since'), sinceRange: byId('sl-since'),
sinceLabel: byId('sl-since-label'), sinceLabel: byId('sl-since-label'),
legend: byId('sl-legend'), legend: byId('sl-legend'),
followBtn: byId('sl-follow-btn'),
viewerWrap: byId('sl-viewer-wrap'), viewerWrap: byId('sl-viewer-wrap'),
viewerExpand: byId('sl-viewer-expand'), viewerExpand: byId('sl-viewer-expand'),
viewerClose: byId('sl-viewer-close'), viewerClose: byId('sl-viewer-close'),
@@ -82,7 +71,6 @@ export class StreetLevelControls {
imageLink: byId('sl-image-link'), imageLink: byId('sl-image-link'),
coverageMeta: byId('sl-coverage-meta'), coverageMeta: byId('sl-coverage-meta'),
panoButtons: all('[data-sl-pano]'), panoButtons: all('[data-sl-pano]'),
// Held, not re-queried: EXPAND moves them to <body> with the viewer.
renderButtons: all('[data-sl-render]'), renderButtons: all('[data-sl-render]'),
}; };
} }
@@ -100,17 +88,6 @@ export class StreetLevelControls {
this.layer.attachViewerHost?.(el.viewer); this.layer.attachViewerHost?.(el.viewer);
this.listen(el.status, 'click', () => this._toggleEnabled()); this.listen(el.status, 'click', () => this._toggleEnabled());
this.listen(el.providerChips, 'click', (event) => {
const button = event.target?.closest?.('.data-toggle-chip');
if (!button || button.disabled) return;
// Without a key the chips stay focusable for their tooltip; a click
// says the same thing rather than switching on a layer that cannot draw.
if (this._view?.controlsDisabled) {
if (button.title) this.actions.showToast?.(button.title);
return;
}
this._toggleProvider(button.dataset.chipId);
});
for (const button of el.panoButtons) { for (const button of el.panoButtons) {
this.listen(button, 'click', () => this.listen(button, 'click', () =>
this._setParams({ pano: button.dataset.slPano }), this._setParams({ pano: button.dataset.slPano }),
@@ -129,13 +106,13 @@ export class StreetLevelControls {
this.listen(el.sinceRange, 'change', () => this.listen(el.sinceRange, 'change', () =>
this._setParams({ sinceDays: sinceDays() }), this._setParams({ sinceDays: sinceDays() }),
); );
this.listen(el.followBtn, 'click', () => {
this.layer.setFollow?.(!(this._state?.street?.follow === true));
});
this.listen(el.viewerClose, 'click', () => this.layer.closeViewer?.()); this.listen(el.viewerClose, 'click', () => this.layer.closeViewer?.());
this.listen(el.viewerExpand, 'click', () => // EXPAND is the browser's own fullscreen: it handles Esc, focus and the
this.setViewerExpanded(!this.isViewerExpanded()), // rest of the page. Without the API the button is simply not offered.
); if (typeof el.viewerWrap?.requestFullscreen === 'function') {
this.listen(el.viewerExpand, 'click', () => this._toggleFullscreen());
this.listen(document, 'fullscreenchange', () => this._syncFullscreen());
} else setProp(el.viewerExpand, 'hidden', true);
for (const button of el.renderButtons) { for (const button of el.renderButtons) {
this.listen(button, 'click', () => { this.listen(button, 'click', () => {
const mode = button.dataset.slRender; const mode = button.dataset.slRender;
@@ -154,13 +131,6 @@ export class StreetLevelControls {
} catch { } catch {
/* storage unavailable */ /* storage unavailable */
} }
// 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,
});
const endRestoreWindow = () => { const endRestoreWindow = () => {
this._restoreWindow = false; this._restoreWindow = false;
}; };
@@ -208,21 +178,17 @@ export class StreetLevelControls {
}); });
} }
async _ensureEnabled() {
if (this.actions.isEnabled?.()) return true;
try {
await this.actions.setEnabled?.(true);
} catch (error) {
this.actions.showToast?.(
error?.message || 'Street Level could not start',
);
return false;
}
return this.actions.isEnabled?.() === true;
}
async _toggleEnabled() { async _toggleEnabled() {
const enabled = this.actions.isEnabled?.() === true; const enabled = this.actions.isEnabled?.() === true;
// The pill is the only switch: switching on also brings back a provider
// a share link or a tool turned off, or the layer would draw nothing.
const dark = enabled
? []
: (this._state?.providers || []).filter((entry) => entry.on === false);
if (dark.length)
this._setParams(
Object.fromEntries(dark.map((entry) => [entry.id, true])),
);
try { try {
await this.actions.setEnabled?.(!enabled); await this.actions.setEnabled?.(!enabled);
} catch (error) { } catch (error) {
@@ -237,32 +203,45 @@ export class StreetLevelControls {
else this.layer.setParams?.(params); else this.layer.setParams?.(params);
} }
/** isViewerFullscreen() {
* Lighting a chip turns the layer on; darkening the last lit one turns the const wrap = this._elements.viewerWrap;
* layer off but keeps the provider on, so the layer comes back with it. return Boolean(wrap) && document.fullscreenElement === wrap;
*/ }
async _toggleProvider(providerId) {
const chip = this._view?.providers.find((entry) => entry.id === providerId); async _toggleFullscreen() {
if (!chip) return;
if (!chip.active) {
this._setParams({ [providerId]: true });
await this._ensureEnabled();
return;
}
const othersLit = this._view.providers.some(
(entry) => entry.id !== providerId && entry.active,
);
if (othersLit) {
this._setParams({ [providerId]: false });
return;
}
try { try {
await this.actions.setEnabled?.(false); if (this.isViewerFullscreen()) await document.exitFullscreen();
} catch (error) { else await this._elements.viewerWrap.requestFullscreen();
this.actions.showToast?.(error?.message || 'Street Level toggle failed'); } catch {
/* refused (no user gesture, policy): the panel view stays */
} }
} }
_exitFullscreen() {
if (this.isViewerFullscreen()) document.exitFullscreen().catch(() => {});
}
/** Mirror the browser's fullscreen state on EXPAND and refit the viewer. */
_syncFullscreen() {
const on = this.isViewerFullscreen();
const button = this._elements.viewerExpand;
if (button) {
setProp(
button.querySelector('.sl-btn-icon'),
'textContent',
on ? '⤡' : '⤢',
);
setProp(
button.querySelector('.sl-btn-text'),
'textContent',
on ? 'SHRINK' : 'EXPAND',
);
setAttr(button, 'aria-pressed', String(on));
setAttr(button, 'aria-label', on ? 'Shrink' : 'Expand');
}
this._requestResize();
}
connect() { connect() {
this._unsubscribe?.(); this._unsubscribe?.();
this._unsubscribe = null; this._unsubscribe = null;
@@ -281,183 +260,17 @@ export class StreetLevelControls {
if (!collapsed) this._requestResize(); if (!collapsed) this._requestResize();
} }
isViewerExpanded() {
return (
this._elements.viewerWrap?.classList.contains(
'sl-viewer-wrap-expanded',
) === true
);
}
setViewerExpanded(expanded) {
const wrap = this._elements.viewerWrap;
if (!wrap) return;
const on = expanded === true;
if (on === this.isViewerExpanded()) return;
if (on) {
// Lift the viewer out of the panel: the panel's backdrop-filter would
// otherwise pin a fixed-position child inside it.
this._wrapHome = { parent: wrap.parentNode, next: wrap.nextSibling };
this._expandReturnFocus = document.activeElement;
document.body.appendChild(wrap);
wrap.classList.add('sl-viewer-wrap-expanded');
wrap.setAttribute('role', 'dialog');
wrap.setAttribute('aria-modal', 'true');
wrap.setAttribute('aria-label', 'Street-level image');
wrap.tabIndex = -1;
wrap.focus({ preventScroll: true });
this._watchModal(true);
} else {
// Release the application first: the focus returned below may be in it.
this._watchModal(false);
wrap.classList.remove('sl-viewer-wrap-expanded');
wrap.removeAttribute('role');
wrap.removeAttribute('aria-modal');
wrap.removeAttribute('aria-label');
wrap.removeAttribute('tabindex');
if (this._wrapHome?.parent)
this._wrapHome.parent.insertBefore(wrap, this._wrapHome.next);
this._wrapHome = null;
const target = this._expandReturnFocus;
this._expandReturnFocus = null;
// 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) {
const icon = button.querySelector('.sl-btn-icon');
const text = button.querySelector('.sl-btn-text');
if (icon) icon.textContent = on ? '⤡' : '⤢';
if (text) text.textContent = on ? 'SHRINK' : 'EXPAND';
button.setAttribute('aria-pressed', String(on));
button.setAttribute('aria-label', on ? 'Shrink' : 'Expand');
}
this._requestResize();
}
/** 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 });
}
/** Clean View, recording and the cockpit hide the expanded viewer with CSS. */
_isDialogShown() {
if (!this.isViewerExpanded()) return false;
const wrap = this._elements.viewerWrap;
return (
typeof wrap.checkVisibility !== 'function' ||
// `checkVisibilityCSS` is the option's name before Chrome 121.
wrap.checkVisibility({
visibilityProperty: true,
checkVisibilityCSS: true,
})
);
}
/**
* Make every other <body> child inert while the expanded viewer is shown.
* A CSS-hidden viewer releases them so a hidden modal never leaves the app
* dead. Returns whether the dialog is shown.
*/
_syncModal() {
if (this.destroyed || !this._isDialogShown()) {
this._releaseModal();
return false;
}
const wrap = this._elements.viewerWrap;
for (const node of [...(document.body?.children || [])]) {
if (node === wrap || node.inert) continue;
node.inert = true;
this._inerted.add(node);
}
return true;
}
_releaseModal() {
for (const node of this._inerted) node.inert = false;
this._inerted.clear();
}
/** Re-check the modal when <body> changes class or gains a child. */
_watchModal(on) {
this._modalObserver?.disconnect();
this._modalObserver = null;
if (!on) return this._releaseModal();
if (typeof MutationObserver === 'function' && document.body) {
this._modalObserver = new MutationObserver(() => this._syncModal());
this._modalObserver.observe(document.body, {
attributes: true,
attributeFilter: ['class'],
childList: true,
});
}
this._syncModal();
}
_onDialogKey(event) {
if (event.key !== 'Escape' && event.key !== 'Tab') return;
if (!this._syncModal()) return;
// Only keys meant for the viewer: from inside it, from nowhere (<body>)
// or from the globe. A field elsewhere (search) keeps its Esc and Tab.
const target = event.target;
const wrap = this._elements.viewerWrap;
if (
!wrap.contains(target) &&
target !== document.body &&
target !== document.documentElement &&
!target?.closest?.('#cesiumContainer')
)
return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.setViewerExpanded(false);
return;
}
// A radio off the roving tab stop (tabindex -1) is not a stop either.
const focusable = [...wrap.querySelectorAll(FOCUSABLE)].filter(
(node) =>
node.getAttribute('tabindex') !== '-1' &&
(node.offsetParent !== null || node === document.activeElement),
);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
} else if (!wrap.contains(document.activeElement)) {
event.preventDefault();
first.focus();
}
}
render(state) { render(state) {
if (this.destroyed || !state || !this.root) return; if (this.destroyed || !state || !this.root) return;
this._state = state; this._state = state;
const view = presentStreetLevelPanel(state); const view = presentStreetLevelPanel(state);
this._view = view;
this._renderHeader(view); this._renderHeader(view);
this._renderGate(view); setProp(this._elements.controls, 'disabled', view.controlsDisabled);
this._renderProviders(view);
this._renderError(view); this._renderError(view);
this._renderFilters(view); this._renderFilters(view);
this._renderViewer(view); this._renderViewer(view);
this._renderMeta(view); setProp(this._elements.coverageMeta, 'textContent', view.meta);
this._reactToTransitions(view, state); this._reactToTransitions(view);
} }
_renderHeader(view) { _renderHeader(view) {
@@ -475,14 +288,6 @@ export class StreetLevelControls {
} }
} }
_renderGate(view) {
setProp(this._elements.controls, 'disabled', view.controlsDisabled);
}
_renderProviders(view) {
syncChipGroup(this._elements.providerChips, view.providers);
}
_renderError(view) { _renderError(view) {
const el = this._elements; const el = this._elements;
if (!el.error) return; if (!el.error) return;
@@ -519,8 +324,7 @@ export class StreetLevelControls {
setAttr(el.sinceRange, 'aria-valuetext', view.since.label); setAttr(el.sinceRange, 'aria-valuetext', view.since.label);
} }
} }
// Rebuild when the swatches change, not only their count: one provider // Rebuild when the swatches change, not only their count.
// can replace another with the same number of entries.
const legendKey = view.legend const legendKey = view.legend
.map((entry) => `${entry.key}:${entry.color}:${entry.label}`) .map((entry) => `${entry.key}:${entry.color}:${entry.label}`)
.join('|'); .join('|');
@@ -545,25 +349,15 @@ export class StreetLevelControls {
const el = this._elements; const el = this._elements;
const { viewer } = view; const { viewer } = view;
const wrap = el.viewerWrap; const wrap = el.viewerWrap;
if (!viewer.open) this._exitFullscreen();
if (wrap) { if (wrap) {
// Closing the image (its × button) hides the wrap: move focus out // Closing the image (its × button) hides the wrap: move focus out
// first, or it drops to <body>. A shrink below returns it itself. // first, or it drops to <body>.
if ( if (!viewer.open && !wrap.hidden && wrap.contains(document.activeElement))
!viewer.open && el.status?.focus?.({ preventScroll: true });
!wrap.hidden &&
!this.isViewerExpanded() &&
wrap.contains(document.activeElement)
)
this._focusPanelControl();
setProp(wrap, 'hidden', !viewer.open); setProp(wrap, 'hidden', !viewer.open);
} }
if (!viewer.open && this.isViewerExpanded()) this.setViewerExpanded(false);
setProp(el.viewerPlaceholder, 'hidden', !viewer.loading); setProp(el.viewerPlaceholder, 'hidden', !viewer.loading);
if (el.followBtn) {
setAttr(el.followBtn, 'aria-pressed', String(viewer.follow.pressed));
setProp(el.followBtn, 'disabled', viewer.follow.disabled);
setProp(el.followBtn, 'title', viewer.follow.title);
}
this._renderRadios( this._renderRadios(
el.renderButtons, el.renderButtons,
(button) => button.dataset.slRender === viewer.renderMode, (button) => button.dataset.slRender === viewer.renderMode,
@@ -579,15 +373,11 @@ export class StreetLevelControls {
} }
} }
_renderMeta(view) {
setProp(this._elements.coverageMeta, 'textContent', view.meta);
}
/** /**
* Open the panel on a switch-on or when an image opens. Never persisted: * Open the panel on a switch-on or when an image opens. Never persisted:
* the stored collapse state stays the user's own choice. * the stored collapse state stays the user's own choice.
*/ */
_reactToTransitions(view, state) { _reactToTransitions(view) {
const enabled = view.enabled; const enabled = view.enabled;
if (enabled && this._wasEnabled === false) { if (enabled && this._wasEnabled === false) {
// A restore must not reopen a panel the user or a share link kept // A restore must not reopen a panel the user or a share link kept
@@ -605,17 +395,15 @@ export class StreetLevelControls {
this._wasEnabled = enabled; this._wasEnabled = enabled;
// Other size changes reach the viewer through the ResizeObserver; opening // Other size changes reach the viewer through the ResizeObserver; opening
// asks for one resize because the element may not have a size yet. // asks for one resize because the element may not have a size yet.
const open = state.street.open === true; const open = view.viewer.open;
if (open && !this._wasStreetOpen) if (open && !this._wasOpen) this.setCollapsed(false, { persist: false });
this.setCollapsed(false, { persist: false }); this._wasOpen = open;
this._wasStreetOpen = open;
} }
destroy() { destroy() {
if (this.destroyed) return; if (this.destroyed) return;
this.destroyed = true; this.destroyed = true;
this.setViewerExpanded(false); this._exitFullscreen();
this._watchModal(false);
this.listeners.abort(); this.listeners.abort();
this._resizeObserver?.disconnect(); this._resizeObserver?.disconnect();
this._resizeObserver = null; this._resizeObserver = null;
+140 -569
View File
@@ -16,20 +16,6 @@ import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
/** Every write a page would see as a mutation, even one to the same value. */ /** Every write a page would see as a mutation, even one to the same value. */
const mutations = { count: 0 }; const mutations = { count: 0 };
const REFLECTED = ['hidden', 'disabled', 'textContent', 'title', 'value']; const REFLECTED = ['hidden', 'disabled', 'textContent', 'title', 'value'];
/** The reflected properties a MutationObserver sees, as the record it gets. */
const REFLECTED_RECORDS = {
hidden: { type: 'attributes', attributeName: 'hidden' },
disabled: { type: 'attributes', attributeName: 'disabled' },
title: { type: 'attributes', attributeName: 'title' },
textContent: { type: 'childList' },
};
const liveObservers = new Set();
function recordMutation(target, { type, attributeName = null }) {
for (const observer of liveObservers)
observer.consider(target, type, attributeName);
}
class FakeNode { class FakeNode {
constructor(document, tag, { id = null, dataset = {}, classes = [] } = {}) { constructor(document, tag, { id = null, dataset = {}, classes = [] } = {}) {
@@ -53,42 +39,20 @@ class FakeNode {
set: (value) => { set: (value) => {
mutations.count++; mutations.count++;
this.props[key] = value; this.props[key] = value;
if (REFLECTED_RECORDS[key])
recordMutation(this, REFLECTED_RECORDS[key]);
}, },
}); });
this.attributes = new Map(); this.attributes = new Map();
const names = new Set(classes); const names = new Set(classes);
// Every DOMTokenList write sets the class attribute, changed or not.
const classChanged = () =>
recordMutation(this, { type: 'attributes', attributeName: 'class' });
this.classList = { this.classList = {
add: (name) => {
names.add(name);
classChanged();
},
remove: (name) => {
names.delete(name);
classChanged();
},
contains: (name) => names.has(name), contains: (name) => names.has(name),
toggle: (name, force) => { toggle: (name, force) => {
const on = force ?? !names.has(name); const on = force ?? !names.has(name);
if (on) names.add(name); if (on) names.add(name);
else names.delete(name); else names.delete(name);
classChanged();
return on; return on;
}, },
}; };
const style = {}; this.style = {};
this.style = Object.assign(style, {
setProperty: (key, value) => {
style[key] = value;
},
removeProperty: (key) => {
delete style[key];
},
});
Object.defineProperty(this, 'className', { Object.defineProperty(this, 'className', {
get: () => [...names].join(' '), get: () => [...names].join(' '),
set: (value) => { set: (value) => {
@@ -96,92 +60,39 @@ class FakeNode {
names.clear(); names.clear();
for (const name of String(value).split(/\s+/).filter(Boolean)) for (const name of String(value).split(/\s+/).filter(Boolean))
names.add(name); names.add(name);
classChanged();
}, },
}); });
} }
get childElementCount() { get childElementCount() {
return this.children.length; return this.children.length;
} }
get parentNode() {
return this.parent;
}
get nextSibling() {
if (!this.parent) return null;
const siblings = this.parent.children;
return siblings[siblings.indexOf(this) + 1] || null;
}
/** Null when this node or an ancestor is hidden or disconnected. */
get offsetParent() {
if (!this.isConnected) return null;
for (let node = this; node; node = node.parent)
if (node.hidden) return null;
return this.parent;
}
setAttribute(key, value) { setAttribute(key, value) {
mutations.count++; mutations.count++;
this.attributes.set(key, String(value)); this.attributes.set(key, String(value));
recordMutation(this, { type: 'attributes', attributeName: key });
} }
getAttribute(key) { getAttribute(key) {
return this.attributes.get(key) ?? null; return this.attributes.get(key) ?? null;
} }
removeAttribute(key) {
if (!this.attributes.delete(key)) return;
mutations.count++;
recordMutation(this, { type: 'attributes', attributeName: key });
}
appendChild(child) { appendChild(child) {
return this.insertBefore(child, null);
}
/** Like the DOM's: a reference that is not a child throws NotFoundError. */
insertBefore(child, reference) {
if (reference != null && reference.parent !== this)
throw new DOMException('not a child of this node', 'NotFoundError');
if (reference === child) return child;
child.remove();
const index = reference
? this.children.indexOf(reference)
: this.children.length;
this.children.splice(index, 0, child);
child.parent = this; child.parent = this;
recordMutation(this, { type: 'childList' }); this.children.push(child);
return child; return child;
} }
append(...nodes) { append(...nodes) {
for (const node of nodes) this.appendChild(node); for (const node of nodes) this.appendChild(node);
} }
replaceChildren(...nodes) { replaceChildren(...nodes) {
for (const child of [...this.children]) child.remove(); this.children = [];
this.append(...nodes); this.append(...nodes);
} }
remove() {
const parent = this.parent;
if (!parent) return;
parent.children.splice(parent.children.indexOf(this), 1);
this.parent = null;
recordMutation(parent, { type: 'childList' });
}
contains(node) { contains(node) {
for (let current = node; current; current = current.parent) for (let current = node; current; current = current.parent)
if (current === this) return true; if (current === this) return true;
return false; return false;
} }
get isConnected() {
return this.ownerDocument?.contains(this) === true;
}
focus() { focus() {
this.ownerDocument.activeElement = this; this.ownerDocument.activeElement = this;
} }
/** `.class` or `#id`. */
closest(selector) {
const byId = /^#(.+)$/.exec(selector);
const name = selector.replace(/^\./, '');
for (let node = this; node; node = node.parent)
if (byId ? node.id === byId[1] : node.classList?.contains(name))
return node;
return null;
}
*walk() { *walk() {
for (const child of this.children) { for (const child of this.children) {
yield child; yield child;
@@ -191,79 +102,40 @@ class FakeNode {
querySelector(selector) { querySelector(selector) {
return this.querySelectorAll(selector)[0] || null; return this.querySelectorAll(selector)[0] || null;
} }
/** `#id`, `[data-x]`, and the tag / attribute forms of a focusable list. */ /** `#id`, `.class` or `[data-x]`. */
matches(selector) { matches(selector) {
return selector.split(',').some((part) => { const byId = /^#(.+)$/.exec(selector);
const one = part.trim(); if (byId) return this.id === byId[1];
const byId = /^#(.+)$/.exec(one); const byClass = /^\.(.+)$/.exec(selector);
if (byId) return this.id === byId[1]; if (byClass) return this.classList.contains(byClass[1]);
const byData = /^\[data-([a-z-]+)\]$/.exec(one); const byData = /^\[data-([a-z-]+)\]$/.exec(selector);
if (byData) return Boolean(
return ( byData &&
byData[1].replace(/-([a-z])/g, (_, c) => c.toUpperCase()) in byData[1].replace(/-([a-z])/g, (_, c) => c.toUpperCase()) in this.dataset,
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);
});
} }
querySelectorAll(selector) { querySelectorAll(selector) {
return [...this.walk()].filter((node) => node.matches(selector)); return [...this.walk()].filter((node) => node.matches(selector));
} }
addEventListener(type, listener, { signal, capture = false } = {}) { addEventListener(type, listener, { signal } = {}) {
if (signal?.aborted) return; if (signal?.aborted) return;
if (!this.listeners.has(type)) this.listeners.set(type, new Map()); if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type).set(listener, capture); this.listeners.get(type).add(listener);
signal?.addEventListener('abort', () => signal?.addEventListener('abort', () =>
this.listeners.get(type)?.delete(listener), this.listeners.get(type)?.delete(listener),
); );
} }
removeEventListener(type, listener) { /** Deliver to this node, then bubble up with the same target. */
this.listeners.get(type)?.delete(listener);
}
/**
* Capture down from the document, then deliver to this node and bubble
* back up with the same target; `stopPropagation` ends the trip.
*/
dispatchEvent(event) { dispatchEvent(event) {
let stopped = false;
const delivered = { const delivered = {
type: event.type, type: event.type,
target: this, target: this,
bubbles: event.bubbles,
key: event.key, key: event.key,
shiftKey: event.shiftKey === true, preventDefault() {},
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true;
},
stopPropagation() {
stopped = true;
},
}; };
const path = []; for (let node = this; node; node = event.bubbles ? node.parent : null)
for (let node = this; node; node = node.parent) path.push(node); for (const listener of [...(node.listeners.get(event.type) || [])])
const run = (node, phase) => { listener(delivered);
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; return true;
} }
click() { click() {
@@ -271,14 +143,24 @@ class FakeNode {
} }
} }
/** The Street Level panel's body, as layer-panels.html lays it out. */ /**
function panelDom() { * The Street Level panel's body, as layer-panels.html lays it out, on a page
* with the Fullscreen API unless `fullscreen` is false.
*/
function panelDom({ fullscreen = true } = {}) {
const document = new FakeNode(null, '#document'); const document = new FakeNode(null, '#document');
Object.assign(document, { Object.assign(document, {
ownerDocument: document, ownerDocument: document,
activeElement: null, activeElement: null,
fullscreenElement: null,
createElement: (tag) => new FakeNode(document, tag), createElement: (tag) => new FakeNode(document, tag),
}); });
// The browser reports a change after the request settles.
const fullscreenTo = (element) => {
document.fullscreenElement = element;
setTimeout(() => document.dispatchEvent({ type: 'fullscreenchange' }), 0);
};
document.exitFullscreen = async () => fullscreenTo(null);
document.body = document.appendChild(new FakeNode(document, 'body')); document.body = document.appendChild(new FakeNode(document, 'body'));
const root = document.body.appendChild( const root = document.body.appendChild(
new FakeNode(document, 'section', { id: 'street-level-panel' }), new FakeNode(document, 'section', { id: 'street-level-panel' }),
@@ -286,14 +168,14 @@ function panelDom() {
const add = (parent, tag, options) => const add = (parent, tag, options) =>
parent.appendChild(new FakeNode(document, tag, options)); parent.appendChild(new FakeNode(document, tag, options));
add(root, 'button', { id: 'sl-status' }); add(root, 'button', { id: 'sl-status' });
const main = add(root, 'div', { classes: ['sl-main'] }); const wrap = add(root, 'div', { id: 'sl-viewer-wrap' });
// The viewer's own tool bar lives inside the wrap, as in the markup. if (fullscreen) wrap.requestFullscreen = async () => fullscreenTo(wrap);
const wrap = add(main, 'div', { id: 'sl-viewer-wrap' }); const expand = add(wrap, 'button', { id: 'sl-viewer-expand' });
add(wrap, 'button', { id: 'sl-viewer-expand' }); add(expand, 'span', { classes: ['sl-btn-icon'] });
add(expand, 'span', { classes: ['sl-btn-text'] });
for (const mode of ['letterbox', 'fill']) for (const mode of ['letterbox', 'fill'])
add(wrap, 'button', { dataset: { slRender: mode } }); add(wrap, 'button', { dataset: { slRender: mode } });
for (const id of ['sl-follow-btn', 'sl-viewer-close']) add(wrap, 'button', { id: 'sl-viewer-close' });
add(wrap, 'button', { id });
for (const id of [ for (const id of [
'sl-viewer-placeholder', 'sl-viewer-placeholder',
'sl-viewer', 'sl-viewer',
@@ -302,35 +184,25 @@ function panelDom() {
'sl-image-link', 'sl-image-link',
]) ])
add(wrap, id === 'sl-image-link' ? 'a' : 'div', { id }); add(wrap, id === 'sl-image-link' ? 'a' : 'div', { id });
const settings = add(main, 'div', { classes: ['sl-settings'] }); const error = add(root, 'div', { id: 'sl-error' });
for (const id of ['sl-provider-chips', 'sl-error', 'sl-error-text']) add(error, 'span', { id: 'sl-error-text' });
add(settings, 'div', { id }); const controls = add(root, 'fieldset', { id: 'sl-controls' });
// A missing key gates the filters only; the chips stay outside the gate.
const controls = add(settings, 'fieldset', { id: 'sl-controls' });
for (const pano of ['all', 'pano', 'flat']) for (const pano of ['all', 'pano', 'flat'])
add(controls, 'button', { dataset: { slPano: pano } }); add(controls, 'button', { dataset: { slPano: pano } });
add(controls, 'input', { id: 'sl-since' }); add(controls, 'input', { id: 'sl-since' });
add(controls, 'output', { id: 'sl-since-label' }); add(controls, 'output', { id: 'sl-since-label' });
add(settings, 'ul', { id: 'sl-legend' }); add(root, 'ul', { id: 'sl-legend' });
add(settings, 'div', { id: 'sl-coverage-meta' }); add(root, 'div', { id: 'sl-coverage-meta' });
// The globe's canvas is focusable too (tabindex=0). return { document, root, wrap };
const globe = add(
add(document.body, 'div', { id: 'cesiumContainer' }),
'canvas',
);
globe.setAttribute('tabindex', '0');
// A field in another panel (the location search).
const search = add(document.body, 'input', { id: 'location-search' });
return { document, root, globe, search, main, wrap, settings };
} }
/** Run `fn` with the fake document and an immediate animation frame. */ /** Run `fn` with the fake document and an immediate animation frame. */
async function withDom(fn) { async function withDom(fn, options) {
const saved = { const saved = {
document: globalThis.document, document: globalThis.document,
requestAnimationFrame: globalThis.requestAnimationFrame, requestAnimationFrame: globalThis.requestAnimationFrame,
}; };
const page = panelDom(); const page = panelDom(options);
globalThis.document = page.document; globalThis.document = page.document;
globalThis.requestAnimationFrame = (task) => setTimeout(task, 0); globalThis.requestAnimationFrame = (task) => setTimeout(task, 0);
try { try {
@@ -436,7 +308,7 @@ test('a 360° click reaches the share link through the data manager', () =>
'durable state recorded it', 'durable state recorded it',
); );
assert.ok(share().includes(`${TOKEN}.p.p`), `share link: ${share()}`); assert.ok(share().includes(`${TOKEN}.p.p`), `share link: ${share()}`);
controls.destroy?.(); controls.destroy();
coordinator.destroy(); coordinator.destroy();
})); }));
@@ -451,11 +323,11 @@ test('releasing the SINCE slider records the window in the share link', () =>
coordinator.getDurableState().options['street-level'].sinceDays; coordinator.getDurableState().options['street-level'].sinceDays;
assert.ok(days > 0, 'a window was recorded'); assert.ok(days > 0, 'a window was recorded');
assert.ok(share().includes(`${TOKEN}.s.${days}`), `share link: ${share()}`); assert.ok(share().includes(`${TOKEN}.s.${days}`), `share link: ${share()}`);
controls.destroy?.(); controls.destroy();
coordinator.destroy(); coordinator.destroy();
})); }));
/* ── Chip rules and render behaviour, against a stand-in layer ─────────── */ /* ── Render behaviour, against a stand-in layer ────────────────────────── */
function stubLayer() { function stubLayer() {
const listeners = new Set(); const listeners = new Set();
@@ -501,7 +373,7 @@ function uiState({
on, on,
configured: !keyRequired, configured: !keyRequired,
keyRequired, keyRequired,
requiresKeyId: keyRequired ? 'mapillary' : null, requiresKeyId: 'mapillary',
loading: false, loading: false,
count: 3, count: 3,
hint: '', hint: '',
@@ -517,8 +389,6 @@ function uiState({
street: { street: {
open, open,
loading: false, loading: false,
follow: false,
followAvailable: false,
renderMode, renderMode,
providerId: open ? 'mapillary' : null, providerId: open ? 'mapillary' : null,
imageId: open ? 'img-1' : null, imageId: open ? 'img-1' : null,
@@ -530,7 +400,7 @@ function uiState({
function stubPanel(dom, state, extraActions = {}) { function stubPanel(dom, state, extraActions = {}) {
const layer = stubLayer(); const layer = stubLayer();
const calls = { setParams: [], setEnabled: [], collapsed: [], toasts: [] }; const calls = { setParams: [], setEnabled: [], collapsed: [] };
let enabled = state.enabled; let enabled = state.enabled;
layer.publish(state); layer.publish(state);
const controls = new StreetLevelControls({ const controls = new StreetLevelControls({
@@ -545,60 +415,71 @@ function stubPanel(dom, state, extraActions = {}) {
setParams: (params, options) => calls.setParams.push([params, options]), setParams: (params, options) => calls.setParams.push([params, options]),
setPanelCollapsed: (collapsed, options) => setPanelCollapsed: (collapsed, options) =>
calls.collapsed.push([collapsed, options]), calls.collapsed.push([collapsed, options]),
showToast: (message) => calls.toasts.push(message), showToast() {},
...extraActions, ...extraActions,
}, },
}); });
controls.connect(); controls.connect();
const chip = () => dom.root.querySelector('#sl-provider-chips').children[0]; return { layer, calls, controls };
return { layer, calls, controls, chip };
} }
test('darkening the only lit chip turns the layer off and keeps the provider switched on', () => test('the header pill switches the layer, bringing back a provider switched off elsewhere', () =>
withDom(async (dom) => { withDom(async (dom) => {
const { calls, chip } = stubPanel(dom, uiState()); const status = dom.root.querySelector('#sl-status');
assert.equal(chip().dataset.chipId, 'mapillary'); const on = stubPanel(dom, uiState());
chip().click(); status.click();
await settle(); await settle();
assert.deepEqual(calls.setEnabled, [false]); assert.deepEqual(on.calls.setEnabled, [false]);
assert.deepEqual(calls.setParams, [], 'the provider switch is untouched'); assert.deepEqual(
})); on.calls.setParams,
[],
test('lighting a dark chip switches the provider on as a user params request, then the layer', () => 'switching off keeps the provider',
withDom(async (dom) => {
const { calls, chip } = stubPanel(
dom,
uiState({ enabled: false, on: false }),
); );
chip().click(); on.controls.destroy();
const dark = stubPanel(dom, uiState({ enabled: false, on: false }));
status.click();
await settle(); await settle();
assert.deepEqual(calls.setParams, [ assert.deepEqual(dark.calls.setParams, [
[{ mapillary: true }, { origin: 'user' }], [{ mapillary: true }, { origin: 'user' }],
]); ]);
assert.deepEqual(calls.setEnabled, [true]); assert.deepEqual(dark.calls.setEnabled, [true]);
dark.controls.destroy();
}));
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 }));
assert.equal(dom.root.querySelector('#sl-controls').disabled, true);
assert.equal(dom.root.querySelector('#sl-error').hidden, false);
assert.match(
dom.root.querySelector('#sl-error-text').textContent,
/^Mapillary: Needs MAPILLARY_CLIENT_TOKEN/,
);
controls.destroy();
})); }));
test('the legend rebuilds when a swatch changes, even at the same count', () => test('the legend rebuilds when a swatch changes, even at the same count', () =>
withDom(async (dom) => { withDom(async (dom) => {
const { layer } = stubPanel(dom, uiState()); const { layer, controls } = stubPanel(dom, uiState());
const legend = dom.root.querySelector('#sl-legend'); const legend = dom.root.querySelector('#sl-legend');
const swatch = () => legend.children[0].children[0].style.background; const swatch = () => legend.children[0].children[0].style.background;
assert.equal(swatch(), '#05cb63'); assert.equal(swatch(), '#05cb63');
layer.publish( layer.publish(
uiState({ uiState({
legend: [ legend: [
{ key: 'panoramax', label: 'Panoramax', color: '#a66bff' }, { key: 'mapillary', label: 'Mapillary', color: '#a66bff' },
{ key: 'selected', label: 'Selected', color: '#00d4ff' }, { key: 'selected', label: 'Selected', color: '#00d4ff' },
], ],
}), }),
); );
assert.equal(legend.childElementCount, 2); assert.equal(legend.childElementCount, 2);
assert.equal(swatch(), '#a66bff'); assert.equal(swatch(), '#a66bff');
controls.destroy();
})); }));
test('the viewer is resized once when it opens, not on every render', () => test('the viewer is resized once when it opens, not on every render', () =>
withDom(async (dom) => { withDom(async (dom) => {
const { layer } = stubPanel(dom, uiState()); const { layer, controls } = stubPanel(dom, uiState());
await settle(); await settle();
assert.equal(layer.calls.resize, 0); assert.equal(layer.calls.resize, 0);
layer.publish(uiState({ open: true })); layer.publish(uiState({ open: true }));
@@ -612,64 +493,6 @@ test('the viewer is resized once when it opens, not on every render', () =>
layer.publish(uiState({ open: true })); layer.publish(uiState({ open: true }));
await settle(); await settle();
assert.equal(layer.calls.resize, 2, 'the next photo resizes again'); 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 }));
// A tool hidden in this state (no box, so not a tab stop) ends the bar.
const hiddenTool = dom.wrap.appendChild(
new FakeNode(dom.document, 'button', { id: 'sl-hidden-tool' }),
);
hiddenTool.hidden = 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, past the hidden one',
);
keydown(dom.document.activeElement, 'Tab');
assert.equal(
dom.document.activeElement,
wrap.querySelector('#sl-viewer-expand'),
'Tab from the last shown control wraps to the first',
);
controls.destroy(); controls.destroy();
})); }));
@@ -678,7 +501,7 @@ test('closing the image from its × button leaves focus on the panel, not <body>
const { layer, controls } = stubPanel(dom, uiState({ open: true })); const { layer, controls } = stubPanel(dom, uiState({ open: true }));
dom.root.querySelector('#sl-viewer-close').focus(); dom.root.querySelector('#sl-viewer-close').focus();
layer.publish(uiState({ open: false })); layer.publish(uiState({ open: false }));
assert.equal(dom.root.querySelector('#sl-viewer-wrap').hidden, true); assert.equal(dom.wrap.hidden, true);
assert.equal( assert.equal(
dom.document.activeElement, dom.document.activeElement,
dom.root.querySelector('#sl-status'), dom.root.querySelector('#sl-status'),
@@ -686,21 +509,61 @@ test('closing the image from its × button leaves focus on the panel, not <body>
controls.destroy(); controls.destroy();
})); }));
test('an image closed while expanded does not return focus into the hidden viewer', () => /* ── EXPAND is the browser's fullscreen ────────────────────────────────── */
const expandLabel = (dom) => {
const button = dom.root.querySelector('#sl-viewer-expand');
return [
button.querySelector('.sl-btn-text').textContent,
button.getAttribute('aria-pressed'),
];
};
test('EXPAND puts the viewer full screen in place, and the button shrinks it again', () =>
withDom(async (dom) => { withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState({ open: true })); const { layer, controls } = stubPanel(dom, uiState({ open: true }));
dom.root.querySelector('#sl-viewer-expand').focus(); await settle();
controls.setViewerExpanded(true); // remembers EXPAND to return to const resizes = layer.calls.resize;
layer.publish(uiState({ open: false })); const expand = dom.root.querySelector('#sl-viewer-expand');
assert.equal(controls.isViewerExpanded(), false); expand.click();
assert.equal( await settle();
dom.document.activeElement, await settle();
dom.root.querySelector('#sl-status'), assert.equal(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);
assert.deepEqual(expandLabel(dom), ['EXPAND', 'false']);
controls.destroy(); controls.destroy();
})); }));
/* ── Restores, the modal viewer and keyboard rules ─────────────────────── */ test('an image closed while full screen leaves full screen', () =>
withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
dom.root.querySelector('#sl-viewer-expand').click();
await settle();
layer.publish(uiState({ open: false }));
await settle();
assert.equal(dom.document.fullscreenElement, null);
assert.equal(dom.wrap.hidden, true);
assert.deepEqual(expandLabel(dom), ['EXPAND', 'false']);
controls.destroy();
}));
test('without the Fullscreen API there is no EXPAND button', () =>
withDom(
async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
assert.equal(dom.root.querySelector('#sl-viewer-expand').hidden, true);
controls.destroy();
},
{ fullscreen: false },
));
/* ── Restores and keyboard rules ───────────────────────────────────────── */
test('a restored layer does not reopen a panel the user collapsed, and automatic opens are never stored (P2-1)', () => test('a restored layer does not reopen a panel the user collapsed, and automatic opens are never stored (P2-1)', () =>
withDom(async (dom) => { withDom(async (dom) => {
@@ -763,205 +626,11 @@ test('with request origins, only a user switch-on opens the panel; a restore nev
assert.equal(announce, null, 'destroy unsubscribes'); assert.equal(announce, null, 'destroy unsubscribes');
})); }));
test('Esc and Tab from a field outside the expanded viewer stay with that field (P3)', () => test('FIT / FILL show the selected render mode (P3)', () =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
const reached = mapEscape(dom);
controls.setViewerExpanded(true);
dom.search.focus();
keydown(dom.search, 'Escape');
assert.equal(controls.isViewerExpanded(), true, 'the search keeps its Esc');
assert.equal(reached.length, 1, 'and the event was not swallowed');
keydown(dom.search, 'Tab');
assert.equal(dom.document.activeElement, dom.search, 'Tab stays put');
// From <body> (focus nowhere), Esc is still the viewer's.
keydown(dom.document.body, 'Escape');
assert.equal(controls.isViewerExpanded(), false);
controls.destroy();
}));
test('a hidden expanded viewer (Clean View, recording, cockpit) holds neither Esc nor Tab (P2-5)', () =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
const reached = mapEscape(dom);
controls.setViewerExpanded(true);
const wrap = dom.document.body.querySelector('#sl-viewer-wrap');
wrap.checkVisibility = () => false; // the mode's CSS hides it
dom.globe.focus();
keydown(dom.globe, 'Tab');
assert.equal(dom.document.activeElement, dom.globe);
keydown(dom.globe, 'Escape');
assert.equal(controls.isViewerExpanded(), true);
assert.equal(reached.length, 1, 'Esc went on to the map');
controls.destroy();
}));
/**
* A MutationObserver stand-in: queues only the records its options cover and
* delivers them in a microtask; `fire()` delivers at once.
*/
function fakeMutationObserver() {
const observers = [];
class FakeMutationObserver {
constructor(callback) {
this.callback = callback;
this.target = null;
this.options = null;
this.records = [];
observers.push(this);
}
observe(target, options = {}) {
this.target = target;
this.options = {
...options,
// Per the spec, an attribute filter implies `attributes`.
attributes: options.attributes ?? Boolean(options.attributeFilter),
};
liveObservers.add(this);
}
disconnect() {
this.target = null;
this.records = [];
liveObservers.delete(this);
}
consider(node, type, attributeName) {
const { childList, attributes, attributeFilter, subtree } = this.options;
const inScope =
node === this.target ||
(subtree === true && this.target.contains(node));
if (!inScope) return;
if (type === 'childList' && childList !== true) return;
if (type === 'attributes') {
if (attributes !== true) return;
if (attributeFilter && !attributeFilter.includes(attributeName)) return;
}
this.records.push({ type, target: node, attributeName });
if (this.records.length === 1) queueMicrotask(() => this.deliver());
}
deliver() {
if (!this.target || !this.records.length) return;
const records = this.records;
this.records = [];
this.callback(records, this);
}
}
const fire = () => {
for (const observer of observers) observer.deliver();
};
const watching = () => observers.some((observer) => observer.target);
return { FakeMutationObserver, fire, watching };
}
/** Run `fn` with a fake MutationObserver installed. */
async function withObserver(fn) {
const saved = globalThis.MutationObserver;
const fake = fakeMutationObserver();
globalThis.MutationObserver = fake.FakeMutationObserver;
try {
return await fn(fake);
} finally {
liveObservers.clear();
if (saved === undefined) delete globalThis.MutationObserver;
else globalThis.MutationObserver = saved;
}
}
/** Every <body> child but the viewer, by id. */
const inertOutside = (dom) =>
dom.document.body.children
.filter((node) => node.id !== 'sl-viewer-wrap')
.map((node) => [node.id, node.inert === true]);
test('the expanded viewer makes the rest of the app inert and gives it back on shrink', () =>
withObserver(() =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
// Another dialog's own inert backdrop: it is not ours to release.
const owned = dom.document.body.appendChild(
new dom.globe.constructor(dom.document, 'div', { id: 'owned' }),
);
owned.inert = true;
controls.setViewerExpanded(true);
assert.deepEqual(inertOutside(dom), [
['street-level-panel', true],
['cesiumContainer', true],
['location-search', true],
['owned', true],
]);
const wrap = dom.document.body.querySelector('#sl-viewer-wrap');
assert.notEqual(wrap.inert, true, 'the viewer itself stays live');
// Shrinking returns focus into the panel: it must be live again first.
dom.root.querySelector('#sl-status').focus();
controls.setViewerExpanded(false);
assert.deepEqual(inertOutside(dom), [
['street-level-panel', false],
['cesiumContainer', false],
['location-search', false],
['owned', true],
]);
controls.destroy();
}),
));
test('a CSS-hidden expanded viewer releases the app; showing it again, and new children, go inert', () =>
withObserver((observer) =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
controls.setViewerExpanded(true);
assert.equal(observer.watching(), true);
const wrap = dom.document.body.querySelector('#sl-viewer-wrap');
let visible = true;
wrap.checkVisibility = () => visible;
// Clean View (or recording, or the cockpit) hides the viewer by CSS.
visible = false;
dom.document.body.classList.add('ui-clean-view');
observer.fire();
assert.ok(
inertOutside(dom).every(([, inert]) => !inert),
'a hidden modal leaves nothing inert',
);
// Leaving the mode shows the viewer again: the app is inert once more,
// and so is a child added to <body> meanwhile.
visible = true;
const toast = dom.document.body.appendChild(
new dom.globe.constructor(dom.document, 'div', { id: 'toast' }),
);
observer.fire();
assert.ok(inertOutside(dom).every(([, inert]) => inert));
assert.equal(toast.inert, true);
controls.destroy();
assert.ok(
inertOutside(dom).every(([, inert]) => !inert),
'destroy releases the app',
);
assert.equal(observer.watching(), false, 'and stops watching');
}),
));
test('an image closed while expanded releases the app', () =>
withObserver(() =>
withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
controls.setViewerExpanded(true);
assert.ok(inertOutside(dom).every(([, inert]) => inert));
layer.publish(uiState({ open: false }));
assert.equal(controls.isViewerExpanded(), false);
assert.ok(inertOutside(dom).every(([, inert]) => !inert));
assert.equal(
dom.document.activeElement,
dom.root.querySelector('#sl-status'),
);
controls.destroy();
}),
));
test('FIT / FILL show the selection while the viewer is expanded (P3)', () =>
withDom(async (dom) => { withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState({ open: true })); const { layer, controls } = stubPanel(dom, uiState({ open: true }));
controls.setViewerExpanded(true);
layer.publish(uiState({ open: true, renderMode: 'fill' })); layer.publish(uiState({ open: true, renderMode: 'fill' }));
const wrap = dom.document.body.querySelector('#sl-viewer-wrap'); const [fit, fill] = dom.wrap.querySelectorAll('[data-sl-render]');
const [fit, fill] = wrap.querySelectorAll('[data-sl-render]');
assert.equal(fill.getAttribute('aria-checked'), 'true'); assert.equal(fill.getAttribute('aria-checked'), 'true');
assert.equal(fit.getAttribute('aria-checked'), 'false'); assert.equal(fit.getAttribute('aria-checked'), 'false');
assert.equal(fill.classList.contains('is-active'), true); assert.equal(fill.classList.contains('is-active'), true);
@@ -1011,6 +680,8 @@ test('identical renders write nothing to the DOM (P3: rail MutationObserver)', (
test('arrow keys move the selection within a radiogroup, which has one tab stop (P3)', () => test('arrow keys move the selection within a radiogroup, which has one tab stop (P3)', () =>
withDom(async (dom) => { withDom(async (dom) => {
const { layer, calls, controls } = stubPanel(dom, uiState()); 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]'); const [all, pano, flat] = dom.root.querySelectorAll('[data-sl-pano]');
assert.deepEqual( assert.deepEqual(
[all, pano, flat].map((button) => button.getAttribute('tabindex')), [all, pano, flat].map((button) => button.getAttribute('tabindex')),
@@ -1034,103 +705,3 @@ test('arrow keys move the selection within a radiogroup, which has one tab stop
); );
controls.destroy(); 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(
dom,
uiState({ keyRequired: true }),
);
assert.equal(dom.root.querySelector('#sl-controls').disabled, true);
assert.equal(
dom.root.querySelector('#sl-controls').contains(chip()),
false,
'the chip is outside the disabled fieldset',
);
chip().click();
await settle();
assert.deepEqual(calls.setEnabled, []);
assert.deepEqual(calls.setParams, []);
assert.equal(calls.toasts.length, 1);
assert.match(calls.toasts[0], /^Mapillary: Needs /);
controls.destroy();
}));
/* ── The expanded viewer goes back where it came from ──────────────────── */
/** A node by name, so a failed comparison prints a word, not the DOM. */
const nameOf = (node) =>
node ? node.id || node.className || node.tagName.toLowerCase() : null;
/** Where the viewer sits: its parent and the sibling it sits before. */
const placeOf = (wrap) => [nameOf(wrap.parentNode), nameOf(wrap.nextSibling)];
const PANEL_PLACE = ['sl-main', 'sl-settings'];
test('shrinking returns the viewer into the panel at its own place', () =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE);
controls.setViewerExpanded(true);
assert.equal(nameOf(dom.wrap.parentNode), 'body', 'lifted out');
controls.setViewerExpanded(false);
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE, 'before the settings');
assert.equal(dom.main.children.indexOf(dom.wrap), 0);
assert.equal(dom.root.contains(dom.wrap), true);
// A second round trip lands in the same place.
controls.setViewerExpanded(true);
controls.setViewerExpanded(false);
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE);
controls.destroy();
}));
test('an image closed while expanded returns the viewer into the panel', () =>
withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
controls.setViewerExpanded(true);
layer.publish(uiState({ open: false }));
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE);
assert.equal(dom.wrap.hidden, true, 'and hidden there');
controls.destroy();
}));
test('destroying the panel while expanded returns the viewer into it', () =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
controls.setViewerExpanded(true);
controls.destroy();
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE);
assert.equal(
dom.document.body.children.includes(dom.wrap),
false,
'nothing left on <body>',
);
}));
test('a <body> child added while the viewer is expanded goes inert with the rest', () =>
withObserver((observer) =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
controls.setViewerExpanded(true);
// A toast, a menu or another panel's popup, appended later.
const toast = dom.document.body.appendChild(
new FakeNode(dom.document, 'div', { id: 'toast' }),
);
assert.equal(toast.inert, undefined, 'not before the observer runs');
await settle(); // records are delivered in a microtask
assert.equal(toast.inert, true);
// Changes inside the app are not the modal's business: nothing runs.
let syncs = 0;
const sync = controls._syncModal.bind(controls);
controls._syncModal = () => {
syncs++;
return sync();
};
dom.root.querySelector('#sl-status').setAttribute('class', 'x');
dom.root.appendChild(new FakeNode(dom.document, 'div'));
await settle();
assert.equal(syncs, 0);
controls.destroy();
assert.equal(toast.inert, false, 'destroy releases it too');
assert.equal(observer.watching(), false);
}),
));
+13 -49
View File
@@ -67,35 +67,13 @@ function presentStatus(state) {
: { text: 'OFF', tone: '', pressed, title }; : { text: 'OFF', tone: '', pressed, title };
} }
/** A chip is lit only while the layer and its provider are both on. */ /** How to add the missing key, for the provider that needs one. */
function presentProviders(state) { function keyHint(state) {
const enabled = state.enabled === true; const provider = (state.providers || []).find(
return (state.providers || []).map((provider) => { (entry) => entry.keyRequired === true && entry.requiresKeyId,
const keyRequired = provider.keyRequired === true; );
const on = enabled && provider.on === true; const requirement = provider && keySetupRequirement(provider.requiresKeyId);
let title = `${provider.name} imagery ${on ? 'on' : 'off'}`; return requirement ? `${provider.name}: ${requirement}` : null;
if (provider.keyRejected && provider.error)
title = `${provider.name}: ${provider.error}`;
else if (keyRequired && provider.requiresKeyId)
title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`;
else if (provider.error) title = `${provider.name}: ${provider.error}`;
return {
id: provider.id,
label: provider.label,
color: provider.color || null,
title,
active: on,
disabled: false,
state: keyRequired
? 'error'
: on && provider.loading
? 'loading'
: on
? 'active'
: 'idle',
busy: on && provider.loading === true,
};
});
} }
function presentViewer(state) { function presentViewer(state) {
@@ -113,19 +91,11 @@ function presentViewer(state) {
captionRight: right.join(' · '), captionRight: right.join(' · '),
link: street.externalUrl || null, link: street.externalUrl || null,
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '', linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
follow: {
pressed: street.follow === true,
disabled: street.open !== true || street.followAvailable !== true,
title:
street.followAvailable === true
? 'Camera follows view: move the globe camera wherever the street-level view looks'
: 'Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE',
},
}; };
} }
function presentMeta(state) { function presentMeta(state) {
if (!state.enabled) return 'Switch a provider on to draw its coverage.'; if (!state.enabled) return 'Switch Street Level on to draw its coverage.';
if (state.sequence.selectedId) if (state.sequence.selectedId)
return state.sequence.loading return state.sequence.loading
? 'Loading this sequence…' ? 'Loading this sequence…'
@@ -137,26 +107,20 @@ function presentMeta(state) {
/** @param {{now?: number}} [options] Clock for the SINCE readout. */ /** @param {{now?: number}} [options] Clock for the SINCE readout. */
export function presentStreetLevelPanel(state, { now = Date.now() } = {}) { export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
const enabled = state.enabled === true;
const keyRequired = state.keyRequired === true;
const filter = state.filter || { pano: 'all', sinceDays: 0 }; const filter = state.filter || { pano: 'all', sinceDays: 0 };
// A missing key is already shown by the status and chip tooltip; only a // A missing key says how to add it; a rejected key's own error names the fix.
// rejected key keeps its error line, since that message names the fix. const keyMissing = state.keyRequired === true && state.keyRejected !== true;
const keyMissing = keyRequired && state.keyRejected !== true;
return { return {
enabled, enabled: state.enabled === true,
keyRequired,
status: presentStatus(state), status: presentStatus(state),
controlsDisabled: keyRequired, controlsDisabled: state.keyRequired === true,
providers: presentProviders(state),
error: keyMissing error: keyMissing
? null ? keyHint(state)
: state.street.error || state.coverage.error || null, : state.street.error || state.coverage.error || null,
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 }, filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
since: presentSince(filter.sinceDays, now), since: presentSince(filter.sinceDays, now),
legend: state.legend || [], legend: state.legend || [],
viewer: presentViewer(state), viewer: presentViewer(state),
meta: presentMeta(state), meta: presentMeta(state),
wantsOpen: state.street.open === true,
}; };
} }
+4 -131
View File
@@ -23,7 +23,6 @@ function snapshot(overrides = {}) {
sequence: { providerId: null, selectedId: null, images: 0, loading: false }, sequence: { providerId: null, selectedId: null, images: 0, loading: false },
street: { street: {
open: false, open: false,
follow: false,
loading: false, loading: false,
error: null, error: null,
providerId: null, providerId: null,
@@ -65,7 +64,7 @@ test('a key-gated layer disables every control and flags KEY REQUIRED', () => {
assert.equal(view.status.tone, 'warn'); assert.equal(view.status.tone, 'warn');
}); });
test('KEY REQUIRED shows no raw error code under the pill', () => { test('KEY REQUIRED says how to add the key instead of a raw error code', () => {
const view = presentStreetLevelPanel( const view = presentStreetLevelPanel(
snapshot({ snapshot({
enabled: true, enabled: true,
@@ -75,9 +74,7 @@ test('KEY REQUIRED shows no raw error code under the pill', () => {
}), }),
); );
assert.equal(view.status.text, 'KEY REQUIRED'); assert.equal(view.status.text, 'KEY REQUIRED');
assert.equal(view.error, null); assert.match(view.error, /^Mapillary: Needs MAPILLARY_CLIENT_TOKEN/);
// The chip still says how to add the key.
assert.match(view.providers[0].title, /^Mapillary: Needs /);
}); });
test('a key Mapillary rejected reads KEY REJECTED and names the fix', () => { test('a key Mapillary rejected reads KEY REJECTED and names the fix', () => {
@@ -96,8 +93,6 @@ test('a key Mapillary rejected reads KEY REJECTED and names the fix', () => {
assert.equal(view.status.tone, 'warn'); assert.equal(view.status.tone, 'warn');
assert.equal(view.controlsDisabled, true); assert.equal(view.controlsDisabled, true);
assert.equal(view.error, error); assert.equal(view.error, error);
assert.equal(view.providers[0].state, 'error');
assert.equal(view.providers[0].title, `Mapillary: ${error}`);
}); });
test('a key problem outranks loading on the pill: KEY REJECTED, then KEY REQUIRED (M65)', () => { test('a key problem outranks loading on the pill: KEY REJECTED, then KEY REQUIRED (M65)', () => {
@@ -147,61 +142,6 @@ test('the header pill is the on/off switch and reads OFF, LOADING or ON', () =>
); );
}); });
test('one chip per provider: on, off, loading, and keyless as an error chip', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
providers: [
provider(),
provider({
id: 'panoramax',
name: 'Panoramax',
label: 'PANORAMAX',
on: false,
requiresKeyId: null,
}),
provider({
id: 'kartaview',
name: 'KartaView',
label: 'KARTAVIEW',
loading: true,
requiresKeyId: null,
}),
provider({
id: 'google-street-view',
name: 'Google Street View',
label: 'STREET VIEW',
keyRequired: true,
requiresKeyId: 'google-maps',
}),
],
}),
);
assert.deepEqual(
view.providers.map((chip) => [chip.id, chip.active, chip.state, chip.busy]),
[
['mapillary', true, 'active', false],
['panoramax', false, 'idle', false],
['kartaview', true, 'loading', true],
['google-street-view', true, 'error', false],
],
);
assert.equal(view.providers[0].label, 'MAPILLARY');
assert.equal(view.providers[1].title, 'Panoramax imagery off');
assert.match(
view.providers[3].title,
/^Google Street View: Needs GOOGLE_MAPS_API_KEY/,
);
assert.ok(view.providers.every((chip) => chip.disabled === false));
});
test('a provider error is explained on its chip', () => {
const view = presentStreetLevelPanel(
snapshot({ providers: [provider({ error: 'Tile HTTP 502' })] }),
);
assert.equal(view.providers[0].title, 'Mapillary: Tile HTTP 502');
});
test('errors from the viewer or the coverage web surface in one alert', () => { test('errors from the viewer or the coverage web surface in one alert', () => {
assert.equal(presentStreetLevelPanel(snapshot()).error, null); assert.equal(presentStreetLevelPanel(snapshot()).error, null);
assert.equal( assert.equal(
@@ -217,24 +157,6 @@ test('errors from the viewer or the coverage web surface in one alert', () => {
); );
}); });
test('chips are dark while the layer is off, so the chip is the layer switch', () => {
const view = presentStreetLevelPanel(snapshot({ enabled: false }));
assert.deepEqual(
view.providers.map((chip) => [chip.id, chip.active, chip.state]),
[['mapillary', false, 'idle']],
);
assert.equal(view.providers[0].title, 'Mapillary imagery off');
assert.equal(view.enableButton, undefined, 'no separate ON/OFF button');
const keyless = presentStreetLevelPanel(
snapshot({ providers: [provider({ keyRequired: true })] }),
);
assert.equal(
keyless.providers[0].state,
'error',
'a keyless chip still says why',
);
});
test('the SINCE slider runs from any date on the left to the last month on the right', () => { test('the SINCE slider runs from any date on the left to the last month on the right', () => {
assert.deepEqual( assert.deepEqual(
SINCE_STOPS.map((stop) => stop.days), SINCE_STOPS.map((stop) => stop.days),
@@ -272,36 +194,10 @@ test('the SINCE readout names the window and the cut-off date it means today', (
assert.equal(custom.since.label, 'LAST 400 DAYS · SINCE 2025-08-21'); assert.equal(custom.since.label, 'LAST 400 DAYS · SINCE 2025-08-21');
}); });
test('legend passes through in the layer’s order', () => {
const view = presentStreetLevelPanel(snapshot());
assert.deepEqual(
view.legend.map((entry) => entry.key),
['mapillary', 'selected'],
);
});
test('each provider chip carries its source colour', () => {
const view = presentStreetLevelPanel(
snapshot({
providers: [
provider(),
provider({ id: 'panoramax', label: 'PANORAMAX', color: '#a66bff' }),
],
}),
);
assert.deepEqual(
view.providers.map((chip) => [chip.id, chip.color]),
[
['mapillary', '#05cb63'],
['panoramax', '#a66bff'],
],
);
});
test('the meta line never mixes the visible-sequence count with the selected sequence', () => { test('the meta line never mixes the visible-sequence count with the selected sequence', () => {
assert.equal( assert.equal(
presentStreetLevelPanel(snapshot()).meta, presentStreetLevelPanel(snapshot()).meta,
'Switch a provider on to draw its coverage.', 'Switch Street Level on to draw its coverage.',
); );
const browsing = presentStreetLevelPanel( const browsing = presentStreetLevelPanel(
snapshot({ enabled: true, coverage: { count: 812 } }), snapshot({ enabled: true, coverage: { count: 812 } }),
@@ -341,7 +237,6 @@ test('viewer caption reads "Image by" left, date right, and links to the provide
isPano: true, isPano: true,
externalUrl: externalUrl:
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo', 'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
followAvailable: true,
}, },
}), }),
); );
@@ -352,8 +247,7 @@ test('viewer caption reads "Image by" left, date right, and links to the provide
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo', 'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
); );
assert.equal(view.viewer.linkLabel, 'MAPILLARY ↗'); assert.equal(view.viewer.linkLabel, 'MAPILLARY ↗');
assert.equal(view.viewer.follow.disabled, false); assert.equal(view.viewer.open, true);
assert.equal(view.wantsOpen, true);
}); });
test('an image without a creator name leaves the left caption empty', () => { test('an image without a creator name leaves the left caption empty', () => {
@@ -367,24 +261,3 @@ test('an image without a creator name leaves the left caption empty', () => {
assert.equal(view.viewer.link, null); assert.equal(view.viewer.link, null);
assert.equal(view.viewer.linkLabel, ''); assert.equal(view.viewer.linkLabel, '');
}); });
test('FOLLOW is disabled off Google 3D and says where to switch', () => {
const open = { open: true, imageId: '1' };
const off3d = presentStreetLevelPanel(
snapshot({ street: { ...open, followAvailable: false } }),
);
assert.equal(off3d.viewer.follow.disabled, true);
assert.match(
off3d.viewer.follow.title,
/needs the Google 3D map.*MAP SOURCE/,
);
const on3d = presentStreetLevelPanel(
snapshot({ street: { ...open, followAvailable: true } }),
);
assert.equal(on3d.viewer.follow.disabled, false);
assert.match(on3d.viewer.follow.title, /^Camera follows view/);
const closed = presentStreetLevelPanel(
snapshot({ street: { open: false, followAvailable: true } }),
);
assert.equal(closed.viewer.follow.disabled, true, 'nothing to follow');
});
+1 -2
View File
@@ -1942,8 +1942,7 @@ body.cockpit-mode
#command-dock, #command-dock,
#military-awareness-panel, #military-awareness-panel,
#military-awareness-direction-overlay, #military-awareness-direction-overlay,
#cockpit-entry, #cockpit-entry
.sl-viewer-wrap-expanded
) { ) {
display: none !important; display: none !important;
} }
+1 -3
View File
@@ -996,9 +996,7 @@ body.ui-clean-view #data-panel,
body.ui-clean-view #cctv-panel, body.ui-clean-view #cctv-panel,
body.ui-clean-view #radio-panel, body.ui-clean-view #radio-panel,
body.ui-clean-view #global-context-panel, body.ui-clean-view #global-context-panel,
body.ui-clean-view #scene-panel, body.ui-clean-view #scene-panel {
/* The expanded viewer lives on <body>, outside every panel. */
body.ui-clean-view .sl-viewer-wrap-expanded {
opacity: 0 !important; opacity: 0 !important;
visibility: hidden !important; visibility: hidden !important;
pointer-events: none !important; pointer-events: none !important;
+1 -3
View File
@@ -15,9 +15,7 @@ body.recording-mode #cctv-panel,
body.recording-mode #radio-panel, body.recording-mode #radio-panel,
body.recording-mode #global-context-panel, body.recording-mode #global-context-panel,
body.recording-mode #gev-voice-control, body.recording-mode #gev-voice-control,
body.recording-mode #scene-panel, body.recording-mode #scene-panel {
/* The expanded viewer lives on <body>, outside every panel. */
body.recording-mode .sl-viewer-wrap-expanded {
opacity: 0 !important; opacity: 0 !important;
visibility: hidden !important; visibility: hidden !important;
pointer-events: none !important; pointer-events: none !important;
+13 -105
View File
@@ -1,8 +1,6 @@
/* ── Street Level panel ───────────────────────────────────────── /* ── Street Level panel ─────────────────────────────────────────
The rail owns position and width (layers.css). The expanded viewer is The rail owns position and width (layers.css). */
re-parented to <body>, so it carries the tokens too. */ #street-level-panel {
#street-level-panel,
.sl-viewer-wrap {
--sl-green: #05cb63; --sl-green: #05cb63;
--sl-text: 0.62rem; --sl-text: 0.62rem;
--sl-text-small: 0.6rem; --sl-text-small: 0.6rem;
@@ -132,56 +130,6 @@ button.sl-status:focus-visible {
min-height: 0; min-height: 0;
} }
/* ── Providers ───────────────────────────────────────────────────────────── */
.sl-providers {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.sl-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
/* `--chip-color` comes from the provider. */
.sl-chips .data-toggle-chip {
display: inline-flex;
align-items: center;
gap: 5px;
}
.sl-chips .data-toggle-chip::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--chip-color, currentColor);
opacity: 0.45;
flex: none;
}
.sl-chips .data-toggle-chip.active {
color: var(--chip-color, var(--accent));
border-color: var(--chip-color, var(--accent));
background: color-mix(
in srgb,
var(--chip-color, var(--accent)) 14%,
transparent
);
}
.sl-chips .data-toggle-chip.active::before {
opacity: 1;
}
.sl-chips .data-toggle-chip.chip-error {
color: #ffb300;
border-color: rgba(255, 179, 0, 0.55);
}
/* ── Errors ──────────────────────────────────────────────────────────────── */ /* ── Errors ──────────────────────────────────────────────────────────────── */
.sl-error { .sl-error {
font-family: var(--font-sans); font-family: var(--font-sans);
@@ -383,6 +331,10 @@ button.sl-status:focus-visible {
gap: 4px; gap: 4px;
} }
.sl-viewer-tools .scene-btn[hidden] {
display: none;
}
.sl-viewer-tools .sl-seg { .sl-viewer-tools .sl-seg {
background: rgba(10, 10, 15, 0.75); background: rgba(10, 10, 15, 0.75);
} }
@@ -475,65 +427,21 @@ button.sl-status:focus-visible {
display: none; display: none;
} }
/* ── Expanded viewer ─────────────────────────────────────────────────────── */ /* ── Full screen (EXPAND) ────────────────────────────────────────────────── */
/* Re-parented to <body>: the panel's backdrop-filter would contain a fixed /* The browser lifts the viewer into the top layer; the photo takes the rest. */
child. z-index sits above panels (≤139), below the voice pill (150). */ .sl-viewer-wrap:fullscreen {
.sl-viewer-wrap-expanded { border: 0;
position: fixed; border-radius: 0;
left: 4vw;
right: 4vw;
top: 5vh;
bottom: 9vh;
z-index: 141;
display: flex;
flex-direction: column;
font-family: var(--font-mono);
border-color: rgba(0, 212, 255, 0.55);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7);
outline: none;
} }
.sl-viewer-wrap-expanded .sl-viewer-stage { .sl-viewer-wrap:fullscreen .sl-viewer-stage,
flex: 1 1 auto; .sl-viewer-wrap:fullscreen .sl-viewer {
min-height: 0;
}
.sl-viewer-wrap-expanded .sl-viewer {
flex: 1 1 auto; flex: 1 1 auto;
height: auto; height: auto;
min-height: 0; min-height: 0;
aspect-ratio: auto; aspect-ratio: auto;
} }
.sl-viewer-wrap-expanded .sl-image-meta {
font-size: 0.66rem;
padding: 10px 14px;
}
.sl-viewer-wrap-expanded .sl-viewer-bar {
padding: 8px 10px;
}
.sl-viewer-wrap-expanded .sl-viewer-tools .scene-btn {
padding: 8px 14px;
font-size: 0.7rem;
letter-spacing: 0.12em;
color: var(--text-primary);
border-color: rgba(0, 212, 255, 0.6);
background: rgba(10, 10, 15, 0.82);
}
.sl-viewer-wrap-expanded #sl-viewer-expand::after {
content: ' · ESC';
color: var(--text-secondary);
}
.sl-viewer-wrap-expanded .sl-viewer-x {
width: 34px;
height: 34px;
font-size: 1.3rem;
}
/* ── Short viewports ─────────────────────────────────────────────────────── */ /* ── Short viewports ─────────────────────────────────────────────────────── */
@media (max-height: 640px) { @media (max-height: 640px) {
.street-level-panel-inner { .street-level-panel-inner {
+2 -8
View File
@@ -86,12 +86,11 @@
<div id="sl-viewer-wrap" class="sl-viewer-wrap" hidden> <div id="sl-viewer-wrap" class="sl-viewer-wrap" hidden>
<div class="sl-viewer-bar"> <div class="sl-viewer-bar">
<div class="sl-viewer-tools"> <div class="sl-viewer-tools">
<button id="sl-viewer-expand" class="scene-btn" type="button" aria-pressed="false" title="Expand the street-level view (Esc shrinks it)"><span class="sl-btn-icon" aria-hidden="true">⤢</span><span class="sl-btn-text">EXPAND</span></button> <button id="sl-viewer-expand" class="scene-btn" type="button" aria-pressed="false" title="Show the street-level view full screen (Esc shrinks it)"><span class="sl-btn-icon" aria-hidden="true">⤢</span><span class="sl-btn-text">EXPAND</span></button>
<div class="sl-seg" role="radiogroup" aria-label="Image fit"> <div class="sl-seg" role="radiogroup" aria-label="Image fit">
<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">FIT</button> <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">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">FILL</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">FILL</button>
</div> </div>
<button id="sl-follow-btn" class="scene-btn" type="button" aria-pressed="false" aria-label="Camera follows view" title="Camera follows view: move the globe camera wherever the street-level view looks"><span class="sl-btn-icon" aria-hidden="true">⌖</span><span class="sl-btn-text">FOLLOW</span></button>
</div> </div>
<div class="sl-viewer-tools sl-viewer-tools-right"> <div class="sl-viewer-tools sl-viewer-tools-right">
<button id="sl-viewer-close" class="sl-viewer-x" type="button" title="Close the image and deselect it on the map" aria-label="Close the street-level image">×</button> <button id="sl-viewer-close" class="sl-viewer-x" type="button" title="Close the image and deselect it on the map" aria-label="Close the street-level image">×</button>
@@ -110,13 +109,8 @@
</div> </div>
</div> </div>
<div class="sl-settings"> <div class="sl-settings">
<div class="sl-providers" role="group" aria-label="Imagery providers">
<span class="cctv-summary-label">PROVIDERS</span>
<div id="sl-provider-chips" class="sl-chips"></div>
</div>
<div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></span></div> <div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></span></div>
<!-- A missing key gates the filters only: the chips above stay <!-- A missing key gates the filters; the line above says how to add it. -->
focusable so their tooltip can say how to add the key. -->
<fieldset id="sl-controls" class="sl-controls"> <fieldset id="sl-controls" class="sl-controls">
<div class="sl-filters" role="group" aria-label="Imagery filters"> <div class="sl-filters" role="group" aria-label="Imagery filters">
<span class="cctv-summary-label">IMAGERY</span> <span class="cctv-summary-label">IMAGERY</span>