feat(panels): render collapse, dock and pin buttons from the store

PanelChrome no longer writes the panels' collapse buttons, the command
dock tray toggles' names or the dock pins' pressed state. It presents each
panel's open and pinned state to the new gev-panel-chrome store, and the
buttons, now views, call it there: PanelCollapseButton for the panel
headers, bound toggles and pins in the command dock. Clicks take effect
once the chrome is initialized; a new owner starts from its own states.

bindPanelDisclosure binds only a panel's Escape and refuses the retired
buttons and onChange arguments. UiButton joins the component kit.
This commit is contained in:
Sameh Khamis committed 2026-10-31 23:30:08 -07:00
1 parent ec4c55e78c
commit dd3a2c87f9
25 files changed
+635 -212

No files matched your search

+8
View File
@@ -100,6 +100,14 @@
panel: ESC still stops a playing scene and the runtime chip still shows
(samehkhamis).
- The shell's panel chrome no longer writes the panels' collapse buttons,
the dock tray toggles' names or the dock pins' pressed state: it presents
each panel's open and pinned state to the `gev-panel-chrome` store, and
the buttons, now views, call it there. `bindPanelDisclosure`
(`./ui/panels`) binds only a panel's Escape; it no longer takes `buttons`
or `onChange`. `UiButton` joins the component kit with ui-vue's props
(samehkhamis).
## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language
- Show a Street Level view facing 359.5° or more as 0°. The viewer caption
+12 -6
View File
@@ -36,11 +36,14 @@ one-panel rule too. Share links continue to record which panels are open.
## Component kit
`src/ui/kit/` holds the generic components (`UiRail`, `UiPanel`, `UiButton`,
`UiChip`, `UiTooltip` and so on). Names, props and events follow Halfpixel's
`ui-vue` kit so the two can converge later, but the components are written for
this repository and styled by its existing stylesheet and custom properties.
They contain no application state.
`src/ui/kit/` holds the generic components: `UiRail`, `UiTooltip`, `UiIcon`
and `UiButton` so far, with more (`UiPanel`, `UiSegmented`, `UiSelect` and
so on) joining as the controls they serve are converted. Names, props,
events and class names follow Halfpixel's `ui-vue` kit so the two can merge
later, but the components are written for this repository and styled by its
existing stylesheet and custom properties; a kit class such as
`ui-button--icon` carries no Core style yet, and a converted control keeps
its own classes beside it. They contain no application state.
Controls share one skin, so every panel reads the same. Every scrolling
surface carries the kit's `.ui-scroll` (ui-vue's name), which draws the thin
@@ -61,7 +64,10 @@ a feature supplying only its accent.
when a view needs one, mirroring state an owner already publishes, so
there is never a second owner of the same state.
2. **Rail.** `PanelChrome` enforces one panel per side; the panel store
mirrors each side's panels and the rail renders from it. Done.
mirrors each side's panels and the rail renders from it. Each panel's
collapse button (`PanelCollapseButton`), the dock tray toggles and the
dock pins render the open and pinned state PanelChrome presents to the
`gev-panel-chrome` store, and their clicks call it there. Done.
3. **Panels.** Each template and its wiring become a component, keeping ids,
classes, `data-i18n` text and CSS. The page places each view with a
`.gev-view` host (`display: contents`), and `mountApplicationViews()`
+15 -14
View File
@@ -12,7 +12,7 @@ A rail panel uses an outer owner with its existing stable `id`, matching
`data-panel-id`, and `panel-collapsible` class. Its first non-glow child is the
surface. Keep the header and any outward popup outside the scrolling body:
```html
```vue
<section
id="example-panel"
data-panel-id="example-panel"
@@ -21,14 +21,12 @@ surface. Keep the header and any outward popup outside the scrolling body:
<div data-panel-surface>
<header data-panel-header>
<h2 class="panel-title panel-surface-title">Example</h2>
<button
class="panel-collapse-btn panel-surface-control"
data-collapse-target="example-panel"
aria-expanded="true"
aria-label="Collapse Example"
>
−
</button>
<PanelCollapseButton
class="panel-surface-control"
target="example-panel"
:name="t('Example')"
initial="−"
/>
</header>
<div data-panel-body data-rail-scroller>
<label>Filter <input class="panel-surface-control" type="text" /></label>
@@ -92,12 +90,15 @@ Do not copy the rail example's `.panel-collapse-btn` class into the dock:
existing dock rules intentionally hide that class. The generic surface skin
does not replace the dock's disclosure policy.
The existing `PanelChrome` binds `.panel-collapse-btn[data-collapse-target]`
at initialization and routes state through `setPanelCollapsed`. For additions
`PanelCollapseButton` (`src/ui/rail/`) renders a panel's collapse button
(`.panel-collapse-btn`) from the open state `PanelChrome` presents to the
`gev-panel-chrome` store, and its click asks `PanelChrome` to toggle the
panel through `setPanelCollapsed`. `PanelChrome` collects the buttons'
targets at initialization and binds each panel's Escape. For additions
present at startup, keep those hooks and the existing chrome lifecycle. A
dynamically mounted panel must bind and dispose its own disclosure using
`bindPanelDisclosure` / `collapsePanelOnEscape` from `panelDisclosure.js`, with
the application owning state changes. Do not rerun the whole application chrome
dynamically mounted panel must own its disclosure: its own state, button and
Escape, bound and disposed with `bindPanelDisclosure` /
`collapsePanelOnEscape` from `panelDisclosure.js` (which bind Escape only). Do not rerun the whole application chrome
initializer just to add one panel. `layoutLeftPanelRail` and
`layoutRightPanelRail` accept arbitrary direct `data-panel-id` children; their
host owns layout scheduling, observations and collapse callbacks. Dynamic
+12 -5
View File
@@ -119,7 +119,10 @@
"src/ui/stores/hud.js",
"src/ui/views/IntelHudView.vue",
"src/ui/stores/firstRun.js",
"src/firstRunCopy.js"
"src/firstRunCopy.js",
"src/ui/kit/UiButton.vue",
"src/ui/stores/panelChrome.js",
"src/ui/rail/PanelCollapseButton.vue"
],
"external": ["vue", "pinia"]
},
@@ -1044,7 +1047,8 @@
"src/ui/stores/celestialRing.js",
"src/ui/stores/worldOverlayActions.js",
"src/ui/stores/awarenessDirection.js",
"src/ui/stores/hud.js"
"src/ui/stores/hud.js",
"src/ui/stores/panelChrome.js"
],
"external": ["cesium", "vue", "pinia"]
},
@@ -3049,7 +3053,8 @@
"src/hudReadouts.js",
"src/ui/stores/hud.js",
"src/ui/stores/firstRun.js",
"src/firstRunCopy.js"
"src/firstRunCopy.js",
"src/ui/stores/panelChrome.js"
],
"external": [
"hls.js",
@@ -3856,9 +3861,11 @@
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/panelRailCatalog.js",
"src/ui/panelAccordion.js"
"src/ui/panelAccordion.js",
"src/ui/vueRuntime.js",
"src/ui/stores/panelChrome.js"
],
"external": []
"external": ["vue", "pinia"]
},
"civil-flight-records": {
"exports": ["./layers/flights/records"],
+3 -1
View File
@@ -99,7 +99,9 @@ test('Context header holds only its title and collapse control', () => {
assert.ok(panel, 'Global Context panel is missing');
const header = panel[0].match(/<div class="panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/);
assert.ok(header, 'Global Context header is missing');
const tags = [...header[1].matchAll(/<(\w+)\s+class="([^"]+)"/g)].map(([, tag, cls]) => `${tag}.${cls}`);
// The kit button's own ui-button and ui-focus classes carry no Core styles.
const tags = [...header[1].matchAll(/<(\w+)\s+(?:type="button"\s+)?class="([^"]+)"/g)].map(([, tag, cls]) =>
`${tag}.${cls.split(' ').filter((name) => !/^ui-(button|focus)/.test(name)).join(' ')}`);
assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']);
});
+15 -11
View File
@@ -4,7 +4,9 @@ import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosu
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createPinia } from 'pinia';
import { msg, t } from './i18n/index.js';
import { usePanelChromeStore } from './ui/stores/panelChrome.js';
// Exercise the installed event routes and central close method, without WebGL.
const markup = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
@@ -113,8 +115,11 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
const saves = [];
const claims = [];
let shareSyncs = 0;
const chromeStore = usePanelChromeStore(createPinia());
const manager = {
_lifetime: { frame() {} },
_chromeStore: chromeStore,
_chromeToken: chromeStore.connect({}),
_panelLayout: { _leftStackPreferredPanelId: null, _rightStackPreferredPanelId: null },
...methods,
_savePanelCollapsedState(...args) { saves.push(args); },
@@ -136,6 +141,8 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
manager, panel, location, disclosure, locationDisclosure, locationSearch,
first, active, elsewhere, document, timers, emit, focus, tick, saves, claims,
calls: () => focusCalls,
/** A tray's state as PanelChrome presents it to the dock's buttons. */
tray: (id) => chromeStore.panels[id],
shareSyncs: () => shareSyncs,
show() { hidden = false; },
select(value) { selected = value; },
@@ -297,8 +304,7 @@ test('Location Enter stays immediate while Space waits for native key release',
h.locationKeyUp(key);
}
assert.equal(h.location.classList.contains('collapsed'), false);
assert.equal(h.locationDisclosure.getAttribute('aria-expanded'), 'true');
assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Collapse LOCATION');
assert.equal(h.tray('location-bar').collapsed, false);
assert.equal(h.document.activeElement, h.locationDisclosure);
assert.equal(h.timers.size, 0);
assert.equal(h.calls(), 0);
@@ -313,7 +319,7 @@ test('Location Enter stays immediate while Space waits for native key release',
h.locationKey(key);
if (key === ' ') h.locationKeyUp(key);
assert.equal(h.location.classList.contains('collapsed'), true);
assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Expand LOCATION');
assert.equal(h.tray('location-bar').collapsed, true);
}
});
@@ -327,9 +333,7 @@ test('Location synthesized click and Escape retain focus and its accessible name
const escape = h.locationEscape();
assert.equal(escape.defaultPrevented, true);
assert.equal(h.location.classList.contains('collapsed'), true);
assert.equal(h.locationDisclosure.getAttribute('aria-expanded'), 'false');
assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Expand LOCATION');
assert.equal(h.locationDisclosure.title, 'Expand LOCATION');
assert.equal(h.tray('location-bar').collapsed, true);
assert.equal(h.document.activeElement, h.locationDisclosure);
h.drain();
assert.equal(h.calls(), 0);
@@ -357,11 +361,11 @@ test('Location keyboard opening preserves a pinned sibling and a pinned Location
h.manager.setPanelCollapsed('control-panel', true);
h.focus(h.disclosure); h.key(); h.tick();
assert.equal(h.location.classList.contains('collapsed'), false);
assert.equal(h.locationDisclosure.getAttribute('aria-expanded'), 'true');
assert.equal(h.tray('location-bar').collapsed, false);
assert.equal(h.document.activeElement, h.active);
});
test('Location restoration syncs its real name without claiming focus, collapsing its sibling or persisting', () => {
test('Location restoration presents its state without claiming focus, collapsing its sibling or persisting', () => {
const h = harness(); h.key(); h.tick();
const saves = h.saves.length;
const claims = h.claims.length;
@@ -369,14 +373,14 @@ test('Location restoration syncs its real name without claiming focus, collapsin
h.manager.setPanelCollapsed('location-bar', false, { restore: true, persist: false, syncShare: false });
assert.equal(h.panel.classList.contains('collapsed'), false);
assert.equal(h.location.classList.contains('collapsed'), false);
assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Collapse LOCATION');
assert.equal(h.tray('location-bar').collapsed, false);
assert.equal(h.document.activeElement, h.active);
assert.equal(h.saves.length, saves);
assert.equal(h.claims.length, claims);
assert.equal(h.shareSyncs(), shareSyncs);
h.locationDisclosure.setAttribute('aria-label', 'stale');
const presented = h.tray('location-bar');
h.manager.setPanelCollapsed('location-bar', false, { restore: true, persist: false, syncShare: false });
assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Collapse LOCATION', 'same-state synchronization keeps the Location name');
assert.ok(h.tray('location-bar') === presented, 'same-state synchronization does not republish');
assert.equal(h.timers.size, 0);
});
+5 -19
View File
@@ -42,7 +42,7 @@ function panelFixture({ id = 'data-panel', nested = false, nestedCollapsed = fal
disclosure.closest = () => panel;
}
const document = { getElementById: (candidate) => candidate === id ? panel : null };
const manager = new Function('document', 'getComputedStyle', 'collapsePanelOnEscape', `return {${method('_collapsePanelOnEscape', '_initCommandDockPins')}};`)(document, (el) => ({ visibility: el.visibility ?? 'visible' }), collapsePanelOnEscape);
const manager = new Function('document', 'getComputedStyle', 'collapsePanelOnEscape', `return {${method('_collapsePanelOnEscape', '_togglePanel')}};`)(document, (el) => ({ visibility: el.visibility ?? 'visible' }), collapsePanelOnEscape);
manager.setPanelCollapsed = (candidate, value, options) => {
assert.equal(candidate, id);
assert.equal(value, true);
@@ -167,7 +167,7 @@ test('Location Escape clears a hidden draft search before restoring disclosure f
test('panel chrome wires Escape for every declared collapse target', () => {
const init = method('_initPanelChrome', '_collapsePanelOnEscape');
assert.match(init, /for \(const \[targetId, buttons\] of targets\)/);
assert.match(init, /for \(const targetId of targets\)/);
assert.match(init, /bindPanelDisclosure\(\{[\s\S]*?onEscape: \(event\) => this\._collapsePanelOnEscape\(event, targetId\)/);
assert.match(source, /createHoverDisclosure\(\{[\s\S]*?onEscape: \(event\) => this\._collapsePanelOnEscape\(event, panelId\)/);
});
@@ -206,31 +206,17 @@ test('Cockpit utility Escape leaves an expanded nested Parameters panel to the s
test('panel bindings have a single owner and are inert after destruction', () => {
const panel = new EventTarget();
const button = new EventTarget();
let collapsed = true;
let changes = 0;
let keys = 0;
panel.classList = { contains: () => collapsed };
const bind = () => bindPanelDisclosure({
panel, buttons: [button, button],
onChange(value, options) { collapsed = value; changes += 1; assert.equal(options.explicit, true); },
onEscape() { keys += 1; },
});
const bind = () => bindPanelDisclosure({ panel, onEscape() { keys += 1; } });
const first = bind();
button.dispatchEvent(new Event('click'));
panel.dispatchEvent(new Event('keydown'));
assert.equal(collapsed, false);
assert.equal(changes, 1);
assert.equal(keys, 1);
first.destroy();
first.destroy();
button.dispatchEvent(new Event('click'));
panel.dispatchEvent(new Event('keydown'));
assert.equal(changes, 1);
assert.equal(keys, 1);
const second = bind();
button.dispatchEvent(new Event('click'));
assert.equal(changes, 2);
assert.equal(collapsed, true);
panel.dispatchEvent(new Event('keydown'));
assert.equal(keys, 2);
second.destroy();
});
+3 -1
View File
@@ -167,7 +167,9 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
);
assert.match(ui, /panelId === 'radio-panel'[\s\S]*?document\.getElementById\('global-context-panel'\)/);
assert.match(rightRail, /focusedExpandedPanel = expandedPanelsInDomOrder\.find\(\s*\(panel\) =>\s*panel\.contains\(documentRef\.activeElement\),?\s*\)/);
assert.match(ui, /setAttribute\('aria-expanded', String\(!collapsed\)\)/);
// Collapse buttons render aria-expanded from the state the chrome presents
// (panelChromeButtons.test.mjs covers the views).
assert.match(ui, /_chromeStore\.publish\(this\._chromeToken, panelEl\.id, \{\s*collapsed: panelEl\.classList\.contains\('collapsed'\),/);
assert.match(leftRail, /--left-panel-allocated-height/);
assert.match(rightRail, /--right-panel-allocated-height/);
assert.match(
+2 -1
View File
@@ -358,7 +358,8 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
assert.doesNotMatch(enableMethod, /playSelectedRadio|togglePlayback\(\).*radio-enable/i);
assert.doesNotMatch(enableMethod, /contextRadio/);
assert.match(enableMethod, /toggleRadio\(this\._radioEnableBtn\)/);
assert.match(ui, /setAttribute\('aria-expanded', String\(/);
// The collapse buttons render aria-expanded from the state the chrome presents.
assert.match(ui, /_chromeStore\.publish\(this\._chromeToken, panelEl\.id, \{/);
assert.doesNotMatch(method, /panelId === 'global-context-panel'[\s\S]*?this\._radioState\?\.enabled[\s\S]*?setPanelCollapsed\('radio-panel', false\)/);
});
+4 -3
View File
@@ -22,7 +22,7 @@ test('Street Level is a collapsible right-rail panel that starts collapsed', ()
);
assert.match(
html,
/<button class="panel-collapse-btn" data-collapse-target="street-level-panel"/,
/<button type="button" class="[^"]*\bpanel-collapse-btn\b[^"]*"[^>]*data-collapse-target="street-level-panel"/,
);
assert.match(
html,
@@ -46,9 +46,10 @@ test('the panel is registered with panel chrome, cockpit entry and the right rai
panelChrome,
/COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object\.freeze\(\[[\s\S]*'street-level-panel'/,
);
// A right-rail panel's collapse button opens sideways (◀ / ▶).
assert.match(
panelChrome,
/const isRightRail = \[[\s\S]*'street-level-panel'/,
read('src/ui/views/panels/StreetLevelPanel.vue'),
/<PanelCollapseButton[^>]*target="street-level-panel"[^>]*\bsideways\b/,
);
assert.match(
layoutController,
+47
View File
@@ -0,0 +1,47 @@
<script setup>
// Core's button, with @halfpixel/ui-vue UiButton's props, classes and
// attribute contract so the two kits can merge: the button is always the root,
// attributes passed in win over its own (a `title` replaces the label's), and
// `pressed` renders aria-pressed only when it is passed. Core styles the
// button through the classes its callers pass; the kit classes carry no Core
// styles yet. Not mirrored yet: icon, loading, disabledReason and the
// missing-label warning.
defineOptions({ inheritAttrs: false });
const props = defineProps({
variant: { type: String, default: 'ghost' },
tone: { type: String, default: 'accent' },
/** The accessible name (aria-label) and, with nativeTitle, the tip. */
label: { type: String, default: undefined },
pressed: { type: Boolean, default: undefined },
disabled: { type: Boolean, default: false },
type: { type: String, default: 'button' },
/** The id of the form a submit button belongs to, where it sits outside it. */
form: { type: String, default: undefined },
nativeTitle: { type: Boolean, default: true },
});
const emit = defineEmits(['click']);
</script>
<template>
<button
:type="props.type"
:form="props.form"
class="ui-button ui-focus"
:class="[
`ui-button--${props.variant}`,
`ui-button--${props.tone}`,
{ 'ui-button--pressed': props.pressed },
]"
:aria-label="props.label"
:title="props.nativeTitle ? props.label : undefined"
:aria-pressed="
props.pressed === undefined ? undefined : props.pressed ? 'true' : 'false'
"
:disabled="props.disabled"
v-bind="$attrs"
@click="emit('click', $event)"
>
<slot />
</button>
</template>
+52 -89
View File
@@ -1,5 +1,6 @@
import { UiLifetime } from './uiLifetime.js';
import { t, msg } from '../i18n/index.js';
import { usePanelChromeStore } from './stores/panelChrome.js';
import { applicationPinia } from './vueRuntime.js';
import { displayPanelScroller } from './displayPanelScroll.js';
import { PanelPositionControls } from './panelPositionControls.js';
import { PanelLayoutController } from './panelLayoutController.js';
@@ -8,6 +9,7 @@ import {
collapsePanelOnEscape,
createHoverDisclosure,
} from './panelDisclosure.js';
const SHARE_PANEL_STATE_SPECS = Object.freeze([
{ id: 'control-panel', pinnable: true },
{ id: 'location-bar', pinnable: true },
@@ -22,6 +24,12 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([
{ id: 'pp-toggles' },
{ id: 'param-slider-panel' },
]);
/** The command dock trays with a pin button. */
const PINNABLE_PANEL_IDS = new Set(
SHARE_PANEL_STATE_SPECS.filter((spec) => spec.pinnable).map(
(spec) => spec.id,
),
);
/** Standard map-view panels cleared out of the way on a fresh Cockpit entry. */
const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
'data-panel',
@@ -47,6 +55,7 @@ export class PanelChrome {
readScrollRestoreOwner,
readDisplayScrollTop,
readSharedCollapsed = () => undefined,
store = usePanelChromeStore(applicationPinia()),
}) {
Object.assign(this, elements, operations, {
readHud,
@@ -59,6 +68,16 @@ export class PanelChrome {
});
this._disposed = false;
this._lifetime = new UiLifetime();
// The collapse, dock and pin buttons render each panel's state from the
// store; their clicks take effect only once the chrome is initialized.
this._chromeStore = store;
this._chromeToken = store.connect({
toggle: (panelId) => this._togglePanel(panelId),
pin: (panelId) => {
if (this._disclosurePanelIds)
this._setCommandDockPanelPinState(panelId);
},
});
this._cockpitPanelRestore = null;
this._cockpitContextCollapsedForDataPanel = false;
this._panelPosition = new PanelPositionControls({
@@ -101,24 +120,23 @@ export class PanelChrome {
for (const control of this._panelDisclosureControls || [])
control.destroy();
this._panelDisclosureControls = [];
const targets = new Map();
document
.querySelectorAll('.panel-collapse-btn[data-collapse-target]')
.forEach((button) => {
const targetId = button.dataset.collapseTarget;
if (!targetId) return;
if (!targets.has(targetId)) targets.set(targetId, []);
targets.get(targetId).push(button);
});
for (const [targetId, buttons] of targets) {
this._disclosurePanelIds = new Set();
const targets = new Set(
[
...document.querySelectorAll(
'.panel-collapse-btn[data-collapse-target]',
),
]
.map((button) => button.dataset.collapseTarget)
.filter(Boolean),
);
for (const targetId of targets) {
const panel = document.getElementById(targetId);
if (!panel) continue;
this._disclosurePanelIds.add(targetId);
this._panelDisclosureControls.push(
bindPanelDisclosure({
panel,
buttons,
onChange: (collapsed, options) =>
this.setPanelCollapsed(targetId, collapsed, options),
onEscape: (event) => this._collapsePanelOnEscape(event, targetId),
}),
);
@@ -144,7 +162,6 @@ export class PanelChrome {
openDelayMs: 140,
closeDelayMs: 420,
});
this._initCommandDockPins();
this._initCommandDockTrayMetrics();
this._maybeNotifyLayoutReset();
}
@@ -182,16 +199,14 @@ export class PanelChrome {
});
}
_initCommandDockPins() {
document
.querySelectorAll('.dock-pin-btn[data-pin-target]')
.forEach((button) => {
this._lifetime.listen(button, 'click', (event) => {
event.stopPropagation();
const panelId = button.dataset.pinTarget;
this._setCommandDockPanelPinState(panelId);
});
});
/** A collapse button's click: flip its panel. */
_togglePanel(panelId) {
if (this._disposed || !this._disclosurePanelIds?.has(panelId)) return;
const panel = document.getElementById(panelId);
if (!panel) return;
this.setPanelCollapsed(panelId, !panel.classList.contains('collapsed'), {
explicit: true,
});
}
_setCommandDockPanelPinState(
@@ -199,17 +214,18 @@ export class PanelChrome {
pin,
{ restore = false, persist = true, syncShare = true } = {},
) {
if (this._disposed || !PINNABLE_PANEL_IDS.has(panelId)) return undefined;
const panelEl = document.getElementById(panelId);
const button = document.querySelector(
`.dock-pin-btn[data-pin-target="${panelId}"]`,
);
if (!panelEl || !button) return undefined;
if (!panelEl) return undefined;
const shouldPin =
typeof pin === 'boolean'
? pin
: !panelEl.classList.contains('dock-pinned');
panelEl.classList.toggle('dock-pinned', shouldPin);
button.setAttribute('aria-pressed', String(shouldPin));
this._chromeStore.publish(this._chromeToken, panelId, {
collapsed: panelEl.classList.contains('collapsed'),
pinned: shouldPin,
});
document
.querySelectorAll('#command-dock .dock-pinned-top')
.forEach((pinnedPanel) => {
@@ -307,66 +323,12 @@ export class PanelChrome {
return this._panelLayout._scheduleLeftPanelLayout(options0);
}
/** Present a panel's open state to its collapse and dock buttons. */
_syncPanelCollapseButton(panelEl) {
const isRightRail = [
'pp-toggles',
'cctv-panel',
'weather-panel',
'recent-imagery-panel',
'street-level-panel',
'global-context-panel',
].includes(panelEl?.id);
const collapsed = panelEl.classList.contains('collapsed');
panelEl
.querySelectorAll('.panel-collapse-btn[data-collapse-target]')
.forEach((btn) => {
const owner = btn.closest('[data-panel-id], #param-slider-panel');
if (owner !== panelEl) return;
if (isRightRail) {
btn.textContent = collapsed ? '◀' : '▶';
} else {
btn.textContent = collapsed ? '+' : '−';
}
btn.setAttribute('aria-expanded', String(!collapsed));
const panelName =
panelEl
.querySelector('.panel-title, .pp-header-label')
?.textContent?.trim() || t('panel');
const titleKey = collapsed
? msg('Expand {name}')
: msg('Collapse {name}');
btn.title = t(titleKey, { name: panelName });
btn.setAttribute('aria-label', t(titleKey, { name: panelName }));
if (panelEl.id === 'radio-panel') {
// Radio keeps the wording of its static button (context.html).
btn.title = t(
collapsed ? msg('Expand Radio') : msg('Collapse Radio'),
);
btn.setAttribute(
'aria-label',
t(
collapsed
? msg('Expand Radio section')
: msg('Collapse Radio section'),
),
);
}
});
const dockToggle = panelEl.querySelector(
`[data-dock-toggle-target="${panelEl.id}"]`,
);
if (dockToggle) {
const panelName =
panelEl
.querySelector('.panel-title, .location-toolbar-label')
?.textContent?.trim() || t('panel');
const titleKey = collapsed
? msg('Expand {name}')
: msg('Collapse {name}');
dockToggle.setAttribute('aria-expanded', String(!collapsed));
dockToggle.setAttribute('aria-label', t(titleKey, { name: panelName }));
dockToggle.title = t(titleKey, { name: panelName });
}
if (!panelEl?.id) return;
this._chromeStore.publish(this._chromeToken, panelEl.id, {
collapsed: panelEl.classList.contains('collapsed'),
});
}
_buildSharePanelState() {
@@ -626,6 +588,7 @@ export class PanelChrome {
if (this._disposed) return;
this._disposed = true;
this._lifetime.destroy();
this._chromeStore.detach(this._chromeToken);
this._panelLayout.destroy();
for (const control of this._panelDisclosureControls || [])
control.destroy();
+240
View File
@@ -0,0 +1,240 @@
// src/ui/panelChromeButtons.test.mjs — panel collapse, dock tray and pin
// buttons render each panel's state from the panel chrome store, and their
// clicks reach PanelChrome only while it presents the panels.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, h, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { PanelChrome } from './panelChrome.js';
import { bindPanelDisclosure } from './panelDisclosure.js';
import PanelCollapseButton from './rail/PanelCollapseButton.vue';
import { usePanelChromeStore } from './stores/panelChrome.js';
import CommandDockView from './views/CommandDockView.vue';
function mount(t, component) {
document.body.replaceChildren();
const pinia = createPinia();
const store = usePanelChromeStore(pinia);
const app = createApp(component).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
return store;
}
test('the store republishes a panel only when its state changes', () => {
const store = usePanelChromeStore(createPinia());
const token = store.connect({});
store.publish(token, 'data-panel', { collapsed: true });
const first = store.panels;
assert.deepEqual(store.panels['data-panel'], {
collapsed: true,
pinned: false,
});
store.publish(token, 'data-panel', { collapsed: true });
assert.ok(store.panels === first, 'an unchanged state is not republished');
store.publish({}, 'data-panel', { collapsed: false });
assert.equal(
store.panels['data-panel'].collapsed,
true,
'a stranger is ignored',
);
store.connect({});
assert.equal(
store.panels['data-panel'],
undefined,
'a new owner starts empty',
);
store.publish(token, 'data-panel', { collapsed: false });
assert.equal(
store.panels['data-panel'],
undefined,
'a replaced owner is ignored',
);
});
test('a collapse button keeps its markup until presented, then follows the panel', async (t) => {
const store = mount(t, {
render: () => [
h(PanelCollapseButton, {
target: 'data-panel',
name: 'DATA LAYERS',
initial: '+',
}),
h(PanelCollapseButton, {
target: 'cctv-panel',
name: 'CCTV',
sideways: true,
initial: '+',
}),
],
});
const [data, cctv] = document.querySelectorAll('.panel-collapse-btn');
assert.equal(data.textContent, '+');
assert.equal(data.title, 'Collapse panel');
assert.equal(data.hasAttribute('aria-expanded'), false);
assert.equal(data.hasAttribute('aria-label'), false);
assert.equal(data.dataset.collapseTarget, 'data-panel');
const toggled = [];
const token = store.connect({ toggle: (id) => toggled.push(id) });
store.publish(token, 'data-panel', { collapsed: true });
store.publish(token, 'cctv-panel', { collapsed: false });
await nextTick();
assert.equal(data.textContent, '+');
assert.equal(data.getAttribute('aria-expanded'), 'false');
assert.equal(data.getAttribute('aria-label'), 'Expand DATA LAYERS');
assert.equal(data.title, 'Expand DATA LAYERS');
assert.equal(cctv.textContent, '▶');
assert.equal(cctv.getAttribute('aria-expanded'), 'true');
assert.equal(cctv.title, 'Collapse CCTV');
store.publish(token, 'data-panel', { collapsed: false });
await nextTick();
assert.equal(data.textContent, '−');
assert.equal(data.getAttribute('aria-label'), 'Collapse DATA LAYERS');
data.click();
assert.deepEqual(toggled, ['data-panel']);
});
test("Radio's collapse button keeps its own wording", async (t) => {
const words = {
initialLabel: 'Expand Radio section',
expandLabel: 'Expand Radio section',
collapseLabel: 'Collapse Radio section',
expandTitle: 'Expand Radio',
collapseTitle: 'Collapse Radio',
};
const store = mount(t, {
render: () =>
h(PanelCollapseButton, {
target: 'radio-panel',
initial: '+',
initialTitle: 'Expand Radio',
words,
}),
});
const button = document.querySelector('.panel-collapse-btn');
assert.equal(button.title, 'Expand Radio');
assert.equal(button.getAttribute('aria-label'), 'Expand Radio section');
store.publish(store.connect({}), 'radio-panel', { collapsed: false });
await nextTick();
assert.equal(button.textContent, '−');
assert.equal(button.title, 'Collapse Radio');
assert.equal(button.getAttribute('aria-label'), 'Collapse Radio section');
});
test('dock trays name their state and pins press without opening the tray', async (t) => {
const store = mount(t, CommandDockView);
const toggle = document.getElementById('location-bar-toggle');
const pin = document.querySelector(
'.dock-pin-btn[data-pin-target="location-bar"]',
);
assert.equal(toggle.getAttribute('aria-expanded'), 'false');
assert.equal(toggle.getAttribute('aria-label'), 'Expand LOCATION');
assert.equal(pin.getAttribute('aria-pressed'), 'false');
assert.equal(pin.getAttribute('aria-label'), 'Pin location tray');
assert.equal(pin.title, 'Keep location tray open');
const pinned = [];
const token = store.connect({ pin: (id) => pinned.push(id) });
store.publish(token, 'location-bar', { collapsed: false, pinned: true });
await nextTick();
assert.equal(toggle.getAttribute('aria-expanded'), 'true');
assert.equal(toggle.getAttribute('aria-label'), 'Collapse LOCATION');
assert.equal(toggle.title, 'Collapse LOCATION');
assert.equal(pin.getAttribute('aria-pressed'), 'true');
let reachedTray = false;
document
.getElementById('location-bar')
.addEventListener('click', () => (reachedTray = true));
pin.click();
assert.deepEqual(pinned, ['location-bar']);
assert.equal(reachedTray, false, 'the pin click stops at the pin');
});
function panelChrome(store) {
return new PanelChrome({
elements: {},
operations: { _showToast() {}, _syncCctvPanelViewport() {} },
readHud: () => ({ visible: false, getVariant: () => null }),
readCockpit: () => null,
readShareLinks: () => null,
readInitialShare: () => null,
readScrollRestoreOwner: () => null,
readDisplayScrollTop: () => 0,
store,
});
}
test('PanelChrome takes clicks only once initialized, pins only dock trays, and detaches on destroy', () => {
document.body.innerHTML = `
<div id="data-panel" class="collapsed"><button class="panel-collapse-btn" data-collapse-target="data-panel"></button></div>
<div id="cctv-panel"></div>
<div id="control-panel" class="collapsed"></div>`;
const store = usePanelChromeStore(createPinia());
const chrome = panelChrome(store);
const calls = [];
const setPanelCollapsed = chrome.setPanelCollapsed.bind(chrome);
chrome.setPanelCollapsed = (id, collapsed, options) => {
calls.push([id, collapsed, options]);
setPanelCollapsed(id, collapsed, options);
};
store.act('toggle', 'data-panel');
store.act('pin', 'control-panel');
assert.deepEqual(calls, [], 'no clicks before the chrome is initialized');
chrome._disclosurePanelIds = new Set(['data-panel']);
store.act('toggle', 'data-panel');
store.act('toggle', 'cctv-panel');
assert.deepEqual(calls, [['data-panel', false, { explicit: true }]]);
assert.equal(store.panels['data-panel'].collapsed, false);
assert.equal(chrome._setCommandDockPanelPinState('data-panel'), undefined);
store.act('pin', 'control-panel');
assert.deepEqual(store.panels['control-panel'], {
collapsed: false,
pinned: true,
});
assert.equal(
document.getElementById('control-panel').classList.contains('dock-pinned'),
true,
);
chrome.destroy();
const before = calls.length;
store.act('toggle', 'data-panel');
assert.equal(calls.length, before, 'a destroyed owner gets no clicks');
chrome._syncPanelCollapseButton(document.getElementById('cctv-panel'));
assert.equal(store.panels['cctv-panel'], undefined, 'nor publishes');
});
test('a remounted chrome starts from its own states, not a pin left behind', () => {
document.body.innerHTML = '<div id="location-bar" class="collapsed"></div>';
const store = usePanelChromeStore(createPinia());
const first = panelChrome(store);
first._disclosurePanelIds = new Set();
store.act('pin', 'location-bar');
assert.equal(store.panels['location-bar'].pinned, true);
first.destroy();
document.body.innerHTML = '<div id="location-bar" class="collapsed"></div>';
const second = panelChrome(store);
assert.equal(store.panels['location-bar'], undefined);
second._syncPanelCollapseButton(document.getElementById('location-bar'));
assert.deepEqual(store.panels['location-bar'], {
collapsed: true,
pinned: false,
});
second.destroy();
});
test('bindPanelDisclosure refuses the retired button wiring', () => {
const panel = new EventTarget();
for (const retired of [{ buttons: [] }, { onChange() {} }]) {
assert.throws(
() => bindPanelDisclosure({ panel, onEscape() {}, ...retired }),
TypeError,
);
}
});
+11 -18
View File
@@ -13,28 +13,21 @@ function listenerScope() {
};
}
/** Bind collapse buttons and bubbling Escape to an existing panel. */
export function bindPanelDisclosure({
panel,
buttons = [],
onChange,
onEscape,
}) {
if (
!panel ||
typeof onChange !== 'function' ||
typeof onEscape !== 'function'
) {
/**
* Bind bubbling Escape to an existing panel. Its collapse buttons are views
* that ask the panel's owner to toggle it (PanelCollapseButton).
*/
export function bindPanelDisclosure({ panel, onEscape, ...retired }) {
if (!panel || typeof onEscape !== 'function') {
throw new TypeError('Panel disclosure requires a panel and onEscape');
}
// Buttons are views now; a caller still passing them would lose its clicks.
if ('buttons' in retired || 'onChange' in retired) {
throw new TypeError(
'Panel disclosure requires a panel, onChange and onEscape',
'Panel disclosure binds Escape only; render the buttons as views',
);
}
const scope = listenerScope();
for (const button of new Set(buttons)) {
scope.listen(button, 'click', () => {
onChange(!panel.classList.contains('collapsed'), { explicit: true });
});
}
scope.listen(panel, 'keydown', onEscape);
return { destroy: () => scope.destroy() };
}
+66
View File
@@ -0,0 +1,66 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { msg, t } from '../../i18n/index.js';
import UiButton from '../kit/UiButton.vue';
import { usePanelChromeStore } from '../stores/panelChrome.js';
// A panel's collapse button: its glyph, aria-expanded and words follow the
// panel's state in the panel chrome store, and a click asks PanelChrome to
// toggle it. Until PanelChrome presents the panel it shows its markup's glyph
// and tip.
const props = defineProps({
target: { type: String, required: true },
/** The panel's name in "Expand {name}", already translated. */
name: { type: String, default: () => t('panel') },
/** A right-rail panel opens and closes sideways: ◀ / ▶, not + / −. */
sideways: { type: Boolean, default: false },
/** The glyph and tip before the panel is presented. */
initial: { type: String, required: true },
initialTitle: { type: String, default: () => t('Collapse panel') },
/** Words that replace "Expand/Collapse {name}" (Radio's own wording). */
words: { type: Object, default: null },
});
const chrome = usePanelChromeStore();
const { panels } = storeToRefs(chrome);
const state = computed(() => panels.value[props.target]);
const glyph = computed(() => {
if (!state.value) return props.initial;
if (props.sideways) return state.value.collapsed ? '◀' : '▶';
return state.value.collapsed ? '+' : '−';
});
const label = computed(() => {
if (!state.value) return props.words?.initialLabel;
if (props.words)
return state.value.collapsed
? props.words.expandLabel
: props.words.collapseLabel;
const key = state.value.collapsed
? msg('Expand {name}')
: msg('Collapse {name}');
return t(key, { name: props.name });
});
const title = computed(() => {
if (!state.value) return props.initialTitle;
if (props.words)
return state.value.collapsed
? props.words.expandTitle
: props.words.collapseTitle;
return label.value;
});
</script>
<template>
<UiButton
variant="icon"
class="panel-collapse-btn"
:data-collapse-target="target"
:label="label"
:title="title"
:aria-expanded="state ? String(!state.collapsed) : undefined"
@click="chrome.act('toggle', target)"
>{{ glyph }}</UiButton
>
</template>
-9
View File
@@ -856,15 +856,6 @@ export class ShellFacade {
return this._panelChrome._collapsePanelOnEscape(...arguments);
}
/**
* Allows either command-dock tray to remain open until explicitly unpinned.
* Both trays may be pinned; transient and error trays stack above them.
* @returns {void}
*/
_initCommandDockPins() {
return this._panelChrome._initCommandDockPins(...arguments);
}
/**
* Tracks the live pinned-tray height so a hovered sibling can stack above it
* without hardcoded content dimensions.
+6 -18
View File
@@ -1,7 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { UiLifetime } from './uiLifetime.js';
import { createPinia } from 'pinia';
import { PanelChrome } from './panelChrome.js';
import { usePanelChromeStore } from './stores/panelChrome.js';
import { PanelLayoutController } from './panelLayoutController.js';
import { ShellFeedback } from './shellFeedback.js';
import { RecordingControls } from './recordingControls.js';
@@ -292,23 +294,13 @@ test('weather rail orders between CCTV and Context, observes and syncs chrome',
panel.id = id;
panel.style = {};
panel.classList.add('collapsed');
const button = f.element();
button.closest = () => panel;
const attributes = new Map();
button.setAttribute = (name, value) => attributes.set(name, value);
button.getAttribute = (name) => attributes.get(name);
panel.button = button;
panel.querySelectorAll = () => [button];
panel.querySelector = (selector) =>
selector.startsWith('.panel-title')
? { textContent: id === 'weather-panel' ? 'WEATHER' : id }
: null;
f.nodes.set(id, panel);
}
const weather = f.nodes.get('weather-panel');
stack.children = [weather, f.nodes.get('global-context-panel')];
const synced = [];
const chrome = {};
const store = usePanelChromeStore(createPinia());
const chrome = { _chromeStore: store, _chromeToken: store.connect({}) };
const owner = new PanelLayoutController({
readHud: () => ({}),
scheduleCockpitLayout() {},
@@ -325,14 +317,10 @@ test('weather rail orders between CCTV and Context, observes and syncs chrome',
);
assert.ok(synced.includes('weather-panel'));
assert.ok(observed.has(weather));
assert.equal(weather.button.textContent, '◀');
assert.equal(weather.button.getAttribute('aria-expanded'), 'false');
assert.equal(weather.button.title, 'Expand WEATHER');
assert.equal(store.panels['weather-panel'].collapsed, true);
weather.classList.remove('collapsed');
PanelChrome.prototype._syncPanelCollapseButton.call(chrome, weather);
assert.equal(weather.button.textContent, '▶');
assert.equal(weather.button.getAttribute('aria-expanded'), 'true');
assert.equal(weather.button.getAttribute('aria-label'), 'Collapse WEATHER');
assert.equal(store.panels['weather-panel'].collapsed, false);
owner.destroy();
assert.equal(observed.size, 0);
assert.equal(f.frames.size, 0);
+83
View File
@@ -0,0 +1,83 @@
import { defineStore } from 'pinia';
import { shallowRef } from 'vue';
/**
* @typedef {object} PanelChromeHandlers
* @property {(panelId: string) => void} toggle A collapse button's click.
* @property {(panelId: string) => void} pin A dock pin button's click.
*/
/**
* Each panel's disclosure state as its collapse, dock and pin buttons render
* it. PanelChrome owns the panels: it publishes here whenever a panel opens,
* closes or is pinned, and connects the buttons' clicks; the views only render
* and forward clicks.
*/
export const usePanelChromeStore = defineStore('gev-panel-chrome', () => {
/**
* `{[panelId]: {collapsed, pinned}}`, frozen; a panel is absent until its
* owner first presents it, and its buttons keep their markup until then.
*/
const panels = shallowRef(Object.freeze({}));
/** @type {{handlers: PanelChromeHandlers}|null} */
let owner = null;
/**
* Present the panels for an owner, replacing any earlier owner. The
* buttons start from the new owner's states, not the last owner's.
* @param {PanelChromeHandlers} handlers
* @returns {object} Token for publish() and detach().
*/
function connect(handlers) {
owner = { handlers };
panels.value = Object.freeze({});
return owner;
}
/** @returns {boolean} Whether `token` is the owner presenting now. */
function owns(token) {
return !!token && token === owner;
}
/**
* Merge `patch` into a panel's state for the owner holding `token`; an
* unchanged state is not republished.
* @param {object} token
* @param {string} panelId
* @param {{collapsed?: boolean, pinned?: boolean}} patch
*/
function publish(token, panelId, patch) {
if (!owns(token) || !panelId) return;
const prior = panels.value[panelId];
const next = { collapsed: false, pinned: false, ...prior, ...patch };
if (
prior &&
prior.collapsed === next.collapsed &&
prior.pinned === next.pinned
)
return;
panels.value = Object.freeze({
...panels.value,
[panelId]: Object.freeze(next),
});
}
/**
* Stop forwarding clicks for the owner holding `token`; the buttons keep
* the last state it presented. A later owner is left alone.
*/
function detach(token) {
if (token === owner) owner = null;
}
/**
* Forward a button's click to the owner, if one is connected.
* @param {keyof PanelChromeHandlers} name
* @param {string} panelId
*/
function act(name, panelId) {
owner?.handlers[name]?.(panelId);
}
return { panels, connect, owns, publish, detach, act };
});
+27 -7
View File
@@ -1,10 +1,12 @@
<script setup>
import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js';
import { msg, t } from '../../i18n/index.js';
import UiButton from '../kit/UiButton.vue';
import { vClassToggle } from '../rail/classToggle.js';
import { useDisplayStore } from '../stores/display.js';
import { computed } from 'vue';
import { useLocationStore } from '../stores/location.js';
import { usePanelChromeStore } from '../stores/panelChrome.js';
import { usePointerReticleStore } from '../stores/pointerReticle.js';
import { useVoiceStore } from '../stores/voice.js';
import PhoneTabBar from './panels/PhoneTabBar.vue';
@@ -18,6 +20,24 @@ const { style, mapSource, mapSourceControls } = storeToRefs(useDisplayStore());
// and the search field keeps their listeners.
const { view: loc, controls: locationControls } = storeToRefs(useLocationStore());
// PanelChrome presents each tray's open and pinned state; the tray toggles
// keep its hover and focus listeners, and the pins call it.
const chrome = usePanelChromeStore();
const { panels } = storeToRefs(chrome);
/** A tray toggle's words, or `fallback` until the tray is presented. */
function trayWords(panelId, name, fallback) {
const state = panels.value[panelId];
if (!state) return fallback;
return t(state.collapsed ? msg('Expand {name}') : msg('Collapse {name}'), {
name,
});
}
/** A pin's click: a pin never opens the tray behind it. */
function pin(event, panelId) {
event.stopPropagation();
chrome.act('pin', panelId);
}
// The voice control, while a voice session presents one. It sits between the
// location bar and the visual presets; the trays keep their elements when the
// order changes.
@@ -51,13 +71,13 @@ function selectMapSource(chip) {
<div class="panel-glow"></div>
<div class="panel-inner">
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
data-dock-toggle-target="control-panel" aria-expanded="false"
aria-controls="control-panel-popover" aria-label="Expand Visual Presets" data-i18n-aria-label>
data-dock-toggle-target="control-panel" :aria-expanded="String(panels['control-panel']?.collapsed === false)"
aria-controls="control-panel-popover" :aria-label="trayWords('control-panel', t('VISUAL PRESETS'), t('Expand Visual Presets'))" :title="trayWords('control-panel', t('VISUAL PRESETS'), undefined)">
<span class="panel-title" data-i18n-runtime><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
<span class="panel-divider"></span>
</button>
<div id="control-panel-popover" class="dock-popover-content">
<button class="dock-pin-btn" data-pin-target="control-panel" type="button" aria-label="Pin visual presets" aria-pressed="false" title="Keep visual presets open" data-i18n-aria-label data-i18n-title><img src="/pin.svg" alt="" /></button>
<UiButton variant="icon" class="dock-pin-btn" data-pin-target="control-panel" :label="t('Pin visual presets')" :title="t('Keep visual presets open')" :pressed="!!panels['control-panel']?.pinned" @click="pin($event, 'control-panel')"><img src="/pin.svg" alt="" /></UiButton>
<div id="style-buttons" class="button-grid">
<button class="style-btn active" data-style="normal" v-class-toggle="{ ...(style && { active: style.name === 'normal' }) }" title="Show the globe without a visual filter." data-i18n-title>
<span class="btn-icon">◯</span>
@@ -112,17 +132,17 @@ function selectMapSource(chip) {
<div class="panel-glow"></div>
<div class="location-inner">
<div class="location-toolbar">
<button id="location-bar-toggle" class="dock-tray-toggle" type="button" data-dock-toggle-target="location-bar" aria-controls="location-bar-popover" aria-expanded="false" aria-label="Expand LOCATION" title="Expand LOCATION" data-i18n-aria-label data-i18n-title>
<button id="location-bar-toggle" class="dock-tray-toggle" type="button" data-dock-toggle-target="location-bar" aria-controls="location-bar-popover" :aria-expanded="String(panels['location-bar']?.collapsed === false)" :aria-label="trayWords('location-bar', t('LOCATION'), t('Expand LOCATION'))" :title="trayWords('location-bar', t('LOCATION'), t('Expand LOCATION'))">
<span class="location-toolbar-label" data-i18n-runtime><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
</button>
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel" data-i18n-title>+</button>
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel" data-i18n-title @click="chrome.act('toggle', 'location-bar')">+</button>
</div>
<div id="location-mini-status" class="location-mini-status" aria-live="polite">
<div id="location-mini-city" class="location-mini-city">{{ loc ? loc.status.city : t('📍 Location: --') }}</div>
<div id="location-mini-poi" class="location-mini-poi">{{ loc ? loc.status.poi : t('Landmark: --') }}</div>
</div>
<div id="location-bar-popover" class="dock-popover-content">
<button class="dock-pin-btn" data-pin-target="location-bar" type="button" aria-label="Pin location tray" aria-pressed="false" title="Keep location tray open" data-i18n-aria-label data-i18n-title><img src="/pin.svg" alt="" /></button>
<UiButton variant="icon" class="dock-pin-btn" data-pin-target="location-bar" :label="t('Pin location tray')" :title="t('Keep location tray open')" :pressed="!!panels['location-bar']?.pinned" @click="pin($event, 'location-bar')"><img src="/pin.svg" alt="" /></UiButton>
<div id="poi-row" class="poi-row-container" v-class-toggle="{ expanded: !!loc?.poisExpanded }"><button v-for="poi in loc?.pois" :key="`${loc.poiCity}:${poi.index}`" type="button" class="poi-pill" v-class-toggle="{ active: poi.active }" :data-poi-index="poi.index" @click="locationControls?.choosePoi(poi.index)"><span class="poi-pill-key">{{ poi.key }}</span><span class="poi-pill-name">{{ poi.name }}</span></button></div>
<div class="location-bar-divider" id="location-bar-divider" v-class-toggle="{ visible: !!loc?.poisExpanded }"></div>
<div class="location-city-row">
+4 -3
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js';
import PanelCollapseButton from '../rail/PanelCollapseButton.vue';
import { vClassToggle } from '../rail/classToggle.js';
import { useCockpitStore } from '../stores/cockpit.js';
import { useBhoteKoshiPanelStore } from '../stores/bhoteKoshiPanel.js';
@@ -34,7 +35,7 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
<span class="panel-title" data-i18n>WEATHER</span>
<span id="weather-panel-count" aria-label="Active weather products" data-i18n-aria-label>0</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="weather-panel" type="button" title="Collapse panel" data-i18n-title>▼</button>
<PanelCollapseButton target="weather-panel" :name="t('WEATHER')" sideways initial="▼" />
</div>
<div id="weather-panel-body" class="ui-scroll" data-rail-scroller></div>
</div>
@@ -46,7 +47,7 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
<span class="panel-title" data-i18n>RECENT IMAGERY</span>
<span id="recent-imagery-panel-count" aria-label="Imagery days in the box" data-i18n-aria-label></span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="recent-imagery-panel" type="button" title="Collapse panel" data-i18n-title>▼</button>
<PanelCollapseButton target="recent-imagery-panel" :name="t('RECENT IMAGERY')" sideways initial="▼" />
</div>
<div id="recent-imagery-panel-body" class="ui-scroll" data-rail-scroller></div>
</div>
@@ -57,7 +58,7 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
<div class="panel-header">
<span class="panel-title" data-i18n>CONTEXT</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="global-context-panel" title="Expand panel" data-i18n-title>▶</button>
<PanelCollapseButton target="global-context-panel" :name="t('CONTEXT')" sideways initial="▶" :initial-title="t('Expand panel')" />
</div>
<div class="cyber-panel-body ui-scroll" data-rail-scroller>
<div class="global-context-modes" role="tablist" aria-label="Context mode" data-i18n-aria-label>
+3 -2
View File
@@ -1,6 +1,7 @@
<script setup>
import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js';
import PanelCollapseButton from '../rail/PanelCollapseButton.vue';
import { useSceneSharingStore } from '../stores/sceneSharing.js';
import { useSceneStore } from '../stores/scenes.js';
import { vClassToggle } from '../rail/classToggle.js';
@@ -42,7 +43,7 @@ async function importChosen(event) {
<div class="panel-header">
<span class="panel-title" data-i18n>DATA LAYERS</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="data-panel" title="Collapse panel" data-i18n-title>+</button>
<PanelCollapseButton target="data-panel" :name="t('DATA LAYERS')" initial="+" />
</div>
<div id="data-toggles" class="data-toggle-list ui-scroll"></div>
</div>
@@ -61,7 +62,7 @@ async function importChosen(event) {
<div class="panel-header">
<span class="panel-title" title="Create and edit scenes" data-i18n data-i18n-title>AUTHORING</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel" data-i18n-title>+</button>
<PanelCollapseButton target="scene-panel" :name="t('AUTHORING')" initial="+" />
</div>
<p class="scene-step-heading" data-i18n>SCENE</p>
<div class="scene-controls">
+2 -1
View File
@@ -2,6 +2,7 @@
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import CctvCalValue from '../../cctv/CctvCalValue.vue';
import CctvPicker from '../../cctv/CctvPicker.vue';
import { useCctvStore } from '../../stores/cctv.js';
@@ -32,7 +33,7 @@ const frameData = computed(() =>
<div class="panel-header">
<span class="panel-title" data-i18n>CCTV</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel" data-i18n-title>+</button>
<PanelCollapseButton target="cctv-panel" :name="t('CCTV')" sideways initial="+" />
</div>
<div class="cyber-panel-body ui-scroll" data-rail-scroller>
<div id="cctv-frame-wrap" :class="['cctv-frame-wrap', { loading: frame?.wrapLoading, 'has-frame': frame?.hasFrame }]">
+3 -2
View File
@@ -2,6 +2,7 @@
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import { vRangeValue } from '../../rail/rangeValue.js';
import { useDisplayStore } from '../../stores/display.js';
@@ -46,7 +47,7 @@ const geoListed = computed(() => geoRows.value.length > 0);
<div class="compact pp-header-row">
<span data-i18n class="pp-header-label">DISPLAY</span>
<span class="panel-divider" aria-hidden="true"></span>
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel" data-i18n-title>&#x25C0;</button>
<PanelCollapseButton class="pp-collapse-btn" target="pp-toggles" :name="t('DISPLAY')" sideways initial="◀" />
</div>
<div class="pp-panel-body ui-scroll" data-rail-scroller>
<div class="pp-toggle-group">
@@ -126,7 +127,7 @@ const geoListed = computed(() => geoRows.value.length > 0);
<div class="param-panel-header">
<span class="param-panel-title" data-i18n>PARAMETERS</span>
<span class="param-panel-divider"></span>
<button class="panel-collapse-btn" id="param-panel-collapse-btn" data-collapse-target="param-slider-panel" title="Collapse panel" data-i18n-title>−</button>
<PanelCollapseButton id="param-panel-collapse-btn" target="param-slider-panel" initial="−" />
</div>
<div id="param-sliders"></div>
</div>
+10 -1
View File
@@ -2,6 +2,7 @@
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import { vClassToggle } from '../../rail/classToggle.js';
import { vFieldValue } from '../../rail/fieldValue.js';
import { vRangeValue } from '../../rail/rangeValue.js';
@@ -17,6 +18,14 @@ const { view: radio, sdr } = storeToRefs(useRadioStore());
// Only whether the receiver is connected reaches the panel; the card renders
// its own frames.
const sdrConnected = computed(() => sdr.value?.connectionActive ?? null);
// The collapse button keeps Radio's own wording, not "Expand RADIO".
const radioWords = {
initialLabel: t('Expand Radio section'),
expandLabel: t('Expand Radio section'),
collapseLabel: t('Collapse Radio section'),
expandTitle: t('Expand Radio'),
collapseTitle: t('Collapse Radio'),
};
</script>
<template>
@@ -26,7 +35,7 @@ const sdrConnected = computed(() => sdr.value?.connectionActive ?? null);
<span class="panel-title" data-i18n>RADIO</span>
<span id="radio-layer-state" class="radio-layer-state" :class="{ active: radio?.layerState.active }">{{ radio ? radio.layerState.text : t('OFF') }}</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="radio-panel" title="Expand Radio" aria-label="Expand Radio section" data-i18n-title data-i18n-aria-label>+</button>
<PanelCollapseButton target="radio-panel" initial="+" :initial-title="t('Expand Radio')" :words="radioWords" />
</div>
<div class="radio-directory-row">
<button id="radio-enable-btn" class="panel-layer-toggle" :class="{ active: radio?.enable.active }" type="button" :aria-pressed="String(radio?.enable.active ?? false)" :aria-label="radio?.enable.label" :aria-disabled="radio ? String(radio.enable.busy) : undefined" :aria-busy="radio ? String(radio.enable.busy) : undefined">{{ radio ? radio.enable.text : t('ENABLE') }}</button>
+2 -1
View File
@@ -2,6 +2,7 @@
import { storeToRefs } from 'pinia';
import { computed, shallowRef, watch } from 'vue';
import { t } from '../../../i18n/index.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import RailChips from '../../rail/RailChips.vue';
import { useStreetLevelStore } from '../../stores/streetLevel.js';
@@ -107,7 +108,7 @@ const vThumb = { mounted: placeThumb, updated: placeThumb };
<span class="panel-title" data-i18n>STREET LEVEL</span>
<span class="panel-divider"></span>
<button id="sl-status" :class="`sl-status${status.tone ? ` is-${status.tone}` : ''}`" type="button" :aria-pressed="String(status.pressed)" :title="status.title">{{ status.text }}</button>
<button class="panel-collapse-btn" data-collapse-target="street-level-panel" title="Expand panel" data-i18n-title>◀</button>
<PanelCollapseButton target="street-level-panel" :name="t('STREET LEVEL')" sideways initial="◀" :initial-title="t('Expand panel')" />
</div>
<div id="sl-body" class="sl-body">
<div id="sl-viewer-wrap" class="sl-viewer-wrap" :hidden="!viewer.open">