mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +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
@@ -100,6 +100,14 @@
|
|||||||
panel: ESC still stops a playing scene and the runtime chip still shows
|
panel: ESC still stops a playing scene and the runtime chip still shows
|
||||||
(samehkhamis).
|
(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
|
## [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
|
- Show a Street Level view facing 359.5° or more as 0°. The viewer caption
|
||||||
|
|||||||
+12
-6
@@ -36,11 +36,14 @@ one-panel rule too. Share links continue to record which panels are open.
|
|||||||
|
|
||||||
## Component kit
|
## Component kit
|
||||||
|
|
||||||
`src/ui/kit/` holds the generic components (`UiRail`, `UiPanel`, `UiButton`,
|
`src/ui/kit/` holds the generic components: `UiRail`, `UiTooltip`, `UiIcon`
|
||||||
`UiChip`, `UiTooltip` and so on). Names, props and events follow Halfpixel's
|
and `UiButton` so far, with more (`UiPanel`, `UiSegmented`, `UiSelect` and
|
||||||
`ui-vue` kit so the two can converge later, but the components are written for
|
so on) joining as the controls they serve are converted. Names, props,
|
||||||
this repository and styled by its existing stylesheet and custom properties.
|
events and class names follow Halfpixel's `ui-vue` kit so the two can merge
|
||||||
They contain no application state.
|
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
|
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
|
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
|
when a view needs one, mirroring state an owner already publishes, so
|
||||||
there is never a second owner of the same state.
|
there is never a second owner of the same state.
|
||||||
2. **Rail.** `PanelChrome` enforces one panel per side; the panel store
|
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,
|
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
|
classes, `data-i18n` text and CSS. The page places each view with a
|
||||||
`.gev-view` host (`display: contents`), and `mountApplicationViews()`
|
`.gev-view` host (`display: contents`), and `mountApplicationViews()`
|
||||||
|
|||||||
+15
-14
@@ -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
|
`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:
|
surface. Keep the header and any outward popup outside the scrolling body:
|
||||||
|
|
||||||
```html
|
```vue
|
||||||
<section
|
<section
|
||||||
id="example-panel"
|
id="example-panel"
|
||||||
data-panel-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>
|
<div data-panel-surface>
|
||||||
<header data-panel-header>
|
<header data-panel-header>
|
||||||
<h2 class="panel-title panel-surface-title">Example</h2>
|
<h2 class="panel-title panel-surface-title">Example</h2>
|
||||||
<button
|
<PanelCollapseButton
|
||||||
class="panel-collapse-btn panel-surface-control"
|
class="panel-surface-control"
|
||||||
data-collapse-target="example-panel"
|
target="example-panel"
|
||||||
aria-expanded="true"
|
:name="t('Example')"
|
||||||
aria-label="Collapse Example"
|
initial="−"
|
||||||
>
|
/>
|
||||||
−
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
<div data-panel-body data-rail-scroller>
|
<div data-panel-body data-rail-scroller>
|
||||||
<label>Filter <input class="panel-surface-control" type="text" /></label>
|
<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
|
existing dock rules intentionally hide that class. The generic surface skin
|
||||||
does not replace the dock's disclosure policy.
|
does not replace the dock's disclosure policy.
|
||||||
|
|
||||||
The existing `PanelChrome` binds `.panel-collapse-btn[data-collapse-target]`
|
`PanelCollapseButton` (`src/ui/rail/`) renders a panel's collapse button
|
||||||
at initialization and routes state through `setPanelCollapsed`. For additions
|
(`.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
|
present at startup, keep those hooks and the existing chrome lifecycle. A
|
||||||
dynamically mounted panel must bind and dispose its own disclosure using
|
dynamically mounted panel must own its disclosure: its own state, button and
|
||||||
`bindPanelDisclosure` / `collapsePanelOnEscape` from `panelDisclosure.js`, with
|
Escape, bound and disposed with `bindPanelDisclosure` /
|
||||||
the application owning state changes. Do not rerun the whole application chrome
|
`collapsePanelOnEscape` from `panelDisclosure.js` (which bind Escape only). Do not rerun the whole application chrome
|
||||||
initializer just to add one panel. `layoutLeftPanelRail` and
|
initializer just to add one panel. `layoutLeftPanelRail` and
|
||||||
`layoutRightPanelRail` accept arbitrary direct `data-panel-id` children; their
|
`layoutRightPanelRail` accept arbitrary direct `data-panel-id` children; their
|
||||||
host owns layout scheduling, observations and collapse callbacks. Dynamic
|
host owns layout scheduling, observations and collapse callbacks. Dynamic
|
||||||
|
|||||||
@@ -119,7 +119,10 @@
|
|||||||
"src/ui/stores/hud.js",
|
"src/ui/stores/hud.js",
|
||||||
"src/ui/views/IntelHudView.vue",
|
"src/ui/views/IntelHudView.vue",
|
||||||
"src/ui/stores/firstRun.js",
|
"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"]
|
"external": ["vue", "pinia"]
|
||||||
},
|
},
|
||||||
@@ -1044,7 +1047,8 @@
|
|||||||
"src/ui/stores/celestialRing.js",
|
"src/ui/stores/celestialRing.js",
|
||||||
"src/ui/stores/worldOverlayActions.js",
|
"src/ui/stores/worldOverlayActions.js",
|
||||||
"src/ui/stores/awarenessDirection.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"]
|
"external": ["cesium", "vue", "pinia"]
|
||||||
},
|
},
|
||||||
@@ -3049,7 +3053,8 @@
|
|||||||
"src/hudReadouts.js",
|
"src/hudReadouts.js",
|
||||||
"src/ui/stores/hud.js",
|
"src/ui/stores/hud.js",
|
||||||
"src/ui/stores/firstRun.js",
|
"src/ui/stores/firstRun.js",
|
||||||
"src/firstRunCopy.js"
|
"src/firstRunCopy.js",
|
||||||
|
"src/ui/stores/panelChrome.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -3856,9 +3861,11 @@
|
|||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json",
|
"src/i18n/locales/zh-Hant.json",
|
||||||
"src/ui/panelRailCatalog.js",
|
"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": {
|
"civil-flight-records": {
|
||||||
"exports": ["./layers/flights/records"],
|
"exports": ["./layers/flights/records"],
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ test('Context header holds only its title and collapse control', () => {
|
|||||||
assert.ok(panel, 'Global Context panel is missing');
|
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[ "]/);
|
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');
|
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']);
|
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 test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
import { msg, t } from './i18n/index.js';
|
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.
|
// Exercise the installed event routes and central close method, without WebGL.
|
||||||
const markup = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
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 saves = [];
|
||||||
const claims = [];
|
const claims = [];
|
||||||
let shareSyncs = 0;
|
let shareSyncs = 0;
|
||||||
|
const chromeStore = usePanelChromeStore(createPinia());
|
||||||
const manager = {
|
const manager = {
|
||||||
_lifetime: { frame() {} },
|
_lifetime: { frame() {} },
|
||||||
|
_chromeStore: chromeStore,
|
||||||
|
_chromeToken: chromeStore.connect({}),
|
||||||
_panelLayout: { _leftStackPreferredPanelId: null, _rightStackPreferredPanelId: null },
|
_panelLayout: { _leftStackPreferredPanelId: null, _rightStackPreferredPanelId: null },
|
||||||
...methods,
|
...methods,
|
||||||
_savePanelCollapsedState(...args) { saves.push(args); },
|
_savePanelCollapsedState(...args) { saves.push(args); },
|
||||||
@@ -136,6 +141,8 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
|
|||||||
manager, panel, location, disclosure, locationDisclosure, locationSearch,
|
manager, panel, location, disclosure, locationDisclosure, locationSearch,
|
||||||
first, active, elsewhere, document, timers, emit, focus, tick, saves, claims,
|
first, active, elsewhere, document, timers, emit, focus, tick, saves, claims,
|
||||||
calls: () => focusCalls,
|
calls: () => focusCalls,
|
||||||
|
/** A tray's state as PanelChrome presents it to the dock's buttons. */
|
||||||
|
tray: (id) => chromeStore.panels[id],
|
||||||
shareSyncs: () => shareSyncs,
|
shareSyncs: () => shareSyncs,
|
||||||
show() { hidden = false; },
|
show() { hidden = false; },
|
||||||
select(value) { selected = value; },
|
select(value) { selected = value; },
|
||||||
@@ -297,8 +304,7 @@ test('Location Enter stays immediate while Space waits for native key release',
|
|||||||
h.locationKeyUp(key);
|
h.locationKeyUp(key);
|
||||||
}
|
}
|
||||||
assert.equal(h.location.classList.contains('collapsed'), false);
|
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.locationDisclosure.getAttribute('aria-label'), 'Collapse LOCATION');
|
|
||||||
assert.equal(h.document.activeElement, h.locationDisclosure);
|
assert.equal(h.document.activeElement, h.locationDisclosure);
|
||||||
assert.equal(h.timers.size, 0);
|
assert.equal(h.timers.size, 0);
|
||||||
assert.equal(h.calls(), 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);
|
h.locationKey(key);
|
||||||
if (key === ' ') h.locationKeyUp(key);
|
if (key === ' ') h.locationKeyUp(key);
|
||||||
assert.equal(h.location.classList.contains('collapsed'), true);
|
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();
|
const escape = h.locationEscape();
|
||||||
assert.equal(escape.defaultPrevented, true);
|
assert.equal(escape.defaultPrevented, true);
|
||||||
assert.equal(h.location.classList.contains('collapsed'), true);
|
assert.equal(h.location.classList.contains('collapsed'), true);
|
||||||
assert.equal(h.locationDisclosure.getAttribute('aria-expanded'), 'false');
|
assert.equal(h.tray('location-bar').collapsed, true);
|
||||||
assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Expand LOCATION');
|
|
||||||
assert.equal(h.locationDisclosure.title, 'Expand LOCATION');
|
|
||||||
assert.equal(h.document.activeElement, h.locationDisclosure);
|
assert.equal(h.document.activeElement, h.locationDisclosure);
|
||||||
h.drain();
|
h.drain();
|
||||||
assert.equal(h.calls(), 0);
|
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.manager.setPanelCollapsed('control-panel', true);
|
||||||
h.focus(h.disclosure); h.key(); h.tick();
|
h.focus(h.disclosure); h.key(); h.tick();
|
||||||
assert.equal(h.location.classList.contains('collapsed'), false);
|
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);
|
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 h = harness(); h.key(); h.tick();
|
||||||
const saves = h.saves.length;
|
const saves = h.saves.length;
|
||||||
const claims = h.claims.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 });
|
h.manager.setPanelCollapsed('location-bar', false, { restore: true, persist: false, syncShare: false });
|
||||||
assert.equal(h.panel.classList.contains('collapsed'), false);
|
assert.equal(h.panel.classList.contains('collapsed'), false);
|
||||||
assert.equal(h.location.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.document.activeElement, h.active);
|
||||||
assert.equal(h.saves.length, saves);
|
assert.equal(h.saves.length, saves);
|
||||||
assert.equal(h.claims.length, claims);
|
assert.equal(h.claims.length, claims);
|
||||||
assert.equal(h.shareSyncs(), shareSyncs);
|
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 });
|
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);
|
assert.equal(h.timers.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function panelFixture({ id = 'data-panel', nested = false, nestedCollapsed = fal
|
|||||||
disclosure.closest = () => panel;
|
disclosure.closest = () => panel;
|
||||||
}
|
}
|
||||||
const document = { getElementById: (candidate) => candidate === id ? panel : null };
|
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) => {
|
manager.setPanelCollapsed = (candidate, value, options) => {
|
||||||
assert.equal(candidate, id);
|
assert.equal(candidate, id);
|
||||||
assert.equal(value, true);
|
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', () => {
|
test('panel chrome wires Escape for every declared collapse target', () => {
|
||||||
const init = method('_initPanelChrome', '_collapsePanelOnEscape');
|
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(init, /bindPanelDisclosure\(\{[\s\S]*?onEscape: \(event\) => this\._collapsePanelOnEscape\(event, targetId\)/);
|
||||||
assert.match(source, /createHoverDisclosure\(\{[\s\S]*?onEscape: \(event\) => this\._collapsePanelOnEscape\(event, panelId\)/);
|
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', () => {
|
test('panel bindings have a single owner and are inert after destruction', () => {
|
||||||
const panel = new EventTarget();
|
const panel = new EventTarget();
|
||||||
const button = new EventTarget();
|
|
||||||
let collapsed = true;
|
|
||||||
let changes = 0;
|
|
||||||
let keys = 0;
|
let keys = 0;
|
||||||
panel.classList = { contains: () => collapsed };
|
const bind = () => bindPanelDisclosure({ panel, onEscape() { keys += 1; } });
|
||||||
const bind = () => bindPanelDisclosure({
|
|
||||||
panel, buttons: [button, button],
|
|
||||||
onChange(value, options) { collapsed = value; changes += 1; assert.equal(options.explicit, true); },
|
|
||||||
onEscape() { keys += 1; },
|
|
||||||
});
|
|
||||||
const first = bind();
|
const first = bind();
|
||||||
button.dispatchEvent(new Event('click'));
|
|
||||||
panel.dispatchEvent(new Event('keydown'));
|
panel.dispatchEvent(new Event('keydown'));
|
||||||
assert.equal(collapsed, false);
|
|
||||||
assert.equal(changes, 1);
|
|
||||||
assert.equal(keys, 1);
|
assert.equal(keys, 1);
|
||||||
first.destroy();
|
first.destroy();
|
||||||
first.destroy();
|
first.destroy();
|
||||||
button.dispatchEvent(new Event('click'));
|
|
||||||
panel.dispatchEvent(new Event('keydown'));
|
panel.dispatchEvent(new Event('keydown'));
|
||||||
assert.equal(changes, 1);
|
|
||||||
assert.equal(keys, 1);
|
assert.equal(keys, 1);
|
||||||
const second = bind();
|
const second = bind();
|
||||||
button.dispatchEvent(new Event('click'));
|
panel.dispatchEvent(new Event('keydown'));
|
||||||
assert.equal(changes, 2);
|
assert.equal(keys, 2);
|
||||||
assert.equal(collapsed, true);
|
|
||||||
second.destroy();
|
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(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(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(leftRail, /--left-panel-allocated-height/);
|
||||||
assert.match(rightRail, /--right-panel-allocated-height/);
|
assert.match(rightRail, /--right-panel-allocated-height/);
|
||||||
assert.match(
|
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, /playSelectedRadio|togglePlayback\(\).*radio-enable/i);
|
||||||
assert.doesNotMatch(enableMethod, /contextRadio/);
|
assert.doesNotMatch(enableMethod, /contextRadio/);
|
||||||
assert.match(enableMethod, /toggleRadio\(this\._radioEnableBtn\)/);
|
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\)/);
|
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(
|
assert.match(
|
||||||
html,
|
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(
|
assert.match(
|
||||||
html,
|
html,
|
||||||
@@ -46,9 +46,10 @@ test('the panel is registered with panel chrome, cockpit entry and the right rai
|
|||||||
panelChrome,
|
panelChrome,
|
||||||
/COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object\.freeze\(\[[\s\S]*'street-level-panel'/,
|
/COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object\.freeze\(\[[\s\S]*'street-level-panel'/,
|
||||||
);
|
);
|
||||||
|
// A right-rail panel's collapse button opens sideways (◀ / ▶).
|
||||||
assert.match(
|
assert.match(
|
||||||
panelChrome,
|
read('src/ui/views/panels/StreetLevelPanel.vue'),
|
||||||
/const isRightRail = \[[\s\S]*'street-level-panel'/,
|
/<PanelCollapseButton[^>]*target="street-level-panel"[^>]*\bsideways\b/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
layoutController,
|
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 { 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 { displayPanelScroller } from './displayPanelScroll.js';
|
||||||
import { PanelPositionControls } from './panelPositionControls.js';
|
import { PanelPositionControls } from './panelPositionControls.js';
|
||||||
import { PanelLayoutController } from './panelLayoutController.js';
|
import { PanelLayoutController } from './panelLayoutController.js';
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
collapsePanelOnEscape,
|
collapsePanelOnEscape,
|
||||||
createHoverDisclosure,
|
createHoverDisclosure,
|
||||||
} from './panelDisclosure.js';
|
} from './panelDisclosure.js';
|
||||||
|
|
||||||
const SHARE_PANEL_STATE_SPECS = Object.freeze([
|
const SHARE_PANEL_STATE_SPECS = Object.freeze([
|
||||||
{ id: 'control-panel', pinnable: true },
|
{ id: 'control-panel', pinnable: true },
|
||||||
{ id: 'location-bar', pinnable: true },
|
{ id: 'location-bar', pinnable: true },
|
||||||
@@ -22,6 +24,12 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([
|
|||||||
{ id: 'pp-toggles' },
|
{ id: 'pp-toggles' },
|
||||||
{ id: 'param-slider-panel' },
|
{ 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. */
|
/** Standard map-view panels cleared out of the way on a fresh Cockpit entry. */
|
||||||
const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
|
const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
|
||||||
'data-panel',
|
'data-panel',
|
||||||
@@ -47,6 +55,7 @@ export class PanelChrome {
|
|||||||
readScrollRestoreOwner,
|
readScrollRestoreOwner,
|
||||||
readDisplayScrollTop,
|
readDisplayScrollTop,
|
||||||
readSharedCollapsed = () => undefined,
|
readSharedCollapsed = () => undefined,
|
||||||
|
store = usePanelChromeStore(applicationPinia()),
|
||||||
}) {
|
}) {
|
||||||
Object.assign(this, elements, operations, {
|
Object.assign(this, elements, operations, {
|
||||||
readHud,
|
readHud,
|
||||||
@@ -59,6 +68,16 @@ export class PanelChrome {
|
|||||||
});
|
});
|
||||||
this._disposed = false;
|
this._disposed = false;
|
||||||
this._lifetime = new UiLifetime();
|
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._cockpitPanelRestore = null;
|
||||||
this._cockpitContextCollapsedForDataPanel = false;
|
this._cockpitContextCollapsedForDataPanel = false;
|
||||||
this._panelPosition = new PanelPositionControls({
|
this._panelPosition = new PanelPositionControls({
|
||||||
@@ -101,24 +120,23 @@ export class PanelChrome {
|
|||||||
for (const control of this._panelDisclosureControls || [])
|
for (const control of this._panelDisclosureControls || [])
|
||||||
control.destroy();
|
control.destroy();
|
||||||
this._panelDisclosureControls = [];
|
this._panelDisclosureControls = [];
|
||||||
const targets = new Map();
|
this._disclosurePanelIds = new Set();
|
||||||
document
|
const targets = new Set(
|
||||||
.querySelectorAll('.panel-collapse-btn[data-collapse-target]')
|
[
|
||||||
.forEach((button) => {
|
...document.querySelectorAll(
|
||||||
const targetId = button.dataset.collapseTarget;
|
'.panel-collapse-btn[data-collapse-target]',
|
||||||
if (!targetId) return;
|
),
|
||||||
if (!targets.has(targetId)) targets.set(targetId, []);
|
]
|
||||||
targets.get(targetId).push(button);
|
.map((button) => button.dataset.collapseTarget)
|
||||||
});
|
.filter(Boolean),
|
||||||
for (const [targetId, buttons] of targets) {
|
);
|
||||||
|
for (const targetId of targets) {
|
||||||
const panel = document.getElementById(targetId);
|
const panel = document.getElementById(targetId);
|
||||||
if (!panel) continue;
|
if (!panel) continue;
|
||||||
|
this._disclosurePanelIds.add(targetId);
|
||||||
this._panelDisclosureControls.push(
|
this._panelDisclosureControls.push(
|
||||||
bindPanelDisclosure({
|
bindPanelDisclosure({
|
||||||
panel,
|
panel,
|
||||||
buttons,
|
|
||||||
onChange: (collapsed, options) =>
|
|
||||||
this.setPanelCollapsed(targetId, collapsed, options),
|
|
||||||
onEscape: (event) => this._collapsePanelOnEscape(event, targetId),
|
onEscape: (event) => this._collapsePanelOnEscape(event, targetId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -144,7 +162,6 @@ export class PanelChrome {
|
|||||||
openDelayMs: 140,
|
openDelayMs: 140,
|
||||||
closeDelayMs: 420,
|
closeDelayMs: 420,
|
||||||
});
|
});
|
||||||
this._initCommandDockPins();
|
|
||||||
this._initCommandDockTrayMetrics();
|
this._initCommandDockTrayMetrics();
|
||||||
this._maybeNotifyLayoutReset();
|
this._maybeNotifyLayoutReset();
|
||||||
}
|
}
|
||||||
@@ -182,16 +199,14 @@ export class PanelChrome {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_initCommandDockPins() {
|
/** A collapse button's click: flip its panel. */
|
||||||
document
|
_togglePanel(panelId) {
|
||||||
.querySelectorAll('.dock-pin-btn[data-pin-target]')
|
if (this._disposed || !this._disclosurePanelIds?.has(panelId)) return;
|
||||||
.forEach((button) => {
|
const panel = document.getElementById(panelId);
|
||||||
this._lifetime.listen(button, 'click', (event) => {
|
if (!panel) return;
|
||||||
event.stopPropagation();
|
this.setPanelCollapsed(panelId, !panel.classList.contains('collapsed'), {
|
||||||
const panelId = button.dataset.pinTarget;
|
explicit: true,
|
||||||
this._setCommandDockPanelPinState(panelId);
|
});
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_setCommandDockPanelPinState(
|
_setCommandDockPanelPinState(
|
||||||
@@ -199,17 +214,18 @@ export class PanelChrome {
|
|||||||
pin,
|
pin,
|
||||||
{ restore = false, persist = true, syncShare = true } = {},
|
{ restore = false, persist = true, syncShare = true } = {},
|
||||||
) {
|
) {
|
||||||
|
if (this._disposed || !PINNABLE_PANEL_IDS.has(panelId)) return undefined;
|
||||||
const panelEl = document.getElementById(panelId);
|
const panelEl = document.getElementById(panelId);
|
||||||
const button = document.querySelector(
|
if (!panelEl) return undefined;
|
||||||
`.dock-pin-btn[data-pin-target="${panelId}"]`,
|
|
||||||
);
|
|
||||||
if (!panelEl || !button) return undefined;
|
|
||||||
const shouldPin =
|
const shouldPin =
|
||||||
typeof pin === 'boolean'
|
typeof pin === 'boolean'
|
||||||
? pin
|
? pin
|
||||||
: !panelEl.classList.contains('dock-pinned');
|
: !panelEl.classList.contains('dock-pinned');
|
||||||
panelEl.classList.toggle('dock-pinned', shouldPin);
|
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
|
document
|
||||||
.querySelectorAll('#command-dock .dock-pinned-top')
|
.querySelectorAll('#command-dock .dock-pinned-top')
|
||||||
.forEach((pinnedPanel) => {
|
.forEach((pinnedPanel) => {
|
||||||
@@ -307,66 +323,12 @@ export class PanelChrome {
|
|||||||
return this._panelLayout._scheduleLeftPanelLayout(options0);
|
return this._panelLayout._scheduleLeftPanelLayout(options0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Present a panel's open state to its collapse and dock buttons. */
|
||||||
_syncPanelCollapseButton(panelEl) {
|
_syncPanelCollapseButton(panelEl) {
|
||||||
const isRightRail = [
|
if (!panelEl?.id) return;
|
||||||
'pp-toggles',
|
this._chromeStore.publish(this._chromeToken, panelEl.id, {
|
||||||
'cctv-panel',
|
collapsed: panelEl.classList.contains('collapsed'),
|
||||||
'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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_buildSharePanelState() {
|
_buildSharePanelState() {
|
||||||
@@ -626,6 +588,7 @@ export class PanelChrome {
|
|||||||
if (this._disposed) return;
|
if (this._disposed) return;
|
||||||
this._disposed = true;
|
this._disposed = true;
|
||||||
this._lifetime.destroy();
|
this._lifetime.destroy();
|
||||||
|
this._chromeStore.detach(this._chromeToken);
|
||||||
this._panelLayout.destroy();
|
this._panelLayout.destroy();
|
||||||
for (const control of this._panelDisclosureControls || [])
|
for (const control of this._panelDisclosureControls || [])
|
||||||
control.destroy();
|
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({
|
* Bind bubbling Escape to an existing panel. Its collapse buttons are views
|
||||||
panel,
|
* that ask the panel's owner to toggle it (PanelCollapseButton).
|
||||||
buttons = [],
|
*/
|
||||||
onChange,
|
export function bindPanelDisclosure({ panel, onEscape, ...retired }) {
|
||||||
onEscape,
|
if (!panel || typeof onEscape !== 'function') {
|
||||||
}) {
|
throw new TypeError('Panel disclosure requires a panel and onEscape');
|
||||||
if (
|
}
|
||||||
!panel ||
|
// Buttons are views now; a caller still passing them would lose its clicks.
|
||||||
typeof onChange !== 'function' ||
|
if ('buttons' in retired || 'onChange' in retired) {
|
||||||
typeof onEscape !== 'function'
|
|
||||||
) {
|
|
||||||
throw new TypeError(
|
throw new TypeError(
|
||||||
'Panel disclosure requires a panel, onChange and onEscape',
|
'Panel disclosure binds Escape only; render the buttons as views',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const scope = listenerScope();
|
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);
|
scope.listen(panel, 'keydown', onEscape);
|
||||||
return { destroy: () => scope.destroy() };
|
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);
|
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
|
* Tracks the live pinned-tray height so a hovered sibling can stack above it
|
||||||
* without hardcoded content dimensions.
|
* without hardcoded content dimensions.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { UiLifetime } from './uiLifetime.js';
|
import { UiLifetime } from './uiLifetime.js';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
import { PanelChrome } from './panelChrome.js';
|
import { PanelChrome } from './panelChrome.js';
|
||||||
|
import { usePanelChromeStore } from './stores/panelChrome.js';
|
||||||
import { PanelLayoutController } from './panelLayoutController.js';
|
import { PanelLayoutController } from './panelLayoutController.js';
|
||||||
import { ShellFeedback } from './shellFeedback.js';
|
import { ShellFeedback } from './shellFeedback.js';
|
||||||
import { RecordingControls } from './recordingControls.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.id = id;
|
||||||
panel.style = {};
|
panel.style = {};
|
||||||
panel.classList.add('collapsed');
|
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);
|
f.nodes.set(id, panel);
|
||||||
}
|
}
|
||||||
const weather = f.nodes.get('weather-panel');
|
const weather = f.nodes.get('weather-panel');
|
||||||
stack.children = [weather, f.nodes.get('global-context-panel')];
|
stack.children = [weather, f.nodes.get('global-context-panel')];
|
||||||
const synced = [];
|
const synced = [];
|
||||||
const chrome = {};
|
const store = usePanelChromeStore(createPinia());
|
||||||
|
const chrome = { _chromeStore: store, _chromeToken: store.connect({}) };
|
||||||
const owner = new PanelLayoutController({
|
const owner = new PanelLayoutController({
|
||||||
readHud: () => ({}),
|
readHud: () => ({}),
|
||||||
scheduleCockpitLayout() {},
|
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(synced.includes('weather-panel'));
|
||||||
assert.ok(observed.has(weather));
|
assert.ok(observed.has(weather));
|
||||||
assert.equal(weather.button.textContent, '◀');
|
assert.equal(store.panels['weather-panel'].collapsed, true);
|
||||||
assert.equal(weather.button.getAttribute('aria-expanded'), 'false');
|
|
||||||
assert.equal(weather.button.title, 'Expand WEATHER');
|
|
||||||
weather.classList.remove('collapsed');
|
weather.classList.remove('collapsed');
|
||||||
PanelChrome.prototype._syncPanelCollapseButton.call(chrome, weather);
|
PanelChrome.prototype._syncPanelCollapseButton.call(chrome, weather);
|
||||||
assert.equal(weather.button.textContent, '▶');
|
assert.equal(store.panels['weather-panel'].collapsed, false);
|
||||||
assert.equal(weather.button.getAttribute('aria-expanded'), 'true');
|
|
||||||
assert.equal(weather.button.getAttribute('aria-label'), 'Collapse WEATHER');
|
|
||||||
owner.destroy();
|
owner.destroy();
|
||||||
assert.equal(observed.size, 0);
|
assert.equal(observed.size, 0);
|
||||||
assert.equal(f.frames.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>
|
<script setup>
|
||||||
import { storeToRefs } from 'pinia';
|
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 { vClassToggle } from '../rail/classToggle.js';
|
||||||
import { useDisplayStore } from '../stores/display.js';
|
import { useDisplayStore } from '../stores/display.js';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useLocationStore } from '../stores/location.js';
|
import { useLocationStore } from '../stores/location.js';
|
||||||
|
import { usePanelChromeStore } from '../stores/panelChrome.js';
|
||||||
import { usePointerReticleStore } from '../stores/pointerReticle.js';
|
import { usePointerReticleStore } from '../stores/pointerReticle.js';
|
||||||
import { useVoiceStore } from '../stores/voice.js';
|
import { useVoiceStore } from '../stores/voice.js';
|
||||||
import PhoneTabBar from './panels/PhoneTabBar.vue';
|
import PhoneTabBar from './panels/PhoneTabBar.vue';
|
||||||
@@ -18,6 +20,24 @@ const { style, mapSource, mapSourceControls } = storeToRefs(useDisplayStore());
|
|||||||
// and the search field keeps their listeners.
|
// and the search field keeps their listeners.
|
||||||
const { view: loc, controls: locationControls } = storeToRefs(useLocationStore());
|
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
|
// 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
|
// location bar and the visual presets; the trays keep their elements when the
|
||||||
// order changes.
|
// order changes.
|
||||||
@@ -51,13 +71,13 @@ function selectMapSource(chip) {
|
|||||||
<div class="panel-glow"></div>
|
<div class="panel-glow"></div>
|
||||||
<div class="panel-inner">
|
<div class="panel-inner">
|
||||||
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
|
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
|
||||||
data-dock-toggle-target="control-panel" aria-expanded="false"
|
data-dock-toggle-target="control-panel" :aria-expanded="String(panels['control-panel']?.collapsed === false)"
|
||||||
aria-controls="control-panel-popover" aria-label="Expand Visual Presets" data-i18n-aria-label>
|
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-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>
|
<span class="panel-divider"></span>
|
||||||
</button>
|
</button>
|
||||||
<div id="control-panel-popover" class="dock-popover-content">
|
<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">
|
<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>
|
<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>
|
<span class="btn-icon">◯</span>
|
||||||
@@ -112,17 +132,17 @@ function selectMapSource(chip) {
|
|||||||
<div class="panel-glow"></div>
|
<div class="panel-glow"></div>
|
||||||
<div class="location-inner">
|
<div class="location-inner">
|
||||||
<div class="location-toolbar">
|
<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>
|
<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>
|
||||||
<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>
|
||||||
<div id="location-mini-status" class="location-mini-status" aria-live="polite">
|
<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-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 id="location-mini-poi" class="location-mini-poi">{{ loc ? loc.status.poi : t('Landmark: --') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="location-bar-popover" class="dock-popover-content">
|
<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 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-bar-divider" id="location-bar-divider" v-class-toggle="{ visible: !!loc?.poisExpanded }"></div>
|
||||||
<div class="location-city-row">
|
<div class="location-city-row">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
|
import PanelCollapseButton from '../rail/PanelCollapseButton.vue';
|
||||||
import { vClassToggle } from '../rail/classToggle.js';
|
import { vClassToggle } from '../rail/classToggle.js';
|
||||||
import { useCockpitStore } from '../stores/cockpit.js';
|
import { useCockpitStore } from '../stores/cockpit.js';
|
||||||
import { useBhoteKoshiPanelStore } from '../stores/bhoteKoshiPanel.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 class="panel-title" data-i18n>WEATHER</span>
|
||||||
<span id="weather-panel-count" aria-label="Active weather products" data-i18n-aria-label>0</span>
|
<span id="weather-panel-count" aria-label="Active weather products" data-i18n-aria-label>0</span>
|
||||||
<span class="panel-divider"></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>
|
||||||
<div id="weather-panel-body" class="ui-scroll" data-rail-scroller></div>
|
<div id="weather-panel-body" class="ui-scroll" data-rail-scroller></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,7 +47,7 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
|
|||||||
<span class="panel-title" data-i18n>RECENT IMAGERY</span>
|
<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 id="recent-imagery-panel-count" aria-label="Imagery days in the box" data-i18n-aria-label></span>
|
||||||
<span class="panel-divider"></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>
|
||||||
<div id="recent-imagery-panel-body" class="ui-scroll" data-rail-scroller></div>
|
<div id="recent-imagery-panel-body" class="ui-scroll" data-rail-scroller></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,7 +58,7 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
|
|||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title" data-i18n>CONTEXT</span>
|
<span class="panel-title" data-i18n>CONTEXT</span>
|
||||||
<span class="panel-divider"></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>
|
||||||
<div class="cyber-panel-body ui-scroll" data-rail-scroller>
|
<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>
|
<div class="global-context-modes" role="tablist" aria-label="Context mode" data-i18n-aria-label>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
|
import PanelCollapseButton from '../rail/PanelCollapseButton.vue';
|
||||||
import { useSceneSharingStore } from '../stores/sceneSharing.js';
|
import { useSceneSharingStore } from '../stores/sceneSharing.js';
|
||||||
import { useSceneStore } from '../stores/scenes.js';
|
import { useSceneStore } from '../stores/scenes.js';
|
||||||
import { vClassToggle } from '../rail/classToggle.js';
|
import { vClassToggle } from '../rail/classToggle.js';
|
||||||
@@ -42,7 +43,7 @@ async function importChosen(event) {
|
|||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title" data-i18n>DATA LAYERS</span>
|
<span class="panel-title" data-i18n>DATA LAYERS</span>
|
||||||
<span class="panel-divider"></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>
|
||||||
<div id="data-toggles" class="data-toggle-list ui-scroll"></div>
|
<div id="data-toggles" class="data-toggle-list ui-scroll"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,7 +62,7 @@ async function importChosen(event) {
|
|||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title" title="Create and edit scenes" data-i18n data-i18n-title>AUTHORING</span>
|
<span class="panel-title" title="Create and edit scenes" data-i18n data-i18n-title>AUTHORING</span>
|
||||||
<span class="panel-divider"></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>
|
</div>
|
||||||
<p class="scene-step-heading" data-i18n>SCENE</p>
|
<p class="scene-step-heading" data-i18n>SCENE</p>
|
||||||
<div class="scene-controls">
|
<div class="scene-controls">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { t } from '../../../i18n/index.js';
|
import { t } from '../../../i18n/index.js';
|
||||||
|
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
|
||||||
import CctvCalValue from '../../cctv/CctvCalValue.vue';
|
import CctvCalValue from '../../cctv/CctvCalValue.vue';
|
||||||
import CctvPicker from '../../cctv/CctvPicker.vue';
|
import CctvPicker from '../../cctv/CctvPicker.vue';
|
||||||
import { useCctvStore } from '../../stores/cctv.js';
|
import { useCctvStore } from '../../stores/cctv.js';
|
||||||
@@ -32,7 +33,7 @@ const frameData = computed(() =>
|
|||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title" data-i18n>CCTV</span>
|
<span class="panel-title" data-i18n>CCTV</span>
|
||||||
<span class="panel-divider"></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>
|
||||||
<div class="cyber-panel-body ui-scroll" data-rail-scroller>
|
<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 }]">
|
<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 { storeToRefs } from 'pinia';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { t } from '../../../i18n/index.js';
|
import { t } from '../../../i18n/index.js';
|
||||||
|
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
|
||||||
import { vRangeValue } from '../../rail/rangeValue.js';
|
import { vRangeValue } from '../../rail/rangeValue.js';
|
||||||
import { useDisplayStore } from '../../stores/display.js';
|
import { useDisplayStore } from '../../stores/display.js';
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ const geoListed = computed(() => geoRows.value.length > 0);
|
|||||||
<div class="compact pp-header-row">
|
<div class="compact pp-header-row">
|
||||||
<span data-i18n class="pp-header-label">DISPLAY</span>
|
<span data-i18n class="pp-header-label">DISPLAY</span>
|
||||||
<span class="panel-divider" aria-hidden="true"></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>
|
||||||
<div class="pp-panel-body ui-scroll" data-rail-scroller>
|
<div class="pp-panel-body ui-scroll" data-rail-scroller>
|
||||||
<div class="pp-toggle-group">
|
<div class="pp-toggle-group">
|
||||||
@@ -126,7 +127,7 @@ const geoListed = computed(() => geoRows.value.length > 0);
|
|||||||
<div class="param-panel-header">
|
<div class="param-panel-header">
|
||||||
<span class="param-panel-title" data-i18n>PARAMETERS</span>
|
<span class="param-panel-title" data-i18n>PARAMETERS</span>
|
||||||
<span class="param-panel-divider"></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>
|
||||||
<div id="param-sliders"></div>
|
<div id="param-sliders"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { t } from '../../../i18n/index.js';
|
import { t } from '../../../i18n/index.js';
|
||||||
|
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
|
||||||
import { vClassToggle } from '../../rail/classToggle.js';
|
import { vClassToggle } from '../../rail/classToggle.js';
|
||||||
import { vFieldValue } from '../../rail/fieldValue.js';
|
import { vFieldValue } from '../../rail/fieldValue.js';
|
||||||
import { vRangeValue } from '../../rail/rangeValue.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
|
// Only whether the receiver is connected reaches the panel; the card renders
|
||||||
// its own frames.
|
// its own frames.
|
||||||
const sdrConnected = computed(() => sdr.value?.connectionActive ?? null);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -26,7 +35,7 @@ const sdrConnected = computed(() => sdr.value?.connectionActive ?? null);
|
|||||||
<span class="panel-title" data-i18n>RADIO</span>
|
<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 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>
|
<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>
|
||||||
<div class="radio-directory-row">
|
<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>
|
<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 { storeToRefs } from 'pinia';
|
||||||
import { computed, shallowRef, watch } from 'vue';
|
import { computed, shallowRef, watch } from 'vue';
|
||||||
import { t } from '../../../i18n/index.js';
|
import { t } from '../../../i18n/index.js';
|
||||||
|
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
|
||||||
import RailChips from '../../rail/RailChips.vue';
|
import RailChips from '../../rail/RailChips.vue';
|
||||||
import { useStreetLevelStore } from '../../stores/streetLevel.js';
|
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-title" data-i18n>STREET LEVEL</span>
|
||||||
<span class="panel-divider"></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 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>
|
||||||
<div id="sl-body" class="sl-body">
|
<div id="sl-body" class="sl-body">
|
||||||
<div id="sl-viewer-wrap" class="sl-viewer-wrap" :hidden="!viewer.open">
|
<div id="sl-viewer-wrap" class="sl-viewer-wrap" :hidden="!viewer.open">
|
||||||
|
|||||||
Reference in new issue
Block a user