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 @@
+
+
+
+
+
+
+
+