mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
ec4c55e78c
commit
dd3a2c87f9
25 files changed
+635
-212
No files matched your search
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 }]">
|
||||
|
||||
@@ -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>◀</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>
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
Reference in new issue
Block a user