Files
gods-eye-view/src/ui/mobileShell.js
T
Sameh Khamis d134aefd77 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.
2026-10-31 13:31:23 -07:00

520 lines
19 KiB
JavaScript

/**
* Phone shell for narrow viewports (≤720px). The globe stays full screen and a
* bottom tab bar opens one sheet at a time; each sheet shows existing panels,
* which stay where they are in the rails and dock while CSS presents them
* (styles/mobile-shell.css). Desktop never sees the class or the sheet state.
*
* Sheets borrow panels: the panels a sheet expands are put back exactly as
* they were when the sheets close, and nothing a sheet does is persisted or
* treated as the user's own panel choice.
*
* Adapted from the phone tab-shell proposed in public PR #203 by blearymoth.
*/
import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js';
import { msg } from '../i18n/index.js';
export const MOBILE_SHELL_MEDIA_QUERY = '(max-width: 720px)';
export const MOBILE_SHELL_CLASS = 'mobile-shell';
export const MOBILE_SHEET_ATTR = 'data-mobile-sheet';
/**
* Panels each sheet expands. A sheet also shows its rail's other panels
* collapsed (Scenes under Layers), per styles/mobile-shell.css.
*/
export const MOBILE_SHEET_PANELS = Object.freeze({
layers: Object.freeze(['data-panel']),
cameras: Object.freeze(['cctv-panel', 'street-level-panel']),
context: Object.freeze(['global-context-panel']),
search: Object.freeze(['location-bar']),
more: Object.freeze(['control-panel', 'pp-toggles']),
});
export const MOBILE_TABS = Object.freeze([
Object.freeze({ sheet: 'layers', label: msg('Layers'), icon: 'layers' }),
Object.freeze({ sheet: 'cameras', label: msg('Cameras'), icon: 'videocam' }),
Object.freeze({ sheet: 'context', label: msg('Context'), icon: 'radar' }),
Object.freeze({ sheet: 'search', label: msg('Search'), icon: 'search' }),
Object.freeze({ sheet: 'more', label: msg('More'), icon: 'more_horiz' }),
]);
/**
* Surfaces that own the screen close the open sheet and keep sheets closed.
* Scene playback is the exception: its STOP control lives in the Scenes panel
* (Layers sheet), and a phone has no Escape key to stop it otherwise.
*/
const SHEET_BLOCKING_CLASSES = Object.freeze(
EXCLUSIVE_SURFACE_CLASSES.filter((name) => name !== 'scene-playback-mode'),
);
/** Every panel any sheet borrows; the dock trays close each other. */
const BORROWED_PANELS = Object.freeze([
...new Set(Object.values(MOBILE_SHEET_PANELS).flat()),
]);
const BORROW_OPTIONS = Object.freeze({ persist: false, syncShare: false });
/**
* @param {string} panelId
* @returns {string|null} The sheet that presents `panelId`.
*/
export function sheetForPanelId(panelId) {
for (const [sheet, ids] of Object.entries(MOBILE_SHEET_PANELS)) {
if (ids.includes(panelId)) return sheet;
}
return null;
}
/**
* The sheet after a tab press: the same tab closes its sheet, another opens.
* @param {string|null} current
* @param {string|null} pressed
* @returns {string|null}
*/
export function nextSheet(current, pressed) {
if (!pressed || !Object.hasOwn(MOBILE_SHEET_PANELS, pressed)) return null;
return current === pressed ? null : pressed;
}
function sheetBlocked(body) {
return SHEET_BLOCKING_CLASSES.some((name) => body.classList.contains(name));
}
function currentSheetOf(body) {
const value = body.getAttribute(MOBILE_SHEET_ATTR);
return value && Object.hasOwn(MOBILE_SHEET_PANELS, value) ? value : null;
}
/**
* Wires the phone shell: the media gate, tab bar, backdrop, Escape, focus,
* 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]
* @param {(query: string) => MediaQueryList} [input.matchMedia]
* @param {typeof MutationObserver} [input.MutationObserverImpl]
* @param {typeof ResizeObserver} [input.ResizeObserverImpl]
* @param {(callback: () => void) => number} [input.requestFrame]
* @param {() => void} [input.onSheetsClosed] Runs after the last sheet closes
* 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({
setPanelCollapsed,
documentRef = globalThis.document,
matchMedia = globalThis.matchMedia?.bind(globalThis),
MutationObserverImpl = globalThis.MutationObserver,
ResizeObserverImpl = globalThis.ResizeObserver,
requestFrame = globalThis.requestAnimationFrame?.bind(globalThis),
onSheetsClosed = () => {},
onTabOpened = () => {},
publish = () => {},
afterPublish = (callback) => callback(),
} = {}) {
const body = documentRef.body;
const viewport = documentRef.querySelector('meta[name="viewport"]');
const viewportContent = viewport?.getAttribute('content') ?? null;
const removers = [];
const listen = (target, type, handler, options) => {
target.addEventListener(type, handler, options);
removers.push(() => target.removeEventListener(type, handler, options));
};
const element = (id) => documentRef.getElementById(id);
const rails = ['left-panel-stack', 'right-context-rail']
.map(element)
.filter(Boolean);
const dock = element('command-dock');
const shown = (node) =>
node && globalThis.getComputedStyle?.(node).display !== 'none';
/**
* Panel states from before the first sheet opened (`before`) and right after
* the open sheet was set up (`applied`); null when closed.
*/
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();
const writeVar = (name, value) => {
if (written.get(name) === value) return;
written.set(name, value);
body.style.setProperty(name, value);
};
// The dock (Presets, Search) sits on top of whichever rail is the sheet,
// and the required map attribution sits above everything the sheet covers.
const measureSheet = () => {
if (!body.classList.contains(MOBILE_SHELL_CLASS)) return;
const height = Math.max(
0,
...rails.map((rail) =>
shown(rail) ? rail.getBoundingClientRect().height : 0,
),
);
writeVar('--mobile-sheet-height', `${Math.round(height)}px`);
const viewportHeight = globalThis.innerHeight || 0;
const cover = Math.max(
0,
...[...rails, currentSheetOf(body) ? dock : null]
.filter(shown)
.map((node) => viewportHeight - node.getBoundingClientRect().top),
);
writeVar('--mobile-sheet-cover', `${Math.round(cover)}px`);
};
const resize =
typeof ResizeObserverImpl === 'function'
? new ResizeObserverImpl(measureSheet)
: null;
removers.push(() => resize?.disconnect());
const isCollapsed = (panelId) =>
element(panelId)?.classList.contains('collapsed') ?? true;
// The user's preference, as share links record it: a collapse that layout
// applies only for space (Tactical, Cyber) still counts as expanded.
const prefersCollapsed = (panelId) => {
const classes = element(panelId)?.classList;
if (!classes) return true;
return (
classes.contains('collapsed') &&
!classes.contains('layout-auto-collapsed') &&
!classes.contains('accordion-collapsed')
);
};
const panelStates = () =>
new Map(
BORROWED_PANELS.map((panelId) => [panelId, prefersCollapsed(panelId)]),
);
// Undo only what opening the sheet did (including side effects, such as dock
// trays closing each other). A panel the user changed since stays as they
// left it, and becomes the state later sheets put back.
const restoreBorrowed = () => {
if (!borrowed) return;
for (const [panelId, before] of borrowed.before) {
const now = prefersCollapsed(panelId);
if (now !== borrowed.applied.get(panelId))
borrowed.before.set(panelId, now);
else if (now !== before)
setPanelCollapsed(panelId, before, BORROW_OPTIONS);
}
};
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);
publishChrome();
measureSheet();
};
/** The first visible control of the sheet's first panel, for focus on open. */
const sheetFocusTarget = (sheet) => {
if (sheet === 'search') return element('location-search');
const panel = element(MOBILE_SHEET_PANELS[sheet][0]);
const controls =
panel?.querySelectorAll?.(
'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
) ?? [];
// Sheets hide some panel chrome (tray toggles, pins) that stays in the
// DOM, and a panel's own collapse control is a poor first stop.
return (
[...controls].find(
(control) =>
control.getClientRects().length > 0 &&
!control.matches?.('.panel-collapse-btn'),
) ?? null
);
};
const setSheet = (requested) => {
swipe = null;
if (!body.classList.contains(MOBILE_SHELL_CLASS)) return;
const sheet = sheetBlocked(body) ? null : requested;
const previous = currentSheetOf(body);
if (previous === sheet) return;
if (!previous) {
focusReturns += 1;
borrowed = { before: panelStates(), applied: panelStates() };
returnFocus = documentRef.activeElement ?? null;
} else {
if (previous === 'search') element('location-search')?.blur?.();
restoreBorrowed();
}
present(sheet);
if (!sheet) {
borrowed = null;
onSheetsClosed();
const focus = returnFocus;
returnFocus = null;
// 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])
if (isCollapsed(panelId))
setPanelCollapsed(panelId, false, BORROW_OPTIONS);
borrowed.applied = panelStates();
// Rail layout settles on a later frame; what it does is part of opening.
const opening = (borrowed.opening ?? 0) + 1;
borrowed.opening = opening;
const opened = borrowed;
requestFrame?.(() => {
if (borrowed === opened && opened.opening === opening)
opened.applied = panelStates();
});
measureSheet();
sheetFocusTarget(sheet)?.focus?.();
};
const tabFor = (sheet) =>
[
...(element('mobile-tab-bar')?.querySelectorAll('[data-mobile-sheet]') ??
[]),
].find((button) => button.dataset.mobileSheet === sheet);
const onMedia = (matches) => {
if (matches) {
body.classList.add(MOBILE_SHELL_CLASS);
// Lets the page draw under the notch and system bars; the shell's CSS
// keeps its chrome clear of them.
if (viewport && !/viewport-fit\s*=/.test(viewportContent || ''))
viewport.setAttribute(
'content',
`${viewportContent || ''}, viewport-fit=cover`,
);
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.
setSheet(null);
body.classList.remove(MOBILE_SHELL_CLASS);
present(null);
resize?.disconnect();
if (viewport && viewportContent !== null)
viewport.setAttribute('content', viewportContent);
}
};
const media =
typeof matchMedia === 'function'
? matchMedia(MOBILE_SHELL_MEDIA_QUERY)
: null;
onMedia(Boolean(media?.matches));
const onChange = (event) => onMedia(event.matches);
if (media?.addEventListener) {
media.addEventListener('change', onChange);
removers.push(() => media.removeEventListener('change', onChange));
} else if (media?.addListener) {
// Safari before 14 only offers the legacy listener API.
media.addListener(onChange);
removers.push(() => media.removeListener(onChange));
}
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.
if (!sheet) returnFocus = button;
setSheet(sheet);
if (sheet && currentSheetOf(body) === sheet) onTabOpened(sheet);
});
// 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.
const sheetRoot = (target, sheet) => {
const roots =
sheet === 'layers'
? [element('left-panel-stack')]
: sheet === 'search'
? [dock]
: sheet === 'more'
? [element('right-context-rail'), dock]
: [element('right-context-rail')];
return roots.find((root) => root?.contains(target));
};
const scrolled = (target, root) => {
for (let node = target; node; node = node.parentElement) {
if (node.scrollTop > 0) return true;
if (node === root) break;
}
return false;
};
listen(
documentRef,
'touchstart',
(event) => {
swipe = null;
const sheet = currentSheetOf(body);
const target = event.target;
if (
!sheet ||
!body.classList.contains(MOBILE_SHELL_CLASS) ||
event.defaultPrevented ||
event.touches.length !== 1 ||
target?.closest?.(
'button, a, input, select, textarea, label, [contenteditable]:not([contenteditable="false"]), [role="slider"], [role="button"]',
)
)
return;
const root = sheetRoot(target, sheet);
if (!root || scrolled(target, root)) return;
const touch = event.touches[0];
swipe = {
sheet,
target,
root,
id: touch.identifier,
x: touch.clientX,
y: touch.clientY,
claimed: false,
};
},
{ passive: true },
);
listen(
documentRef,
'touchmove',
(event) => {
if (!swipe) return;
const touch = event.touches[0];
if (
event.defaultPrevented ||
event.touches.length !== 1 ||
touch.identifier !== swipe.id ||
currentSheetOf(body) !== swipe.sheet ||
scrolled(swipe.target, swipe.root)
) {
swipe = null;
return;
}
const dx = touch.clientX - swipe.x;
const dy = touch.clientY - swipe.y;
if (!swipe.claimed) {
// Claim direction before the browser crosses its native scroll slop.
// Android can make the next move non-cancelable after a ~9px first move.
if (Math.max(Math.abs(dx), Math.abs(dy)) < 4) return;
if (dy <= Math.abs(dx) * 1.25 || !event.cancelable) {
swipe = null;
return;
}
swipe.claimed = true;
}
// Cancelling only the claimed pull leaves taps, sliders and normal scroll
// native, and suppresses the synthetic click at the end of a dismissal.
event.preventDefault();
},
{ passive: false },
);
listen(
documentRef,
'touchend',
(event) => {
const pull = swipe;
swipe = null;
if (!pull?.claimed || event.touches.length) return;
const touch = [...event.changedTouches].find(
(item) => item.identifier === pull.id,
);
if (!touch || currentSheetOf(body) !== pull.sheet) return;
const dx = Math.abs(touch.clientX - pull.x);
const dy = touch.clientY - pull.y;
if (dy < 64 || dy <= dx * 1.25) return;
returnFocus = tabFor(pull.sheet) ?? returnFocus;
setSheet(null);
},
{ passive: true },
);
listen(
documentRef,
'touchcancel',
() => {
swipe = null;
},
{ passive: true },
);
// Bubble phase, so fields cancelling an edit, lightboxes, viewers and tools
// take Escape first; panels yield it to the sheet (PanelChrome), and an open
// dialog keeps its own native close.
listen(documentRef, 'keydown', (event) => {
if (event.key !== 'Escape' || event.defaultPrevented) return;
const sheet = currentSheetOf(body);
if (!sheet || documentRef.querySelector('dialog[open]')) return;
event.preventDefault();
returnFocus = tabFor(sheet) ?? returnFocus;
setSheet(null);
});
if (typeof MutationObserverImpl === 'function') {
const observer = new MutationObserverImpl(() => {
if (currentSheetOf(body) && sheetBlocked(body)) setSheet(null);
});
observer.observe(body, { attributes: true, attributeFilter: ['class'] });
removers.push(() => observer.disconnect());
}
return {
setSheet,
getSheet: () => currentSheetOf(body),
/**
* The collapsed state to share for a panel a sheet borrowed: its state
* before the sheets, or the user's own change since. Undefined otherwise.
*/
sharedCollapsed(panelId) {
if (!borrowed?.before.has(panelId)) return undefined;
const now = prefersCollapsed(panelId);
return now !== borrowed.applied.get(panelId)
? now
: borrowed.before.get(panelId);
},
destroy() {
setSheet(null);
focusReturns += 1;
for (const remove of removers.splice(0)) remove();
body.classList.remove(MOBILE_SHELL_CLASS);
body.removeAttribute(MOBILE_SHEET_ATTR);
body.style.removeProperty('--mobile-sheet-height');
body.style.removeProperty('--mobile-sheet-cover');
if (viewport && viewportContent !== null)
viewport.setAttribute('content', viewportContent);
publish(null);
},
};
}