diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 759e454b0..e12681ca6 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -147,7 +147,9 @@ a feature supplying only its accent. (the top-center loading status and the road network and camera grid chips render what ShellFeedback and CctvControls publish to the `gev-status` store; `SplitFlapText` plays the departure-board flip that - `splitFlap.js` plans, writing only the label's one text node), then + `splitFlap.js` plans, writing only the label's one text node; the toast + renders what each owner's `createToast()` publishes there, on that + owner's own timer), then Cockpit (in progress: `CockpitBriefing` renders the briefing card's pages, cycle toggle, regional news and local conditions, and `CockpitContext` the Contact card's scope, navigation, counts, nearest diff --git a/scripts/format-scope.json b/scripts/format-scope.json index d44430021..c4cb057aa 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1130,5 +1130,7 @@ "src/ui/stores/panels.test.mjs", "src/ui/stores/status.js", "src/ui/statusChipsView.test.mjs", - "src/ui/statusChipOwners.test.mjs" + "src/ui/statusChipOwners.test.mjs", + "src/ui/toast.js", + "src/ui/toastView.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 31759cc44..d3f80f6dd 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1016,7 +1016,8 @@ "src/ui/stores/voice.js", "src/ui/stores/location.js", "src/ui/stores/display.js", - "src/ui/stores/status.js" + "src/ui/stores/status.js", + "src/ui/toast.js" ], "external": ["cesium", "vue", "pinia"] }, @@ -1144,7 +1145,9 @@ "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/visualStylePolicy.js", - "src/ui/vueRuntime.js" + "src/ui/vueRuntime.js", + "src/ui/stores/status.js", + "src/ui/toast.js" ], "external": ["cesium", "vue", "pinia"] }, @@ -3000,7 +3003,8 @@ "src/ui/stores/location.js", "src/ui/stores/display.js", "src/ui/stores/imagerySplit.js", - "src/ui/stores/status.js" + "src/ui/stores/status.js", + "src/ui/toast.js" ], "external": [ "hls.js", diff --git a/scripts/qa-context-arrivals.mjs b/scripts/qa-context-arrivals.mjs index 0128f44e5..72a6ffab8 100644 --- a/scripts/qa-context-arrivals.mjs +++ b/scripts/qa-context-arrivals.mjs @@ -474,12 +474,22 @@ async function contacts() { // SEARCH NEARBY SITES, as the user presses it. await page.evaluate(() => { - document.getElementById('toast').textContent = ''; + const toast = document.getElementById('toast'); + const before = toast.firstChild; document.getElementById('installations-search-btn').click(); + window.__qaToastBefore = before; }); + // Every toast show writes a new text node, even for a repeated message. await page .waitForFunction( - () => document.getElementById('toast').textContent.trim().length > 0, + () => { + const toast = document.getElementById('toast'); + return ( + toast.classList.contains('visible') && + toast.firstChild !== window.__qaToastBefore && + toast.textContent.trim().length > 0 + ); + }, { timeout: 20_000 }, ) .catch(() => {}); diff --git a/src/scenes/director.js b/src/scenes/director.js index a0c61cec1..88e4e3a8f 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -17,6 +17,7 @@ import { resolveCameraPose, resolveCameraMove } from '../director/camera.js'; import { createCameraMotion } from './cameraMotion.js'; import { createStateChannel } from '../app/stateChannel.js'; import { SceneControls } from '../ui/scenes.js'; +import { createToast } from '../ui/toast.js'; import { buildPlaybackQueue, playSceneQueue } from '../director/playback.js'; import { createScenePlaybackAdapter } from './playbackAdapter.js'; import * as Cesium from 'cesium'; @@ -252,7 +253,7 @@ export class SceneDirector { this._loadAbort?.abort(); this._loadGeneration++; this.viewer.camera.cancelFlight(); - clearTimeout(this._storageToastTimer); + this._storageToast?.cancel(); await Promise.allSettled(this._pendingWork || []); this._clock.destroy(); this._cancelActiveSceneTravel(); @@ -365,19 +366,8 @@ export class SceneDirector { message = t('Scene not saved — browser storage unavailable'), ) { this._updateStatus(message); - try { - const toast = document.getElementById('toast'); - if (toast) { - toast.textContent = message; - toast.classList.add('visible'); - clearTimeout(this._storageToastTimer); - this._storageToastTimer = setTimeout(() => { - toast.classList.remove('visible'); - }, 2600); - } - } catch { - /* toast is best-effort */ - } + this._storageToast ??= createToast({ durationMs: 2600 }); + this._storageToast.show(message); } /** Immutable playback snapshots and completed editing actions. */ diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index 7b59507be..aad01054d 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -1,4 +1,6 @@ import { readShellSource } from '../testSupport/readShellSource.mjs'; +import { useStatusStore } from '../ui/stores/status.js'; +import { applicationPinia } from '../ui/vueRuntime.js'; // Director-level pins for scene playback. // // scenePolicy.test.mjs pins the pure decisions; these pin the wiring, which is @@ -1995,6 +1997,9 @@ test('a capture, update or import that cannot be saved keeps the "Scene not save { prepared: { project: structuredClone(PROJECT_FIXTURE), assets: [] } }, ), true); assert.match(status, /^Scene not saved/, 'failed import keeps the save failure'); + const toast = useStatusStore(applicationPinia()).toast; + assert.equal(toast.visible, true); + assert.match(toast.text, /^Scene not saved/, 'the page toast says so too'); } finally { console.warn = warn; await director.destroy(); restore(); } }); diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index bf2ac0ceb..c1944b995 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -103,7 +103,6 @@ export function readShellElements(document = globalThis.document) { _resetGlobeBtn: document.getElementById('reset-globe-view'), _cockpitResetGlobeBtn: document.getElementById('cockpit-reset-globe'), _styleButtons: document.getElementById('style-buttons'), - _toast: document.getElementById('toast'), _locationSearch: document.getElementById('location-search'), _searchToggle: document.getElementById('search-toggle'), _safeFrameOverlay: document.getElementById('safe-frame-overlay'), diff --git a/src/ui/shellFeedback.js b/src/ui/shellFeedback.js index 22c2346b2..6de4f62a8 100644 --- a/src/ui/shellFeedback.js +++ b/src/ui/shellFeedback.js @@ -12,6 +12,7 @@ import { getLocale, t, msg } from '../i18n/index.js'; import { localizeLayerName } from './layerPanel.js'; import { translateFeedError } from './statusText.js'; import { useStatusStore } from './stores/status.js'; +import { createToast } from './toast.js'; import { applicationPinia } from './vueRuntime.js'; // The server also loads loadingFeedback.js (through the HUD summary), so its @@ -130,10 +131,7 @@ export class ShellFeedback { this._trafficSyncFeedbackState = createTrafficSyncFeedbackState(); this._loadingFeedbackEvent = null; this._globalStatusNotice = null; - this._loadingFeedbackTicker = - this._trafficChipTicker = - this._toastTimer = - null; + this._loadingFeedbackTicker = this._trafficChipTicker = null; this._loadingVisibilityHandler = null; this._lastLoadingFeedbackUpdateAt = 0; this._lastTrafficChipUpdateAt = 0; @@ -153,7 +151,7 @@ export class ShellFeedback { progress: '0%', stopped: false, }; - this._toast = document.getElementById('toast'); + this._toast = createToast({ durationMs: 2000, store }); } observeVisibility() { if (this.destroyed || this._loadingVisibilityHandler) return; @@ -326,27 +324,21 @@ export class ShellFeedback { _showToast(message) { if (this.destroyed) return; - this._toast.textContent = message; - this._toast.classList.add('visible'); - clearTimeout(this._toastTimer); - this._toastTimer = setTimeout(() => { - this._toast.classList.remove('visible'); - }, 2000); + this._toast.show(message); } destroy() { if (this.destroyed) return; this.destroyed = true; this._stopLoadingFeedbackTicker(); clearInterval(this._trafficChipTicker); - clearTimeout(this._toastTimer); - this._trafficChipTicker = this._toastTimer = null; + this._trafficChipTicker = null; if (this._loadingVisibilityHandler) document.removeEventListener( 'visibilitychange', this._loadingVisibilityHandler, ); this._loadingVisibilityHandler = null; - this._toast?.classList.remove('visible'); + this._toast.hide(); // The labels rest at once; a cascade does not outlive its owner. this._publishLoading({ hidden: true, state: null, stopped: true }); this._publishTraffic({ stopped: true }); diff --git a/src/ui/shellLifecycle.test.mjs b/src/ui/shellLifecycle.test.mjs index 94c955c38..fac9316a0 100644 --- a/src/ui/shellLifecycle.test.mjs +++ b/src/ui/shellLifecycle.test.mjs @@ -53,7 +53,6 @@ function fixture() { 'left-panel-stack', 'right-context-rail', 'pp-toggles', - 'toast', 'safe-frame-overlay', 'safe-frame-box', 'hud-toggle', @@ -175,9 +174,15 @@ test('feedback disposal clears toast and polling work and rejects retained notic const f = fixture(); try { const loading = []; + const toasts = []; const owner = new ShellFeedback({ readLayers: () => [], store: { + toast: null, + publishToast(view) { + this.toast = view; + toasts.push(view); + }, publishLoading: (view) => loading.push(view), publishTraffic() {}, }, @@ -194,8 +199,13 @@ test('feedback disposal clears toast and polling work and rejects retained notic owner._startTrafficChipTicker(); document.dispatchEvent(new Event('visibilitychange')); assert.equal(f.timers.size, 0); - assert.equal(f.nodes.get('toast').textContent, 'Saved'); - assert.equal(f.nodes.get('toast').classList.contains('visible'), false); + assert.deepEqual( + toasts.map(({ text, visible }) => ({ text, visible })), + [ + { text: 'Saved', visible: true }, + { text: 'Saved', visible: false }, + ], + ); assert.equal(loading.at(-1).hidden, true); assert.equal(loading.at(-1).label, 'Saving', 'the hidden label stays'); } finally { diff --git a/src/ui/stores/status.js b/src/ui/stores/status.js index efe0cbe03..599b320b7 100644 --- a/src/ui/stores/status.js +++ b/src/ui/stores/status.js @@ -4,7 +4,8 @@ import { markRaw, shallowRef } from 'vue'; /** * The scene's status chips as their views render them. ShellFeedback * (src/ui/shellFeedback.js) publishes the top-center loading status and the - * road network chip, CctvControls the camera grid chip. Each label keeps its + * road network chip, CctvControls the camera grid chip; the toast shows + * what any owner's createToast() (src/ui/toast.js) publishes. Each label keeps its * last text while hidden, so the next change flaps from what was shown. */ export const useStatusStore = defineStore('gev-status', () => { @@ -18,6 +19,12 @@ export const useStatusStore = defineStore('gev-status', () => { /** `{visible, label, immediate, progress, stopped}`, or null. */ const cctv = shallowRef(null); + /** + * The page toast, `{text, visible, shown}`; null until the first one. + * `shown` changes with every show, which rewrites the text. + */ + const toast = shallowRef(null); + /** @param {?object} next A fresh presentation of the loading status. */ function publishLoading(next) { loading.value = next && markRaw(next); @@ -33,7 +40,14 @@ export const useStatusStore = defineStore('gev-status', () => { cctv.value = next && markRaw(next); } + /** @param {?{text: string, visible: boolean, shown: number}} next */ + function publishToast(next) { + toast.value = next && markRaw(next); + } + return { + toast, + publishToast, loading, publishLoading, traffic, diff --git a/src/ui/toast.js b/src/ui/toast.js new file mode 100644 index 000000000..0df985467 --- /dev/null +++ b/src/ui/toast.js @@ -0,0 +1,42 @@ +import { useStatusStore } from './stores/status.js'; +import { applicationPinia } from './vueRuntime.js'; + +// Counts every show on the page, so a repeated message is announced again. +let shown = 0; + +/** + * One owner's use of the page toast, which the scene chrome renders from the + * status store. Each owner keeps its own timer, so a later toast from another + * owner is hidden when this one's time is up, as the shared element was. + * @param {object} options + * @param {number} options.durationMs How long a toast stays up. + * @param {object} [options.store] The status store. + */ +export function createToast({ + durationMs, + store = useStatusStore(applicationPinia()), +}) { + let timer = null; + // The text stays while the toast fades out. + const hide = () => { + clearTimeout(timer); + timer = null; + if (store.toast?.visible) + store.publishToast({ ...store.toast, visible: false }); + }; + return { + /** @param {string} message */ + show(message) { + store.publishToast({ text: message, visible: true, shown: ++shown }); + clearTimeout(timer); + timer = setTimeout(hide, durationMs); + }, + /** Hide the toast now and drop the timer. */ + hide, + /** Drop the timer, leaving the toast as it is. */ + cancel() { + clearTimeout(timer); + timer = null; + }, + }; +} diff --git a/src/ui/toastView.test.mjs b/src/ui/toastView.test.mjs new file mode 100644 index 000000000..e82dd0b4a --- /dev/null +++ b/src/ui/toastView.test.mjs @@ -0,0 +1,79 @@ +// src/ui/toastView.test.mjs — the page toast renders what its owners' +// createToast() publish, each owner on its own timer. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, nextTick } from 'vue'; +import { createPinia } from 'pinia'; +import { useStatusStore } from './stores/status.js'; +import { createToast } from './toast.js'; +import SceneChromeView from './views/SceneChromeView.vue'; + +function fixture(t) { + t.mock.timers.enable({ apis: ['setTimeout'] }); + document.body.replaceChildren(); + const pinia = createPinia(); + const app = createApp(SceneChromeView).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => app.unmount()); + const store = useStatusStore(pinia); + const toast = () => document.getElementById('toast'); + return { store, toast }; +} + +test('a toast shows its message, then fades out keeping its text', async (t) => { + const { store, toast } = fixture(t); + assert.equal(toast().textContent, ''); + assert.equal(toast().classList.contains('visible'), false); + const owner = createToast({ durationMs: 2000, store }); + owner.show('Link copied'); + await nextTick(); + assert.equal(toast().textContent, 'Link copied'); + assert.equal(toast().classList.contains('visible'), true); + assert.equal(toast().getAttribute('role'), 'status'); + t.mock.timers.tick(1999); + await nextTick(); + assert.equal(toast().classList.contains('visible'), true); + t.mock.timers.tick(1); + await nextTick(); + assert.equal(toast().classList.contains('visible'), false); + assert.equal(toast().textContent, 'Link copied'); +}); + +test('a newer toast restarts the time, and another owner hides it on its own time', async (t) => { + const { store, toast } = fixture(t); + const shell = createToast({ durationMs: 2000, store }); + const scenes = createToast({ durationMs: 2600, store }); + scenes.show('Scene not saved'); + t.mock.timers.tick(1000); + shell.show('Saved'); + t.mock.timers.tick(1600); + await nextTick(); + assert.equal(toast().textContent, 'Saved'); + assert.equal(toast().classList.contains('visible'), false); + shell.show('Again'); + scenes.cancel(); + shell.hide(); + await nextTick(); + assert.equal(toast().classList.contains('visible'), false); + assert.equal(toast().textContent, 'Again'); + t.mock.timers.tick(5000); + assert.equal(store.toast.text, 'Again'); + assert.equal(store.toast.visible, false); +}); + +test('a repeated message is written again so the live region announces it', async (t) => { + const { store, toast } = fixture(t); + const owner = createToast({ durationMs: 2000, store }); + owner.show('Fly to a POI first'); + await nextTick(); + const first = toast().firstChild; + t.mock.timers.tick(2000); + await nextTick(); + assert.ok(toast().firstChild === first, 'fading out leaves the text alone'); + owner.show('Fly to a POI first'); + await nextTick(); + assert.equal(toast().textContent, 'Fly to a POI first'); + assert.ok(toast().firstChild !== first, 'the repeat is a new text node'); + assert.equal(toast().classList.contains('visible'), true); +}); diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index 4ba941e7d..552880233 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -9,8 +9,19 @@ import SplitFlapText from './panels/SplitFlapText.vue'; // The ACTIVE STYLE corner shows the style VisualSettings publishes. const { style } = storeToRefs(useDisplayStore()); -// The status chips show what ShellFeedback and CctvControls publish. -const { loading, traffic, cctv } = storeToRefs(useStatusStore()); +// The status chips and toast show what ShellFeedback, CctvControls and +// createToast() publish. +const { loading, traffic, cctv, toast } = storeToRefs(useStatusStore()); +// Every show rewrites the toast's text, so the live region announces a +// repeated message again; hiding leaves it as it fades. +const vToastText = { + mounted(el, { value }) { + el.textContent = value?.text ?? ''; + }, + updated(el, { value, oldValue }) { + if (value?.shown !== oldValue?.shown) el.textContent = value?.text ?? ''; + }, +};