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` what `createMapSourceControls()` publishes beside it, so `./ui/maps`
needs no Vue; the location tray's city and landmark pills, mini status needs no Vue; the location tray's city and landmark pills, mini status
and search error render what `LocationControls` publishes to the and search error render what `LocationControls` publishes to the
`gev-location` store, and the search field keeps its listeners), `gev-location` store, and the search field keeps its listeners), the
the phone shell, then Cockpit. Layers that write into the right rail 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. directly gain a slot instead.
4. **Cleanup.** The build-time template expansion and the attribute 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/ContextView.vue",
"src/ui/views/panels/RadioPanel.vue", "src/ui/views/panels/RadioPanel.vue",
"src/ui/views/panels/SdrCard.vue", "src/ui/views/panels/SdrCard.vue",
"src/ui/views/panels/PhoneTabBar.vue",
"src/ui/views/panels/MissionDetail.vue", "src/ui/views/panels/MissionDetail.vue",
"src/ui/views/panels/MissionRoster.vue", "src/ui/views/panels/MissionRoster.vue",
"src/ui/views/panels/AwarenessPanel.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 { resolveImageryHost } from '../layers/weather/imageryHost.js';
import { ShellFacade } from './shellFacade.js'; import { ShellFacade } from './shellFacade.js';
import { AircraftDisplay } from './aircraftDisplay.js'; import { AircraftDisplay } from './aircraftDisplay.js';
@@ -852,6 +853,11 @@ export class StyleManager extends ShellFacade {
onTabOpened: (sheet) => { onTabOpened: (sheet) => {
void this._onMobileTabOpened(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 { useDisplayStore } from './stores/display.js';
import { useLocationStore } from './stores/location.js'; import { useLocationStore } from './stores/location.js';
import { LocationControls } from './locationControls.js'; import { LocationControls } from './locationControls.js';
import { MOBILE_TABS, initMobileShell } from './mobileShell.js';
import CommandDockView from './views/CommandDockView.vue'; import CommandDockView from './views/CommandDockView.vue';
import SceneChromeView from './views/SceneChromeView.vue'; import SceneChromeView from './views/SceneChromeView.vue';
import { VisualSettings } from './visualSettings.js'; 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(row.classList.contains('expanded'), false);
assert.equal($('#location-bar-divider').classList.contains('visible'), 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 { 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_MEDIA_QUERY = '(max-width: 720px)';
export const MOBILE_SHELL_CLASS = 'mobile-shell'; export const MOBILE_SHELL_CLASS = 'mobile-shell';
@@ -85,37 +85,12 @@ function currentSheetOf(body) {
return value && Object.hasOwn(MOBILE_SHEET_PANELS, value) ? value : null; 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, * 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 {object} input
* @param {(panelId: string, collapsed: boolean, options?: object) => void} input.setPanelCollapsed * @param {(panelId: string, collapsed: boolean, options?: object) => void} input.setPanelCollapsed
* @param {Document} [input.documentRef] * @param {Document} [input.documentRef]
@@ -127,6 +102,12 @@ function buildChrome(documentRef) {
* and its panels are put back (e.g. to refresh the share link). * and its panels are put back (e.g. to refresh the share link).
* @param {(sheet: string) => void} [input.onTabOpened] Runs only when the user * @param {(sheet: string) => void} [input.onTabOpened] Runs only when the user
* opens a sheet from its tab, never on close or programmatic presentation. * 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 }} * @returns {{ destroy: () => void, setSheet: (sheet: string|null) => void, getSheet: () => string|null, sharedCollapsed: (panelId: string) => boolean|undefined }}
*/ */
export function initMobileShell({ export function initMobileShell({
@@ -138,9 +119,10 @@ export function initMobileShell({
requestFrame = globalThis.requestAnimationFrame?.bind(globalThis), requestFrame = globalThis.requestAnimationFrame?.bind(globalThis),
onSheetsClosed = () => {}, onSheetsClosed = () => {},
onTabOpened = () => {}, onTabOpened = () => {},
publish = () => {},
afterPublish = (callback) => callback(),
} = {}) { } = {}) {
const body = documentRef.body; const body = documentRef.body;
const { backdrop, tabBar } = buildChrome(documentRef);
const viewport = documentRef.querySelector('meta[name="viewport"]'); const viewport = documentRef.querySelector('meta[name="viewport"]');
const viewportContent = viewport?.getAttribute('content') ?? null; const viewportContent = viewport?.getAttribute('content') ?? null;
const removers = []; const removers = [];
@@ -162,6 +144,8 @@ export function initMobileShell({
*/ */
let borrowed = null; let borrowed = null;
let returnFocus = null; let returnFocus = null;
// Opening a sheet or destroying the shell cancels a pending focus return.
let focusReturns = 0;
let swipe = null; let swipe = null;
const written = new Map(); 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) => { const present = (sheet) => {
if (sheet) body.setAttribute(MOBILE_SHEET_ATTR, sheet); if (sheet) body.setAttribute(MOBILE_SHEET_ATTR, sheet);
else body.removeAttribute(MOBILE_SHEET_ATTR); else body.removeAttribute(MOBILE_SHEET_ATTR);
backdrop.hidden = !sheet; publishChrome();
for (const button of tabBar.querySelectorAll('[data-mobile-sheet]'))
button.setAttribute(
'aria-expanded',
String(button.dataset.mobileSheet === sheet),
);
measureSheet(); measureSheet();
}; };
@@ -269,6 +255,7 @@ export function initMobileShell({
const previous = currentSheetOf(body); const previous = currentSheetOf(body);
if (previous === sheet) return; if (previous === sheet) return;
if (!previous) { if (!previous) {
focusReturns += 1;
borrowed = { before: panelStates(), applied: panelStates() }; borrowed = { before: panelStates(), applied: panelStates() };
returnFocus = documentRef.activeElement ?? null; returnFocus = documentRef.activeElement ?? null;
} else { } else {
@@ -281,7 +268,14 @@ export function initMobileShell({
onSheetsClosed(); onSheetsClosed();
const focus = returnFocus; const focus = returnFocus;
returnFocus = null; 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; return;
} }
for (const panelId of MOBILE_SHEET_PANELS[sheet]) for (const panelId of MOBILE_SHEET_PANELS[sheet])
@@ -301,9 +295,10 @@ export function initMobileShell({
}; };
const tabFor = (sheet) => 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) => { const onMedia = (matches) => {
if (matches) { if (matches) {
@@ -317,6 +312,7 @@ export function initMobileShell({
); );
for (const node of [...rails, dock].filter(Boolean)) for (const node of [...rails, dock].filter(Boolean))
resize?.observe(node); resize?.observe(node);
publishChrome();
measureSheet(); measureSheet();
} else { } else {
// Crossing to desktop closes the sheet, which puts its panels back. // Crossing to desktop closes the sheet, which puts its panels back.
@@ -327,7 +323,6 @@ export function initMobileShell({
if (viewport && viewportContent !== null) if (viewport && viewportContent !== null)
viewport.setAttribute('content', viewportContent); viewport.setAttribute('content', viewportContent);
} }
tabBar.setAttribute('aria-hidden', String(!matches));
}; };
const media = const media =
@@ -345,9 +340,14 @@ export function initMobileShell({
removers.push(() => media.removeListener(onChange)); removers.push(() => media.removeListener(onChange));
} }
listen(tabBar, 'click', (event) => { listen(documentRef, 'click', (event) => {
const button = event.target?.closest?.('[data-mobile-sheet]'); const target = event.target;
if (!button) return; 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(); event.preventDefault();
const sheet = nextSheet(currentSheetOf(body), button.dataset.mobileSheet); const sheet = nextSheet(currentSheetOf(body), button.dataset.mobileSheet);
// Closing from the tab bar returns focus to the tab that was pressed. // Closing from the tab bar returns focus to the tab that was pressed.
@@ -355,7 +355,6 @@ export function initMobileShell({
setSheet(sheet); setSheet(sheet);
if (sheet && currentSheetOf(body) === sheet) onTabOpened(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 // Touch events let native scrolling continue until a downward pull at the
// top is intentional. Pointer capture/touch-action:none would also seize // top is intentional. Pointer capture/touch-action:none would also seize
// ordinary vertical scrolling inside the borrowed panels. // ordinary vertical scrolling inside the borrowed panels.
@@ -506,6 +505,7 @@ export function initMobileShell({
}, },
destroy() { destroy() {
setSheet(null); setSheet(null);
focusReturns += 1;
for (const remove of removers.splice(0)) remove(); for (const remove of removers.splice(0)) remove();
body.classList.remove(MOBILE_SHELL_CLASS); body.classList.remove(MOBILE_SHELL_CLASS);
body.removeAttribute(MOBILE_SHEET_ATTR); body.removeAttribute(MOBILE_SHEET_ATTR);
@@ -513,8 +513,7 @@ export function initMobileShell({
body.style.removeProperty('--mobile-sheet-cover'); body.style.removeProperty('--mobile-sheet-cover');
if (viewport && viewportContent !== null) if (viewport && viewportContent !== null)
viewport.setAttribute('content', viewportContent); viewport.setAttribute('content', viewportContent);
backdrop.remove(); publish(null);
tabBar.remove();
}, },
}; };
} }
+109 -5
View File
@@ -114,6 +114,7 @@ function fixture({
mobile = true, mobile = true,
expanded = [], expanded = [],
onTabOpened = () => {}, onTabOpened = () => {},
afterPublish,
} = {}) { } = {}) {
const document = new EventTarget(); const document = new EventTarget();
document.ids = new Map(); document.ids = new Map();
@@ -158,6 +159,37 @@ function fixture({
rails[root].append(panel); 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(); const media = new EventTarget();
media.matches = mobile; media.matches = mobile;
const calls = []; const calls = [];
@@ -176,6 +208,8 @@ function fixture({
const shell = initMobileShell({ const shell = initMobileShell({
documentRef: document, documentRef: document,
onTabOpened, onTabOpened,
publish,
afterPublish,
matchMedia: () => media, matchMedia: () => media,
MutationObserverImpl: FakeMutationObserver, MutationObserverImpl: FakeMutationObserver,
ResizeObserverImpl: null, ResizeObserverImpl: null,
@@ -197,7 +231,7 @@ function fixture({
const press = (sheet) => { const press = (sheet) => {
const event = new Event('click', { cancelable: true }); const event = new Event('click', { cancelable: true });
Object.defineProperty(event, 'target', { value: tab(sheet) }); Object.defineProperty(event, 'target', { value: tab(sheet) });
document.getElementById('mobile-tab-bar').dispatchEvent(event); document.dispatchEvent(event);
}; };
const resize = (matches) => { const resize = (matches) => {
media.matches = matches; media.matches = matches;
@@ -215,6 +249,7 @@ function fixture({
return { return {
document, document,
shell, shell,
views,
calls, calls,
runFrames, runFrames,
closedCount: () => closedCount, 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'); 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', () => { test('the backdrop closes the open sheet', () => {
const { document, shell, press } = fixture(); const { document, shell, press } = fixture();
press('context'); press('context');
document const event = new Event('click');
.getElementById('mobile-sheet-backdrop') Object.defineProperty(event, 'target', {
.dispatchEvent(new Event('click')); value: document.getElementById('mobile-sheet-backdrop'),
});
document.dispatchEvent(event);
assert.equal(shell.getSheet(), null); 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', () => { 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'); press('layers');
shell.destroy(); shell.destroy();
assert.equal(collapsed('data-panel'), true); 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-tab-bar'), null);
assert.equal(document.getElementById('mobile-sheet-backdrop'), null); assert.equal(document.getElementById('mobile-sheet-backdrop'), null);
assert.equal(document.body.classList.contains('mobile-shell'), false); 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); 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 { return {
view, view,
publish, publish,
@@ -77,6 +85,8 @@ export const useDisplayStore = defineStore('gev-display', () => {
publishGeoImport, publishGeoImport,
style, style,
publishStyle, publishStyle,
phoneShell,
publishPhoneShell,
mapSource, mapSource,
publishMapSource, publishMapSource,
mapSourceControls, mapSourceControls,
+2
View File
@@ -4,6 +4,7 @@ import { t } from '../../i18n/index.js';
import { vClassToggle } from '../rail/classToggle.js'; import { vClassToggle } from '../rail/classToggle.js';
import { useDisplayStore } from '../stores/display.js'; import { useDisplayStore } from '../stores/display.js';
import { useLocationStore } from '../stores/location.js'; import { useLocationStore } from '../stores/location.js';
import PhoneTabBar from './panels/PhoneTabBar.vue';
// VisualSettings publishes the active style and the map source controls // VisualSettings publishes the active style and the map source controls
// their chip row; until they do, the dock shows its initial markup. The // their chip row; until they do, the dock shows its initial markup. The
@@ -112,5 +113,6 @@ function selectMapSource(chip) {
</div> </div>
</div> </div>
</div> </div>
<PhoneTabBar />
</div> </div>
</template> </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>