mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
fix(ui): right rail tabs: review fixes, launcher in the strip, strip never scrolls on common windows
Review fixes P1.1-P2.7: a panel hidden at mount gets its tab when revealed; any live opening of a rail panel makes it the owner, restores do not; a short lane scrolls the strip with a thin scrollbar and brings a newly opened tab into view once; the join and the panel cover stay inside the lane and the fillet shrinks to the room left; a share link carries an owner-collapsed peer as collapsed so the recipient opens the sender's panel; border-box on phones; the strip's column folds into the panel width at 721-760px; the focus ring draws inside the tab. The compact Radio launcher moves from Context's header into a tool slot under the tabs on desktop (back into the header for the phone sheet) and always toggles the compact player, also while Context is open; the player's Details button leads to the Radio section. The jump-to-section role, its two labels and the #804 closings of the player on Context expansion and rail layout passes are gone with it. The rail panels' own collapse arrows are hidden on desktop: the tab opens and closes a panel, Escape inside closes it and returns focus to the tab. The tabs are denser and the rail climbs toward the HUD corner above its left-rail baseline when the strip's column is taller than the lane, so the strip no longer scrolls on windows down to about 680px tall; the measuring pass keeps the strip's scroll offset. scripts/qa-radio.mjs expectations follow the launcher's new role. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
ff017c99ca
commit
bff1d0eb29
24 files changed
+804
-379
No files matched your search
@@ -2,7 +2,13 @@
|
||||
Imagery while shown) is a vertical tab strip with sideways labels instead of
|
||||
an accordion of collapsed launcher cards: at most one panel is open in every
|
||||
desktop HUD layout, and the open tab and its panel share one surface and
|
||||
outline, like a folder tab. A first-time visitor on a desktop finds Display
|
||||
open; a stored choice still wins. The strip is a keyboard-navigable tablist
|
||||
and Escape inside a panel returns focus to its tab. Saved preferences, share
|
||||
links and the phone sheet shell are unchanged (gekh, #1425).
|
||||
outline, like a folder tab. On a short window the rail rises toward the HUD
|
||||
corner above it so the whole strip stays in view. A first-time visitor on a
|
||||
desktop finds Display open; a stored choice still wins. The panels' own
|
||||
collapse arrows are gone on desktop: the tab opens and closes a panel, and
|
||||
Escape inside one closes it and returns focus to its tab; the strip is a
|
||||
keyboard-navigable tablist. The compact Radio launcher moves from Context's
|
||||
header to the bottom of the strip on desktop and always opens the compact
|
||||
player, also while Context is open; the player's Details button leads to the
|
||||
Radio section. Saved preferences, share links and the phone sheet shell are
|
||||
unchanged (gekh, #1425).
|
||||
+24
-11
@@ -1478,14 +1478,16 @@ The left rail's automatic collapse remains presentation only and prioritizes
|
||||
the latest explicit panel. The right rail keeps one expanded owner (the latest
|
||||
explicit panel, else the focused one, else one with a stored or shared choice
|
||||
over one open only by default, else the first) and presentation-collapses its
|
||||
peers with `rail-owner-collapsed`, which share links and the phone shell read
|
||||
as "expanded". Mobile layout still releases desktop allocation styles and gives
|
||||
every owner-collapsed panel back. Stable Display allocation avoids unnecessary
|
||||
style writes.
|
||||
Context expansion through the panel owner or adaptive right-rail restoration
|
||||
closes an open compact Radio disclosure before Context/Space Missions becomes
|
||||
visible. The mini remains available while Context is collapsed. This fixes the
|
||||
Radio-overlap path reported in #804 without changing z-index or layout policy.
|
||||
peers with `rail-owner-collapsed`, which the phone shell reads as "expanded"
|
||||
and a share link carries as collapsed, so a recipient opens the panel the
|
||||
sender sees. Any live opening of a rail panel, with or without the explicit
|
||||
flag (a camera click, Street Level switching on, a radio pick), makes that
|
||||
panel the owner. Mobile layout still releases desktop allocation styles and
|
||||
gives every owner-collapsed panel back. Stable Display allocation avoids
|
||||
unnecessary style writes.
|
||||
The compact Radio player is independent of Context: its launcher toggles it
|
||||
whether Context is collapsed or open, and only an outside pointer, Escape, its
|
||||
close button or its Details button closes it, never a layout pass.
|
||||
The right rail skips panels hidden by other CSS (computed `display: none`), but
|
||||
keeps the collapsed panels its own exclusive presentation hides, so the owner
|
||||
rule can still give them back.
|
||||
@@ -1500,11 +1502,22 @@ panels hidden in every HUD layout. A tab toggles its panel through
|
||||
`setPanelCollapsed` with the explicit flag, so opening one collapses the others
|
||||
and saved preferences, share links and the Context/Radio ownership rules are
|
||||
unchanged; the panels' `collapsed` classes remain the only state, mirrored
|
||||
through a MutationObserver. The strip is a vertical `role="tablist"`: Up/Down
|
||||
through a MutationObserver. On desktop the panels' own header collapse
|
||||
buttons are hidden: the tab is the one control, Escape its keyboard twin.
|
||||
The strip is a vertical `role="tablist"`: Up/Down
|
||||
wrap, Home/End jump, the open tab (else the first) is the tab stop, Escape
|
||||
inside a panel returns focus to its tab, and the strip precedes the panels in
|
||||
the DOM so Tab from the open tab enters the panel. Labels are the panels' own
|
||||
translated header titles. The strip marks the rail with `data-rail-tabs`, which
|
||||
the DOM so Tab from the open tab reaches the tool slot and then the panel.
|
||||
Labels are the panels' own translated header titles. The rail's lane shares
|
||||
the left rail's top; when it is too short for the strip, the rail climbs toward
|
||||
the HUD corner above as far as the strip needs (`src/ui/rightPanelRail.js`),
|
||||
and only a lane still shorter scrolls the strip, with a thin scrollbar and the
|
||||
open tab kept in view. Under the tabs the strip's
|
||||
column keeps a tool slot (`#right-rail-tools`) that holds the compact Radio
|
||||
launcher on desktop: `src/ui/radioPanelPlacement.js` moves it there from
|
||||
Context's header, and back for the phone sheet, so the compact player stays one
|
||||
click away; the player's Details button opens the Radio section inside Context.
|
||||
The strip marks the rail with `data-rail-tabs`, which
|
||||
the stylesheet keys the strip's column on; a rail without the strip keeps its
|
||||
plain geometry. Phones hide the strip and keep the sheet shell; Cockpit hides
|
||||
the rail and the strip with it.
|
||||
|
||||
+29
-21
@@ -610,9 +610,9 @@ async function main() {
|
||||
&& enablingPresentation.presentationActive === false
|
||||
&& enablingPresentation.markerSourceVisible === false
|
||||
&& enablingPresentation.full === 'ENABLING'
|
||||
&& enablingPresentation.disclosure === 'Go to expanded Radio section'
|
||||
&& enablingPresentation.disclosureControls === 'radio-panel'
|
||||
&& enablingPresentation.disclosureExpanded === 'true'
|
||||
&& enablingPresentation.disclosure === 'Open compact Radio controls'
|
||||
&& enablingPresentation.disclosureControls === 'context-radio-mini'
|
||||
&& enablingPresentation.disclosureExpanded === 'false'
|
||||
&& enablingPresentation.compactPower === 'ENABLING'
|
||||
&& enablingPresentation.layerState === 'ENABLING'
|
||||
&& /enabling/i.test(enablingPresentation.message)
|
||||
@@ -1633,7 +1633,8 @@ async function main() {
|
||||
playLabel: document.getElementById('context-radio-mini-play-btn').getAttribute('aria-label'),
|
||||
nextEnabled: !document.getElementById('context-radio-mini-next-btn').disabled,
|
||||
volumeEnabled: !document.getElementById('context-radio-mini-volume').disabled,
|
||||
offsetBelowToggle: Math.round(miniRect.top - toggleRect.bottom),
|
||||
gapLeftOfLauncher: Math.round(toggleRect.left - miniRect.right),
|
||||
bottomOffset: Math.round(miniRect.bottom - toggleRect.bottom),
|
||||
};
|
||||
});
|
||||
check(
|
||||
@@ -1642,7 +1643,11 @@ async function main() {
|
||||
JSON.stringify(compact),
|
||||
);
|
||||
check('compact Play is available before a station is selected', compact.playLabel === 'Play nearest radio station');
|
||||
check('compact Radio controls sit 20px below the launcher', compact.offsetBelowToggle === 20, `${compact.offsetBelowToggle}px`);
|
||||
check(
|
||||
'compact Radio controls open left of the launcher in the strip, bottom-aligned',
|
||||
compact.gapLeftOfLauncher > 0 && Math.abs(compact.bottomOffset) <= 1,
|
||||
`gap ${compact.gapLeftOfLauncher}px, bottom offset ${compact.bottomOffset}px`,
|
||||
);
|
||||
const compactCloseDisclosure = await page.evaluate(() => {
|
||||
const launcher = document.getElementById('context-radio-toggle-btn');
|
||||
const close = document.getElementById('context-radio-mini-close-btn');
|
||||
@@ -4510,6 +4515,7 @@ async function main() {
|
||||
|
||||
const expandedContextRadioBefore = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
gev.styleManager._setRadioDisclosure(false);
|
||||
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
|
||||
scroller.scrollTop = 0;
|
||||
const camera = gev.viewer.camera.positionWC;
|
||||
@@ -4521,26 +4527,24 @@ async function main() {
|
||||
};
|
||||
});
|
||||
await page.click('#context-radio-toggle-btn');
|
||||
await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed'));
|
||||
await page.waitForFunction((priorScroll) => (
|
||||
document.activeElement?.getAttribute('data-collapse-target') === 'radio-panel'
|
||||
&& document.querySelector('#global-context-panel .global-context-panel-inner').scrollTop > priorScroll
|
||||
), { timeout: 10_000 }, expandedContextRadioBefore.scrollTop);
|
||||
await page.waitForFunction(() => !document.getElementById('context-radio-mini').hidden);
|
||||
const expandedContextRadioAfter = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
gev.styleManager._renderRadioState(gev.dataManager.layers.get('radio').module.getUIState());
|
||||
const launcher = document.getElementById('context-radio-toggle-btn');
|
||||
const radio = document.getElementById('radio-panel');
|
||||
const mini = document.getElementById('context-radio-mini');
|
||||
const miniRect = mini.getBoundingClientRect();
|
||||
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
|
||||
const camera = gev.viewer.camera.positionWC;
|
||||
return {
|
||||
ariaControls: launcher.getAttribute('aria-controls'),
|
||||
expanded: launcher.getAttribute('aria-expanded'),
|
||||
label: launcher.getAttribute('aria-label'),
|
||||
radioCollapsed: radio.classList.contains('collapsed'),
|
||||
compactHidden: document.getElementById('context-radio-mini').hidden,
|
||||
radioCollapsed: document.getElementById('radio-panel').classList.contains('collapsed'),
|
||||
contextCollapsed: document.getElementById('global-context-panel').classList.contains('collapsed'),
|
||||
compactHidden: mini.hidden,
|
||||
compactVisible: miniRect.width > 0 && miniRect.height > 0,
|
||||
scrollTop: scroller.scrollTop,
|
||||
focusTarget: document.activeElement?.getAttribute('data-collapse-target'),
|
||||
pageY: window.scrollY,
|
||||
camera: { x: camera.x, y: camera.y, z: camera.z },
|
||||
plays: window.__qaRadioPlayCalls.length,
|
||||
@@ -4553,14 +4557,15 @@ async function main() {
|
||||
expandedContextRadioAfter.camera.z - expandedContextRadioBefore.camera.z,
|
||||
);
|
||||
check(
|
||||
'expanded Context Radio icon reveals and scrolls to embedded Radio without an overlay or playback/camera theft',
|
||||
expandedContextRadioAfter.ariaControls === 'radio-panel'
|
||||
'expanded Context Radio icon opens the compact player over Context without playback/camera theft',
|
||||
expandedContextRadioAfter.ariaControls === 'context-radio-mini'
|
||||
&& expandedContextRadioAfter.expanded === 'true'
|
||||
&& expandedContextRadioAfter.label === 'Go to expanded Radio section'
|
||||
&& !expandedContextRadioAfter.radioCollapsed
|
||||
&& expandedContextRadioAfter.compactHidden
|
||||
&& expandedContextRadioAfter.scrollTop > expandedContextRadioBefore.scrollTop
|
||||
&& expandedContextRadioAfter.focusTarget === 'radio-panel'
|
||||
&& expandedContextRadioAfter.label === 'Close compact Radio controls'
|
||||
&& expandedContextRadioAfter.radioCollapsed
|
||||
&& !expandedContextRadioAfter.contextCollapsed
|
||||
&& !expandedContextRadioAfter.compactHidden
|
||||
&& expandedContextRadioAfter.compactVisible
|
||||
&& expandedContextRadioAfter.scrollTop === expandedContextRadioBefore.scrollTop
|
||||
&& expandedContextRadioAfter.pageY === expandedContextRadioBefore.pageY
|
||||
&& expandedContextRadioCameraDelta < 0.01
|
||||
&& expandedContextRadioAfter.plays === expandedContextRadioBefore.plays
|
||||
@@ -4571,9 +4576,12 @@ async function main() {
|
||||
cameraDelta: expandedContextRadioCameraDelta,
|
||||
}),
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => document.getElementById('context-radio-mini').hidden);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setPanelCollapsed('radio-panel', false);
|
||||
manager.setPanelCollapsed('global-context-panel', true);
|
||||
manager._setRadioDisclosure(true);
|
||||
});
|
||||
|
||||
@@ -2441,8 +2441,6 @@
|
||||
"Exit cockpit to fly to a route": "Sal de la cabina para volar a una ruta",
|
||||
"Exit cockpit to fly to a file": "Sal de la cabina para volar a un archivo",
|
||||
"Exit cockpit to fly to a scene": "Sal de la cabina para volar a una escena",
|
||||
"Go to expanded Radio section": "Ir a la sección de Radio expandida",
|
||||
"Expand Radio section in Context": "Expandir la sección de Radio en Contexto",
|
||||
"New scene name": "Nombre de la nueva escena",
|
||||
"Delete scene \"{scene}\" and all shots?": "¿Eliminar la escena \"{scene}\" y todos sus planos?",
|
||||
"Detailed outline unavailable": "Contorno detallado no disponible",
|
||||
|
||||
@@ -2482,8 +2482,6 @@
|
||||
"Exit cockpit to fly to a route": "Выйдите из кабины, чтобы перелететь к маршруту",
|
||||
"Exit cockpit to fly to a file": "Выйдите из кабины, чтобы перелететь к файлу",
|
||||
"Exit cockpit to fly to a scene": "Выйдите из кабины, чтобы перелететь к сцене",
|
||||
"Go to expanded Radio section": "Перейти к развёрнутому разделу радио",
|
||||
"Expand Radio section in Context": "Развернуть раздел «Радио» в панели «Контекст»",
|
||||
"New scene name": "Название новой сцены",
|
||||
"Delete scene \"{scene}\" and all shots?": "Удалить сцену «{scene}» и все кадры?",
|
||||
"Detailed outline unavailable": "Подробный контур недоступен",
|
||||
|
||||
@@ -1859,8 +1859,6 @@
|
||||
"STATION UNAVAILABLE": "电台不可用",
|
||||
"Station unavailable after directory refresh": "目录刷新后该电台不可用",
|
||||
"Radio could not {action} cleanly": "电台未能正常{action}",
|
||||
"Go to expanded Radio section": "前往展开的电台区域",
|
||||
"Expand Radio section in Context": "在态势面板中展开电台区域",
|
||||
"Close compact Radio controls": "关闭精简电台控件",
|
||||
"Open compact Radio controls": "打开精简电台控件",
|
||||
"SYNC": "同步",
|
||||
|
||||
@@ -202,8 +202,8 @@ test('share-panel state excludes responsive collapse and preserves recipient pre
|
||||
|
||||
assert.match(
|
||||
ui,
|
||||
/const collapsed =[\s\S]*?panelEl\.classList\.contains\('layout-auto-collapsed'\)[\s\S]*?panelEl\.classList\.contains\('rail-owner-collapsed'\)[\s\S]*?\? false\s*: panelEl\.classList\.contains\('collapsed'\);/,
|
||||
'responsive auto-collapse must serialize the explicit expanded preference',
|
||||
/const collapsed = panelEl\.classList\.contains\('layout-auto-collapsed'\)\s*\? false\s*: panelEl\.classList\.contains\('collapsed'\);/,
|
||||
'the left rail\'s responsive auto-collapse serializes the expanded preference; a right-rail peer hidden behind the owner travels as collapsed',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
|
||||
@@ -361,32 +361,26 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
|
||||
assert.match(enableMethod, /toggleRadio\(this\._contextRadioMiniEnableBtn\)/);
|
||||
assert.match(disclosureBindings, /contextRadioMiniCloseBtn[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: true \}\)/);
|
||||
assert.match(disclosureBindings, /contextRadioDetailsBtn[\s\S]*?setPanelCollapsed\('radio-panel', false, \{ explicit: true \}\)/);
|
||||
const launcherBinding = disclosureBindings.slice(0, disclosureBindings.indexOf('contextRadioMiniCloseBtn'));
|
||||
assert.match(
|
||||
disclosureBindings,
|
||||
/contextRadioToggleBtn[\s\S]*?!contextPanel\.classList\.contains\('collapsed'\)[\s\S]*?setRadioDisclosure\(false\)[\s\S]*?setPanelCollapsed\('radio-panel', false, \{ explicit: true \}\)[\s\S]*?_revealRadioPanelInsideContext/,
|
||||
launcherBinding,
|
||||
/setRadioDisclosure\(\s*!this\._contextRadioDock\?\.classList\.contains\('disclosure-open'\),?\s*\)/,
|
||||
);
|
||||
assert.doesNotMatch(launcherBinding, /global-context-panel|setPanelCollapsed|_revealRadioPanelInsideContext/);
|
||||
assert.doesNotMatch(disclosureBindings, /setPanelCollapsed\('radio-panel', !/);
|
||||
assert.match(ui, /setAttribute\('aria-expanded', String\(/);
|
||||
assert.match(readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8'), /\.hidden = !/);
|
||||
assert.doesNotMatch(method, /panelId === 'global-context-panel'[\s\S]*?this\._radioState\?\.enabled[\s\S]*?setPanelCollapsed\('radio-panel', false\)/);
|
||||
});
|
||||
|
||||
test('expanded Context routes its Radio icon to the embedded section and keeps collapsed Context compact', () => {
|
||||
const revealStart = radioControlsSource.indexOf('\n async _revealRadioPanelInsideContext');
|
||||
const revealEnd = radioControlsSource.indexOf('\n /**', revealStart + 10);
|
||||
const revealMethod = radioControlsSource.slice(revealStart, revealEnd);
|
||||
assert.ok(revealStart >= 0 && revealEnd > revealStart, 'embedded Radio reveal helper is missing');
|
||||
assert.match(revealMethod, /requestAnimationFrame\(\(\) => requestAnimationFrame/);
|
||||
assert.match(revealMethod, /scroller\.scrollTo\(\{\s*top: next,\s*behavior: reducedMotion \? 'auto' : 'smooth',?\s*\}\)/);
|
||||
assert.match(revealMethod, /focus\?\.\(\{ preventScroll: true \}\)/);
|
||||
assert.doesNotMatch(revealMethod, /setEnabled|togglePlayback|selectStation|setContextMode/);
|
||||
|
||||
test('the Radio launcher always toggles the compact player, whatever Context shows', () => {
|
||||
assert.doesNotMatch(radioControlsSource, /_revealRadioPanelInsideContext/);
|
||||
const syncStart = radioControlsSource.indexOf('\n _syncContextRadioLauncherState()');
|
||||
const syncEnd = radioControlsSource.indexOf('\n destroy()', syncStart + 10);
|
||||
const syncMethod = radioControlsSource.slice(syncStart, syncEnd);
|
||||
assert.ok(syncStart >= 0 && syncEnd > syncStart, 'Context Radio launcher state sync is missing');
|
||||
assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded',\s*String\(radioExpanded\)/);
|
||||
assert.match(syncMethod, /aria-controls',\s*'context-radio-mini'[\s\S]*?aria-expanded',\s*String\(compactOpen\)/);
|
||||
assert.match(syncMethod, /aria-expanded',\s*String\(compactOpen\)/);
|
||||
assert.doesNotMatch(syncMethod, /global-context-panel|radio-panel|aria-controls/);
|
||||
const renderMethod = radioPresentation;
|
||||
assert.match(renderMethod, /this\._syncContextRadioLauncherState\(\)/);
|
||||
assert.doesNotMatch(renderMethod, /compact Radio controls/);
|
||||
|
||||
@@ -115,18 +115,14 @@ export class StyleManager extends ShellFacade {
|
||||
this._panelChrome = new PanelChrome({
|
||||
elements: {
|
||||
_contextRadioDetailsBtn: this._contextRadioDetailsBtn,
|
||||
_contextRadioDock: this._contextRadioDock,
|
||||
_leftPanelStack: this._leftPanelStack,
|
||||
_locationSearch: this._locationSearch,
|
||||
_ppToggles: this._ppToggles,
|
||||
_rightPanelStack: this._rightPanelStack,
|
||||
},
|
||||
operations: {
|
||||
_setRadioDisclosure: (...args) => this._setRadioDisclosure(...args),
|
||||
_syncCctvPanelViewport: (...args) =>
|
||||
this._syncCctvPanelViewport(...args),
|
||||
_syncContextRadioLauncherState: (...args) =>
|
||||
this._syncContextRadioLauncherState(...args),
|
||||
_showToast: (...args) => this._showToast(...args),
|
||||
},
|
||||
readHud: () => this.hud,
|
||||
|
||||
@@ -41,6 +41,21 @@ function fixture(theme = 'cyber') {
|
||||
};
|
||||
}
|
||||
|
||||
test('any live opening of a rail panel makes it the preferred owner; restores do not', () => {
|
||||
const f = fixture('tactical'), prior = globalThis.document;
|
||||
globalThis.document = f.doc;
|
||||
try {
|
||||
f.owner.setPanelCollapsed('cctv-panel', false, {});
|
||||
assert.equal(f.owner._panelLayout._rightStackPreferredPanelId, 'cctv-panel', 'a camera click opens CCTV without the explicit flag');
|
||||
assert.equal(f.display.classList.contains('collapsed'), true, 'peers are left to the rail pass, not persisted');
|
||||
f.owner.setPanelCollapsed('radio-panel', false, {});
|
||||
assert.equal(f.owner._panelLayout._rightStackPreferredPanelId, 'global-context-panel', 'a radio pick owns the rail through Context');
|
||||
f.owner.setPanelCollapsed('cctv-panel', true, { explicit: true });
|
||||
f.owner.setPanelCollapsed('cctv-panel', false, { restore: true, persist: false });
|
||||
assert.equal(f.owner._panelLayout._rightStackPreferredPanelId, 'global-context-panel', 'a restore never claims ownership');
|
||||
} finally { globalThis.document = prior; }
|
||||
});
|
||||
|
||||
test('explicit Cyber opening collapses peers and claims their restoration lanes', () => {
|
||||
const f = fixture(), prior = globalThis.document;
|
||||
globalThis.document = f.doc;
|
||||
|
||||
@@ -742,7 +742,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
|
||||
);
|
||||
assert.match(
|
||||
rightRail,
|
||||
/const leftStackTop = settledLeftRailTop\([\s\S]*?const alignedTop = Number\.isFinite\(leftStackTop\)[\s\S]*?baseTop: alignedTop,/,
|
||||
/const leftStackTop = settledLeftRailTop\([\s\S]*?const alignedTop = Number\.isFinite\(leftStackTop\)[\s\S]*?layout = resolve\(alignedTop\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
|
||||
+12
-42
@@ -72,8 +72,7 @@ export class PanelChrome {
|
||||
variant: this.hud.getVariant(),
|
||||
}),
|
||||
scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
|
||||
syncPanelCollapseButton: (panel) =>
|
||||
this._syncRailPanelCollapseButton(panel),
|
||||
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
|
||||
readDisplayScrollTop: () =>
|
||||
this._displayPortalScrollRestoreOwner === 'standard'
|
||||
? this._standardDisplayScrollTop
|
||||
@@ -303,19 +302,6 @@ export class PanelChrome {
|
||||
return this._panelLayout._scheduleLeftPanelLayout(options0);
|
||||
}
|
||||
|
||||
_syncRailPanelCollapseButton(panelEl) {
|
||||
// Adaptive rail layout may directly restore an auto-collapsed Context
|
||||
// without invoking setPanelCollapsed(). Enforce Radio ownership there too.
|
||||
if (
|
||||
panelEl?.id === 'global-context-panel' &&
|
||||
!panelEl.classList.contains('collapsed') &&
|
||||
this._contextRadioDock?.classList.contains('disclosure-open')
|
||||
) {
|
||||
this._setRadioDisclosure?.(false);
|
||||
}
|
||||
this._syncPanelCollapseButton(panelEl);
|
||||
}
|
||||
|
||||
_syncPanelCollapseButton(panelEl) {
|
||||
const isRightRail = [
|
||||
'pp-toggles',
|
||||
@@ -382,9 +368,6 @@ export class PanelChrome {
|
||||
String(!collapsed),
|
||||
);
|
||||
}
|
||||
if (panelEl.id === 'radio-panel' || panelEl.id === 'global-context-panel') {
|
||||
this._syncContextRadioLauncherState();
|
||||
}
|
||||
}
|
||||
|
||||
_buildSharePanelState() {
|
||||
@@ -392,13 +375,13 @@ export class PanelChrome {
|
||||
for (const spec of SHARE_PANEL_STATE_SPECS) {
|
||||
const panelEl = document.getElementById(spec.id);
|
||||
if (!panelEl) continue;
|
||||
// Responsive auto-collapse is presentation only; the recipient should
|
||||
// restore the user's explicit expanded preference at its own viewport.
|
||||
const collapsed =
|
||||
panelEl.classList.contains('layout-auto-collapsed') ||
|
||||
panelEl.classList.contains('rail-owner-collapsed')
|
||||
? false
|
||||
: panelEl.classList.contains('collapsed');
|
||||
// The left rail's responsive auto-collapse is presentation only; the
|
||||
// recipient restores the user's expanded preference at its own viewport.
|
||||
// A right-rail peer hidden behind the owner travels as collapsed, so the
|
||||
// recipient opens the panel the sender sees.
|
||||
const collapsed = panelEl.classList.contains('layout-auto-collapsed')
|
||||
? false
|
||||
: panelEl.classList.contains('collapsed');
|
||||
// A panel the phone shell only borrowed for a sheet shares its own state.
|
||||
const entry = {
|
||||
id: spec.id,
|
||||
@@ -502,7 +485,10 @@ export class PanelChrome {
|
||||
) {
|
||||
this._panelLayout._leftStackPreferredPanelId = null;
|
||||
}
|
||||
if (explicit && !restore && !nextCollapsed && rightOwnerPanel) {
|
||||
// Any live opening of a rail panel makes it the owner, including the
|
||||
// ones a camera click, Street Level or a radio pick request without the
|
||||
// explicit flag: the rail's one-owner pass would otherwise hide it again.
|
||||
if (!restore && !nextCollapsed && rightOwnerPanel) {
|
||||
this._panelLayout._rightStackPreferredPanelId = rightOwnerPanel.id;
|
||||
} else if (
|
||||
explicit &&
|
||||
@@ -529,15 +515,6 @@ export class PanelChrome {
|
||||
syncShare,
|
||||
});
|
||||
}
|
||||
// A repeated Context-open request must clear a stale compact Radio mini
|
||||
// even when no collapsed class changes.
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
panelId === 'global-context-panel' &&
|
||||
this._contextRadioDock?.classList.contains('disclosure-open')
|
||||
) {
|
||||
this._setRadioDisclosure?.(false);
|
||||
}
|
||||
if (
|
||||
panelEl.classList.contains('collapsed') === nextCollapsed &&
|
||||
!wasAutoCollapsed
|
||||
@@ -563,13 +540,6 @@ export class PanelChrome {
|
||||
this.cockpitView.setContextCollapsed(true);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
panelId === 'global-context-panel' &&
|
||||
this._contextRadioDock?.classList.contains('disclosure-open')
|
||||
) {
|
||||
this._setRadioDisclosure?.(false);
|
||||
}
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
panelId === 'radio-panel' &&
|
||||
|
||||
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { PanelChrome } from './panelChrome.js';
|
||||
import { PanelLayoutController } from './panelLayoutController.js';
|
||||
|
||||
function classList(initial = []) {
|
||||
const values = new Set(initial);
|
||||
@@ -20,7 +19,10 @@ function classList(initial = []) {
|
||||
};
|
||||
}
|
||||
|
||||
test('opening an already-open Context still closes a stale compact Radio disclosure', (t) => {
|
||||
// The compact Radio player floats beside the strip and may stay open over an
|
||||
// expanded Context: opening Context, by request or by the adaptive rail
|
||||
// restore, leaves it alone.
|
||||
test('a repeated Context-open request leaves an open compact Radio player alone', (t) => {
|
||||
const priorDocument = globalThis.document;
|
||||
const contextPanel = {
|
||||
id: 'global-context-panel',
|
||||
@@ -42,7 +44,6 @@ test('opening an already-open Context still closes a stale compact Radio disclos
|
||||
_contextRadioDock: radioDock,
|
||||
_setRadioDisclosure(open) {
|
||||
calls.push(open);
|
||||
radioDock.classList.toggle('disclosure-open', open);
|
||||
},
|
||||
_leftPanelStack: null,
|
||||
_rightPanelStack: null,
|
||||
@@ -63,76 +64,11 @@ test('opening an already-open Context still closes a stale compact Radio disclos
|
||||
false,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.equal(radioDock.classList.contains('disclosure-open'), false);
|
||||
assert.deepEqual(calls, []);
|
||||
assert.equal(radioDock.classList.contains('disclosure-open'), true);
|
||||
});
|
||||
|
||||
test('adaptive rail restore hides compact Radio before revealing Context', (t) => {
|
||||
const priorRaf = globalThis.requestAnimationFrame;
|
||||
let nextFrame;
|
||||
globalThis.requestAnimationFrame = (callback) => {
|
||||
nextFrame = callback;
|
||||
return 1;
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.requestAnimationFrame = priorRaf;
|
||||
});
|
||||
|
||||
// Valid starting state: the tactical rail automatically collapsed Context,
|
||||
// and the user opened the compact Radio controls from its header.
|
||||
const contextPanel = {
|
||||
id: 'global-context-panel',
|
||||
classList: classList(['collapsed', 'layout-auto-collapsed']),
|
||||
};
|
||||
const radioDock = { classList: classList(['disclosure-open']) };
|
||||
const radioMini = { hidden: false };
|
||||
const calls = [];
|
||||
const chrome = {
|
||||
_contextRadioDock: radioDock,
|
||||
_setRadioDisclosure(open) {
|
||||
calls.push(`disclosure:${open}`);
|
||||
radioDock.classList.toggle('disclosure-open', open);
|
||||
radioMini.hidden = !open;
|
||||
},
|
||||
_syncPanelCollapseButton(panel) {
|
||||
calls.push(`button:${panel.id}`);
|
||||
},
|
||||
};
|
||||
const layout = {
|
||||
destroyed: false,
|
||||
_rightStackLayoutFrame: null,
|
||||
_rightStackReconsiderAutoCollapse: false,
|
||||
_rightPanelStack: {
|
||||
querySelectorAll: (selector) =>
|
||||
selector === '.layout-auto-collapsed' ? [contextPanel] : [],
|
||||
},
|
||||
_syncPanelCollapseButton(panel) {
|
||||
PanelChrome.prototype._syncRailPanelCollapseButton.call(chrome, panel);
|
||||
},
|
||||
_syncRightPanelAdaptiveLayout() {
|
||||
calls.push('layout');
|
||||
},
|
||||
};
|
||||
|
||||
PanelLayoutController.prototype._scheduleRightPanelLayout.call(layout, {
|
||||
reconsiderAutoCollapse: true,
|
||||
});
|
||||
assert.equal(contextPanel.classList.contains('collapsed'), true);
|
||||
assert.equal(radioMini.hidden, false);
|
||||
nextFrame();
|
||||
|
||||
assert.equal(contextPanel.classList.contains('collapsed'), false);
|
||||
assert.equal(contextPanel.classList.contains('layout-auto-collapsed'), false);
|
||||
assert.equal(radioDock.classList.contains('disclosure-open'), false);
|
||||
assert.equal(radioMini.hidden, true);
|
||||
assert.deepEqual(calls, [
|
||||
'disclosure:false',
|
||||
'button:global-context-panel',
|
||||
'layout',
|
||||
]);
|
||||
});
|
||||
|
||||
test('real PanelChrome constructor wires rail restore to compact Radio owner', (t) => {
|
||||
test('adaptive rail restore reveals Context without touching the compact player', (t) => {
|
||||
const priorDocument = globalThis.document;
|
||||
const priorRaf = globalThis.requestAnimationFrame;
|
||||
let nextFrame;
|
||||
@@ -166,7 +102,6 @@ test('real PanelChrome constructor wires rail restore to compact Radio owner', (
|
||||
operations: {
|
||||
_setRadioDisclosure(open) {
|
||||
calls.push(`disclosure:${open}`);
|
||||
dock.classList.toggle('disclosure-open', open);
|
||||
},
|
||||
_syncPanelCollapseButton(panel) {
|
||||
calls.push(`button:${panel.id}`);
|
||||
@@ -187,14 +122,10 @@ test('real PanelChrome constructor wires rail restore to compact Radio owner', (
|
||||
reconsiderAutoCollapse: true,
|
||||
});
|
||||
assert.equal(typeof nextFrame, 'function');
|
||||
assert.equal(dock.classList.contains('disclosure-open'), true);
|
||||
nextFrame();
|
||||
|
||||
assert.equal(contextPanel.classList.contains('collapsed'), false);
|
||||
assert.equal(dock.classList.contains('disclosure-open'), false);
|
||||
assert.deepEqual(calls, [
|
||||
'disclosure:false',
|
||||
'button:global-context-panel',
|
||||
'layout',
|
||||
]);
|
||||
assert.equal(contextPanel.classList.contains('layout-auto-collapsed'), false);
|
||||
assert.equal(dock.classList.contains('disclosure-open'), true);
|
||||
assert.deepEqual(calls, ['button:global-context-panel', 'layout']);
|
||||
});
|
||||
@@ -13,6 +13,13 @@ function listenerScope() {
|
||||
};
|
||||
}
|
||||
|
||||
/** The strip tab that controls a right-rail panel, if the panel has one. */
|
||||
function railTabFor(panel) {
|
||||
return panel.ownerDocument?.querySelector?.(
|
||||
`[role="tab"][aria-controls="${panel.id}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Bind collapse buttons and bubbling Escape to an existing panel. */
|
||||
export function bindPanelDisclosure({
|
||||
panel,
|
||||
@@ -63,9 +70,7 @@ export function collapsePanelOnEscape(
|
||||
panel.querySelector(`[data-dock-toggle-target="${panel.id}"]`) ||
|
||||
panel.querySelector(`[data-collapse-target="${panel.id}"]`);
|
||||
// A right-rail panel collapses out of view; its tab stays and takes focus.
|
||||
const tab = panel.ownerDocument?.querySelector?.(
|
||||
`[role="tab"][aria-controls="${panel.id}"]`,
|
||||
);
|
||||
const tab = railTabFor(panel);
|
||||
const escapedFromDisclosure =
|
||||
event.target === disclosure || disclosure?.contains?.(event.target);
|
||||
if (tab) tab.focus({ preventScroll: true });
|
||||
|
||||
@@ -375,6 +375,35 @@ test('right rail aligns to the current left rail and excludes hidden obstacles',
|
||||
assert.equal(f.stack.dataset.safeTop, '200.0');
|
||||
});
|
||||
|
||||
test('right rail climbs above the left rail baseline when its tab strip needs the room, no further than the HUD above', () => {
|
||||
const f = fixture('right');
|
||||
f.options.leftStack.rect.top = 300;
|
||||
const above = element('above', { left: 1100, top: 100, height: 50 });
|
||||
const below = element('below', { left: 1100, top: 600, height: 50 });
|
||||
f.options.obstacles = [above, below];
|
||||
const column = element('column', { height: 380 });
|
||||
f.stack.querySelector = (selector) =>
|
||||
selector.includes('#right-rail-column') ? column : null;
|
||||
// The gap is 10.8 at 900px: the lane 300..589.2 is 90.8 short of the column.
|
||||
f.run();
|
||||
assert.equal(f.stack.dataset.safeTop, '209.2');
|
||||
assert.equal(f.stack.dataset.safeBottom, '589.2');
|
||||
column.rect.height = 600;
|
||||
f.run();
|
||||
assert.equal(
|
||||
f.stack.dataset.safeTop,
|
||||
'160.8',
|
||||
'the HUD corner above stops the climb',
|
||||
);
|
||||
column.rect.height = 200;
|
||||
f.run();
|
||||
assert.equal(
|
||||
f.stack.dataset.safeTop,
|
||||
'300.0',
|
||||
'a column that fits leaves the baseline alone',
|
||||
);
|
||||
});
|
||||
|
||||
// The left rail's `top` animates after the right pass runs, and nothing
|
||||
// re-runs it when the transition lands: the right rail must read the end value.
|
||||
function animatingLeftRail({ keyframes = true } = {}) {
|
||||
|
||||
@@ -410,17 +410,6 @@ export function bindRadioControls() {
|
||||
() => void toggleRadio(this._cockpitRadioEnableBtn),
|
||||
);
|
||||
this.listen(this._contextRadioToggleBtn, 'click', () => {
|
||||
const contextPanel = document.getElementById('global-context-panel');
|
||||
if (contextPanel && !contextPanel.classList.contains('collapsed')) {
|
||||
setRadioDisclosure(false);
|
||||
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
|
||||
void this._revealRadioPanelInsideContext({
|
||||
focusTarget: this._radioPanel?.querySelector(
|
||||
'[data-collapse-target="radio-panel"]',
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setRadioDisclosure(
|
||||
!this._contextRadioDock?.classList.contains('disclosure-open'),
|
||||
);
|
||||
|
||||
+1
-83
@@ -109,94 +109,12 @@ export class RadioControls {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the embedded Radio section into the expanded Context scroller.
|
||||
* This never changes Radio power, playback, selection, or Context mode.
|
||||
* @param {{focusTarget?: HTMLElement|null}} [options]
|
||||
* @returns {Promise<boolean>} Whether the internal scroller moved.
|
||||
*/
|
||||
async _revealRadioPanelInsideContext({ focusTarget = null } = {}) {
|
||||
const contextPanel = document.getElementById('global-context-panel');
|
||||
const scroller =
|
||||
(document.documentElement?.dataset.uiTheme === 'cyber' &&
|
||||
contextPanel?.querySelector('.cyber-panel-body')) ||
|
||||
contextPanel?.querySelector('.global-context-panel-inner');
|
||||
if (
|
||||
!contextPanel ||
|
||||
contextPanel.classList.contains('collapsed') ||
|
||||
!scroller ||
|
||||
!this._radioPanel ||
|
||||
this._radioPanel.classList.contains('collapsed')
|
||||
)
|
||||
return false;
|
||||
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
if (
|
||||
this.destroyed ||
|
||||
contextPanel.classList.contains('collapsed') ||
|
||||
this._radioPanel.classList.contains('collapsed')
|
||||
)
|
||||
return false;
|
||||
|
||||
const viewport = scroller.getBoundingClientRect();
|
||||
const radioRect = this._radioPanel.getBoundingClientRect();
|
||||
const desired = scroller.scrollTop + radioRect.top - viewport.top - 10;
|
||||
const next = Math.min(
|
||||
Math.max(0, scroller.scrollHeight - scroller.clientHeight),
|
||||
Math.max(0, desired),
|
||||
);
|
||||
const moved = Math.abs(next - scroller.scrollTop) >= 1;
|
||||
if (moved) {
|
||||
const reducedMotion = window.matchMedia?.(
|
||||
'(prefers-reduced-motion: reduce)',
|
||||
)?.matches;
|
||||
scroller.scrollTo({
|
||||
top: next,
|
||||
behavior: reducedMotion ? 'auto' : 'smooth',
|
||||
});
|
||||
}
|
||||
focusTarget?.focus?.({ preventScroll: true });
|
||||
return moved;
|
||||
}
|
||||
|
||||
/** Keep the launcher truthful for the active theme and Context route. */
|
||||
/** Keep the launcher's ARIA state matched to the compact player. */
|
||||
_syncContextRadioLauncherState() {
|
||||
if (this.destroyed || !this._contextRadioToggleBtn) return;
|
||||
const contextPanel = globalThis.document?.getElementById(
|
||||
'global-context-panel',
|
||||
);
|
||||
const contextExpanded = Boolean(
|
||||
contextPanel && !contextPanel.classList.contains('collapsed'),
|
||||
);
|
||||
if (contextExpanded) {
|
||||
const radioExpanded = Boolean(
|
||||
this._radioPanel && !this._radioPanel.classList.contains('collapsed'),
|
||||
);
|
||||
this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel');
|
||||
this._contextRadioToggleBtn.setAttribute(
|
||||
'aria-expanded',
|
||||
String(radioExpanded),
|
||||
);
|
||||
const label = t(
|
||||
radioExpanded
|
||||
? msg('Go to expanded Radio section')
|
||||
: msg('Expand Radio section in Context'),
|
||||
);
|
||||
this._contextRadioToggleBtn.setAttribute('aria-label', label);
|
||||
this._contextRadioToggleBtn.title = label;
|
||||
return;
|
||||
}
|
||||
// This launcher toggles the compact player even when the detailed panel
|
||||
// is open elsewhere. Its ARIA state must describe that same disclosure.
|
||||
const compactOpen = Boolean(
|
||||
this._contextRadioDock?.classList.contains('disclosure-open'),
|
||||
);
|
||||
this._contextRadioToggleBtn.setAttribute(
|
||||
'aria-controls',
|
||||
'context-radio-mini',
|
||||
);
|
||||
this._contextRadioToggleBtn.setAttribute(
|
||||
'aria-expanded',
|
||||
String(compactOpen),
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
/** Keep Radio in Context in every theme, reusing the same playback controls. */
|
||||
/**
|
||||
* Keep Radio in Context in every theme, reusing the same playback controls.
|
||||
* The compact launcher (the dock) sits in the right rail's tool slot under the
|
||||
* tab strip on desktop, where a collapsed Context is hidden, and in Context's
|
||||
* header on phones, where the sheet shows that header.
|
||||
*/
|
||||
export function syncRadioPanelPlacement(doc) {
|
||||
if (!doc?.getElementById) return;
|
||||
const panel = doc.getElementById('radio-panel');
|
||||
@@ -11,7 +16,12 @@ export function syncRadioPanelPlacement(doc) {
|
||||
const header = context?.querySelector('.panel-header');
|
||||
if (!panel || !parent || !header || !dock || !mini) return;
|
||||
if (panel.parentElement !== parent) parent.append(panel);
|
||||
if (dock.parentElement !== header)
|
||||
const slot = doc.getElementById('right-rail-tools');
|
||||
const phone = Boolean(doc.body?.classList?.contains?.('mobile-shell'));
|
||||
if (slot && !phone) {
|
||||
if (dock.parentElement !== slot) slot.append(dock);
|
||||
} else if (dock.parentElement !== header) {
|
||||
header.insertBefore(dock, header.querySelector('.panel-collapse-btn'));
|
||||
}
|
||||
if (mini.parentElement !== dock) dock.append(mini);
|
||||
}
|
||||
@@ -94,6 +94,59 @@ test('all themes keep the same Radio controls nested in Context', () => {
|
||||
assert.equal(mini.parentElement, dock);
|
||||
});
|
||||
|
||||
test('the compact launcher sits in the rail tool slot on desktop and in Context header on phones', () => {
|
||||
const panel = node(),
|
||||
context = node(),
|
||||
dock = node(),
|
||||
mini = node(),
|
||||
slot = node();
|
||||
const inner = node(),
|
||||
header = node(),
|
||||
collapse = node();
|
||||
context.selectors['.global-context-panel-inner'] = inner;
|
||||
context.selectors['.panel-header'] = header;
|
||||
header.selectors['.panel-collapse-btn'] = collapse;
|
||||
inner.append(panel);
|
||||
header.append(dock);
|
||||
header.append(collapse);
|
||||
dock.append(mini);
|
||||
const nodes = {
|
||||
'radio-panel': panel,
|
||||
'global-context-panel': context,
|
||||
'context-radio-dock': dock,
|
||||
'context-radio-mini': mini,
|
||||
'right-rail-tools': slot,
|
||||
};
|
||||
let phone = false;
|
||||
const doc = {
|
||||
documentElement: { dataset: {} },
|
||||
body: { classList: { contains: (name) => phone && name === 'mobile-shell' } },
|
||||
getElementById: (id) => nodes[id],
|
||||
};
|
||||
syncRadioPanelPlacement(doc);
|
||||
assert.deepEqual(slot.children, [dock], 'desktop: the launcher is in the slot');
|
||||
assert.deepEqual(header.children, [collapse]);
|
||||
assert.equal(mini.parentElement, dock);
|
||||
const moves = [dock.moves, mini.moves];
|
||||
syncRadioPanelPlacement(doc);
|
||||
assert.deepEqual([dock.moves, mini.moves], moves, 'no mutation feedback loop');
|
||||
phone = true;
|
||||
syncRadioPanelPlacement(doc);
|
||||
assert.deepEqual(slot.children, []);
|
||||
assert.deepEqual(
|
||||
header.children,
|
||||
[dock, collapse],
|
||||
'phone: back in the header, before the collapse button',
|
||||
);
|
||||
assert.equal(mini.parentElement, dock);
|
||||
phone = false;
|
||||
syncRadioPanelPlacement(doc);
|
||||
assert.deepEqual(slot.children, [dock], 'desktop again: back in the slot');
|
||||
delete nodes['right-rail-tools'];
|
||||
syncRadioPanelPlacement(doc);
|
||||
assert.deepEqual(header.children, [dock, collapse], 'no slot: the header');
|
||||
});
|
||||
|
||||
test('placement is inert before the complete shell exists', () => {
|
||||
assert.doesNotThrow(() => syncRadioPanelPlacement(null));
|
||||
assert.doesNotThrow(() =>
|
||||
|
||||
+26
-12
@@ -30,7 +30,9 @@ function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) {
|
||||
* The caller owns scheduling, obstacle selection, disclosure preferences and
|
||||
* persistence. The rail presents its panels as vertical tabs
|
||||
* (rightRailTabs.js): one owner stays expanded, its peers are
|
||||
* presentation-collapsed and hidden, and the owner takes the whole lane.
|
||||
* presentation-collapsed and hidden, and the owner takes the whole lane. The
|
||||
* lane's top follows the left rail's, lifted toward the HUD above when the tab
|
||||
* strip's column (rightRailTabs.js) is taller than the lane.
|
||||
* @param {object} options Live DOM and caller policy.
|
||||
* @param {HTMLElement} options.stack Rail element.
|
||||
* @param {Iterable<HTMLElement>} options.obstacles Caller-selected obstacle nodes.
|
||||
@@ -172,6 +174,10 @@ export function layoutRightPanelRail({
|
||||
.map((node) => [node, node.scrollTop || 0])
|
||||
.filter(([, top]) => top > 0);
|
||||
const naturalHeights = new Map();
|
||||
// The tab strip's column stands beside the panels at its own height; the
|
||||
// measuring override lifts its lane clamp too.
|
||||
const column = stack.querySelector?.(':scope > #right-rail-column');
|
||||
let columnHeight = 0;
|
||||
// Remove live height allocations through a synchronous CSS override. Keeping
|
||||
// the stored properties intact avoids REMOVE/SET churn on unchanged panels.
|
||||
// Do not toggle layout classes here: the class observer schedules new passes.
|
||||
@@ -187,6 +193,7 @@ export function layoutRightPanelRail({
|
||||
),
|
||||
);
|
||||
}
|
||||
columnHeight = column?.getBoundingClientRect().height || 0;
|
||||
} finally {
|
||||
stack.removeAttribute('data-rail-measuring');
|
||||
for (const [node, top] of scrollers)
|
||||
@@ -199,18 +206,25 @@ export function layoutRightPanelRail({
|
||||
0,
|
||||
) +
|
||||
gap * Math.max(0, visiblePanels.length - 1);
|
||||
const layout = resolveHudRailLayout({
|
||||
viewportHeight,
|
||||
panelHeight: naturalHeight,
|
||||
laneLeft: stackRect.left,
|
||||
laneRight: stackRect.right,
|
||||
obstacles: obstacleRects,
|
||||
baseTop: alignedTop,
|
||||
baseBottom: viewportHeight * 0.96,
|
||||
gap: safeGap,
|
||||
align: 'start',
|
||||
});
|
||||
const resolve = (baseTop) =>
|
||||
resolveHudRailLayout({
|
||||
viewportHeight,
|
||||
panelHeight: naturalHeight,
|
||||
laneLeft: stackRect.left,
|
||||
laneRight: stackRect.right,
|
||||
obstacles: obstacleRects,
|
||||
baseTop,
|
||||
baseBottom: viewportHeight * 0.96,
|
||||
gap: safeGap,
|
||||
align: 'start',
|
||||
});
|
||||
let layout = resolve(alignedTop);
|
||||
if (!layout) return;
|
||||
// A lane too short for the strip's column: the rail climbs toward the HUD
|
||||
// above, as far as the strip needs and the obstacles allow. A lane still
|
||||
// shorter scrolls the strip (rightRailTabs.js).
|
||||
const shortfall = columnHeight - (layout.safeBottom - layout.safeTop);
|
||||
if (shortfall > 0) layout = resolve(alignedTop - shortfall) || layout;
|
||||
const { safeTop, safeBottom, maxHeight: availableHeight } = layout;
|
||||
const stabilityBand = Math.max(48, viewportHeight * 0.06);
|
||||
const wasFocused = stack.classList.contains('layout-focus');
|
||||
|
||||
+148
-55
@@ -10,10 +10,16 @@
|
||||
* their joined silhouette as an SVG path (railJoinPath) and keeps a join
|
||||
* element under the panels whose layers blur, tint, outline and shadow that
|
||||
* shape (styles/right-rail-tabs.css).
|
||||
*
|
||||
* The strip sits in a column with a tool slot under it. The compact Radio
|
||||
* launcher lives in the slot on desktop (radioPanelPlacement.js), where the
|
||||
* collapsed Context that carries it otherwise is hidden.
|
||||
*/
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
export const RIGHT_RAIL_TABS_ID = 'right-rail-tabs';
|
||||
export const RIGHT_RAIL_COLUMN_ID = 'right-rail-column';
|
||||
export const RIGHT_RAIL_TOOLS_ID = 'right-rail-tools';
|
||||
|
||||
/**
|
||||
* Marks the rail while its strip is mounted; styles/right-rail-tabs.css keys
|
||||
@@ -69,8 +75,9 @@ export function railTabPanels(rail) {
|
||||
*
|
||||
* @param {object} g
|
||||
* @param {{x0: number, y0: number, x1: number, y1: number}} g.panel
|
||||
* @param {{top: number, bottom: number, right: number}} g.tab The tab's
|
||||
* extent; its left edge is the panel's right edge.
|
||||
* @param {{top: number, bottom: number, right: number}|null} g.tab The tab's
|
||||
* extent; its left edge is the panel's right edge. Null draws the panel
|
||||
* alone.
|
||||
* @param {number} [g.corner] Panel corner radius, or the chamfer size.
|
||||
* @param {number} [g.tabRadius] The tab's outer corner radius.
|
||||
* @param {number} [g.fillet] Concave fillet radius at the junction.
|
||||
@@ -93,12 +100,24 @@ export function railJoinPath({
|
||||
const y0 = panel.y0 + inset;
|
||||
const x1 = panel.x1 - inset;
|
||||
const y1 = panel.y1 - inset;
|
||||
const ty0 = tab.top + inset;
|
||||
const ty1 = tab.bottom - inset;
|
||||
const tx1 = tab.right - inset;
|
||||
// No tab (it is scrolled out of the strip): the panel alone.
|
||||
const ty0 = tab ? tab.top + inset : NaN;
|
||||
const ty1 = tab ? tab.bottom - inset : NaN;
|
||||
const tx1 = tab ? tab.right - inset : NaN;
|
||||
if (style === 'chamfer') {
|
||||
const c = corner;
|
||||
const cx = (panel.x0 + panel.x1) / 2;
|
||||
// The tab's edges along the panel stay between the chamfers.
|
||||
const tyA = Math.max(ty0, y0 + c);
|
||||
const tyB = Math.max(tyA, Math.min(ty1, y1 - c));
|
||||
const edge = tab
|
||||
? [
|
||||
`L ${P(x1, tyA)}`,
|
||||
`L ${P(tx1, tyA)}`,
|
||||
`L ${P(tx1, tyB)}`,
|
||||
`L ${P(x1, tyB)}`,
|
||||
]
|
||||
: [];
|
||||
return [
|
||||
`M ${P(x0, y0 + c)}`,
|
||||
`L ${P(x0 + c, y0)}`,
|
||||
@@ -108,10 +127,7 @@ export function railJoinPath({
|
||||
`L ${P(cx + 25, y0)}`,
|
||||
`L ${P(x1 - c, y0)}`,
|
||||
`L ${P(x1, y0 + c)}`,
|
||||
`L ${P(x1, ty0)}`,
|
||||
`L ${P(tx1, ty0)}`,
|
||||
`L ${P(tx1, ty1)}`,
|
||||
`L ${P(x1, ty1)}`,
|
||||
...edge,
|
||||
`L ${P(x1, y1 - c)}`,
|
||||
`L ${P(x1 - c, y1)}`,
|
||||
`L ${P(x0 + c, y1)}`,
|
||||
@@ -122,36 +138,68 @@ export function railJoinPath({
|
||||
const R = Math.max(0, corner - inset);
|
||||
const r = Math.max(0, tabRadius - inset);
|
||||
const f = fillet + inset;
|
||||
// sweep 1 turns clockwise (convex), 0 counter-clockwise (concave).
|
||||
const arc = (radius, sweep, x, y) =>
|
||||
`A ${n(radius)} ${n(radius)} 0 0 ${sweep} ${P(x, y)}`;
|
||||
const first = tab.top - panel.y0 < 0.5;
|
||||
const d = [`M ${P(x0 + R, y0)}`];
|
||||
if (first) {
|
||||
d.push(`L ${P(tx1 - r, y0)}`, arc(r, 1, tx1, y0 + r));
|
||||
const d = [];
|
||||
let at = null;
|
||||
const same = (x, y) => at && at[0] === n(x) && at[1] === n(y);
|
||||
const move = (x, y) => {
|
||||
d.push(`M ${P(x, y)}`);
|
||||
at = [n(x), n(y)];
|
||||
};
|
||||
const line = (x, y) => {
|
||||
if (same(x, y)) return;
|
||||
d.push(`L ${P(x, y)}`);
|
||||
at = [n(x), n(y)];
|
||||
};
|
||||
// sweep 1 turns clockwise (convex), 0 counter-clockwise (concave); a zero
|
||||
// radius is a plain corner.
|
||||
const arc = (radius, sweep, x, y) => {
|
||||
if (!(radius > 0)) return line(x, y);
|
||||
d.push(`A ${n(radius)} ${n(radius)} 0 0 ${sweep} ${P(x, y)}`);
|
||||
at = [n(x), n(y)];
|
||||
};
|
||||
// The run of panel edge above or below the tab may be shorter than a fillet
|
||||
// plus a corner: shrink them to what fits, down to a straight edge shared
|
||||
// with the panel's top or bottom.
|
||||
const fit = (room) => {
|
||||
const fillet = Math.min(f, Math.max(0, room - R));
|
||||
return { fillet, corner: Math.min(R, Math.max(0, room - fillet)) };
|
||||
};
|
||||
move(x0 + R, y0);
|
||||
if (!tab) {
|
||||
line(x1 - R, y0);
|
||||
arc(R, 1, x1, y0 + R);
|
||||
line(x1, y1 - R);
|
||||
arc(R, 1, x1 - R, y1);
|
||||
} else {
|
||||
d.push(
|
||||
`L ${P(x1 - R, y0)}`,
|
||||
arc(R, 1, x1, y0 + R),
|
||||
`L ${P(x1, ty0 - f)}`,
|
||||
arc(f, 0, x1 + f, ty0),
|
||||
`L ${P(tx1 - r, ty0)}`,
|
||||
arc(r, 1, tx1, ty0 + r),
|
||||
);
|
||||
const first = tab.top - panel.y0 < 0.5;
|
||||
const last = panel.y1 - tab.bottom < 0.5;
|
||||
const above = fit(ty0 - y0);
|
||||
const below = fit(y1 - ty1);
|
||||
if (first) {
|
||||
line(tx1 - r, y0);
|
||||
arc(r, 1, tx1, y0 + r);
|
||||
} else {
|
||||
line(x1 - above.corner, y0);
|
||||
arc(above.corner, 1, x1, y0 + above.corner);
|
||||
line(x1, ty0 - above.fillet);
|
||||
arc(above.fillet, 0, x1 + above.fillet, ty0);
|
||||
line(tx1 - r, ty0);
|
||||
arc(r, 1, tx1, ty0 + r);
|
||||
}
|
||||
line(tx1, ty1 - r);
|
||||
arc(r, 1, tx1 - r, ty1);
|
||||
if (!last) {
|
||||
line(x1 + below.fillet, ty1);
|
||||
arc(below.fillet, 0, x1, ty1 + below.fillet);
|
||||
line(x1, y1 - below.corner);
|
||||
arc(below.corner, 1, x1 - below.corner, y1);
|
||||
}
|
||||
}
|
||||
d.push(
|
||||
`L ${P(tx1, ty1 - r)}`,
|
||||
arc(r, 1, tx1 - r, ty1),
|
||||
`L ${P(x1 + f, ty1)}`,
|
||||
arc(f, 0, x1, ty1 + f),
|
||||
`L ${P(x1, y1 - R)}`,
|
||||
arc(R, 1, x1 - R, y1),
|
||||
`L ${P(x0 + R, y1)}`,
|
||||
arc(R, 1, x0, y1 - R),
|
||||
`L ${P(x0, y0 + R)}`,
|
||||
arc(R, 1, x0 + R, y0),
|
||||
'Z',
|
||||
);
|
||||
line(x0 + R, y1);
|
||||
arc(R, 1, x0, y1 - R);
|
||||
line(x0, y0 + R);
|
||||
arc(R, 1, x0 + R, y0);
|
||||
d.push('Z');
|
||||
return d.join(' ');
|
||||
}
|
||||
|
||||
@@ -171,7 +219,8 @@ function panelTitle(panel) {
|
||||
* @param {(panelId: string, collapsed: boolean, options: object) => void} input.setPanelCollapsed
|
||||
* Owner callback; the strip never touches panel classes itself.
|
||||
* @param {Document} [input.documentRef]
|
||||
* @returns {{ element: HTMLElement, sync: () => void, destroy: () => void }}
|
||||
* @returns {{ element: HTMLElement, column: HTMLElement, tools: HTMLElement,
|
||||
* join: HTMLElement, sync: () => void, destroy: () => void }}
|
||||
*/
|
||||
export function createRightRailTabs({
|
||||
rail,
|
||||
@@ -183,11 +232,20 @@ export function createRightRailTabs({
|
||||
'Right rail tabs require a rail, a document and setPanelCollapsed',
|
||||
);
|
||||
}
|
||||
const column = documentRef.createElement('div');
|
||||
column.id = RIGHT_RAIL_COLUMN_ID;
|
||||
const strip = documentRef.createElement('div');
|
||||
strip.id = RIGHT_RAIL_TABS_ID;
|
||||
strip.setAttribute('role', 'tablist');
|
||||
strip.setAttribute('aria-orientation', 'vertical');
|
||||
strip.setAttribute('aria-label', t('Panels'));
|
||||
// The rail's measuring pass (rightPanelRail.js) lifts the column's lane
|
||||
// clamp, which would reset a scrolled strip; the marker has it put the
|
||||
// offset back.
|
||||
strip.setAttribute('data-rail-scroller', '');
|
||||
const tools = documentRef.createElement('div');
|
||||
tools.id = RIGHT_RAIL_TOOLS_ID;
|
||||
column.append(strip, tools);
|
||||
rail.setAttribute(RIGHT_RAIL_TABS_ATTRIBUTE, 'vertical');
|
||||
|
||||
// The join: shadow under, glass, outline over; all shaped by one path.
|
||||
@@ -264,25 +322,51 @@ export function createRightRailTabs({
|
||||
* rail pixels; the stylesheet sets the panel's minimum height from it) and
|
||||
* the joined silhouette on the join's layers. The rail can be taller than
|
||||
* its panel in the focus layout, so the join is sized from the panel,
|
||||
* extended down to cover the tab if that is shorter.
|
||||
* extended down to cover the tab if that is shorter, but never past the
|
||||
* rail's lane (--right-stack-max-height).
|
||||
*/
|
||||
let shownTab = null;
|
||||
function placeJoin(tab, panel) {
|
||||
if (!tab) {
|
||||
rail.style.removeProperty('--rail-tab-bottom');
|
||||
join.hidden = true;
|
||||
shownTab = null;
|
||||
return;
|
||||
}
|
||||
// A short lane makes the strip scroll. A newly opened tab is brought into
|
||||
// view once; after that the strip scrolls as the user wishes and the join
|
||||
// follows the visible part of the tab, or the panel alone.
|
||||
const viewHeight = strip.clientHeight;
|
||||
const scrolls = Number.isFinite(viewHeight) && viewHeight > 0;
|
||||
if (scrolls && tab !== shownTab) {
|
||||
const above = tab.offsetTop - strip.scrollTop;
|
||||
const below =
|
||||
tab.offsetTop + tab.offsetHeight - (strip.scrollTop + viewHeight);
|
||||
if (above < 0) strip.scrollTop += above;
|
||||
else if (below > 0) strip.scrollTop += below;
|
||||
}
|
||||
shownTab = tab;
|
||||
const top = tab.offsetTop - (strip.scrollTop || 0);
|
||||
rail.style.setProperty('--rail-tab-bottom', `${top + tab.offsetHeight}px`);
|
||||
const computed = documentRef.defaultView?.getComputedStyle?.(rail);
|
||||
const corner = token(computed, '--right-rail-corner', 16);
|
||||
const tabRadius = token(computed, '--right-rail-tab-radius', 10);
|
||||
const fillet = token(computed, '--right-rail-fillet', 8);
|
||||
const lane = token(computed, '--right-stack-max-height', Infinity);
|
||||
const chamfer =
|
||||
computed?.getPropertyValue?.('--right-rail-join-style').trim() ===
|
||||
'chamfer';
|
||||
const tabTop = (strip.offsetTop || 0) + top;
|
||||
const tabBottom = tabTop + tab.offsetHeight;
|
||||
// Tab offsets are relative to the strip, the strip's to the column, the
|
||||
// column's to the rail.
|
||||
const stripTop = (column.offsetTop || 0) + (strip.offsetTop || 0);
|
||||
const stripLeft = (column.offsetLeft || 0) + (strip.offsetLeft || 0);
|
||||
let tabTop = stripTop + top;
|
||||
let tabBottom = tabTop + tab.offsetHeight;
|
||||
if (scrolls) {
|
||||
tabTop = Math.max(tabTop, stripTop);
|
||||
tabBottom = Math.min(tabBottom, stripTop + viewHeight);
|
||||
}
|
||||
const visible = tabBottom - tabTop >= 1;
|
||||
const geometry = {
|
||||
panel: {
|
||||
x0: panel.offsetLeft,
|
||||
@@ -290,16 +374,20 @@ export function createRightRailTabs({
|
||||
x1: panel.offsetLeft + panel.offsetWidth,
|
||||
y1: Math.max(
|
||||
panel.offsetTop + panel.offsetHeight,
|
||||
tabBottom + fillet + 4 + corner,
|
||||
visible ? Math.min(lane, tabBottom + fillet + 4 + corner) : 0,
|
||||
),
|
||||
},
|
||||
tab: {
|
||||
top: tabTop,
|
||||
bottom: tabBottom,
|
||||
right: (strip.offsetLeft || 0) + tab.offsetLeft + tab.offsetWidth,
|
||||
},
|
||||
tab: visible
|
||||
? {
|
||||
top: tabTop,
|
||||
bottom: tabBottom,
|
||||
right: stripLeft + tab.offsetLeft + tab.offsetWidth,
|
||||
}
|
||||
: null,
|
||||
corner,
|
||||
tabRadius,
|
||||
tabRadius: visible
|
||||
? Math.min(tabRadius, (tabBottom - tabTop) / 2)
|
||||
: tabRadius,
|
||||
fillet,
|
||||
style: chamfer ? 'chamfer' : 'round',
|
||||
};
|
||||
@@ -318,11 +406,15 @@ export function createRightRailTabs({
|
||||
/** Reconcile tabs with the rail: order, visibility, selection, labels. */
|
||||
function sync() {
|
||||
if (destroyed) return;
|
||||
// The tablist precedes its panels in the tab order, so Tab from the open
|
||||
// tab lands inside the open panel. The rail owner moves panels in at
|
||||
// start-up; keep the strip first afterwards too.
|
||||
if (rail.firstElementChild !== strip) rail.prepend(strip);
|
||||
// The column precedes its panels in the tab order, so Tab from the open
|
||||
// tab reaches the tool slot and then the open panel. The rail owner moves
|
||||
// panels in at start-up; keep the column first afterwards too.
|
||||
if (rail.firstElementChild !== column) rail.prepend(column);
|
||||
if (join.parentElement !== rail) rail.append(join);
|
||||
// Hidden panels too: Weather and Recent Imagery arrive hidden and their
|
||||
// owners reveal them by attribute, which must create their tabs.
|
||||
for (const panel of rail.children)
|
||||
if (panel.matches?.('[data-panel-id]')) observe(panel);
|
||||
const panels = railTabPanels(rail);
|
||||
const focusOwner =
|
||||
panels.find((panel) => !panel.classList.contains('collapsed')) ||
|
||||
@@ -344,7 +436,6 @@ export function createRightRailTabs({
|
||||
tab.title = t(collapsed ? msg('Expand {name}') : msg('Collapse {name}'), {
|
||||
name,
|
||||
});
|
||||
observe(panel);
|
||||
}
|
||||
const live = new Set(panels.map((panel) => panel.id));
|
||||
for (const [id, entry] of entries) {
|
||||
@@ -379,12 +470,14 @@ export function createRightRailTabs({
|
||||
strip.addEventListener('keydown', onKeydown);
|
||||
strip.addEventListener('scroll', onScroll, { passive: true });
|
||||
observer?.observe(rail, { childList: true });
|
||||
rail.prepend(strip);
|
||||
rail.prepend(column);
|
||||
rail.append(join);
|
||||
sync();
|
||||
|
||||
return {
|
||||
element: strip,
|
||||
column,
|
||||
tools,
|
||||
join,
|
||||
sync,
|
||||
destroy() {
|
||||
@@ -395,7 +488,7 @@ export function createRightRailTabs({
|
||||
strip.removeEventListener('click', onClick);
|
||||
strip.removeEventListener('keydown', onKeydown);
|
||||
strip.removeEventListener('scroll', onScroll);
|
||||
strip.remove();
|
||||
column.remove();
|
||||
join.remove();
|
||||
rail.removeAttribute(RIGHT_RAIL_TABS_ATTRIBUTE);
|
||||
placeJoin(null, null);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
|
||||
import {
|
||||
RIGHT_RAIL_COLUMN_ID,
|
||||
RIGHT_RAIL_TABS_ATTRIBUTE,
|
||||
RIGHT_RAIL_TABS_ID,
|
||||
RIGHT_RAIL_TOOLS_ID,
|
||||
createRightRailTabs,
|
||||
nextRailTabIndex,
|
||||
railJoinPath,
|
||||
@@ -197,11 +200,24 @@ test('builds one tab per visible rail panel, in rail order, labelled from the pa
|
||||
const f = fixture();
|
||||
assert.equal(
|
||||
f.rail.children[0],
|
||||
f.strip,
|
||||
'the tablist precedes its panels in the tab order',
|
||||
f.tabs.column,
|
||||
'the column precedes its panels in the tab order',
|
||||
);
|
||||
assert.equal(f.tabs.column.id, RIGHT_RAIL_COLUMN_ID);
|
||||
assert.deepEqual(
|
||||
f.tabs.column.children,
|
||||
[f.strip, f.tabs.tools],
|
||||
'the tablist, then the tool slot',
|
||||
);
|
||||
assert.equal(f.tabs.tools.id, RIGHT_RAIL_TOOLS_ID);
|
||||
assert.deepEqual(f.tabs.tools.children, [], 'the slot starts empty');
|
||||
assert.equal(f.strip.id, RIGHT_RAIL_TABS_ID);
|
||||
assert.equal(f.strip.getAttribute('role'), 'tablist');
|
||||
assert.equal(
|
||||
f.strip.getAttribute('data-rail-scroller'),
|
||||
'',
|
||||
'the rail layout pass keeps the strip scroll offset across measuring',
|
||||
);
|
||||
assert.equal(f.strip.getAttribute('aria-orientation'), 'vertical');
|
||||
assert.equal(f.rail.getAttribute(RIGHT_RAIL_TABS_ATTRIBUTE), 'vertical');
|
||||
assert.deepEqual(
|
||||
@@ -425,12 +441,246 @@ test('railJoinPath traces the panel with the tab attached: corners, fillets, tab
|
||||
);
|
||||
});
|
||||
|
||||
test('destroy removes the strip and stops listening', () => {
|
||||
/** A browser-like MutationObserver over the fake tree. */
|
||||
function installObservers(doc) {
|
||||
const live = [];
|
||||
class FakeMutationObserver {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
this.targets = [];
|
||||
live.push(this);
|
||||
}
|
||||
observe(target, options) {
|
||||
this.targets.push({ target, options });
|
||||
}
|
||||
disconnect() {
|
||||
this.targets = [];
|
||||
}
|
||||
}
|
||||
doc.defaultView = { MutationObserver: FakeMutationObserver };
|
||||
return {
|
||||
attributeChanged(target, name) {
|
||||
for (const observer of live) {
|
||||
const hit = observer.targets.some(
|
||||
({ target: observed, options }) =>
|
||||
observed === target &&
|
||||
options.attributes &&
|
||||
(!options.attributeFilter ||
|
||||
options.attributeFilter.includes(name)),
|
||||
);
|
||||
if (hit)
|
||||
observer.callback([
|
||||
{ type: 'attributes', target, attributeName: name },
|
||||
]);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a rail panel hidden at mount gets its tab when its owner unhides it, from the observers alone', () => {
|
||||
const doc = makeDocument();
|
||||
const { attributeChanged } = installObservers(doc);
|
||||
const rail = doc.createElement('aside');
|
||||
rail.id = 'right-context-rail';
|
||||
const display = panel(doc, 'pp-toggles', {
|
||||
title: 'DISPLAY',
|
||||
titleClass: 'pp-header-label',
|
||||
collapsed: false,
|
||||
});
|
||||
const cctv = panel(doc, 'cctv-panel', { title: 'CCTV' });
|
||||
const recent = panel(doc, 'recent-imagery-panel', {
|
||||
title: 'RECENT IMAGERY',
|
||||
hidden: true,
|
||||
});
|
||||
const context = panel(doc, 'global-context-panel', { title: 'CONTEXT' });
|
||||
rail.append(display, cctv, recent, context);
|
||||
doc.body.append(rail);
|
||||
const tabs = createRightRailTabs({
|
||||
rail,
|
||||
documentRef: doc,
|
||||
setPanelCollapsed() {},
|
||||
});
|
||||
const ids = () => tabs.element.children.map((tab) => tab.dataset.railTab);
|
||||
assert.deepEqual(ids(), ['pp-toggles', 'cctv-panel', 'global-context-panel']);
|
||||
// Recent Imagery's layer turns on: its owner removes `hidden`, then the
|
||||
// rail pass marks it collapsed behind the Display owner.
|
||||
recent.hidden = false;
|
||||
attributeChanged(recent, 'hidden');
|
||||
recent.classList.add('rail-owner-collapsed');
|
||||
attributeChanged(recent, 'class');
|
||||
assert.deepEqual(ids(), [
|
||||
'pp-toggles',
|
||||
'cctv-panel',
|
||||
'recent-imagery-panel',
|
||||
'global-context-panel',
|
||||
]);
|
||||
tabs.destroy();
|
||||
});
|
||||
|
||||
test('a newly opened tab is scrolled into the strip window once; the join follows the visible part', () => {
|
||||
const f = fixture();
|
||||
// The strip's offsets are relative to its column, the column's to the rail.
|
||||
Object.assign(f.tabs.column, { offsetTop: 0, offsetLeft: 320 });
|
||||
Object.assign(f.strip, { clientHeight: 100, scrollTop: 0, offsetLeft: 10 });
|
||||
const tab = f.tabFor('global-context-panel');
|
||||
Object.assign(tab, {
|
||||
offsetTop: 150,
|
||||
offsetLeft: 0,
|
||||
offsetHeight: 56,
|
||||
offsetWidth: 42,
|
||||
});
|
||||
Object.assign(f.context, {
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
offsetHeight: 300,
|
||||
offsetWidth: 330,
|
||||
});
|
||||
f.context.classList.remove('collapsed');
|
||||
f.tabs.sync();
|
||||
assert.equal(
|
||||
f.strip.scrollTop,
|
||||
106,
|
||||
'opened: scrolled so the tab bottom meets the window',
|
||||
);
|
||||
assert.equal(f.rail.style.props.get('--rail-tab-bottom'), '100px');
|
||||
const [, glass] = f.tabs.join.children;
|
||||
f.strip.scrollTop = 220;
|
||||
f.tabs.sync();
|
||||
assert.equal(
|
||||
f.strip.scrollTop,
|
||||
220,
|
||||
'a sync after the user scrolled leaves the strip alone',
|
||||
);
|
||||
assert.equal(
|
||||
glass.style.clipPath,
|
||||
"path('M 16 0 L 314 0 A 16 16 0 0 1 330 16 L 330 284 A 16 16 0 0 1 314 300 L 16 300 A 16 16 0 0 1 0 284 L 0 16 A 16 16 0 0 1 16 0 Z')",
|
||||
'the tab is out of view: the join is the panel alone',
|
||||
);
|
||||
f.strip.scrollTop = 190;
|
||||
f.tabs.sync();
|
||||
assert.match(
|
||||
glass.style.clipPath,
|
||||
/^path\('M 16 0 L 364 0 A 8 8 0 0 1 372 8 A 8 8 0 0 1 364 16 L 338 16 A 8 8 0 0 0 330 24 L 330 284 /,
|
||||
'a tab half in view joins with the visible 16px and smaller corners',
|
||||
);
|
||||
const cctvTab = f.tabFor('cctv-panel');
|
||||
Object.assign(cctvTab, {
|
||||
offsetTop: 86,
|
||||
offsetLeft: 0,
|
||||
offsetHeight: 56,
|
||||
offsetWidth: 42,
|
||||
});
|
||||
Object.assign(f.cctv, {
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
offsetHeight: 300,
|
||||
offsetWidth: 330,
|
||||
});
|
||||
f.context.classList.add('collapsed');
|
||||
f.cctv.classList.remove('collapsed');
|
||||
f.tabs.sync();
|
||||
assert.equal(f.strip.scrollTop, 86, 'another tab opens: brought into view');
|
||||
});
|
||||
|
||||
test('railJoinPath shrinks the fillet and corner to the room left, down to a shared edge', () => {
|
||||
const base = {
|
||||
panel: { x0: 0, y0: 0, x1: 330, y1: 160 },
|
||||
corner: 16,
|
||||
tabRadius: 10,
|
||||
fillet: 8,
|
||||
};
|
||||
const tight = railJoinPath({
|
||||
...base,
|
||||
tab: { top: 60, bottom: 148, right: 372 },
|
||||
});
|
||||
assert.match(
|
||||
tight,
|
||||
/A 10 10 0 0 1 362 148 L 330 148 A 12 12 0 0 1 318 160 L 16 160 /,
|
||||
'12px below the tab: no fillet, a 12px corner',
|
||||
);
|
||||
const flush = railJoinPath({
|
||||
...base,
|
||||
tab: { top: 60, bottom: 160, right: 372 },
|
||||
});
|
||||
assert.match(
|
||||
flush,
|
||||
/A 10 10 0 0 1 362 160 L 16 160 A 16 16 0 0 1 0 144 /,
|
||||
'a tab level with the panel bottom shares its bottom edge',
|
||||
);
|
||||
const high = railJoinPath({
|
||||
...base,
|
||||
tab: { top: 12, bottom: 100, right: 372 },
|
||||
});
|
||||
assert.match(
|
||||
high,
|
||||
/^M 16 0 L 318 0 A 12 12 0 0 1 330 12 L 362 12 A 10 10 0 0 1 372 22 /,
|
||||
'12px above the tab: no fillet, a 12px corner',
|
||||
);
|
||||
const chamfer = railJoinPath({
|
||||
...base,
|
||||
panel: { x0: 0, y0: 0, x1: 312, y1: 160 },
|
||||
tab: { top: 4, bottom: 158, right: 372 },
|
||||
corner: 10,
|
||||
style: 'chamfer',
|
||||
});
|
||||
assert.match(
|
||||
chamfer,
|
||||
/ L 312 10 L 372 10 L 372 150 L 312 150 L 312 150 L 302 160 /,
|
||||
"Cyber keeps the tab's edges between the chamfers",
|
||||
);
|
||||
});
|
||||
|
||||
test('the stylesheet keeps phones on border-box, folds the strip into narrow desktops, shows a scrollbar and draws the focus ring inside', () => {
|
||||
const css = readStylesheet(new URL('../../style.css', import.meta.url));
|
||||
assert.match(
|
||||
css,
|
||||
/body\.mobile-shell #right-context-rail \{[^}]*box-sizing: border-box;/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/@media \(min-width: 721px\) and \(max-width: 760px\) \{\s*#right-context-rail\[data-rail-tabs\] \{\s*width: calc\(330px - var\(--right-rail-tabs-inset\)\);/,
|
||||
);
|
||||
assert.match(css, /#right-rail-tabs \{[^}]*scrollbar-width: thin;/);
|
||||
assert.doesNotMatch(css, /#right-rail-tabs::-webkit-scrollbar/);
|
||||
// The rail layout pass measures the column's natural height to climb above
|
||||
// its baseline before the strip has to scroll.
|
||||
assert.match(
|
||||
css,
|
||||
/#right-context-rail\[data-rail-measuring\] > #right-rail-column \{\s*max-height: none !important;/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/#right-rail-tabs > \.right-rail-tab:focus-visible \{\s*outline-offset: -3px;/,
|
||||
);
|
||||
assert.match(css, /--right-rail-panel-cover: min\(/);
|
||||
// The column hides on phones, where the Radio launcher is back in Context's
|
||||
// header; in the slot the launcher's popover opens upward and to the left.
|
||||
assert.match(
|
||||
css,
|
||||
/body\.mobile-shell #right-context-rail > #right-rail-column \{\s*display: none;/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/body\.mobile-shell #right-context-rail > #right-rail-tabs/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/#right-rail-tools \.context-radio-mini \{\s*top: auto;\s*right: calc\(100% \+ var\(--right-rail-tabs-gap\) \+ 6px\);\s*bottom: 0;/,
|
||||
);
|
||||
assert.match(css, /#right-rail-tools:empty \{\s*display: none;/);
|
||||
assert.match(
|
||||
css,
|
||||
/body:not\(\.mobile-shell\)\s*#right-context-rail\[data-rail-tabs\]\s*:is\(\s*\[data-collapse-target='pp-toggles'\],\s*\[data-collapse-target='cctv-panel'\],\s*\[data-collapse-target='street-level-panel'\],\s*\[data-collapse-target='global-context-panel'\],\s*\[data-collapse-target='weather-panel'\],\s*\[data-collapse-target='recent-imagery-panel'\]\s*\) \{\s*display: none;/,
|
||||
'the rail panels own collapse buttons are hidden on desktop, the Radio section keeps its own',
|
||||
);
|
||||
});
|
||||
|
||||
test('destroy removes the column and stops listening', () => {
|
||||
const f = fixture();
|
||||
const tab = f.tabFor('cctv-panel');
|
||||
f.tabs.destroy();
|
||||
assert.equal(f.strip.parentElement, null);
|
||||
assert.equal(f.rail.children.includes(f.strip), false);
|
||||
assert.equal(f.tabs.column.parentElement, null);
|
||||
assert.equal(f.rail.children.includes(f.tabs.column), false);
|
||||
assert.equal(f.rail.getAttribute(RIGHT_RAIL_TABS_ATTRIBUTE), null);
|
||||
fire(tab, 'click');
|
||||
assert.deepEqual(f.calls, []);
|
||||
|
||||
@@ -26,8 +26,6 @@ const KEYS = [
|
||||
'Exit cockpit to fly to a location',
|
||||
'Exit cockpit to fly to a photo',
|
||||
'Exit cockpit to fly to a shared view',
|
||||
'Go to expanded Radio section',
|
||||
'Expand Radio section in Context',
|
||||
'New scene name',
|
||||
'Delete scene "{scene}" and all shots?',
|
||||
'Detailed outline unavailable',
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
A sideways tab strip (rightRailTabs.js) hugs the rail's right edge and the
|
||||
open panel sits beside it. Panels keep their `collapsed` state; the rail's
|
||||
exclusive layout (rightPanelRail.js) hides the collapsed ones, so the strip
|
||||
is the only launcher. Phones keep the sheet shell (mobile-shell.css).
|
||||
is the only launcher. The compact Radio launcher, which rides in Context's
|
||||
header, moves to a slot under the tabs so it survives a collapsed Context.
|
||||
Phones keep the sheet shell (mobile-shell.css).
|
||||
|
||||
The open tab and its panel are one surface. The strip builds a join element
|
||||
(.right-rail-join) under the panels: one path traces the panel with the tab
|
||||
@@ -28,10 +30,14 @@
|
||||
--right-rail-fillet: 8px;
|
||||
--right-rail-join-style: round;
|
||||
--right-rail-join-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
/* Lower fillet, a short straight run, then the panel's corner. */
|
||||
--right-rail-panel-cover: calc(
|
||||
var(--rail-tab-bottom, 0px) + var(--right-rail-fillet) + 4px +
|
||||
var(--right-rail-corner)
|
||||
/* Lower fillet, a short straight run, then the panel's corner, within the
|
||||
rail's lane. */
|
||||
--right-rail-panel-cover: min(
|
||||
calc(
|
||||
var(--rail-tab-bottom, 0px) + var(--right-rail-fillet) + 4px +
|
||||
var(--right-rail-corner)
|
||||
),
|
||||
var(--right-stack-max-height, 70vh)
|
||||
);
|
||||
/* The theme's rail width stays the panel width; the strip's column is
|
||||
padding beside it. */
|
||||
@@ -39,28 +45,133 @@
|
||||
padding-right: var(--right-rail-tabs-inset);
|
||||
}
|
||||
|
||||
#right-rail-tabs {
|
||||
/* Just above the phone breakpoint the strip's column would push the rail into
|
||||
the left rail: fold it into the panel width instead. */
|
||||
@media (min-width: 721px) and (max-width: 760px) {
|
||||
#right-context-rail[data-rail-tabs] {
|
||||
width: calc(330px - var(--right-rail-tabs-inset));
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] #right-context-rail[data-rail-tabs] {
|
||||
width: calc(312px - var(--right-rail-tabs-inset));
|
||||
}
|
||||
}
|
||||
|
||||
/* The strip's column: the tablist, then a tool slot that holds the compact
|
||||
Radio launcher on desktop (radioPanelPlacement.js). */
|
||||
#right-rail-column {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
box-sizing: border-box;
|
||||
width: var(--right-rail-tabs-inset);
|
||||
max-height: var(--right-stack-max-height, 70vh);
|
||||
/* Room for the open tab's bridge on the left. */
|
||||
padding-left: var(--right-rail-tabs-gap);
|
||||
overflow: hidden auto;
|
||||
scrollbar-width: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#right-rail-tabs::-webkit-scrollbar {
|
||||
/* The layout pass reads the column's natural height with the lane clamp
|
||||
lifted, to climb when the lane is too short for it (rightPanelRail.js). */
|
||||
#right-context-rail[data-rail-measuring] > #right-rail-column {
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
#right-rail-tabs {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
/* Room for the open tab's bridge on the left. */
|
||||
padding-left: var(--right-rail-tabs-gap);
|
||||
overflow: hidden auto;
|
||||
/* The rail climbs above its baseline to fit the column (rightPanelRail.js);
|
||||
a lane still shorter overflows the strip: a thin scrollbar says there is
|
||||
more, and the strip keeps the open tab in view (rightRailTabs.js). */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.22) transparent;
|
||||
}
|
||||
|
||||
#right-rail-tools {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
box-sizing: border-box;
|
||||
margin-top: 6px;
|
||||
padding-left: var(--right-rail-tabs-gap);
|
||||
}
|
||||
|
||||
#right-rail-tools:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* In the slot the launcher is one more label in the column: no card, the
|
||||
tab's corner, the accent while the radio plays. Its popover opens to the
|
||||
left over the panel, bottom-aligned so it stays inside the lane. */
|
||||
#right-rail-tools .context-radio-dock {
|
||||
width: var(--right-rail-tabs-width);
|
||||
}
|
||||
|
||||
#right-rail-tools .context-radio-toggle {
|
||||
width: var(--right-rail-tabs-width);
|
||||
height: var(--right-rail-tabs-width);
|
||||
border: 0;
|
||||
border-radius: var(--right-rail-tab-radius);
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#right-rail-tools .context-radio-toggle .material-symbols-outlined {
|
||||
color: inherit;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
#right-rail-tools .context-radio-toggle:hover,
|
||||
#right-rail-tools .context-radio-toggle[aria-expanded='true'] {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
#right-rail-tools .context-radio-toggle:focus-visible {
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
#right-rail-tools .context-radio-toggle.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#right-rail-tools .context-radio-mini {
|
||||
top: auto;
|
||||
right: calc(100% + var(--right-rail-tabs-gap) + 6px);
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* The tabs open and close the rail's panels; the headers' own collapse
|
||||
buttons would only duplicate them. Phones keep the sheet shell's buttons. */
|
||||
body:not(.mobile-shell)
|
||||
#right-context-rail[data-rail-tabs]
|
||||
:is(
|
||||
[data-collapse-target='pp-toggles'],
|
||||
[data-collapse-target='cctv-panel'],
|
||||
[data-collapse-target='street-level-panel'],
|
||||
[data-collapse-target='global-context-panel'],
|
||||
[data-collapse-target='weather-panel'],
|
||||
[data-collapse-target='recent-imagery-panel']
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The strip clips overflow, so the focus ring draws inside the tab. */
|
||||
#right-rail-tabs > .right-rail-tab:focus-visible {
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.right-rail-tab {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -69,8 +180,8 @@
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: var(--right-rail-tabs-width);
|
||||
min-height: 56px;
|
||||
padding: 12px 0;
|
||||
min-height: 40px;
|
||||
padding: 6px 0;
|
||||
border: 0;
|
||||
border-radius: var(--right-rail-tab-radius);
|
||||
background: transparent;
|
||||
@@ -78,7 +189,7 @@
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
letter-spacing: 2.2px;
|
||||
letter-spacing: 1.6px;
|
||||
text-transform: uppercase;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
|
||||
cursor: pointer;
|
||||
@@ -110,6 +221,23 @@
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* Short windows: tighter tabs before the strip has to scroll. */
|
||||
@media (max-height: 720px) {
|
||||
#right-rail-tabs {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.right-rail-tab {
|
||||
min-height: 36px;
|
||||
padding: 4px 0;
|
||||
letter-spacing: 1.3px;
|
||||
}
|
||||
|
||||
#right-rail-tools {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── The join: surface, outline and shadow of panel plus tab ──────────── */
|
||||
.right-rail-join {
|
||||
position: absolute;
|
||||
@@ -195,12 +323,9 @@ body:not(.mobile-shell)
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
/* The panel reaches at least to its tab's lower fillet and corner. The
|
||||
wrapper carries the floor for the rail's allocated (focus) heights and the
|
||||
card for its natural height. */
|
||||
body:not(.mobile-shell)
|
||||
#right-context-rail[data-rail-tabs]
|
||||
> [data-panel-id]:not(.collapsed),
|
||||
/* The card reaches at least to its tab's lower fillet and corner. In the
|
||||
focus layout the rail's allocation bounds the wrapper; the card's floor is
|
||||
what the measuring pass sees. */
|
||||
body:not(.mobile-shell)
|
||||
#right-context-rail[data-rail-tabs]
|
||||
> [data-panel-id]:not(.collapsed)
|
||||
@@ -246,6 +371,17 @@ body:not(.mobile-shell)
|
||||
color: var(--cyber-red-bright);
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] #right-rail-tools .context-radio-toggle:hover,
|
||||
:root[data-ui-theme='cyber']
|
||||
#right-rail-tools
|
||||
.context-radio-toggle[aria-expanded='true'] {
|
||||
background-color: rgba(226, 235, 236, 0.08);
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] #right-rail-tools .context-radio-toggle.active {
|
||||
color: var(--cyber-red-bright);
|
||||
}
|
||||
|
||||
/* Cyber gives every .panel-collapsible wrapper a surface with an inset ring
|
||||
(cyber.css); beside the open tab that ring would show as a line. */
|
||||
:root[data-ui-theme='cyber']
|
||||
@@ -264,10 +400,13 @@ body:not(.mobile-shell)
|
||||
}
|
||||
|
||||
/* ── Phones keep the sheet shell ───────────────────────────────────────── */
|
||||
/* The content-box rail is a desktop measure; sheets size their box as before. */
|
||||
body.mobile-shell #right-context-rail {
|
||||
box-sizing: border-box;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
body.mobile-shell #right-context-rail > #right-rail-tabs {
|
||||
/* The launcher is back in Context's header there (radioPanelPlacement.js). */
|
||||
body.mobile-shell #right-context-rail > #right-rail-column {
|
||||
display: none;
|
||||
}
|
||||
Reference in new issue
Block a user