diff --git a/CHANGELOG.md b/CHANGELOG.md index 198b15a5f..44bbcc175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,11 @@ that there is no panel to open instead of writing its classes (samehkhamis). +- The Cockpit Contact and briefing cards' collapse renders from the + `gev-cockpit` store: the controller publishes `contextCollapsed` and + `signalCollapsed` in its presence, and each card's `data-collapsed` and + its toggle's state, words and icon follow it (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 b0b9448ae..402010caa 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -240,9 +240,11 @@ a feature supplying only its accent. publishes which surfaces are shown: the entry button and TR-3B chip in the Context panel, the HUD with its exit and reset buttons and briefing card, and the Contact card. Focus moves to the exit button, and back to - the entry, only after Vue renders them. The controls keep the - controller's listeners, and the cards' collapse and layout stay with the - controller, which reads their visibility when it lays them out. + the entry, only after Vue renders them. The cards' collapse is part of + that presence too: each card's `data-collapsed` and its toggle's words + and icon render from it. The controls keep the controller's listeners, + and the cards' layout stays with the controller, which reads their + visibility when it lays them out. `CockpitUtilityControls` renders the Display and Radio launchers, which popover is open and the compact Radio controls from the `gev-radio` store that RadioControls publishes to; the diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs index e8b78e4bc..c5a94018f 100644 --- a/scripts/qa-cockpit-utility.mjs +++ b/scripts/qa-cockpit-utility.mjs @@ -2087,7 +2087,7 @@ try { } await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('tactical')); - const signalTransition = await page.evaluate(() => { + const signalTransition = await page.evaluate(async () => { const manager = window.__godsEyeView.styleManager; const cockpit = manager.cockpitView; const signalToggle = document.getElementById('cockpit-signal-toggle'); @@ -2114,6 +2114,8 @@ try { manager._setCockpitDisclosure('radio', true); manager._setCockpitDisclosure('radio', false); const manualCollapseRetained = cockpit.signalCollapsed && cockpit.signalUserCollapsed; + // The toggle renders the published collapse on Vue's next flush. + await new Promise((resolve) => requestAnimationFrame(resolve)); return { afterExpansion, afterRepeatedExpansion, @@ -2140,7 +2142,7 @@ try { && signalTransition.signalAriaExpanded === 'false', JSON.stringify(signalTransition), ); - const contextTransition = await page.evaluate(() => { + const contextTransition = await page.evaluate(async () => { const cockpit = window.__godsEyeView.styleManager.cockpitView; const priorCollapsed = cockpit.contextCollapsed; const contextToggle = document.getElementById('cockpit-context-toggle'); @@ -2152,6 +2154,8 @@ try { cockpit.setContextCollapsed(false); const afterExpansion = expandedEvents; cockpit.setContextCollapsed(false); + // The toggle renders the published collapse on Vue's next flush. + await new Promise((resolve) => requestAnimationFrame(resolve)); return { afterExpansion, afterRepeatedExpansion: expandedEvents, diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 9d1b5973c..5845ca9c2 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -1197,9 +1197,13 @@ test('cockpit summary presents the focused item as Contact', () => { ); assert.match(match[0], /aria-label="Collapse Contact panel"/); assert.doesNotMatch(match[0], />GLOBAL CONTEXT { await nextTick(); assert.equal(text('#cockpit-brief-kicker'), 'НОВОСТИ РЕГИОНА'); }); + +test('the card and its toggle render the collapse the controller presents', async (t) => { + const { $, owner } = fixture(t); + const store = useCockpitStore(); + Object.assign(owner, { + active: true, + signalCollapsed: false, + presence: { active: true, signalCollapsed: false }, + publishPresenceView: () => store.publishPresence({ ...owner.presence }), + }); + const card = $('#cockpit-signal-stream'); + const toggle = $('#cockpit-signal-toggle'); + assert.equal(card.hasAttribute('data-collapsed'), false); + assert.equal(toggle.getAttribute('aria-expanded'), 'true'); + setSignalCollapsed.call(owner, true, { user: true }); + await nextTick(); + assert.equal(card.dataset.collapsed, 'true'); + assert.equal(toggle.getAttribute('aria-expanded'), 'false'); + assert.equal(toggle.getAttribute('aria-label'), 'Expand cockpit briefing panel'); + assert.equal(toggle.title, 'Expand briefing panel'); + assert.equal(toggle.textContent.trim(), 'right_panel_open'); + assert.equal(owner.signalUserCollapsed, true); + setSignalCollapsed.call(owner, false); + await nextTick(); + assert.equal(card.dataset.collapsed, 'false'); + assert.equal(toggle.textContent.trim(), 'right_panel_close'); +}); diff --git a/src/ui/cockpitContext.test.mjs b/src/ui/cockpitContext.test.mjs index 7b0ad20f3..47d3719c7 100644 --- a/src/ui/cockpitContext.test.mjs +++ b/src/ui/cockpitContext.test.mjs @@ -13,6 +13,7 @@ import { } from './cockpitContext.js'; import { createContextState } from './cockpitContextState.js'; import { syncWeatherToggle } from './cockpitInstruments.js'; +import { setContextCollapsed } from './cockpitLayout.js'; import { useCockpitStore } from './stores/cockpit.js'; import CockpitContext from './views/panels/CockpitContext.vue'; @@ -218,11 +219,12 @@ test('the weather toggle renders its state and the action it offers', async (t) assert.equal(owner.contextView.weather, true); }); -test('re-renders leave the card visibility and layout to the controller', async (t) => { +test('re-renders keep the collapse the controller presents and leave it the layout', async (t) => { const { $, owner } = fixture(t, snapshot()); updateContext.call(owner, INFO, 0); const card = $('#cockpit-context'); - card.dataset.collapsed = 'true'; + owner.presence.contextCollapsed = true; + owner.publishPresenceView(); card.dataset.layoutMode = 'compact-bottom'; owner.snapshot = snapshot({ evaluatedAt: Date.UTC(2026, 9, 31, 12, 35) }); updateContext.call(owner, INFO, 0); @@ -230,6 +232,11 @@ test('re-renders leave the card visibility and layout to the controller', async assert.equal(card.hidden, false); assert.equal(card.dataset.collapsed, 'true'); assert.equal(card.dataset.layoutMode, 'compact-bottom'); + const toggle = $('#cockpit-context-toggle'); + assert.equal(toggle.getAttribute('aria-expanded'), 'false'); + assert.equal(toggle.getAttribute('aria-label'), 'Expand Contact panel'); + assert.equal(toggle.title, 'Expand contact panel'); + assert.equal(toggle.textContent.trim(), 'chevron_right'); }); test('the card is labelled in the UI language', async (t) => { @@ -248,3 +255,26 @@ test('the card is labelled in the UI language', async (t) => { translate('Disable cockpit weather effects'), ); }); + +test('collapsing the card publishes once per change', async (t) => { + const { $, owner } = fixture(t); + let published = 0; + const publish = owner.publishPresenceView; + Object.assign(owner, { + contextCollapsed: false, + presence: { context: true, contextCollapsed: false }, + publishPresenceView: () => { + published += 1; + publish(); + }, + }); + setContextCollapsed.call(owner, true); + setContextCollapsed.call(owner, true); + assert.equal(published, 1); + await nextTick(); + assert.equal($('#cockpit-context').dataset.collapsed, 'true'); + assert.equal( + $('#cockpit-context-toggle').getAttribute('aria-expanded'), + 'false', + ); +}); diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index 8b62e3b51..d8cb65dd2 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -156,6 +156,9 @@ export class CockpitViewController { entryAvailable: false, tr3b: null, context: false, + // The Contact and briefing cards' collapse, which their toggles show. + contextCollapsed: false, + signalCollapsed: false, }; this.instruments = createInstrumentState(); this.routeView = createRouteState(); diff --git a/src/ui/cockpitLayout.js b/src/ui/cockpitLayout.js index 4f0759d6a..9e1dad26f 100644 --- a/src/ui/cockpitLayout.js +++ b/src/ui/cockpitLayout.js @@ -1,5 +1,4 @@ /** Measure Cockpit panels against visible HUD and viewport obstacles. */ -import { t, msg } from '../i18n/index.js'; import { resolveCockpitUtilityAnchor, resolveCockpitUtilityLayout, @@ -35,20 +34,10 @@ export function scheduleContextLayout() { export function setContextCollapsed(collapsed) { const wasCollapsed = this.contextCollapsed; this.contextCollapsed = Boolean(collapsed); - if (this.context) - this.context.dataset.collapsed = String(this.contextCollapsed); - if (this.contextToggle) { - const expanded = !this.contextCollapsed; - this.contextToggle.setAttribute('aria-expanded', String(expanded)); - this.contextToggle.setAttribute( - 'aria-label', - expanded ? t('Collapse Contact panel') : t('Expand Contact panel'), - ); - this.contextToggle.title = t( - expanded ? msg('Collapse contact panel') : msg('Expand contact panel'), - ); - const icon = this.contextToggle.querySelector('.material-symbols-outlined'); - if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right'; + // The Contact card and its toggle render the collapse from the presence. + if (this.presence.contextCollapsed !== this.contextCollapsed) { + this.presence.contextCollapsed = this.contextCollapsed; + this.publishPresenceView(); } if (this.active && wasCollapsed && !this.contextCollapsed) { window.dispatchEvent(new CustomEvent('gev:cockpit-context-expanded')); @@ -60,25 +49,10 @@ export function setSignalCollapsed(collapsed, { user = false } = {}) { const wasCollapsed = this.signalCollapsed; this.signalCollapsed = Boolean(collapsed); if (user) this.signalUserCollapsed = this.signalCollapsed; - if (this.signalStream) - this.signalStream.dataset.collapsed = String(this.signalCollapsed); - if (this.signalToggle) { - const expanded = !this.signalCollapsed; - this.signalToggle.setAttribute('aria-expanded', String(expanded)); - this.signalToggle.setAttribute( - 'aria-label', - t( - expanded - ? msg('Collapse cockpit briefing panel') - : msg('Expand cockpit briefing panel'), - ), - ); - this.signalToggle.title = t( - expanded ? msg('Collapse briefing panel') : msg('Expand briefing panel'), - ); - const icon = this.signalToggle.querySelector('.material-symbols-outlined'); - if (icon) - icon.textContent = expanded ? 'right_panel_close' : 'right_panel_open'; + // The briefing card and its toggle render the collapse from the presence. + if (this.presence.signalCollapsed !== this.signalCollapsed) { + this.presence.signalCollapsed = this.signalCollapsed; + this.publishPresenceView(); } if (this.signalCollapsed) this.stopBriefRotation(); else this.startBriefRotation({ reset: true }); diff --git a/src/ui/views/panels/CockpitBriefing.vue b/src/ui/views/panels/CockpitBriefing.vue index b13956f94..da0165603 100644 --- a/src/ui/views/panels/CockpitBriefing.vue +++ b/src/ui/views/panels/CockpitBriefing.vue @@ -1,6 +1,7 @@