refactor(ui): trim the right rail tabs surface, share one DOM fake and one phone breakpoint

rightRailTabs.js exports only createRightRailTabs, nextRailTabIndex and railJoinPath and returns element, sync and destroy; the id constants, railTabPanels, the argument guard and the createElementNS fallback existed for the tests alone. The sub-720px density tier and the slot's focus ring go; the five rail cards carry data-rail-card so the stylesheet lists them once.

src/ui/mobileShellContract.js holds the phone shell's media query and class, dependency-free so the rail layout and panel packages can read them; rightPanelRail.js, panelPositionControls.js and radioPanelPlacement.js use it instead of their own literals. src/testSupport/fakeDom.mjs replaces the two hand-made DOM fakes of the rail and strip tests; the rail tests now expect null for a missing attribute, as in the DOM. The two Radio gate checks of the removed several-panels rail policy are gone.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Fable 5.1 committed 2026-10-28 15:17:25 +04:00
1 parent e05d6a29b4
commit 8c788474db
19 files changed
+273 -396

No files matched your search

+5 -6
View File
@@ -1499,12 +1499,11 @@ rule can still give them back.
On desktop the right rail (Display, CCTV, Street Level, Context, plus Weather On desktop the right rail (Display, CCTV, Street Level, Context, plus Weather
and Recent Imagery while shown) is a vertical tab strip and Recent Imagery while shown) is a vertical tab strip
(`src/ui/rightRailTabs.js`, `src/ui/styles/right-rail-tabs.css`): one sideways (`src/ui/rightRailTabs.js`, `src/ui/styles/right-rail-tabs.css`): one sideways
tab per panel at the rail's right edge, the open panel beside it, collapsed tab per panel at the rail's right edge, the open panel beside it. A tab
panels hidden in every HUD layout. A tab toggles its panel through toggles its panel through `setPanelCollapsed` with the explicit flag; the
`setPanelCollapsed` with the explicit flag, so opening one collapses the others panels' `collapsed` classes remain the only state, mirrored through a
and saved preferences, share links and the Context/Radio ownership rules are MutationObserver, and the owner rule above hides the rest. On desktop the
unchanged; the panels' `collapsed` classes remain the only state, mirrored panels' own header collapse
through a MutationObserver. On desktop the panels' own header collapse
buttons are hidden: the tab is the one control, Escape its keyboard twin. buttons are hidden: the tab is the one control, Escape its keyboard twin.
The strip is a vertical `role="tablist"`: Up/Down The strip is a vertical `role="tablist"`: Up/Down
wrap, Home/End jump, the open tab (else the first) is the tab stop, Escape wrap, Home/End jump, the open tab (else the first) is the tab stop, Escape
+5
View File
@@ -424,6 +424,7 @@
"src/ui/displayPanelScroll.js", "src/ui/displayPanelScroll.js",
"src/ui/leftPanelRail.js", "src/ui/leftPanelRail.js",
"src/ui/rightPanelRail.js", "src/ui/rightPanelRail.js",
"src/ui/mobileShellContract.js",
"src/ui/panelMeasurement.js", "src/ui/panelMeasurement.js",
"src/ui/panelRailGeometry.js", "src/ui/panelRailGeometry.js",
"src/panelStackLayout.js" "src/panelStackLayout.js"
@@ -762,6 +763,7 @@
"src/ui/radioTunerModel.js", "src/ui/radioTunerModel.js",
"src/ui/recordingControls.js", "src/ui/recordingControls.js",
"src/ui/rightPanelRail.js", "src/ui/rightPanelRail.js",
"src/ui/mobileShellContract.js",
"src/ui/rightRailTabs.js", "src/ui/rightRailTabs.js",
"src/ui/sceneControls.js", "src/ui/sceneControls.js",
"src/ui/scenePresentation.js", "src/ui/scenePresentation.js",
@@ -2288,6 +2290,7 @@
"src/ui/radioTunerModel.js", "src/ui/radioTunerModel.js",
"src/ui/recordingControls.js", "src/ui/recordingControls.js",
"src/ui/rightPanelRail.js", "src/ui/rightPanelRail.js",
"src/ui/mobileShellContract.js",
"src/ui/rightRailTabs.js", "src/ui/rightRailTabs.js",
"src/ui/sceneControls.js", "src/ui/sceneControls.js",
"src/ui/scenePresentation.js", "src/ui/scenePresentation.js",
@@ -3308,6 +3311,7 @@
"src/hudLayouts.js", "src/hudLayouts.js",
"src/hudLayoutPolicy.js", "src/hudLayoutPolicy.js",
"src/ui/radioPanelPlacement.js", "src/ui/radioPanelPlacement.js",
"src/ui/mobileShellContract.js",
"src/bloom.js", "src/bloom.js",
"src/cockpitVisionPolicy.js", "src/cockpitVisionPolicy.js",
"src/contactsDetectionPolicy.js", "src/contactsDetectionPolicy.js",
@@ -3354,6 +3358,7 @@
"src/ui/panelRailGeometry.js", "src/ui/panelRailGeometry.js",
"src/ui/panelRails.js", "src/ui/panelRails.js",
"src/ui/rightPanelRail.js", "src/ui/rightPanelRail.js",
"src/ui/mobileShellContract.js",
"src/ui/rightRailTabs.js", "src/ui/rightRailTabs.js",
"src/ui/uiLifetime.js", "src/ui/uiLifetime.js",
"src/i18n/index.js", "src/i18n/index.js",
-22
View File
@@ -1902,18 +1902,6 @@ async function main() {
return result; return result;
}); });
check(
'HUD-off desktop lanes keep Data plus Scenes and Display plus Context expanded inside the viewport corridor',
multiExpandedPanelLayout.independentLeft
&& multiExpandedPanelLayout.independentRight
&& multiExpandedPanelLayout.leftCountsAllExpanded
&& multiExpandedPanelLayout.rightCountsAllExpanded
&& multiExpandedPanelLayout.leftInsideCorridor
&& multiExpandedPanelLayout.rightInsideCorridor
&& multiExpandedPanelLayout.internalScrollOwned
&& multiExpandedPanelLayout.restoredExactly,
JSON.stringify(multiExpandedPanelLayout),
);
check( check(
'HUD-off to Tactical preserves the latest-opened Scenes panel and hides its collapsed Data competitor', 'HUD-off to Tactical preserves the latest-opened Scenes panel and hides its collapsed Data competitor',
multiExpandedPanelLayout.tacticalAutoCollapse.latestOpenedPanel === 'scene-panel' multiExpandedPanelLayout.tacticalAutoCollapse.latestOpenedPanel === 'scene-panel'
@@ -1927,16 +1915,6 @@ async function main() {
&& multiExpandedPanelLayout.tacticalAutoCollapse.contextAriaExpanded === 'false', && multiExpandedPanelLayout.tacticalAutoCollapse.contextAriaExpanded === 'false',
JSON.stringify(multiExpandedPanelLayout.tacticalAutoCollapse), JSON.stringify(multiExpandedPanelLayout.tacticalAutoCollapse),
); );
check(
'closing Tactical lane owners restores eligible temporary siblings without changing disclosure truth',
multiExpandedPanelLayout.tacticalAutoRestore.dataExpanded
&& multiExpandedPanelLayout.tacticalAutoRestore.dataAutoMarkerCleared
&& multiExpandedPanelLayout.tacticalAutoRestore.dataAriaExpanded === 'true'
&& multiExpandedPanelLayout.tacticalAutoRestore.contextExpanded
&& multiExpandedPanelLayout.tacticalAutoRestore.contextAutoMarkerCleared
&& multiExpandedPanelLayout.tacticalAutoRestore.contextAriaExpanded === 'true',
JSON.stringify(multiExpandedPanelLayout.tacticalAutoRestore),
);
check( check(
'Tactical Display hides ordinary collapsed CCTV and Context launchers', 'Tactical Display hides ordinary collapsed CCTV and Context launchers',
Object.values(multiExpandedPanelLayout.tacticalDisplayExclusive).every(Boolean), Object.values(multiExpandedPanelLayout.tacticalDisplayExclusive).every(Boolean),
+4 -2
View File
@@ -491,8 +491,10 @@ test('the full-width rail cannot inherit a height that overrides its floor', ()
// to a mobile mode at the SAME breakpoint and removes both the class and the // to a mobile mode at the SAME breakpoint and removes both the class and the
// custom property. Pin that, or the exemption above is unearned. // custom property. Pin that, or the exemption above is unearned.
const rail = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'rightPanelRail.js'), 'utf8'); const rail = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'rightPanelRail.js'), 'utf8');
const gate = rail.indexOf("windowRef.matchMedia('(max-width: 720px)')"); const contract = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'mobileShellContract.js'), 'utf8');
assert.ok(gate > 0, 'the rail layout pass no longer keys off (max-width: 720px)'); assert.match(contract, /MOBILE_SHELL_MEDIA_QUERY = '\(max-width: 720px\)'/, 'the shell contract no longer names (max-width: 720px)');
const gate = rail.indexOf('windowRef.matchMedia(MOBILE_SHELL_MEDIA_QUERY)');
assert.ok(gate > 0, 'the rail layout pass no longer keys off the shell breakpoint');
const mobileBranch = rail.slice(gate, rail.indexOf("layoutMode = 'mobile'", gate) + 40); const mobileBranch = rail.slice(gate, rail.indexOf("layoutMode = 'mobile'", gate) + 40);
assert.match(mobileBranch, /stack\.classList\.remove\('layout-focus'\)/); assert.match(mobileBranch, /stack\.classList\.remove\('layout-focus'\)/);
assert.match(mobileBranch, /stack\.style\.removeProperty\('--right-stack-max-height'\)/); assert.match(mobileBranch, /stack\.style\.removeProperty\('--right-stack-max-height'\)/);
+1 -2
View File
@@ -992,10 +992,9 @@ test('the DISPLAY rail starts open on a first desktop run, and a stored choice w
// decides for itself. // decides for itself.
assert.match( assert.match(
ui, ui,
/if \(panelId === 'pp-toggles' && stored === null\)\s+collapsed = !allowStored \|\| isPhoneViewport\(\);/, /if \(panelId === 'pp-toggles' && stored === null\)\s+collapsed =\s+!allowStored \|\|\s+globalThis\.matchMedia\?\.\(MOBILE_SHELL_MEDIA_QUERY\)\?\.matches === true;/,
'first desktop run must open DISPLAY; phones and share links keep it closed', 'first desktop run must open DISPLAY; phones and share links keep it closed',
); );
assert.match(ui, /matchMedia\?\.\('\(max-width: 720px\)'\)/);
// The first-run default may only apply when NOTHING is stored: a visitor who // The first-run default may only apply when NOTHING is stored: a visitor who
// opened the rail keeps it open, and one who closed it keeps it closed. // opened the rail keeps it open, and one who closed it keeps it closed.
const block = ui.slice( const block = ui.slice(
+1 -1
View File
@@ -7,7 +7,7 @@ import { shouldExpandGlobalContextPanel } from './rightRailPolicy.js';
test('the desktop right rail hides collapsed panels behind the tab strip and marks them aria-hidden', () => { test('the desktop right rail hides collapsed panels behind the tab strip and marks them aria-hidden', () => {
const ui = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8'); const ui = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url)); const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/); assert.match(ui, /const isMobile = windowRef\.matchMedia\(MOBILE_SHELL_MEDIA_QUERY\)\.matches/);
assert.match(ui, /const exclusive = !isMobile;/); assert.match(ui, /const exclusive = !isMobile;/);
assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/);
assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/);
+1 -1
View File
@@ -32,7 +32,7 @@ test('Street Level is a collapsible right-rail panel that starts collapsed', ()
assert.doesNotMatch(html, /sl-mark|mly-mark/); assert.doesNotMatch(html, /sl-mark|mly-mark/);
assert.match( assert.match(
html, html,
/<div class="street-level-panel-inner" data-rail-scroller>/, /<div class="street-level-panel-inner" data-rail-scroller data-rail-card>/,
); );
assert.doesNotMatch( assert.doesNotMatch(
html, html,
+154
View File
@@ -0,0 +1,154 @@
/**
* Just enough DOM for the rail and strip tests: a tree with classes,
* attributes, inline style properties, boxes and events. There is no layout:
* a test sets the offsets, rects and computed values it needs.
*/
export function makeDocument() {
const doc = { activeElement: null };
const descendants = (node) =>
node.children.flatMap((child) => [child, ...descendants(child)]);
const findIn = (node, predicate) => {
for (const child of node.children) {
if (predicate(child)) return child;
const nested = findIn(child, predicate);
if (nested) return nested;
}
return null;
};
doc.createElement = (tag) => {
const el = new EventTarget();
const attrs = new Map();
const classes = new Set();
const props = new Map();
const writes = [];
Object.assign(el, {
tagName: tag.toUpperCase(),
ownerDocument: doc,
children: [],
parentElement: null,
dataset: {},
scrollHeight: 0,
clientHeight: 0,
scrollTop: 0,
rect: { top: 0, left: 0, width: 0, height: 0, right: 0, bottom: 0 },
computed: { display: 'block', visibility: 'visible', opacity: '1' },
// Every inline style write, in order, for write-if-changed assertions.
writes,
style: {
props,
setProperty(key, value) {
writes.push(['set', key, value]);
props.set(key, value);
},
removeProperty(key) {
writes.push(['remove', key]);
props.delete(key);
},
getPropertyValue: (key) => props.get(key) || '',
},
hidden: false,
textContent: '',
tabIndex: -1,
title: '',
type: '',
id: '',
classList: {
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
contains: (name) => classes.has(name),
toggle(name, on) {
if (on) classes.add(name);
else classes.delete(name);
},
},
setAttribute: (key, value) => attrs.set(key, String(value)),
getAttribute: (key) => attrs.get(key) ?? null,
removeAttribute: (key) => attrs.delete(key),
getBoundingClientRect: () => el.rect,
contains(target) {
return (
target === el || el.children.some((child) => child.contains(target))
);
},
insertBefore(child, reference) {
child.remove();
const index = reference ? el.children.indexOf(reference) : -1;
el.children.splice(index < 0 ? el.children.length : index, 0, child);
child.parentElement = el;
return child;
},
append(...nodes) {
for (const node of nodes) el.insertBefore(node, null);
},
prepend(...nodes) {
for (const node of nodes.reverse())
el.insertBefore(node, el.children[0] ?? null);
},
remove() {
const parent = el.parentElement;
if (!parent) return;
parent.children.splice(parent.children.indexOf(el), 1);
el.parentElement = null;
},
matches(selector) {
if (selector === '[data-panel-id]') return 'panelId' in el.dataset;
if (selector === '[data-rail-tab]') return 'railTab' in el.dataset;
return false;
},
closest(selector) {
return el.matches(selector)
? el
: (el.parentElement?.closest(selector) ?? null);
},
/** Class selectors only, comma-separated. */
querySelector(selector) {
const wanted = selector.split(',').map((part) => part.trim().slice(1));
return findIn(el, (node) =>
wanted.some((name) => node.classList.contains(name)),
);
},
/** A simple selector, or one scoped to the children with ':scope >'. */
querySelectorAll(selector) {
const own = selector.startsWith(':scope > ');
const simple = own ? selector.slice(':scope > '.length) : selector;
const pool = own ? el.children : descendants(el);
return pool.filter((node) => node.matches(simple));
},
focus() {
doc.activeElement = el;
},
});
Object.defineProperties(el, {
className: {
get: () => [...classes].join(' '),
set(value) {
classes.clear();
for (const name of String(value).split(/\s+/).filter(Boolean))
classes.add(name);
},
},
firstElementChild: { get: () => el.children[0] ?? null },
nextElementSibling: {
get: () => {
const siblings = el.parentElement?.children;
if (!siblings) return null;
return siblings[siblings.indexOf(el) + 1] ?? null;
},
},
});
return el;
};
doc.createElementNS = (_namespace, tag) => doc.createElement(tag);
doc.body = doc.createElement('body');
doc.getElementById = (id) => findIn(doc.body, (node) => node.id === id);
return doc;
}
/** Dispatch a bubbling event with `target` fixed to `el`. */
export function fire(el, type, props = {}) {
const event = Object.assign(new Event(type, { cancelable: true }), props);
Object.defineProperty(event, 'target', { value: el });
for (let node = el; node; node = node.parentElement)
node.dispatchEvent(event);
return event;
}
+5 -2
View File
@@ -13,9 +13,12 @@
import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js'; import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js';
import { msg, t } from '../i18n/index.js'; import { msg, t } from '../i18n/index.js';
import {
MOBILE_SHELL_CLASS,
MOBILE_SHELL_MEDIA_QUERY,
} from './mobileShellContract.js';
export const MOBILE_SHELL_MEDIA_QUERY = '(max-width: 720px)'; export { MOBILE_SHELL_CLASS, MOBILE_SHELL_MEDIA_QUERY };
export const MOBILE_SHELL_CLASS = 'mobile-shell';
export const MOBILE_SHEET_ATTR = 'data-mobile-sheet'; export const MOBILE_SHEET_ATTR = 'data-mobile-sheet';
/** /**
+7
View File
@@ -0,0 +1,7 @@
/**
* Where the phone shell begins (mobileShell.js), for the rails and panels
* that present differently under it. Kept apart from the shell so the rail
* layout and panel packages need none of the shell's dependencies.
*/
export const MOBILE_SHELL_MEDIA_QUERY = '(max-width: 720px)';
export const MOBILE_SHELL_CLASS = 'mobile-shell';
+4 -5
View File
@@ -1,5 +1,6 @@
/** Own panel collapsed-state preferences and the one-time layout-reset notice. */ /** Own panel collapsed-state preferences and the one-time layout-reset notice. */
import { t } from '../i18n/index.js'; import { t } from '../i18n/index.js';
import { MOBILE_SHELL_MEDIA_QUERY } from './mobileShellContract.js';
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */ /** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
const PANEL_LAYOUT_STORAGE_VERSION = 'v6'; const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
/** /**
@@ -13,10 +14,6 @@ const RETIRED_PANEL_KEY_PREFIXES = [
`godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelPos.`, `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelPos.`,
`godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`, `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`,
]; ];
/** Phones present the rail as sheets (mobileShell.js), not as a tab strip. */
const isPhoneViewport = () =>
globalThis.matchMedia?.('(max-width: 720px)')?.matches === true;
export class PanelPositionControls { export class PanelPositionControls {
constructor({ syncPanelCollapseButton, showToast }) { constructor({ syncPanelCollapseButton, showToast }) {
this._syncPanelCollapseButton = syncPanelCollapseButton; this._syncPanelCollapseButton = syncPanelCollapseButton;
@@ -76,7 +73,9 @@ export class PanelPositionControls {
// keep it collapsed (their sheets present the rail); a share link decides // keep it collapsed (their sheets present the rail); a share link decides
// for itself. A stored choice wins in both directions. // for itself. A stored choice wins in both directions.
if (panelId === 'pp-toggles' && stored === null) if (panelId === 'pp-toggles' && stored === null)
collapsed = !allowStored || isPhoneViewport(); collapsed =
!allowStored ||
globalThis.matchMedia?.(MOBILE_SHELL_MEDIA_QUERY)?.matches === true;
panelEl.classList.toggle('collapsed', collapsed); panelEl.classList.toggle('collapsed', collapsed);
// Bodies that expand themselves on first appearance must not override a // Bodies that expand themselves on first appearance must not override a
// choice the user (or a share link) already made. // choice the user (or a share link) already made.
+23 -70
View File
@@ -1,3 +1,4 @@
import { makeDocument } from '../testSupport/fakeDom.mjs';
import { readStylesheet } from '../testSupport/readStylesheet.mjs'; import { readStylesheet } from '../testSupport/readStylesheet.mjs';
import { displayPanelScroller } from './displayPanelScroll.js'; import { displayPanelScroller } from './displayPanelScroll.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -73,73 +74,26 @@ test('Cyber restored accordion prefers keyboard focus when no explicit owner exi
assert.equal(f.second.classList.contains('collapsed'), false); assert.equal(f.second.classList.contains('collapsed'), false);
}); });
const doc = makeDocument();
/** A rail panel (or any rail-side box) with its measured box and no content. */
function element( function element(
id, id,
{ height = 42, top = 234, left = 20, width = 272, collapsed = false } = {}, { height = 42, top = 234, left = 20, width = 272, collapsed = false } = {},
) { ) {
const classes = new Set(collapsed ? ['collapsed'] : []); const node = doc.createElement('div');
const properties = new Map(); node.id = id;
const attributes = new Map(); node.dataset.panelId = id;
const writes = []; if (collapsed) node.classList.add('collapsed');
const node = { node.scrollHeight = node.clientHeight = height;
id, node.computed.rowGap = '12px';
children: [], Object.assign(node.rect, {
dataset: {}, top,
parentElement: null, left,
scrollHeight: height, width,
clientHeight: height, height,
scrollTop: 0, right: left + width,
computed: { bottom: top + height,
display: 'block', });
visibility: 'visible',
opacity: '1',
rowGap: '12px',
},
rect: {
top,
left,
width,
height,
right: left + width,
bottom: top + height,
},
classList: {
contains: (name) => classes.has(name),
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
toggle(name, value) {
if (value) classes.add(name);
else classes.delete(name);
},
},
style: {
setProperty(name, value) {
writes.push(['set', name, value]);
properties.set(name, value);
},
getPropertyValue: (name) => properties.get(name) || '',
removeProperty(name) {
writes.push(['remove', name]);
properties.delete(name);
},
},
getBoundingClientRect() {
return this.rect;
},
matches: (selector) => selector === '[data-panel-id]',
contains(target) {
return (
target === this || this.children.some((child) => child.contains(target))
);
},
querySelectorAll() {
return this.children;
},
setAttribute: (name, value) => attributes.set(name, value),
removeAttribute: (name) => attributes.delete(name),
getAttribute: (name) => attributes.get(name),
writes,
};
return node; return node;
} }
function fixture( function fixture(
@@ -245,7 +199,7 @@ for (const side of ['left', 'right']) {
f.first.style.getPropertyValue(`--${side}-panel-allocated-height`), f.first.style.getPropertyValue(`--${side}-panel-allocated-height`),
'', '',
); );
assert.equal(f.first.getAttribute('aria-hidden'), undefined); assert.equal(f.first.getAttribute('aria-hidden'), null);
}); });
} }
@@ -494,8 +448,7 @@ function thrashingRightRail({ focused = false, weatherOpen = false } = {}) {
f.options.preferredPanelId = cctv.id; f.options.preferredPanelId = cctv.id;
f.options.displayPanel = display; f.options.displayPanel = display;
const allocation = '--right-panel-allocated-height'; const allocation = '--right-panel-allocated-height';
const measuring = () => const measuring = () => f.stack.getAttribute('data-rail-measuring') !== null;
f.stack.getAttribute('data-rail-measuring') !== undefined;
for (const panel of f.stack.children) { for (const panel of f.stack.children) {
panel.parentElement = f.stack; panel.parentElement = f.stack;
panel.intrinsicHeight = panel.rect.height; panel.intrinsicHeight = panel.rect.height;
@@ -544,7 +497,7 @@ for (const focused of [false, true]) {
modes.push(f.stack.classList.contains('layout-focus')); modes.push(f.stack.classList.contains('layout-focus'));
needs.push(f.stack.dataset.requiredHeight); needs.push(f.stack.dataset.requiredHeight);
assert.equal(f.stack.dataset.availableHeight, '443.6'); assert.equal(f.stack.dataset.availableHeight, '443.6');
assert.equal(f.stack.getAttribute('data-rail-measuring'), undefined); assert.equal(f.stack.getAttribute('data-rail-measuring'), null);
} }
assert.deepEqual(modes.slice(1), Array(9).fill(true)); assert.deepEqual(modes.slice(1), Array(9).fill(true));
// A collapsed peer is hidden behind the tab strip and takes no room. // A collapsed peer is hidden behind the tab strip and takes no room.
@@ -621,7 +574,7 @@ test('right rail restores presentation even when intrinsic measurement throws',
throw new Error('measurement failed'); throw new Error('measurement failed');
}; };
assert.throws(() => f.run(), /measurement failed/); assert.throws(() => f.run(), /measurement failed/);
assert.equal(f.stack.getAttribute('data-rail-measuring'), undefined); assert.equal(f.stack.getAttribute('data-rail-measuring'), null);
}); });
test('right rail measuring pass keeps an opted-in scroller position', () => { test('right rail measuring pass keeps an opted-in scroller position', () => {
@@ -775,7 +728,7 @@ test('right layout ignores a CSS-hidden expanded panel: it is neither owner nor
assert.equal(f.first.getAttribute('aria-hidden'), 'true'); assert.equal(f.first.getAttribute('aria-hidden'), 'true');
assert.equal(f.second.getAttribute('aria-hidden'), 'true'); assert.equal(f.second.getAttribute('aria-hidden'), 'true');
assert.equal(weather.classList.contains('collapsed'), false); assert.equal(weather.classList.contains('collapsed'), false);
assert.equal(weather.getAttribute('aria-hidden'), undefined); assert.equal(weather.getAttribute('aria-hidden'), null);
assert.equal( assert.equal(
weather.style.getPropertyValue('--right-panel-allocated-height'), weather.style.getPropertyValue('--right-panel-allocated-height'),
'', '',
@@ -829,7 +782,7 @@ test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', (
imagery.style.getPropertyValue('--right-panel-allocated-height'), imagery.style.getPropertyValue('--right-panel-allocated-height'),
'', '',
); );
assert.equal(imagery.getAttribute('aria-hidden'), undefined); assert.equal(imagery.getAttribute('aria-hidden'), null);
const alone = parseFloat( const alone = parseFloat(
f.first.style.getPropertyValue('--right-panel-allocated-height'), f.first.style.getPropertyValue('--right-panel-allocated-height'),
); );
+3 -1
View File
@@ -1,3 +1,5 @@
import { MOBILE_SHELL_CLASS } from './mobileShellContract.js';
/** /**
* 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 * The compact launcher (the dock) sits in the right rail's tool slot under the
@@ -17,7 +19,7 @@ export function syncRadioPanelPlacement(doc) {
if (!panel || !parent || !header || !dock || !mini) return; if (!panel || !parent || !header || !dock || !mini) return;
if (panel.parentElement !== parent) parent.append(panel); if (panel.parentElement !== parent) parent.append(panel);
const slot = doc.getElementById('right-rail-tools'); const slot = doc.getElementById('right-rail-tools');
const phone = Boolean(doc.body?.classList?.contains?.('mobile-shell')); const phone = Boolean(doc.body?.classList?.contains?.(MOBILE_SHELL_CLASS));
if (slot && !phone) { if (slot && !phone) {
if (dock.parentElement !== slot) slot.append(dock); if (dock.parentElement !== slot) slot.append(dock);
} else if (dock.parentElement !== header) { } else if (dock.parentElement !== header) {
+2 -1
View File
@@ -2,6 +2,7 @@ import { allocatePanelStackHeights } from '../panelStackLayout.js';
import { resolveHudRailLayout } from './panelRailGeometry.js'; import { resolveHudRailLayout } from './panelRailGeometry.js';
import { displayPanelScroller } from './displayPanelScroll.js'; import { displayPanelScroller } from './displayPanelScroll.js';
import { MOBILE_SHELL_MEDIA_QUERY } from './mobileShellContract.js';
/** /**
* Where the left rail's top will settle, not where it is mid-transition: * Where the left rail's top will settle, not where it is mid-transition:
@@ -77,7 +78,7 @@ export function layoutRightPanelRail({
// presentation-collapsed; the marker is distinct from the left rail's // presentation-collapsed; the marker is distinct from the left rail's
// space-based collapse. Phone sheets show several panels at once, so phones // space-based collapse. Phone sheets show several panels at once, so phones
// get every panel back. // get every panel back.
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; const isMobile = windowRef.matchMedia(MOBILE_SHELL_MEDIA_QUERY).matches;
if (!isMobile) { if (!isMobile) {
const expanded = panels.filter( const expanded = panels.filter(
(panel) => !panel.classList.contains('collapsed'), (panel) => !panel.classList.contains('collapsed'),
+16 -40
View File
@@ -6,26 +6,18 @@
* and the rail layout pass are untouched. Phones hide the strip and keep their * and the rail layout pass are untouched. Phones hide the strip and keep their
* sheet shell (mobileShell.js). * sheet shell (mobileShell.js).
* *
* The open tab and its panel are one surface: the strip measures both, traces * The open tab and its panel are drawn as one surface by a join element the
* their joined silhouette as an SVG path (railJoinPath) and keeps a join * strip shapes from both (railJoinPath); styles/right-rail-tabs.css has the
* element under the panels whose layers blur, tint, outline and shadow that * layers. A tool slot under the tabs holds the compact Radio launcher on
* shape (styles/right-rail-tabs.css). * desktop (radioPanelPlacement.js).
*
* 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'; 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 * Marks the rail while its strip is mounted; styles/right-rail-tabs.css keys
* the tab geometry on it, so a rail without a strip keeps its plain layout. * the tab geometry on it, so a rail without a strip keeps its plain layout.
*/ */
export const RIGHT_RAIL_TABS_ATTRIBUTE = 'data-rail-tabs'; const RAIL_TABS_ATTRIBUTE = 'data-rail-tabs';
const SVG_NS = 'http://www.w3.org/2000/svg'; const SVG_NS = 'http://www.w3.org/2000/svg';
@@ -54,13 +46,6 @@ export function nextRailTabIndex(key, index, count) {
} }
} }
/** The panels a rail presents as tabs, in rail order. */
export function railTabPanels(rail) {
return [...(rail?.children || [])].filter(
(panel) => panel.matches?.('[data-panel-id]') && !panel.hidden,
);
}
/** /**
* SVG path of the panel with the open tab attached to its right edge, traced * SVG path of the panel with the open tab attached to its right edge, traced
* clockwise from the top-left corner, in rail pixels. * clockwise from the top-left corner, in rail pixels.
@@ -208,35 +193,27 @@ function panelTitle(panel) {
* @param {(panelId: string, collapsed: boolean, options: object) => void} input.setPanelCollapsed * @param {(panelId: string, collapsed: boolean, options: object) => void} input.setPanelCollapsed
* Owner callback; the strip never touches panel classes itself. * Owner callback; the strip never touches panel classes itself.
* @param {Document} [input.documentRef] * @param {Document} [input.documentRef]
* @returns {{ element: HTMLElement, column: HTMLElement, tools: HTMLElement, * @returns {{ element: HTMLElement, sync: () => void, destroy: () => void }}
* join: HTMLElement, sync: () => void, destroy: () => void }}
*/ */
export function createRightRailTabs({ export function createRightRailTabs({
rail, rail,
setPanelCollapsed, setPanelCollapsed,
documentRef = rail?.ownerDocument, documentRef = rail.ownerDocument,
}) { }) {
if (!rail || !documentRef || typeof setPanelCollapsed !== 'function') {
throw new TypeError(
'Right rail tabs require a rail, a document and setPanelCollapsed',
);
}
const column = documentRef.createElement('div'); const column = documentRef.createElement('div');
column.id = RIGHT_RAIL_COLUMN_ID; column.id = 'right-rail-column';
const strip = documentRef.createElement('div'); const strip = documentRef.createElement('div');
strip.id = RIGHT_RAIL_TABS_ID; strip.id = 'right-rail-tabs';
strip.setAttribute('role', 'tablist'); strip.setAttribute('role', 'tablist');
strip.setAttribute('aria-orientation', 'vertical'); strip.setAttribute('aria-orientation', 'vertical');
strip.setAttribute('aria-label', t('Panels')); strip.setAttribute('aria-label', t('Panels'));
const tools = documentRef.createElement('div'); const tools = documentRef.createElement('div');
tools.id = RIGHT_RAIL_TOOLS_ID; tools.id = 'right-rail-tools';
column.append(strip, tools); column.append(strip, tools);
rail.setAttribute(RIGHT_RAIL_TABS_ATTRIBUTE, 'vertical'); rail.setAttribute(RAIL_TABS_ATTRIBUTE, 'vertical');
// The join: shadow under, glass, outline over; all shaped by one path. // The join: shadow under, glass, outline over; all shaped by one path.
const svg = (tag) => const svg = (tag) => documentRef.createElementNS(SVG_NS, tag);
documentRef.createElementNS?.(SVG_NS, tag) ??
documentRef.createElement(tag);
const join = documentRef.createElement('div'); const join = documentRef.createElement('div');
join.className = 'right-rail-join'; join.className = 'right-rail-join';
join.setAttribute('aria-hidden', 'true'); join.setAttribute('aria-hidden', 'true');
@@ -377,7 +354,9 @@ export function createRightRailTabs({
// owners reveal them by attribute, which must create their tabs. // owners reveal them by attribute, which must create their tabs.
for (const panel of rail.children) for (const panel of rail.children)
if (panel.matches?.('[data-panel-id]')) observe(panel); if (panel.matches?.('[data-panel-id]')) observe(panel);
const panels = railTabPanels(rail); const panels = [...rail.children].filter(
(panel) => panel.matches?.('[data-panel-id]') && !panel.hidden,
);
const focusOwner = const focusOwner =
panels.find((panel) => !panel.classList.contains('collapsed')) || panels.find((panel) => !panel.classList.contains('collapsed')) ||
panels[0]; panels[0];
@@ -436,9 +415,6 @@ export function createRightRailTabs({
return { return {
element: strip, element: strip,
column,
tools,
join,
sync, sync,
destroy() { destroy() {
if (destroyed) return; if (destroyed) return;
@@ -449,7 +425,7 @@ export function createRightRailTabs({
strip.removeEventListener('keydown', onKeydown); strip.removeEventListener('keydown', onKeydown);
column.remove(); column.remove();
join.remove(); join.remove();
rail.removeAttribute(RIGHT_RAIL_TABS_ATTRIBUTE); rail.removeAttribute(RAIL_TABS_ATTRIBUTE);
placeJoin(null, null); placeJoin(null, null);
entries.clear(); entries.clear();
}, },
+35 -201
View File
@@ -1,136 +1,13 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { fire, makeDocument } from '../testSupport/fakeDom.mjs';
import { readStylesheet } from '../testSupport/readStylesheet.mjs'; import { readStylesheet } from '../testSupport/readStylesheet.mjs';
import { import {
RIGHT_RAIL_COLUMN_ID,
RIGHT_RAIL_TABS_ATTRIBUTE,
RIGHT_RAIL_TABS_ID,
RIGHT_RAIL_TOOLS_ID,
createRightRailTabs, createRightRailTabs,
nextRailTabIndex, nextRailTabIndex,
railJoinPath, railJoinPath,
railTabPanels,
} from './rightRailTabs.js'; } from './rightRailTabs.js';
/** Just enough DOM for the strip: tree, classes, attributes, events, focus. */
function makeDocument() {
const doc = { activeElement: null };
const findIn = (node, predicate) => {
for (const child of node.children) {
if (predicate(child)) return child;
const nested = findIn(child, predicate);
if (nested) return nested;
}
return null;
};
doc.createElement = (tag) => {
const el = new EventTarget();
const attrs = new Map();
const classes = new Set();
const props = new Map();
Object.assign(el, {
tagName: tag.toUpperCase(),
ownerDocument: doc,
children: [],
parentElement: null,
dataset: {},
style: {
props,
setProperty: (key, value) => props.set(key, value),
removeProperty: (key) => props.delete(key),
},
hidden: false,
textContent: '',
tabIndex: -1,
title: '',
type: '',
id: '',
classList: {
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
contains: (name) => classes.has(name),
toggle(name, on) {
if (on) classes.add(name);
else classes.delete(name);
},
},
setAttribute: (key, value) => attrs.set(key, String(value)),
getAttribute: (key) => attrs.get(key) ?? null,
removeAttribute: (key) => attrs.delete(key),
insertBefore(child, reference) {
child.remove();
const index = reference ? el.children.indexOf(reference) : -1;
el.children.splice(index < 0 ? el.children.length : index, 0, child);
child.parentElement = el;
return child;
},
append(...nodes) {
for (const node of nodes) el.insertBefore(node, null);
},
prepend(...nodes) {
for (const node of nodes.reverse())
el.insertBefore(node, el.children[0] ?? null);
},
remove() {
const parent = el.parentElement;
if (!parent) return;
parent.children.splice(parent.children.indexOf(el), 1);
el.parentElement = null;
},
matches(selector) {
if (selector === '[data-panel-id]') return 'panelId' in el.dataset;
if (selector === '[data-rail-tab]') return 'railTab' in el.dataset;
return false;
},
closest(selector) {
return el.matches(selector)
? el
: (el.parentElement?.closest(selector) ?? null);
},
querySelector(selector) {
const wanted = selector.split(',').map((part) => part.trim().slice(1));
return findIn(el, (node) =>
wanted.some((name) => node.classList.contains(name)),
);
},
focus() {
doc.activeElement = el;
},
});
Object.defineProperties(el, {
className: {
get: () => [...classes].join(' '),
set(value) {
classes.clear();
for (const name of String(value).split(/\s+/).filter(Boolean))
classes.add(name);
},
},
firstElementChild: { get: () => el.children[0] ?? null },
nextElementSibling: {
get: () => {
const siblings = el.parentElement?.children;
if (!siblings) return null;
return siblings[siblings.indexOf(el) + 1] ?? null;
},
},
});
return el;
};
doc.body = doc.createElement('body');
doc.getElementById = (id) => findIn(doc.body, (node) => node.id === id);
return doc;
}
/** Dispatch a bubbling event with `target` fixed to `el`. */
function fire(el, type, props = {}) {
const event = Object.assign(new Event(type, { cancelable: true }), props);
Object.defineProperty(event, 'target', { value: el });
for (let node = el; node; node = node.parentElement)
node.dispatchEvent(event);
return event;
}
function panel( function panel(
doc, doc,
id, id,
@@ -175,6 +52,9 @@ function fixture() {
setPanelCollapsed: (...args) => calls.push(args), setPanelCollapsed: (...args) => calls.push(args),
}); });
const strip = tabs.element; const strip = tabs.element;
const column = doc.getElementById('right-rail-column');
const tools = doc.getElementById('right-rail-tools');
const join = rail.querySelector('.right-rail-join');
const tabFor = (id) => const tabFor = (id) =>
strip.children.find((tab) => tab.dataset.railTab === id); strip.children.find((tab) => tab.dataset.railTab === id);
const labelOf = (tab) => const labelOf = (tab) =>
@@ -190,6 +70,9 @@ function fixture() {
context, context,
tabs, tabs,
strip, strip,
column,
tools,
join,
calls, calls,
tabFor, tabFor,
labelOf, labelOf,
@@ -200,21 +83,18 @@ test('builds one tab per visible rail panel, in rail order, labelled from the pa
const f = fixture(); const f = fixture();
assert.equal( assert.equal(
f.rail.children[0], f.rail.children[0],
f.tabs.column, f.column,
'the column precedes its panels in the tab order', 'the column precedes its panels in the tab order',
); );
assert.equal(f.tabs.column.id, RIGHT_RAIL_COLUMN_ID);
assert.deepEqual( assert.deepEqual(
f.tabs.column.children, f.column.children,
[f.strip, f.tabs.tools], [f.strip, f.tools],
'the tablist, then the tool slot', 'the tablist, then the tool slot',
); );
assert.equal(f.tabs.tools.id, RIGHT_RAIL_TOOLS_ID); assert.deepEqual(f.tools.children, [], 'the slot starts empty');
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('role'), 'tablist');
assert.equal(f.strip.getAttribute('aria-orientation'), 'vertical'); assert.equal(f.strip.getAttribute('aria-orientation'), 'vertical');
assert.equal(f.rail.getAttribute(RIGHT_RAIL_TABS_ATTRIBUTE), 'vertical'); assert.equal(f.rail.getAttribute('data-rail-tabs'), 'vertical');
assert.deepEqual( assert.deepEqual(
f.strip.children.map((tab) => [ f.strip.children.map((tab) => [
tab.dataset.railTab, tab.dataset.railTab,
@@ -232,10 +112,6 @@ test('builds one tab per visible rail panel, in rail order, labelled from the pa
assert.equal(tab.getAttribute('role'), 'tab'); assert.equal(tab.getAttribute('role'), 'tab');
assert.equal(tab.type, 'button'); assert.equal(tab.type, 'button');
} }
assert.deepEqual(
railTabPanels(f.rail).map((p) => p.id),
['pp-toggles', 'cctv-panel', 'global-context-panel'],
);
}); });
test('mirrors collapsed state into aria-selected, a roving tabindex and titles', () => { test('mirrors collapsed state into aria-selected, a roving tabindex and titles', () => {
@@ -342,12 +218,15 @@ test('a sync that changes nothing keeps the same tab elements in place', () => {
test('publishes the open tab extent on the rail and keeps a join element under the panels', () => { test('publishes the open tab extent on the rail and keeps a join element under the panels', () => {
const f = fixture(); const f = fixture();
// The strip's offsets are relative to its column, the column's to the rail.
Object.assign(f.column, { offsetTop: 0, offsetLeft: 320 });
Object.assign(f.strip, { offsetLeft: 10 });
const { props } = f.rail.style; const { props } = f.rail.style;
assert.equal(f.tabs.join.parentElement, f.rail); assert.equal(f.join.parentElement, f.rail);
assert.equal(f.tabs.join.getAttribute('aria-hidden'), 'true'); assert.equal(f.join.getAttribute('aria-hidden'), 'true');
assert.equal(f.tabs.join.hidden, true, 'nothing open, nothing to join'); assert.equal(f.join.hidden, true, 'nothing open, nothing to join');
assert.deepEqual( assert.deepEqual(
f.tabs.join.children.map((layer) => layer.getAttribute('class')), f.join.children.map((layer) => layer.getAttribute('class')),
[ [
'right-rail-join-shadow', 'right-rail-join-shadow',
'right-rail-join-glass', 'right-rail-join-glass',
@@ -370,18 +249,27 @@ test('publishes the open tab extent on the rail and keeps a join element under t
f.cctv.classList.remove('collapsed'); f.cctv.classList.remove('collapsed');
f.tabs.sync(); f.tabs.sync();
assert.deepEqual([...props], [['--rail-tab-bottom', '142px']]); assert.deepEqual([...props], [['--rail-tab-bottom', '142px']]);
assert.equal(f.tabs.join.hidden, false); assert.equal(f.join.hidden, false);
const [, glass] = f.tabs.join.children; const [, glass] = f.join.children;
assert.match( assert.match(
glass.style.clipPath, glass.style.clipPath,
/^path\('M 16 0 L 314 0 A 16 16 0 0 1 330 16 L 330 78 /, /^path\('M 16 0 L 314 0 A 16 16 0 0 1 330 16 L 330 78 /,
); );
// A short strip shrinks the tab: its corners stay within half its height.
tab.offsetHeight = 12;
f.tabs.sync();
assert.equal(props.get('--rail-tab-bottom'), '98px');
assert.match(
glass.style.clipPath,
/L 366 86 A 6 6 0 0 1 372 92 A 6 6 0 0 1 366 98 L 338 98 /,
'a 12px tab gets 6px corners',
);
f.cctv.classList.add('collapsed'); f.cctv.classList.add('collapsed');
f.tabs.sync(); f.tabs.sync();
assert.deepEqual([...props], [], 'no open panel, no join'); assert.deepEqual([...props], [], 'no open panel, no join');
assert.equal(f.tabs.join.hidden, true); assert.equal(f.join.hidden, true);
f.tabs.destroy(); f.tabs.destroy();
assert.equal(f.tabs.join.parentElement, null); assert.equal(f.join.parentElement, null);
}); });
test('railJoinPath traces the panel with the tab attached: corners, fillets, tab corners', () => { test('railJoinPath traces the panel with the tab attached: corners, fillets, tab corners', () => {
@@ -504,35 +392,6 @@ test('a rail panel hidden at mount gets its tab when its owner unhides it, from
tabs.destroy(); tabs.destroy();
}); });
test('a tab shrunk by a short strip joins with corners no larger than half its height', () => {
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, { offsetLeft: 10 });
const tab = f.tabFor('global-context-panel');
Object.assign(tab, {
offsetTop: 150,
offsetLeft: 0,
offsetHeight: 12,
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.rail.style.props.get('--rail-tab-bottom'), '162px');
const [, glass] = f.tabs.join.children;
assert.match(
glass.style.clipPath,
/L 366 150 A 6 6 0 0 1 372 156 A 6 6 0 0 1 366 162 L 338 162 /,
'a 12px tab gets 6px corners',
);
});
test('railJoinPath shrinks the fillet and corner to the room left, down to a shared edge', () => { test('railJoinPath shrinks the fillet and corner to the room left, down to a shared edge', () => {
const base = { const base = {
panel: { x0: 0, y0: 0, x1: 330, y1: 160 }, panel: { x0: 0, y0: 0, x1: 330, y1: 160 },
@@ -558,15 +417,6 @@ test('railJoinPath shrinks the fillet and corner to the room left, down to a sha
/A 10 10 0 0 1 362 160 L 16 160 A 16 16 0 0 1 0 144 /, /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', '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({ const chamfer = railJoinPath({
...base, ...base,
panel: { x0: 0, y0: 0, x1: 312, y1: 160 }, panel: { x0: 0, y0: 0, x1: 312, y1: 160 },
@@ -650,26 +500,10 @@ test('destroy removes the column and stops listening', () => {
const f = fixture(); const f = fixture();
const tab = f.tabFor('cctv-panel'); const tab = f.tabFor('cctv-panel');
f.tabs.destroy(); f.tabs.destroy();
assert.equal(f.tabs.column.parentElement, null); assert.equal(f.column.parentElement, null);
assert.equal(f.rail.children.includes(f.tabs.column), false); assert.equal(f.rail.children.includes(f.column), false);
assert.equal(f.rail.getAttribute(RIGHT_RAIL_TABS_ATTRIBUTE), null); assert.equal(f.rail.getAttribute('data-rail-tabs'), null);
fire(tab, 'click'); fire(tab, 'click');
assert.deepEqual(f.calls, []); assert.deepEqual(f.calls, []);
f.tabs.destroy(); f.tabs.destroy();
}); });
test('refuses to build without a rail, a document or an owner callback', () => {
const doc = makeDocument();
assert.throws(
() => createRightRailTabs({ rail: null, setPanelCollapsed() {} }),
TypeError,
);
assert.throws(
() =>
createRightRailTabs({
rail: doc.createElement('aside'),
documentRef: doc,
}),
TypeError,
);
});
+2 -37
View File
@@ -138,10 +138,6 @@
background: rgba(255, 255, 255, 0.06); 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 { #right-rail-tools .context-radio-toggle.active {
color: var(--accent); color: var(--accent);
} }
@@ -229,23 +225,6 @@ body:not(.mobile-shell)
text-shadow: none; text-shadow: none;
} }
/* Short windows: tighter tabs, so fewer labels clip. */
@media (max-height: 720px) {
#right-rail-tabs {
gap: 3px;
}
.right-rail-tab {
padding: 4px 0;
letter-spacing: 1.3px;
mask-image: linear-gradient(to bottom, #000 calc(100% - 4px), transparent);
}
#right-rail-tools {
margin-top: 4px;
}
}
/* ── The join: surface, outline and shadow of panel plus tab ──────────── */ /* ── The join: surface, outline and shadow of panel plus tab ──────────── */
.right-rail-join { .right-rail-join {
position: absolute; position: absolute;
@@ -303,14 +282,7 @@ body:not(.mobile-shell)
body:not(.mobile-shell) body:not(.mobile-shell)
#right-context-rail[data-rail-tabs] #right-context-rail[data-rail-tabs]
> [data-panel-id]:not(.collapsed) > [data-panel-id]:not(.collapsed)
> :is( > :is([data-rail-card], [data-panel-surface]),
.cctv-panel-inner,
.street-level-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.global-context-panel-inner,
[data-panel-surface]
),
body:not(.mobile-shell) body:not(.mobile-shell)
#right-context-rail[data-rail-tabs] #right-context-rail[data-rail-tabs]
> [data-panel-id]:not(.collapsed) > [data-panel-id]:not(.collapsed)
@@ -337,14 +309,7 @@ body:not(.mobile-shell)
body:not(.mobile-shell) body:not(.mobile-shell)
#right-context-rail[data-rail-tabs] #right-context-rail[data-rail-tabs]
> [data-panel-id]:not(.collapsed) > [data-panel-id]:not(.collapsed)
> :is( > :is([data-rail-card], [data-panel-surface]) {
.cctv-panel-inner,
.street-level-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.global-context-panel-inner,
[data-panel-surface]
) {
min-height: var(--right-rail-panel-cover); min-height: var(--right-rail-panel-cover);
} }
+3 -3
View File
@@ -2,7 +2,7 @@
<aside id="right-context-rail"> <aside id="right-context-rail">
<div id="weather-panel" class="panel-collapsible" data-panel-id="weather-panel" hidden> <div id="weather-panel" class="panel-collapsible" data-panel-id="weather-panel" hidden>
<div class="panel-glow"></div> <div class="panel-glow"></div>
<div class="weather-panel-inner" data-rail-scroller> <div class="weather-panel-inner" data-rail-scroller data-rail-card>
<div class="panel-header"> <div class="panel-header">
<span class="panel-title" data-i18n>WEATHER</span> <span class="panel-title" data-i18n>WEATHER</span>
<span id="weather-panel-count" aria-label="Active weather products" data-i18n-aria-label>0</span> <span id="weather-panel-count" aria-label="Active weather products" data-i18n-aria-label>0</span>
@@ -14,7 +14,7 @@
</div> </div>
<div id="recent-imagery-panel" class="panel-collapsible" data-panel-id="recent-imagery-panel" hidden> <div id="recent-imagery-panel" class="panel-collapsible" data-panel-id="recent-imagery-panel" hidden>
<div class="panel-glow"></div> <div class="panel-glow"></div>
<div class="recent-imagery-panel-inner" data-rail-scroller> <div class="recent-imagery-panel-inner" data-rail-scroller data-rail-card>
<div class="panel-header"> <div class="panel-header">
<span class="panel-title" data-i18n>RECENT IMAGERY</span> <span class="panel-title" data-i18n>RECENT IMAGERY</span>
<span id="recent-imagery-panel-count" aria-label="Imagery days in the box" data-i18n-aria-label></span> <span id="recent-imagery-panel-count" aria-label="Imagery days in the box" data-i18n-aria-label></span>
@@ -26,7 +26,7 @@
</div> </div>
<div id="global-context-panel" class="panel-collapsible collapsed" data-panel-id="global-context-panel"> <div id="global-context-panel" class="panel-collapsible collapsed" data-panel-id="global-context-panel">
<div class="panel-glow"></div> <div class="panel-glow"></div>
<div class="global-context-panel-inner" data-rail-scroller> <div class="global-context-panel-inner" data-rail-scroller data-rail-card>
<div class="panel-header"> <div class="panel-header">
<span class="panel-title" data-i18n>CONTEXT</span> <span class="panel-title" data-i18n>CONTEXT</span>
<span class="panel-divider"></span> <span class="panel-divider"></span>
+2 -2
View File
@@ -15,7 +15,7 @@
<!-- CCTV Coverage Panel --> <!-- CCTV Coverage Panel -->
<div id="cctv-panel" class="panel-collapsible collapsed" data-panel-id="cctv-panel"> <div id="cctv-panel" class="panel-collapsible collapsed" data-panel-id="cctv-panel">
<div class="panel-glow"></div> <div class="panel-glow"></div>
<div class="cctv-panel-inner" data-rail-scroller> <div class="cctv-panel-inner" data-rail-scroller data-rail-card>
<div class="panel-header"> <div class="panel-header">
<span class="panel-title" data-i18n>CCTV</span> <span class="panel-title" data-i18n>CCTV</span>
<span class="panel-divider"></span> <span class="panel-divider"></span>
@@ -75,7 +75,7 @@
<!-- Street Level Panel (moved into #right-context-rail at runtime) --> <!-- Street Level Panel (moved into #right-context-rail at runtime) -->
<div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel"> <div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel">
<div class="panel-glow"></div> <div class="panel-glow"></div>
<div class="street-level-panel-inner" data-rail-scroller> <div class="street-level-panel-inner" data-rail-scroller data-rail-card>
<div class="panel-header"> <div class="panel-header">
<span class="panel-title" data-i18n>STREET LEVEL</span> <span class="panel-title" data-i18n>STREET LEVEL</span>
<span class="panel-divider"></span> <span class="panel-divider"></span>