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 @@