From dd3a2c87f9fb618a284a5b1b80cb5a58c8de21ae Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 23:30:08 -0700 Subject: [PATCH] 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. --- CHANGELOG.md | 8 + docs/VUE-MIGRATION.md | 18 +- docs/panel-surfaces.md | 29 +-- scripts/package-boundaries.json | 17 +- src/contextTabKeyboard.test.mjs | 4 +- src/mapSourceFocus.test.mjs | 26 +-- src/panelEscape.test.mjs | 24 +-- src/panelStackLayout.test.mjs | 4 +- src/radioMarkup.test.mjs | 3 +- src/streetLevelMarkup.test.mjs | 7 +- src/ui/kit/UiButton.vue | 47 +++++ src/ui/panelChrome.js | 141 +++++-------- src/ui/panelChromeButtons.test.mjs | 240 +++++++++++++++++++++++ src/ui/panelDisclosure.js | 29 ++- src/ui/rail/PanelCollapseButton.vue | 66 +++++++ src/ui/shellFacade.js | 9 - src/ui/shellLifecycle.test.mjs | 24 +-- src/ui/stores/panelChrome.js | 83 ++++++++ src/ui/views/CommandDockView.vue | 34 +++- src/ui/views/ContextView.vue | 7 +- src/ui/views/LayerPanelsView.vue | 5 +- src/ui/views/panels/CctvPanel.vue | 3 +- src/ui/views/panels/DisplayPanel.vue | 5 +- src/ui/views/panels/RadioPanel.vue | 11 +- src/ui/views/panels/StreetLevelPanel.vue | 3 +- 25 files changed, 635 insertions(+), 212 deletions(-) create mode 100644 src/ui/kit/UiButton.vue create mode 100644 src/ui/panelChromeButtons.test.mjs create mode 100644 src/ui/rail/PanelCollapseButton.vue create mode 100644 src/ui/stores/panelChrome.js diff --git a/CHANGELOG.md b/CHANGELOG.md index da0f93ac7..b33f11f20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,14 @@ panel: ESC still stops a playing scene and the runtime chip still shows (samehkhamis). +- The shell's panel chrome no longer writes the panels' collapse buttons, + the dock tray toggles' names or the dock pins' pressed state: it presents + each panel's open and pinned state to the `gev-panel-chrome` store, and + the buttons, now views, call it there. `bindPanelDisclosure` + (`./ui/panels`) binds only a panel's Escape; it no longer takes `buttons` + or `onChange`. `UiButton` joins the component kit with ui-vue's props + (samehkhamis). + ## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language - Show a Street Level view facing 359.5° or more as 0°. The viewer caption diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index ffafe08c7..fafca91a9 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -36,11 +36,14 @@ one-panel rule too. Share links continue to record which panels are open. ## Component kit -`src/ui/kit/` holds the generic components (`UiRail`, `UiPanel`, `UiButton`, -`UiChip`, `UiTooltip` and so on). Names, props and events follow Halfpixel's -`ui-vue` kit so the two can converge later, but the components are written for -this repository and styled by its existing stylesheet and custom properties. -They contain no application state. +`src/ui/kit/` holds the generic components: `UiRail`, `UiTooltip`, `UiIcon` +and `UiButton` so far, with more (`UiPanel`, `UiSegmented`, `UiSelect` and +so on) joining as the controls they serve are converted. Names, props, +events and class names follow Halfpixel's `ui-vue` kit so the two can merge +later, but the components are written for this repository and styled by its +existing stylesheet and custom properties; a kit class such as +`ui-button--icon` carries no Core style yet, and a converted control keeps +its own classes beside it. They contain no application state. Controls share one skin, so every panel reads the same. Every scrolling surface carries the kit's `.ui-scroll` (ui-vue's name), which draws the thin @@ -61,7 +64,10 @@ a feature supplying only its accent. when a view needs one, mirroring state an owner already publishes, so there is never a second owner of the same state. 2. **Rail.** `PanelChrome` enforces one panel per side; the panel store - mirrors each side's panels and the rail renders from it. Done. + mirrors each side's panels and the rail renders from it. Each panel's + collapse button (`PanelCollapseButton`), the dock tray toggles and the + dock pins render the open and pinned state PanelChrome presents to the + `gev-panel-chrome` store, and their clicks call it there. Done. 3. **Panels.** Each template and its wiring become a component, keeping ids, classes, `data-i18n` text and CSS. The page places each view with a `.gev-view` host (`display: contents`), and `mountApplicationViews()` diff --git a/docs/panel-surfaces.md b/docs/panel-surfaces.md index 2d18023e7..e84865888 100644 --- a/docs/panel-surfaces.md +++ b/docs/panel-surfaces.md @@ -12,7 +12,7 @@ A rail panel uses an outer owner with its existing stable `id`, matching `data-panel-id`, and `panel-collapsible` class. Its first non-glow child is the surface. Keep the header and any outward popup outside the scrolling body: -```html +```vue

Example

- +
@@ -92,12 +90,15 @@ Do not copy the rail example's `.panel-collapse-btn` class into the dock: existing dock rules intentionally hide that class. The generic surface skin does not replace the dock's disclosure policy. -The existing `PanelChrome` binds `.panel-collapse-btn[data-collapse-target]` -at initialization and routes state through `setPanelCollapsed`. For additions +`PanelCollapseButton` (`src/ui/rail/`) renders a panel's collapse button +(`.panel-collapse-btn`) from the open state `PanelChrome` presents to the +`gev-panel-chrome` store, and its click asks `PanelChrome` to toggle the +panel through `setPanelCollapsed`. `PanelChrome` collects the buttons' +targets at initialization and binds each panel's Escape. For additions present at startup, keep those hooks and the existing chrome lifecycle. A -dynamically mounted panel must bind and dispose its own disclosure using -`bindPanelDisclosure` / `collapsePanelOnEscape` from `panelDisclosure.js`, with -the application owning state changes. Do not rerun the whole application chrome +dynamically mounted panel must own its disclosure: its own state, button and +Escape, bound and disposed with `bindPanelDisclosure` / +`collapsePanelOnEscape` from `panelDisclosure.js` (which bind Escape only). Do not rerun the whole application chrome initializer just to add one panel. `layoutLeftPanelRail` and `layoutRightPanelRail` accept arbitrary direct `data-panel-id` children; their host owns layout scheduling, observations and collapse callbacks. Dynamic diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index bf6824c17..0d237d534 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -119,7 +119,10 @@ "src/ui/stores/hud.js", "src/ui/views/IntelHudView.vue", "src/ui/stores/firstRun.js", - "src/firstRunCopy.js" + "src/firstRunCopy.js", + "src/ui/kit/UiButton.vue", + "src/ui/stores/panelChrome.js", + "src/ui/rail/PanelCollapseButton.vue" ], "external": ["vue", "pinia"] }, @@ -1044,7 +1047,8 @@ "src/ui/stores/celestialRing.js", "src/ui/stores/worldOverlayActions.js", "src/ui/stores/awarenessDirection.js", - "src/ui/stores/hud.js" + "src/ui/stores/hud.js", + "src/ui/stores/panelChrome.js" ], "external": ["cesium", "vue", "pinia"] }, @@ -3049,7 +3053,8 @@ "src/hudReadouts.js", "src/ui/stores/hud.js", "src/ui/stores/firstRun.js", - "src/firstRunCopy.js" + "src/firstRunCopy.js", + "src/ui/stores/panelChrome.js" ], "external": [ "hls.js", @@ -3856,9 +3861,11 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/panelRailCatalog.js", - "src/ui/panelAccordion.js" + "src/ui/panelAccordion.js", + "src/ui/vueRuntime.js", + "src/ui/stores/panelChrome.js" ], - "external": [] + "external": ["vue", "pinia"] }, "civil-flight-records": { "exports": ["./layers/flights/records"], diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index adc176196..71c1b8aa6 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -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(/
([\s\S]*?)<\/div>\s*
`${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']); }); diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs index 5ce67ab5b..e843a077d 100644 --- a/src/mapSourceFocus.test.mjs +++ b/src/mapSourceFocus.test.mjs @@ -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); }); diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs index 0f4a1ca6c..75eba753d 100644 --- a/src/panelEscape.test.mjs +++ b/src/panelEscape.test.mjs @@ -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(); }); diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index 8e14c2266..970de246e 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -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( diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index c4a480b48..44d509db5 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -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\)/); }); diff --git a/src/streetLevelMarkup.test.mjs b/src/streetLevelMarkup.test.mjs index 18a61de42..5cf9ed0a5 100644 --- a/src/streetLevelMarkup.test.mjs +++ b/src/streetLevelMarkup.test.mjs @@ -22,7 +22,7 @@ test('Street Level is a collapsible right-rail panel that starts collapsed', () ); assert.match( html, - /
+
+ `; + 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 = ''; + 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 = ''; + 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, + ); + } +}); diff --git a/src/ui/panelDisclosure.js b/src/ui/panelDisclosure.js index 42715bd50..18026df4a 100644 --- a/src/ui/panelDisclosure.js +++ b/src/ui/panelDisclosure.js @@ -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() }; } diff --git a/src/ui/rail/PanelCollapseButton.vue b/src/ui/rail/PanelCollapseButton.vue new file mode 100644 index 000000000..bbd94226d --- /dev/null +++ b/src/ui/rail/PanelCollapseButton.vue @@ -0,0 +1,66 @@ + + + diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index aeaebb0fd..4939d2861 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -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. diff --git a/src/ui/shellLifecycle.test.mjs b/src/ui/shellLifecycle.test.mjs index fac9316a0..feebf1826 100644 --- a/src/ui/shellLifecycle.test.mjs +++ b/src/ui/shellLifecycle.test.mjs @@ -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); diff --git a/src/ui/stores/panelChrome.js b/src/ui/stores/panelChrome.js new file mode 100644 index 000000000..21f0d3e7f --- /dev/null +++ b/src/ui/stores/panelChrome.js @@ -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 }; +}); diff --git a/src/ui/views/CommandDockView.vue b/src/ui/views/CommandDockView.vue index f6cd5219e..299ca5129 100644 --- a/src/ui/views/CommandDockView.vue +++ b/src/ui/views/CommandDockView.vue @@ -1,10 +1,12 @@