From d134aefd77cdd0ea5a682b2a8d1e581d3ed22580 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 13:31:23 -0700 Subject: [PATCH] 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. --- docs/VUE-MIGRATION.md | 8 +- scripts/package-boundaries.json | 1 + src/ui/applicationShell.js | 6 ++ src/ui/commandDock.test.mjs | 161 ++++++++++++++++++++++++++++ src/ui/mobileShell.js | 95 ++++++++-------- src/ui/mobileShell.test.mjs | 114 +++++++++++++++++++- src/ui/stores/display.js | 10 ++ src/ui/views/CommandDockView.vue | 2 + src/ui/views/panels/PhoneTabBar.vue | 19 ++++ 9 files changed, 361 insertions(+), 55 deletions(-) create mode 100644 src/ui/views/panels/PhoneTabBar.vue 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 @@ + + +