Open the cockpit panels from the rail in one step and give focus back to it

The rail's buttons are disclosures (aria-expanded, aria-controls) and go
through one toggleCockpitUtility action, which already keeps one panel open.
Collapsing returns focus to the rail when the launcher's toggle is hidden.
The edge switch is gone: edge mode does not apply in the cockpit.

Co-authored-by: Manjunath Paparaju <[email protected]>
This commit is contained in:
Sameh KhamisandManjunath Paparaju committed 2026-11-14 15:51:13 -08:00
1 parent 0c295ea770
commit 95a0be13b6
4 files changed
+112 -48

No files matched your search

+39 -18
View File
@@ -26,10 +26,7 @@ import {
setSignalCollapsed,
} from './ui/cockpitLayout.js';
import { createBriefState } from './ui/cockpitBriefPages.js';
import {
publishBriefView,
setBriefAutoRotate,
} from './ui/cockpitBriefing.js';
import { publishBriefView, setBriefAutoRotate } from './ui/cockpitBriefing.js';
import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js';
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
import { clearSelectedLayers } from './ui/contextActions.js';
@@ -438,13 +435,10 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
/data-cockpit-launcher="radio"[\s\S]*?id="cockpit-radio-toggle-btn"/,
);
const cockpitRadio = html.match(
/id="cockpit-radio-panel"[\s\S]*?<\/div>\s*<\/div>\s*<\/aside>/,
/id="cockpit-radio-panel"[\s\S]*?<\/div>\s*<\/div>\s*<div class="cockpit-utility-rail/,
);
assert.ok(cockpitRadio, 'Cockpit compact Radio controls are missing');
assert.doesNotMatch(
cockpitRadio[0],
/radio-filter|radio-tuner/,
);
assert.doesNotMatch(cockpitRadio[0], /radio-filter|radio-tuner/);
assert.match(
css,
/\.cockpit-utility-controls[\s\S]*?top:\s*var\(\s*--cockpit-utility-top/,
@@ -811,18 +805,27 @@ test('Reset releases Contact camera ownership through its selection-preserving r
);
assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/);
assert.match(ui, /this\._connectGlobeReset\?\.\(this\._locationControls\)/);
assert.match(controls, /reset\(\) \{\s*if \(!this\.destroyed\) return this\.onReset\(\);/);
assert.match(
controls,
/reset\(\) \{\s*if \(!this\.destroyed\) return this\.onReset\(\);/,
);
// Both reset buttons ask the one reset owner.
const globeActions = fs.readFileSync(
path.join(ROOT, 'src', 'ui', 'views', 'panels', 'GlobeActions.vue'),
'utf8',
);
assert.match(globeActions, /id="reset-globe-view"[^>]*@click="act\('reset'\)"/);
assert.match(
globeActions,
/id="reset-globe-view"[^>]*@click="act\('reset'\)"/,
);
const cockpitView = readRadioSource(
new URL('./ui/views/CockpitView.vue', import.meta.url),
'utf8',
);
assert.match(cockpitView, /id="cockpit-reset-globe"[^>]*@click="globe\.act\('reset'\)"/);
assert.match(
cockpitView,
/id="cockpit-reset-globe"[^>]*@click="globe\.act\('reset'\)"/,
);
assert.match(
cockpitEnter.toString(),
/Object\.assign\(this\.presence, \{\s*active: true,/,
@@ -1193,7 +1196,10 @@ test('cockpit summary presents the focused item as Contact', () => {
const match = html.match(/<aside id="cockpit-context"[\s\S]*?<\/aside>/);
assert.ok(match, 'cockpit Contact summary is missing');
assert.match(match[0], /aria-label="Contact cockpit summary"/);
assert.match(match[0], /class="cockpit-card-kicker cockpit-context-kicker">CONTACT</);
assert.match(
match[0],
/class="cockpit-card-kicker cockpit-context-kicker">CONTACT</,
);
assert.match(match[0], /aria-label="Contact navigation"/);
assert.match(
match[0],
@@ -1408,15 +1414,30 @@ test('Contact folded for one Cockpit left panel comes back only once neither is
const previous = globalThis.document;
const panels = {};
globalThis.document = { getElementById: (id) => panels[id] ?? null };
const panel = (...classes) => ({ classList: { contains: (name) => classes.includes(name) } });
const panel = (...classes) => ({
classList: { contains: (name) => classes.includes(name) },
});
try {
const otherOpen = (id) => PanelChrome.prototype._otherCockpitLeftPanelOpen(id);
const otherOpen = (id) =>
PanelChrome.prototype._otherCockpitLeftPanelOpen(id);
panels['data-panel'] = panel('collapsed');
assert.equal(otherOpen('utilities-panel'), false, 'Data Layers closed: restore');
assert.equal(
otherOpen('utilities-panel'),
false,
'Data Layers closed: restore',
);
panels['data-panel'] = panel('collapsed', 'layout-auto-collapsed');
assert.equal(otherOpen('utilities-panel'), true, 'Data Layers comes back: keep Contact folded');
assert.equal(
otherOpen('utilities-panel'),
true,
'Data Layers comes back: keep Contact folded',
);
panels['data-panel'] = panel();
assert.equal(otherOpen('utilities-panel'), true, 'Data Layers open: keep it folded');
assert.equal(
otherOpen('utilities-panel'),
true,
'Data Layers open: keep it folded',
);
} finally {
globalThis.document = previous;
}
+39
View File
@@ -450,3 +450,42 @@ test('the real layout pass drives the mounted controls: compact hides the collap
assert.equal(cockpit.utilityLayout.top, '120.00px');
assert.equal(strip.classList.contains('layout-primary-only'), false);
});
test('the rail opens one panel at a time and reads as a disclosure', async (t) => {
const { $, controls } = fixture(t);
const item = (kind) =>
document.querySelector(`[data-cockpit-rail-item="${kind}"]`);
item('display').click();
await nextTick();
assert.equal($('cockpit-display-panel').hidden, false);
assert.equal(item('display').getAttribute('aria-expanded'), 'true');
assert.equal(
item('display').getAttribute('aria-controls'),
'cockpit-display-panel',
);
item('radio').click();
await nextTick();
assert.equal($('cockpit-display-panel').hidden, true);
assert.equal($('cockpit-radio-panel').hidden, false);
assert.equal(item('display').getAttribute('aria-expanded'), 'false');
assert.equal(item('radio').getAttribute('aria-expanded'), 'true');
item('radio').click();
await nextTick();
assert.equal(controls._cockpitDisclosureOpen('radio'), false);
assert.equal(controls._cockpitDisclosureOpen('display'), false);
});
test('collapsing an open panel hands focus to the rail when its toggle is hidden', async (t) => {
const { $, controls } = fixture(t);
const railItem = document.querySelector('[data-cockpit-rail-item="radio"]');
// happy-dom lays nothing out: show the rail and hide the launcher's toggle
// as the desktop look does.
railItem.getClientRects = () => [{ width: 32, height: 32 }];
$('cockpit-radio-toggle-btn').getClientRects = () => [];
controls._setCockpitDisclosure('radio', true);
await nextTick();
document.querySelector('.cockpit-utility-collapse').click();
await nextTick();
assert.equal($('cockpit-radio-panel').hidden, true);
assert.equal(document.activeElement, railItem);
});
+22 -4
View File
@@ -27,10 +27,18 @@ export function bindRadioControls() {
this.store?.publishUtility({ ...this._cockpitDisclosure });
if (displayOpen) this.actions.revealStyleParameters();
if (!expanded && returnFocus) {
(kind === 'display'
? this._cockpitDisplayToggleBtn
: this._cockpitRadioToggleBtn
)?.focus({ preventScroll: true });
// The desktop cockpit hides the launchers' toggles behind its rail, so
// focus goes to whichever of the two is shown.
const toggle =
kind === 'display'
? this._cockpitDisplayToggleBtn
: this._cockpitRadioToggleBtn;
const railItem = toggle?.ownerDocument?.querySelector?.(
`[data-cockpit-rail-item="${kind}"]`,
);
(toggle?.getClientRects?.().length ? toggle : railItem || toggle)?.focus({
preventScroll: true,
});
}
if (
!displayOpen &&
@@ -444,6 +452,16 @@ export function bindRadioControls() {
),
toggleCockpitRadio: () =>
setCockpitDisclosure('radio', !this._cockpitDisclosureOpen('radio')),
/**
* Open or close the cockpit's Display or Radio panel; opening one closes
* the other.
* @param {'display'|'radio'} kind
* @param {{ returnFocus?: boolean }} [options]
*/
toggleCockpitUtility: (kind, { returnFocus = false } = {}) => {
const open = !this._cockpitDisclosureOpen(kind);
setCockpitDisclosure(kind, open, { returnFocus: !open && returnFocus });
},
/**
* @param {string} filter
* @returns {string} The station tag the filter shows afterwards.
+12 -26
View File
@@ -8,25 +8,13 @@ import { useRadioStore } from '../../stores/radio.js';
import { useCockpitStore } from '../../stores/cockpit.js';
import { useDisplayStore } from '../../stores/display.js';
import UiButton from '../../kit/UiButton.vue';
import UiPanelEdge from '../../kit/UiPanelEdge.vue';
// The open panel's header carries the same edge switch and collapse button as
// the main panels; collapsing goes through each launcher's own toggle.
function collapse(which) {
if (which === 'radio') act('toggleCockpitRadio');
else document.getElementById('cockpit-display-toggle-btn')?.click();
}
// The rail opens one panel at a time, as the main page's rails do.
function railToggle(which) {
const other = which === 'radio' ? 'display' : 'radio';
if (open.value[other]) collapse(other);
collapse(which);
}
// The Cockpit's Display and Radio launchers and their popovers. RadioControls
// presents which popover is open and the compact Radio controls; until it
// does, both are closed and the controls show their initial state. The
// controls call RadioControls through the store. The Display popover's slots
// The Cockpit's Display and Radio launchers and their popovers, and the
// desktop look's rail, whose icons stay at the edge and open one panel at a
// time as the main page's rails do. RadioControls presents which popover is
// open and the compact Radio controls; until it does, both are closed and the
// controls show their initial state. The controls call RadioControls through
// the store. The Display popover's slots
// are static: the Display panel teleports its real controls into them while
// the Cockpit display portal publishes them.
const store = useRadioStore();
@@ -66,8 +54,7 @@ const radioLabel = computed(() =>
<div class="cockpit-utility-launcher" data-cockpit-launcher="display">
<span>{{ t('DISPLAY') }}</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
<UiPanelEdge v-if="open.display" />
<button v-if="open.display" type="button" class="cockpit-utility-collapse" :aria-label="t('Collapse panel')" :title="t('Collapse panel')" @click="collapse('display')">−</button>
<button v-if="open.display" type="button" class="cockpit-utility-collapse" :aria-label="t('Collapse panel')" :title="t('Collapse panel')" @click="act('toggleCockpitUtility', 'display', { returnFocus: true })">−</button>
<button id="cockpit-display-toggle-btn" class="cockpit-utility-glyph" type="button" :aria-expanded="String(open.display)" aria-controls="cockpit-display-panel" :aria-label="displayLabel" :title="displayLabel">{{ open.display ? '▶' : '◀' }}</button>
</div>
<div id="cockpit-display-panel" class="cockpit-utility-popover ui-scroll" v-class-toggle="{ 'uses-shared-display-controls': !!cockpitPortal }" role="group" :aria-label="t('Cockpit display options')" :hidden="!open.display">
@@ -81,8 +68,7 @@ const radioLabel = computed(() =>
<div class="cockpit-utility-launcher" data-cockpit-launcher="radio">
<span>{{ t('RADIO') }}</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
<UiPanelEdge v-if="open.radio" />
<button v-if="open.radio" type="button" class="cockpit-utility-collapse" :aria-label="t('Collapse panel')" :title="t('Collapse panel')" @click="collapse('radio')">−</button>
<button v-if="open.radio" type="button" class="cockpit-utility-collapse" :aria-label="t('Collapse panel')" :title="t('Collapse panel')" @click="act('toggleCockpitUtility', 'radio', { returnFocus: true })">−</button>
<button id="cockpit-radio-toggle-btn" @click="act('toggleCockpitRadio')" class="cockpit-utility-glyph" type="button" :aria-expanded="String(open.radio)" aria-controls="cockpit-radio-panel" :aria-label="radioLabel" :title="radioLabel">{{ open.radio ? '▶' : '◀' }}</button>
</div>
<div id="cockpit-radio-panel" class="cockpit-utility-popover cockpit-radio-compact" role="group" :aria-label="t('Cockpit compact Radio controls')" :hidden="!open.radio">
@@ -100,15 +86,15 @@ const radioLabel = computed(() =>
</label>
</div>
</div>
<nav class="cockpit-utility-rail" :class="{ 'is-open': open.display || open.radio }" :aria-label="t('Cockpit display and Radio controls')">
<div class="cockpit-utility-rail" :class="{ 'is-open': open.display || open.radio }">
<span class="cockpit-utility-rail__labels" aria-hidden="true">
<span>{{ t('DISPLAY') }}</span>
<span>{{ t('RADIO') }}</span>
</span>
<span class="cockpit-utility-rail__icons">
<button type="button" class="cockpit-utility-rail__item" :class="{ 'is-on': open.display }" :aria-pressed="String(open.display)" :aria-label="displayLabel" :title="displayLabel" @click="railToggle('display')"><span class="material-symbols-outlined" aria-hidden="true">tune</span></button>
<button type="button" class="cockpit-utility-rail__item" :class="{ 'is-on': open.radio }" :aria-pressed="String(open.radio)" :aria-label="radioLabel" :title="radioLabel" @click="railToggle('radio')"><span class="material-symbols-outlined" aria-hidden="true">radio</span></button>
<button type="button" class="cockpit-utility-rail__item" data-cockpit-rail-item="display" :class="{ 'is-on': open.display }" :aria-expanded="String(open.display)" aria-controls="cockpit-display-panel" :aria-label="displayLabel" :title="displayLabel" @click="act('toggleCockpitUtility', 'display')"><span class="material-symbols-outlined" aria-hidden="true">tune</span></button>
<button type="button" class="cockpit-utility-rail__item" data-cockpit-rail-item="radio" :class="{ 'is-on': open.radio }" :aria-expanded="String(open.radio)" aria-controls="cockpit-radio-panel" :aria-label="radioLabel" :title="radioLabel" @click="act('toggleCockpitUtility', 'radio')"><span class="material-symbols-outlined" aria-hidden="true">radio</span></button>
</span>
</nav>
</div>
</aside>
</template>