diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index edf6fe905..77f30394c 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -122,8 +122,12 @@ They contain no application state. what `createMapSourceControls()` publishes beside it, so `./ui/maps` needs no Vue; the location tray's city and landmark pills, mini status and search error render what `LocationControls` publishes to the - `gev-location` store, and the search field keeps its listeners), - the phone shell, then Cockpit. Layers that write into the right rail + `gev-location` store, and the search field keeps its listeners), the + phone shell (`PhoneTabBar` renders the tabs and open sheet the shell + publishes to the `gev-display` store, teleporting the tab bar and sheet + backdrop to the end of the page from the first publish until the shell + is destroyed; the shell takes their clicks from the document), then + Cockpit. Layers that write into the right rail directly gain a slot instead. 4. **Cleanup.** The build-time template expansion and the attribute diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 2a5f1d2a5..2c61dc94d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -26,6 +26,7 @@ "src/ui/views/ContextView.vue", "src/ui/views/panels/RadioPanel.vue", "src/ui/views/panels/SdrCard.vue", + "src/ui/views/panels/PhoneTabBar.vue", "src/ui/views/panels/MissionDetail.vue", "src/ui/views/panels/MissionRoster.vue", "src/ui/views/panels/AwarenessPanel.vue", diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 04d08329b..3c018859d 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { resolveImageryHost } from '../layers/weather/imageryHost.js'; import { ShellFacade } from './shellFacade.js'; import { AircraftDisplay } from './aircraftDisplay.js'; @@ -852,6 +853,11 @@ export class StyleManager extends ShellFacade { onTabOpened: (sheet) => { void this._onMobileTabOpened(sheet); }, + publish: (view) => + useDisplayStore(applicationPinia()).publishPhoneShell(view), + afterPublish: (callback) => { + void nextTick(callback); + }, }); } diff --git a/src/ui/commandDock.test.mjs b/src/ui/commandDock.test.mjs index ff8f802da..223d4a6d2 100644 --- a/src/ui/commandDock.test.mjs +++ b/src/ui/commandDock.test.mjs @@ -9,6 +9,7 @@ import { setLocale } from '../i18n/index.js'; import { useDisplayStore } from './stores/display.js'; import { useLocationStore } from './stores/location.js'; import { LocationControls } from './locationControls.js'; +import { MOBILE_TABS, initMobileShell } from './mobileShell.js'; import CommandDockView from './views/CommandDockView.vue'; import SceneChromeView from './views/SceneChromeView.vue'; import { VisualSettings } from './visualSettings.js'; @@ -252,3 +253,163 @@ test('the location tray renders what LocationControls publishes', async (t) => { assert.equal(row.classList.contains('expanded'), false); assert.equal($('#location-bar-divider').classList.contains('visible'), false); }); + +// These compare nodes by identity in a boolean: a failing assertion on a +// happy-dom node would try to print the whole document. +test('the phone tab bar joins the end of the page while the shell publishes', async (t) => { + const { $, pinia } = fixture(t); + assert.equal($('#mobile-tab-bar') === null, true); + assert.equal($('#mobile-sheet-backdrop') === null, true); + // The scene adds its credits after the views mount; the bar follows them. + const credits = document.body.appendChild(document.createElement('div')); + const store = useDisplayStore(pinia); + store.publishPhoneShell({ enabled: true, sheet: null, tabs: MOBILE_TABS }); + await nextTick(); + const bar = $('#mobile-tab-bar'); + const backdrop = $('#mobile-sheet-backdrop'); + assert.equal(document.body.lastElementChild === bar, true); + assert.equal(bar.previousElementSibling === backdrop, true); + assert.equal(backdrop.previousElementSibling === credits, true); + assert.equal(bar.getAttribute('aria-label'), 'Panels'); + assert.equal(bar.getAttribute('aria-hidden'), 'false'); + assert.equal(backdrop.hidden, true); + const tabs = [...bar.querySelectorAll('.mobile-tab')]; + assert.deepEqual( + tabs.map( + (tab) => + `${tab.dataset.mobileSheet}:${tab.querySelector('.mobile-tab-icon').textContent}:${tab.querySelector('.mobile-tab-label').textContent}`, + ), + [ + 'layers:layers:Layers', + 'cameras:videocam:Cameras', + 'context:radar:Context', + 'search:search:Search', + 'more:more_horiz:More', + ], + ); + assert.deepEqual( + tabs.map((tab) => tab.getAttribute('aria-expanded')), + ['false', 'false', 'false', 'false', 'false'], + ); + store.publishPhoneShell({ + enabled: true, + sheet: 'context', + tabs: MOBILE_TABS, + }); + await nextTick(); + assert.equal(backdrop.hidden, false); + assert.deepEqual( + tabs.map((tab) => tab.getAttribute('aria-expanded')), + ['false', 'false', 'true', 'false', 'false'], + ); + store.publishPhoneShell({ enabled: false, sheet: null, tabs: MOBILE_TABS }); + await nextTick(); + assert.equal(bar.getAttribute('aria-hidden'), 'true'); + assert.equal(backdrop.hidden, true); + assert.deepEqual( + tabs.map((tab) => tab.getAttribute('aria-expanded')), + ['false', 'false', 'false', 'false', 'false'], + ); + store.publishPhoneShell(null); + await nextTick(); + assert.equal($('#mobile-tab-bar') === null, true); + assert.equal($('#mobile-sheet-backdrop') === null, true); +}); + +test('the phone tabs are labelled in the UI language', async (t) => { + t.after(() => setLocale('en')); + setLocale('ru'); + const { $, pinia } = fixture(t); + useDisplayStore(pinia).publishPhoneShell({ + enabled: true, + sheet: null, + tabs: MOBILE_TABS, + }); + await nextTick(); + assert.equal($('#mobile-tab-bar').getAttribute('aria-label'), 'Панели'); + assert.equal( + $('#mobile-tab-bar [data-mobile-sheet="layers"] .mobile-tab-label') + .textContent, + 'Слои', + ); +}); + +test('closing a sheet returns focus to its tab once the tab bar shows it closed', async (t) => { + const { $, pinia } = fixture(t); + const store = useDisplayStore(pinia); + const init = () => + initMobileShell({ + documentRef: document, + matchMedia: () => ({ matches: true }), + ResizeObserverImpl: null, + requestFrame: () => 0, + setPanelCollapsed() {}, + publish: (view) => store.publishPhoneShell(view), + afterPublish: (callback) => { + void nextTick(callback); + }, + }); + let shell = init(); + t.after(() => shell.destroy()); + await nextTick(); + const tab = $('#mobile-tab-bar [data-mobile-sheet="search"]'); + tab.focus(); + tab.click(); + await nextTick(); + assert.equal(tab.getAttribute('aria-expanded'), 'true'); + assert.equal(document.activeElement?.id, 'location-search'); + let expandedAtFocus = null; + tab.addEventListener('focus', () => { + expandedAtFocus = tab.getAttribute('aria-expanded'); + }); + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + await nextTick(); + await nextTick(); + assert.equal(document.activeElement === tab, true); + assert.equal(expandedAtFocus, 'false'); + assert.equal($('#mobile-sheet-backdrop').hidden, true); + // Reopening before the closed tab bar renders keeps focus in the sheet. + const layers = $('#mobile-tab-bar [data-mobile-sheet="layers"]'); + const inSheet = document.body.appendChild(document.createElement('button')); + const escape = () => + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + layers.focus(); + layers.click(); + inSheet.focus(); + escape(); + layers.click(); + await nextTick(); + await nextTick(); + assert.equal(document.body.dataset.mobileSheet, 'layers'); + assert.equal(document.activeElement === inSheet, true); + // So does destroying the shell with a sheet open. + escape(); + layers.focus(); + layers.click(); + inSheet.focus(); + shell.destroy(); + shell = init(); + await nextTick(); + await nextTick(); + assert.equal(document.activeElement === inSheet, true); + // A destroyed shell takes its bar along; a new one brings it back. + shell.destroy(); + await nextTick(); + assert.equal($('#mobile-tab-bar') === null, true); + shell = init(); + await nextTick(); + assert.equal(document.querySelectorAll('#mobile-tab-bar').length, 1); + $('#mobile-tab-bar [data-mobile-sheet="more"]').click(); + await nextTick(); + assert.equal( + $('#mobile-tab-bar [data-mobile-sheet="more"]').getAttribute( + 'aria-expanded', + ), + 'true', + ); + assert.equal($('#mobile-sheet-backdrop').hidden, false); +}); diff --git a/src/ui/mobileShell.js b/src/ui/mobileShell.js index 07c21a46b..8a6457380 100644 --- a/src/ui/mobileShell.js +++ b/src/ui/mobileShell.js @@ -12,7 +12,7 @@ */ import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js'; -import { msg, t } from '../i18n/index.js'; +import { msg } from '../i18n/index.js'; export const MOBILE_SHELL_MEDIA_QUERY = '(max-width: 720px)'; export const MOBILE_SHELL_CLASS = 'mobile-shell'; @@ -85,37 +85,12 @@ function currentSheetOf(body) { return value && Object.hasOwn(MOBILE_SHEET_PANELS, value) ? value : null; } -function buildChrome(documentRef) { - const backdrop = documentRef.createElement('div'); - backdrop.id = 'mobile-sheet-backdrop'; - backdrop.hidden = true; - - const tabBar = documentRef.createElement('nav'); - tabBar.id = 'mobile-tab-bar'; - tabBar.setAttribute('aria-label', t('Panels')); - for (const tab of MOBILE_TABS) { - const button = documentRef.createElement('button'); - button.type = 'button'; - button.className = 'mobile-tab'; - button.dataset.mobileSheet = tab.sheet; - button.setAttribute('aria-expanded', 'false'); - const icon = documentRef.createElement('span'); - icon.className = 'material-symbols-outlined mobile-tab-icon'; - icon.setAttribute('aria-hidden', 'true'); - icon.textContent = tab.icon; - const label = documentRef.createElement('span'); - label.className = 'mobile-tab-label'; - label.textContent = t(tab.label); - button.append(icon, label); - tabBar.append(button); - } - documentRef.body.append(backdrop, tabBar); - return { backdrop, tabBar }; -} - /** * Wires the phone shell: the media gate, tab bar, backdrop, Escape, focus, - * and closing for full-screen modes. + * and closing for full-screen modes. The shell publishes its tabs, whether it + * is on and which sheet is open; Core's dock renders the tab bar and the + * backdrop at the end of the page from its first publish until it is + * destroyed, and the shell takes their clicks from the document. * @param {object} input * @param {(panelId: string, collapsed: boolean, options?: object) => void} input.setPanelCollapsed * @param {Document} [input.documentRef] @@ -127,6 +102,12 @@ function buildChrome(documentRef) { * and its panels are put back (e.g. to refresh the share link). * @param {(sheet: string) => void} [input.onTabOpened] Runs only when the user * opens a sheet from its tab, never on close or programmatic presentation. + * @param {(view: ?{enabled: boolean, sheet: string|null, tabs: typeof MOBILE_TABS}) => void} [input.publish] + * Receives the tab bar: whether the shell is on, the open sheet and the + * tabs; null once the shell is destroyed. + * @param {(callback: () => void) => void} [input.afterPublish] Runs a callback + * once a publish has rendered, so focus returns to a tab whose state is + * already current. * @returns {{ destroy: () => void, setSheet: (sheet: string|null) => void, getSheet: () => string|null, sharedCollapsed: (panelId: string) => boolean|undefined }} */ export function initMobileShell({ @@ -138,9 +119,10 @@ export function initMobileShell({ requestFrame = globalThis.requestAnimationFrame?.bind(globalThis), onSheetsClosed = () => {}, onTabOpened = () => {}, + publish = () => {}, + afterPublish = (callback) => callback(), } = {}) { const body = documentRef.body; - const { backdrop, tabBar } = buildChrome(documentRef); const viewport = documentRef.querySelector('meta[name="viewport"]'); const viewportContent = viewport?.getAttribute('content') ?? null; const removers = []; @@ -162,6 +144,8 @@ export function initMobileShell({ */ let borrowed = null; let returnFocus = null; + // Opening a sheet or destroying the shell cancels a pending focus return. + let focusReturns = 0; let swipe = null; const written = new Map(); @@ -231,15 +215,17 @@ export function initMobileShell({ } }; + const publishChrome = () => + publish({ + enabled: body.classList.contains(MOBILE_SHELL_CLASS), + sheet: currentSheetOf(body), + tabs: MOBILE_TABS, + }); + const present = (sheet) => { if (sheet) body.setAttribute(MOBILE_SHEET_ATTR, sheet); else body.removeAttribute(MOBILE_SHEET_ATTR); - backdrop.hidden = !sheet; - for (const button of tabBar.querySelectorAll('[data-mobile-sheet]')) - button.setAttribute( - 'aria-expanded', - String(button.dataset.mobileSheet === sheet), - ); + publishChrome(); measureSheet(); }; @@ -269,6 +255,7 @@ export function initMobileShell({ const previous = currentSheetOf(body); if (previous === sheet) return; if (!previous) { + focusReturns += 1; borrowed = { before: panelStates(), applied: panelStates() }; returnFocus = documentRef.activeElement ?? null; } else { @@ -281,7 +268,14 @@ export function initMobileShell({ onSheetsClosed(); const focus = returnFocus; returnFocus = null; - if (focus && focus !== body && body.contains?.(focus)) focus.focus?.(); + // Focus that moved elsewhere meanwhile stays where it went. + const closedWith = documentRef.activeElement; + const pending = ++focusReturns; + afterPublish(() => { + if (pending !== focusReturns) return; + if (documentRef.activeElement !== closedWith) return; + if (focus && focus !== body && body.contains?.(focus)) focus.focus?.(); + }); return; } for (const panelId of MOBILE_SHEET_PANELS[sheet]) @@ -301,9 +295,10 @@ export function initMobileShell({ }; const tabFor = (sheet) => - [...tabBar.querySelectorAll('[data-mobile-sheet]')].find( - (button) => button.dataset.mobileSheet === sheet, - ); + [ + ...(element('mobile-tab-bar')?.querySelectorAll('[data-mobile-sheet]') ?? + []), + ].find((button) => button.dataset.mobileSheet === sheet); const onMedia = (matches) => { if (matches) { @@ -317,6 +312,7 @@ export function initMobileShell({ ); for (const node of [...rails, dock].filter(Boolean)) resize?.observe(node); + publishChrome(); measureSheet(); } else { // Crossing to desktop closes the sheet, which puts its panels back. @@ -327,7 +323,6 @@ export function initMobileShell({ if (viewport && viewportContent !== null) viewport.setAttribute('content', viewportContent); } - tabBar.setAttribute('aria-hidden', String(!matches)); }; const media = @@ -345,9 +340,14 @@ export function initMobileShell({ removers.push(() => media.removeListener(onChange)); } - listen(tabBar, 'click', (event) => { - const button = event.target?.closest?.('[data-mobile-sheet]'); - if (!button) return; + listen(documentRef, 'click', (event) => { + const target = event.target; + if (target?.id === 'mobile-sheet-backdrop') { + setSheet(null); + return; + } + const button = target?.closest?.('[data-mobile-sheet]'); + if (!button || !element('mobile-tab-bar')?.contains(button)) return; event.preventDefault(); const sheet = nextSheet(currentSheetOf(body), button.dataset.mobileSheet); // Closing from the tab bar returns focus to the tab that was pressed. @@ -355,7 +355,6 @@ export function initMobileShell({ setSheet(sheet); if (sheet && currentSheetOf(body) === sheet) onTabOpened(sheet); }); - listen(backdrop, 'click', () => setSheet(null)); // Touch events let native scrolling continue until a downward pull at the // top is intentional. Pointer capture/touch-action:none would also seize // ordinary vertical scrolling inside the borrowed panels. @@ -506,6 +505,7 @@ export function initMobileShell({ }, destroy() { setSheet(null); + focusReturns += 1; for (const remove of removers.splice(0)) remove(); body.classList.remove(MOBILE_SHELL_CLASS); body.removeAttribute(MOBILE_SHEET_ATTR); @@ -513,8 +513,7 @@ export function initMobileShell({ body.style.removeProperty('--mobile-sheet-cover'); if (viewport && viewportContent !== null) viewport.setAttribute('content', viewportContent); - backdrop.remove(); - tabBar.remove(); + publish(null); }, }; } diff --git a/src/ui/mobileShell.test.mjs b/src/ui/mobileShell.test.mjs index bb7599547..61d098bf2 100644 --- a/src/ui/mobileShell.test.mjs +++ b/src/ui/mobileShell.test.mjs @@ -114,6 +114,7 @@ function fixture({ mobile = true, expanded = [], onTabOpened = () => {}, + afterPublish, } = {}) { const document = new EventTarget(); document.ids = new Map(); @@ -158,6 +159,37 @@ function fixture({ rails[root].append(panel); } + // Core's dock renders the tab bar and backdrop while the shell publishes; + // this applies what it publishes the way that component does. + const backdrop = element('div', document); + backdrop.id = 'mobile-sheet-backdrop'; + backdrop.hidden = true; + const tabBar = element('nav', document); + tabBar.id = 'mobile-tab-bar'; + for (const { sheet } of MOBILE_TABS) { + const button = element('button', document); + button.dataset.mobileSheet = sheet; + button.setAttribute('aria-expanded', 'false'); + tabBar.append(button); + } + document.body.append(backdrop, tabBar); + const views = []; + const publish = (view) => { + views.push(view); + if (!view) { + backdrop.remove(); + tabBar.remove(); + return; + } + backdrop.hidden = !view.sheet; + tabBar.setAttribute('aria-hidden', String(!view.enabled)); + for (const button of tabBar.children) + button.setAttribute( + 'aria-expanded', + String(button.dataset.mobileSheet === view.sheet), + ); + }; + const media = new EventTarget(); media.matches = mobile; const calls = []; @@ -176,6 +208,8 @@ function fixture({ const shell = initMobileShell({ documentRef: document, onTabOpened, + publish, + afterPublish, matchMedia: () => media, MutationObserverImpl: FakeMutationObserver, ResizeObserverImpl: null, @@ -197,7 +231,7 @@ function fixture({ const press = (sheet) => { const event = new Event('click', { cancelable: true }); Object.defineProperty(event, 'target', { value: tab(sheet) }); - document.getElementById('mobile-tab-bar').dispatchEvent(event); + document.dispatchEvent(event); }; const resize = (matches) => { media.matches = matches; @@ -215,6 +249,7 @@ function fixture({ return { document, shell, + views, calls, runFrames, closedCount: () => closedCount, @@ -398,12 +433,55 @@ test('layout settling after a sheet opens is part of opening', () => { assert.equal(collapsed('street-level-panel'), false, 'put back as it was'); }); +test('only taps on a tab toggle sheets', () => { + const { document, shell, press, tab } = fixture(); + const click = (target) => { + const event = new Event('click', { cancelable: true }); + Object.defineProperty(event, 'target', { value: target }); + document.dispatchEvent(event); + }; + press('layers'); + // While a sheet is open the body carries data-mobile-sheet too, which the + // DOM mirrors in its dataset. + document.body.dataset.mobileSheet = 'layers'; + click(document.getElementById('data-panel')); + assert.equal(shell.getSheet(), 'layers'); + click(document.getElementById('mobile-tab-bar')); + assert.equal(shell.getSheet(), 'layers'); + // A tap usually lands on the tab's icon or label. + const label = document.createElement('span'); + tab('more').append(label); + click(label); + assert.equal(shell.getSheet(), 'more'); +}); + +test('focus that moved before the tab bar renders stays where it went', () => { + const pending = []; + const { document, shell, press, tab } = fixture({ + afterPublish: (callback) => pending.push(callback), + }); + tab('layers').focus(); + press('layers'); + const elsewhere = document.getElementById('location-search'); + press('layers'); + elsewhere.focus(); + pending.splice(0).forEach((callback) => callback()); + assert.equal(document.activeElement === elsewhere, true); + press('layers'); + press('layers'); + pending.splice(0).forEach((callback) => callback()); + assert.equal(document.activeElement === tab('layers'), true); + assert.equal(shell.getSheet(), null); +}); + test('the backdrop closes the open sheet', () => { const { document, shell, press } = fixture(); press('context'); - document - .getElementById('mobile-sheet-backdrop') - .dispatchEvent(new Event('click')); + const event = new Event('click'); + Object.defineProperty(event, 'target', { + value: document.getElementById('mobile-sheet-backdrop'), + }); + document.dispatchEvent(event); assert.equal(shell.getSheet(), null); }); @@ -448,11 +526,37 @@ test('desktop widths never see the shell; crossing to desktop restores it', () = ); }); +test('the shell publishes whether it is on and which sheet is open', () => { + const phone = fixture(); + assert.deepEqual(phone.views.at(-1), { + enabled: true, + sheet: null, + tabs: MOBILE_TABS, + }); + phone.press('search'); + assert.deepEqual(phone.views.at(-1), { + enabled: true, + sheet: 'search', + tabs: MOBILE_TABS, + }); + phone.resize(false); + assert.deepEqual(phone.views.at(-1), { + enabled: false, + sheet: null, + tabs: MOBILE_TABS, + }); + const desktop = fixture({ mobile: false }); + assert.deepEqual(desktop.views, [ + { enabled: false, sheet: null, tabs: MOBILE_TABS }, + ]); +}); + test('destroy puts panels back and removes the shell', () => { - const { document, shell, press, viewport, collapsed } = fixture(); + const { document, shell, views, press, viewport, collapsed } = fixture(); press('layers'); shell.destroy(); assert.equal(collapsed('data-panel'), true); + assert.equal(views.at(-1), null); assert.equal(document.getElementById('mobile-tab-bar'), null); assert.equal(document.getElementById('mobile-sheet-backdrop'), null); assert.equal(document.body.classList.contains('mobile-shell'), false); diff --git a/src/ui/stores/display.js b/src/ui/stores/display.js index e9ccf41e7..8b94b6f3f 100644 --- a/src/ui/stores/display.js +++ b/src/ui/stores/display.js @@ -66,6 +66,14 @@ export const useDisplayStore = defineStore('gev-display', () => { mapSourceControls.value = controls && markRaw(controls); } + /** The phone shell's tab bar; null until the shell first presents it. */ + const phoneShell = shallowRef(null); + + /** @param {?{enabled: boolean, sheet: string|null, tabs: object[]}} next The phone shell's tabs, whether it is on and its open sheet. */ + function publishPhoneShell(next) { + phoneShell.value = next && markRaw(next); + } + return { view, publish, @@ -77,6 +85,8 @@ export const useDisplayStore = defineStore('gev-display', () => { publishGeoImport, style, publishStyle, + phoneShell, + publishPhoneShell, mapSource, publishMapSource, mapSourceControls, diff --git a/src/ui/views/CommandDockView.vue b/src/ui/views/CommandDockView.vue index 0db92e252..592efaa9b 100644 --- a/src/ui/views/CommandDockView.vue +++ b/src/ui/views/CommandDockView.vue @@ -4,6 +4,7 @@ import { t } from '../../i18n/index.js'; import { vClassToggle } from '../rail/classToggle.js'; import { useDisplayStore } from '../stores/display.js'; import { useLocationStore } from '../stores/location.js'; +import PhoneTabBar from './panels/PhoneTabBar.vue'; // VisualSettings publishes the active style and the map source controls // their chip row; until they do, the dock shows its initial markup. The @@ -112,5 +113,6 @@ function selectMapSource(chip) { + diff --git a/src/ui/views/panels/PhoneTabBar.vue b/src/ui/views/panels/PhoneTabBar.vue new file mode 100644 index 000000000..c8da5668d --- /dev/null +++ b/src/ui/views/panels/PhoneTabBar.vue @@ -0,0 +1,19 @@ + + +