mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
feat(ui): render the page toast from the status store
ShellFeedback and the scene director show toasts through createToast(), each on its own timer as before, and the scene chrome renders the toast. Every show rewrites the text, so a repeated message is announced again.
This commit is contained in:
1 parent
3c391482f6
commit
f846aaed4c
13 files changed
+203
-43
No files matched your search
@@ -147,7 +147,9 @@ a feature supplying only its accent.
|
|||||||
(the top-center loading status and the road network and camera grid
|
(the top-center loading status and the road network and camera grid
|
||||||
chips render what ShellFeedback and CctvControls publish to the
|
chips render what ShellFeedback and CctvControls publish to the
|
||||||
`gev-status` store; `SplitFlapText` plays the departure-board flip that
|
`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
|
Cockpit (in progress: `CockpitBriefing` renders the briefing card's
|
||||||
pages, cycle toggle, regional news and local conditions, and
|
pages, cycle toggle, regional news and local conditions, and
|
||||||
`CockpitContext` the Contact card's scope, navigation, counts, nearest
|
`CockpitContext` the Contact card's scope, navigation, counts, nearest
|
||||||
|
|||||||
@@ -1130,5 +1130,7 @@
|
|||||||
"src/ui/stores/panels.test.mjs",
|
"src/ui/stores/panels.test.mjs",
|
||||||
"src/ui/stores/status.js",
|
"src/ui/stores/status.js",
|
||||||
"src/ui/statusChipsView.test.mjs",
|
"src/ui/statusChipsView.test.mjs",
|
||||||
"src/ui/statusChipOwners.test.mjs"
|
"src/ui/statusChipOwners.test.mjs",
|
||||||
|
"src/ui/toast.js",
|
||||||
|
"src/ui/toastView.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -1016,7 +1016,8 @@
|
|||||||
"src/ui/stores/voice.js",
|
"src/ui/stores/voice.js",
|
||||||
"src/ui/stores/location.js",
|
"src/ui/stores/location.js",
|
||||||
"src/ui/stores/display.js",
|
"src/ui/stores/display.js",
|
||||||
"src/ui/stores/status.js"
|
"src/ui/stores/status.js",
|
||||||
|
"src/ui/toast.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "vue", "pinia"]
|
"external": ["cesium", "vue", "pinia"]
|
||||||
},
|
},
|
||||||
@@ -1144,7 +1145,9 @@
|
|||||||
"src/i18n/locales/zh-Hant.json",
|
"src/i18n/locales/zh-Hant.json",
|
||||||
"src/ui/unitLabels.js",
|
"src/ui/unitLabels.js",
|
||||||
"src/visualStylePolicy.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"]
|
"external": ["cesium", "vue", "pinia"]
|
||||||
},
|
},
|
||||||
@@ -3000,7 +3003,8 @@
|
|||||||
"src/ui/stores/location.js",
|
"src/ui/stores/location.js",
|
||||||
"src/ui/stores/display.js",
|
"src/ui/stores/display.js",
|
||||||
"src/ui/stores/imagerySplit.js",
|
"src/ui/stores/imagerySplit.js",
|
||||||
"src/ui/stores/status.js"
|
"src/ui/stores/status.js",
|
||||||
|
"src/ui/toast.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
|
|||||||
@@ -474,12 +474,22 @@ async function contacts() {
|
|||||||
|
|
||||||
// SEARCH NEARBY SITES, as the user presses it.
|
// SEARCH NEARBY SITES, as the user presses it.
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
document.getElementById('toast').textContent = '';
|
const toast = document.getElementById('toast');
|
||||||
|
const before = toast.firstChild;
|
||||||
document.getElementById('installations-search-btn').click();
|
document.getElementById('installations-search-btn').click();
|
||||||
|
window.__qaToastBefore = before;
|
||||||
});
|
});
|
||||||
|
// Every toast show writes a new text node, even for a repeated message.
|
||||||
await page
|
await page
|
||||||
.waitForFunction(
|
.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 },
|
{ timeout: 20_000 },
|
||||||
)
|
)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|||||||
+4
-14
@@ -17,6 +17,7 @@ import { resolveCameraPose, resolveCameraMove } from '../director/camera.js';
|
|||||||
import { createCameraMotion } from './cameraMotion.js';
|
import { createCameraMotion } from './cameraMotion.js';
|
||||||
import { createStateChannel } from '../app/stateChannel.js';
|
import { createStateChannel } from '../app/stateChannel.js';
|
||||||
import { SceneControls } from '../ui/scenes.js';
|
import { SceneControls } from '../ui/scenes.js';
|
||||||
|
import { createToast } from '../ui/toast.js';
|
||||||
import { buildPlaybackQueue, playSceneQueue } from '../director/playback.js';
|
import { buildPlaybackQueue, playSceneQueue } from '../director/playback.js';
|
||||||
import { createScenePlaybackAdapter } from './playbackAdapter.js';
|
import { createScenePlaybackAdapter } from './playbackAdapter.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
@@ -252,7 +253,7 @@ export class SceneDirector {
|
|||||||
this._loadAbort?.abort();
|
this._loadAbort?.abort();
|
||||||
this._loadGeneration++;
|
this._loadGeneration++;
|
||||||
this.viewer.camera.cancelFlight();
|
this.viewer.camera.cancelFlight();
|
||||||
clearTimeout(this._storageToastTimer);
|
this._storageToast?.cancel();
|
||||||
await Promise.allSettled(this._pendingWork || []);
|
await Promise.allSettled(this._pendingWork || []);
|
||||||
this._clock.destroy();
|
this._clock.destroy();
|
||||||
this._cancelActiveSceneTravel();
|
this._cancelActiveSceneTravel();
|
||||||
@@ -365,19 +366,8 @@ export class SceneDirector {
|
|||||||
message = t('Scene not saved — browser storage unavailable'),
|
message = t('Scene not saved — browser storage unavailable'),
|
||||||
) {
|
) {
|
||||||
this._updateStatus(message);
|
this._updateStatus(message);
|
||||||
try {
|
this._storageToast ??= createToast({ durationMs: 2600 });
|
||||||
const toast = document.getElementById('toast');
|
this._storageToast.show(message);
|
||||||
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 */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Immutable playback snapshots and completed editing actions. */
|
/** Immutable playback snapshots and completed editing actions. */
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { readShellSource } from '../testSupport/readShellSource.mjs';
|
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.
|
// Director-level pins for scene playback.
|
||||||
//
|
//
|
||||||
// scenePolicy.test.mjs pins the pure decisions; these pin the wiring, which is
|
// 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: [] } },
|
{ prepared: { project: structuredClone(PROJECT_FIXTURE), assets: [] } },
|
||||||
), true);
|
), true);
|
||||||
assert.match(status, /^Scene not saved/, 'failed import keeps the save failure');
|
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(); }
|
} finally { console.warn = warn; await director.destroy(); restore(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ export function readShellElements(document = globalThis.document) {
|
|||||||
_resetGlobeBtn: document.getElementById('reset-globe-view'),
|
_resetGlobeBtn: document.getElementById('reset-globe-view'),
|
||||||
_cockpitResetGlobeBtn: document.getElementById('cockpit-reset-globe'),
|
_cockpitResetGlobeBtn: document.getElementById('cockpit-reset-globe'),
|
||||||
_styleButtons: document.getElementById('style-buttons'),
|
_styleButtons: document.getElementById('style-buttons'),
|
||||||
_toast: document.getElementById('toast'),
|
|
||||||
_locationSearch: document.getElementById('location-search'),
|
_locationSearch: document.getElementById('location-search'),
|
||||||
_searchToggle: document.getElementById('search-toggle'),
|
_searchToggle: document.getElementById('search-toggle'),
|
||||||
_safeFrameOverlay: document.getElementById('safe-frame-overlay'),
|
_safeFrameOverlay: document.getElementById('safe-frame-overlay'),
|
||||||
|
|||||||
+6
-14
@@ -12,6 +12,7 @@ import { getLocale, t, msg } from '../i18n/index.js';
|
|||||||
import { localizeLayerName } from './layerPanel.js';
|
import { localizeLayerName } from './layerPanel.js';
|
||||||
import { translateFeedError } from './statusText.js';
|
import { translateFeedError } from './statusText.js';
|
||||||
import { useStatusStore } from './stores/status.js';
|
import { useStatusStore } from './stores/status.js';
|
||||||
|
import { createToast } from './toast.js';
|
||||||
import { applicationPinia } from './vueRuntime.js';
|
import { applicationPinia } from './vueRuntime.js';
|
||||||
|
|
||||||
// The server also loads loadingFeedback.js (through the HUD summary), so its
|
// The server also loads loadingFeedback.js (through the HUD summary), so its
|
||||||
@@ -130,10 +131,7 @@ export class ShellFeedback {
|
|||||||
this._trafficSyncFeedbackState = createTrafficSyncFeedbackState();
|
this._trafficSyncFeedbackState = createTrafficSyncFeedbackState();
|
||||||
this._loadingFeedbackEvent = null;
|
this._loadingFeedbackEvent = null;
|
||||||
this._globalStatusNotice = null;
|
this._globalStatusNotice = null;
|
||||||
this._loadingFeedbackTicker =
|
this._loadingFeedbackTicker = this._trafficChipTicker = null;
|
||||||
this._trafficChipTicker =
|
|
||||||
this._toastTimer =
|
|
||||||
null;
|
|
||||||
this._loadingVisibilityHandler = null;
|
this._loadingVisibilityHandler = null;
|
||||||
this._lastLoadingFeedbackUpdateAt = 0;
|
this._lastLoadingFeedbackUpdateAt = 0;
|
||||||
this._lastTrafficChipUpdateAt = 0;
|
this._lastTrafficChipUpdateAt = 0;
|
||||||
@@ -153,7 +151,7 @@ export class ShellFeedback {
|
|||||||
progress: '0%',
|
progress: '0%',
|
||||||
stopped: false,
|
stopped: false,
|
||||||
};
|
};
|
||||||
this._toast = document.getElementById('toast');
|
this._toast = createToast({ durationMs: 2000, store });
|
||||||
}
|
}
|
||||||
observeVisibility() {
|
observeVisibility() {
|
||||||
if (this.destroyed || this._loadingVisibilityHandler) return;
|
if (this.destroyed || this._loadingVisibilityHandler) return;
|
||||||
@@ -326,27 +324,21 @@ export class ShellFeedback {
|
|||||||
|
|
||||||
_showToast(message) {
|
_showToast(message) {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
this._toast.textContent = message;
|
this._toast.show(message);
|
||||||
this._toast.classList.add('visible');
|
|
||||||
clearTimeout(this._toastTimer);
|
|
||||||
this._toastTimer = setTimeout(() => {
|
|
||||||
this._toast.classList.remove('visible');
|
|
||||||
}, 2000);
|
|
||||||
}
|
}
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
this._stopLoadingFeedbackTicker();
|
this._stopLoadingFeedbackTicker();
|
||||||
clearInterval(this._trafficChipTicker);
|
clearInterval(this._trafficChipTicker);
|
||||||
clearTimeout(this._toastTimer);
|
this._trafficChipTicker = null;
|
||||||
this._trafficChipTicker = this._toastTimer = null;
|
|
||||||
if (this._loadingVisibilityHandler)
|
if (this._loadingVisibilityHandler)
|
||||||
document.removeEventListener(
|
document.removeEventListener(
|
||||||
'visibilitychange',
|
'visibilitychange',
|
||||||
this._loadingVisibilityHandler,
|
this._loadingVisibilityHandler,
|
||||||
);
|
);
|
||||||
this._loadingVisibilityHandler = null;
|
this._loadingVisibilityHandler = null;
|
||||||
this._toast?.classList.remove('visible');
|
this._toast.hide();
|
||||||
// The labels rest at once; a cascade does not outlive its owner.
|
// The labels rest at once; a cascade does not outlive its owner.
|
||||||
this._publishLoading({ hidden: true, state: null, stopped: true });
|
this._publishLoading({ hidden: true, state: null, stopped: true });
|
||||||
this._publishTraffic({ stopped: true });
|
this._publishTraffic({ stopped: true });
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ function fixture() {
|
|||||||
'left-panel-stack',
|
'left-panel-stack',
|
||||||
'right-context-rail',
|
'right-context-rail',
|
||||||
'pp-toggles',
|
'pp-toggles',
|
||||||
'toast',
|
|
||||||
'safe-frame-overlay',
|
'safe-frame-overlay',
|
||||||
'safe-frame-box',
|
'safe-frame-box',
|
||||||
'hud-toggle',
|
'hud-toggle',
|
||||||
@@ -175,9 +174,15 @@ test('feedback disposal clears toast and polling work and rejects retained notic
|
|||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
const loading = [];
|
const loading = [];
|
||||||
|
const toasts = [];
|
||||||
const owner = new ShellFeedback({
|
const owner = new ShellFeedback({
|
||||||
readLayers: () => [],
|
readLayers: () => [],
|
||||||
store: {
|
store: {
|
||||||
|
toast: null,
|
||||||
|
publishToast(view) {
|
||||||
|
this.toast = view;
|
||||||
|
toasts.push(view);
|
||||||
|
},
|
||||||
publishLoading: (view) => loading.push(view),
|
publishLoading: (view) => loading.push(view),
|
||||||
publishTraffic() {},
|
publishTraffic() {},
|
||||||
},
|
},
|
||||||
@@ -194,8 +199,13 @@ test('feedback disposal clears toast and polling work and rejects retained notic
|
|||||||
owner._startTrafficChipTicker();
|
owner._startTrafficChipTicker();
|
||||||
document.dispatchEvent(new Event('visibilitychange'));
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
assert.equal(f.timers.size, 0);
|
assert.equal(f.timers.size, 0);
|
||||||
assert.equal(f.nodes.get('toast').textContent, 'Saved');
|
assert.deepEqual(
|
||||||
assert.equal(f.nodes.get('toast').classList.contains('visible'), false);
|
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).hidden, true);
|
||||||
assert.equal(loading.at(-1).label, 'Saving', 'the hidden label stays');
|
assert.equal(loading.at(-1).label, 'Saving', 'the hidden label stays');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+15
-1
@@ -4,7 +4,8 @@ import { markRaw, shallowRef } from 'vue';
|
|||||||
/**
|
/**
|
||||||
* The scene's status chips as their views render them. ShellFeedback
|
* The scene's status chips as their views render them. ShellFeedback
|
||||||
* (src/ui/shellFeedback.js) publishes the top-center loading status and the
|
* (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.
|
* last text while hidden, so the next change flaps from what was shown.
|
||||||
*/
|
*/
|
||||||
export const useStatusStore = defineStore('gev-status', () => {
|
export const useStatusStore = defineStore('gev-status', () => {
|
||||||
@@ -18,6 +19,12 @@ export const useStatusStore = defineStore('gev-status', () => {
|
|||||||
/** `{visible, label, immediate, progress, stopped}`, or null. */
|
/** `{visible, label, immediate, progress, stopped}`, or null. */
|
||||||
const cctv = shallowRef(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. */
|
/** @param {?object} next A fresh presentation of the loading status. */
|
||||||
function publishLoading(next) {
|
function publishLoading(next) {
|
||||||
loading.value = next && markRaw(next);
|
loading.value = next && markRaw(next);
|
||||||
@@ -33,7 +40,14 @@ export const useStatusStore = defineStore('gev-status', () => {
|
|||||||
cctv.value = next && markRaw(next);
|
cctv.value = next && markRaw(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {?{text: string, visible: boolean, shown: number}} next */
|
||||||
|
function publishToast(next) {
|
||||||
|
toast.value = next && markRaw(next);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
toast,
|
||||||
|
publishToast,
|
||||||
loading,
|
loading,
|
||||||
publishLoading,
|
publishLoading,
|
||||||
traffic,
|
traffic,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -9,8 +9,19 @@ import SplitFlapText from './panels/SplitFlapText.vue';
|
|||||||
|
|
||||||
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
|
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
|
||||||
const { style } = storeToRefs(useDisplayStore());
|
const { style } = storeToRefs(useDisplayStore());
|
||||||
// The status chips show what ShellFeedback and CctvControls publish.
|
// The status chips and toast show what ShellFeedback, CctvControls and
|
||||||
const { loading, traffic, cctv } = storeToRefs(useStatusStore());
|
// 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 ?? '';
|
||||||
|
},
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -69,7 +80,7 @@ const { loading, traffic, cctv } = storeToRefs(useStatusStore());
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Toast notification -->
|
<!-- Toast notification -->
|
||||||
<div id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
<div id="toast" v-toast-text="toast" role="status" aria-live="polite" aria-atomic="true" v-class-toggle="{ visible: !!toast?.visible }"></div>
|
||||||
|
|
||||||
<!-- Safe frame guides for recording composition -->
|
<!-- Safe frame guides for recording composition -->
|
||||||
<div id="safe-frame-overlay">
|
<div id="safe-frame-overlay">
|
||||||
|
|||||||
Reference in new issue
Block a user