feat(ui): render the phone tab bar and sheet backdrop from a store

The phone shell no longer builds its tab bar and backdrop or writes their
aria-expanded, aria-hidden and hidden state: it publishes its tabs,
whether it is on and the open sheet to the gev-display store, and a new
PhoneTabBar component in the dock teleports them to the end of the page
from the first publish until the shell is destroyed, so they still follow
the scene's credits. The shell takes tab and backdrop clicks from the
document and returns focus to a tab once the tab bar shows it closed; a
reopen or destroy cancels a pending return.
This commit is contained in:
Sameh Khamis committed 2026-10-31 13:31:23 -07:00
1 parent 5f8f9bb921
commit d134aefd77
9 files changed
+361 -55

No files matched your search

+6 -2
View File
@@ -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
+1
View File
@@ -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",
+6
View File
@@ -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);
},
});
}
+161
View File
@@ -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);
});
+47 -48
View File
@@ -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);
},
};
}
+109 -5
View File
@@ -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);
+10
View File
@@ -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,
+2
View File
@@ -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) {
</div>
</div>
</div>
<PhoneTabBar />
</div>
</template>
+19
View File
@@ -0,0 +1,19 @@
<script setup>
import { storeToRefs } from 'pinia';
import { t } from '../../../i18n/index.js';
import { useDisplayStore } from '../../stores/display.js';
// The phone shell's tab bar and sheet backdrop. They join the end of the
// page when the shell first publishes, after the scene's credits, and leave
// it when the shell is destroyed; desktop CSS hides them meanwhile.
const { phoneShell: phone } = storeToRefs(useDisplayStore());
</script>
<template>
<Teleport v-if="phone" to="body">
<div id="mobile-sheet-backdrop" :hidden="!phone.sheet"></div>
<nav id="mobile-tab-bar" :aria-label="t('Panels')" :aria-hidden="String(!phone.enabled)">
<button v-for="tab in phone.tabs" :key="tab.sheet" type="button" class="mobile-tab" :data-mobile-sheet="tab.sheet" :aria-expanded="String(phone.sheet === tab.sheet)"><span class="material-symbols-outlined mobile-tab-icon" aria-hidden="true">{{ tab.icon }}</span><span class="mobile-tab-label">{{ t(tab.label) }}</span></button>
</nav>
</Teleport>
</template>