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
and Recent Imagery while shown) is a vertical tab strip
(`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
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. On desktop the panels' own header collapse
tab per panel at the rail's right edge, the open panel beside it. A tab
toggles its panel through `setPanelCollapsed` with the explicit flag; the
panels' `collapsed` classes remain the only state, mirrored through a
MutationObserver, and the owner rule above hides the rest. 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
+5
View File
@@ -424,6 +424,7 @@
"src/ui/displayPanelScroll.js",
"src/ui/leftPanelRail.js",
"src/ui/rightPanelRail.js",
"src/ui/mobileShellContract.js",
"src/ui/panelMeasurement.js",
"src/ui/panelRailGeometry.js",
"src/panelStackLayout.js"
@@ -762,6 +763,7 @@
"src/ui/radioTunerModel.js",
"src/ui/recordingControls.js",
"src/ui/rightPanelRail.js",
"src/ui/mobileShellContract.js",
"src/ui/rightRailTabs.js",
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
@@ -2288,6 +2290,7 @@
"src/ui/radioTunerModel.js",
"src/ui/recordingControls.js",
"src/ui/rightPanelRail.js",
"src/ui/mobileShellContract.js",
"src/ui/rightRailTabs.js",
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
@@ -3308,6 +3311,7 @@
"src/hudLayouts.js",
"src/hudLayoutPolicy.js",
"src/ui/radioPanelPlacement.js",
"src/ui/mobileShellContract.js",
"src/bloom.js",
"src/cockpitVisionPolicy.js",
"src/contactsDetectionPolicy.js",
@@ -3354,6 +3358,7 @@
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.js",
"src/ui/rightPanelRail.js",
"src/ui/mobileShellContract.js",
"src/ui/rightRailTabs.js",
"src/ui/uiLifetime.js",
"src/i18n/index.js",
-22
View File
@@ -1902,18 +1902,6 @@ async function main() {
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(
'HUD-off to Tactical preserves the latest-opened Scenes panel and hides its collapsed Data competitor',
multiExpandedPanelLayout.tacticalAutoCollapse.latestOpenedPanel === 'scene-panel'
@@ -1927,16 +1915,6 @@ async function main() {
&& multiExpandedPanelLayout.tacticalAutoCollapse.contextAriaExpanded === 'false',
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(
'Tactical Display hides ordinary collapsed CCTV and Context launchers',
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
// custom property. Pin that, or the exemption above is unearned.
const rail = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'rightPanelRail.js'), 'utf8');
const gate = rail.indexOf("windowRef.matchMedia('(max-width: 720px)')");
assert.ok(gate > 0, 'the rail layout pass no longer keys off (max-width: 720px)');
const contract = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'mobileShellContract.js'), 'utf8');
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);
assert.match(mobileBranch, /stack\.classList\.remove\('layout-focus'\)/);
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.
assert.match(
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',
);
assert.match(ui, /matchMedia\?\.\('\(max-width: 720px\)'\)/);
// 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.
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', () => {
const ui = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8');
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, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/);
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.match(
html,
/<div class="street-level-panel-inner" data-rail-scroller>/,
/<div class="street-level-panel-inner" data-rail-scroller data-rail-card>/,
);
assert.doesNotMatch(
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 { 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 const MOBILE_SHELL_CLASS = 'mobile-shell';
export { MOBILE_SHELL_CLASS, MOBILE_SHELL_MEDIA_QUERY };
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. */
import { t } from '../i18n/index.js';
import { MOBILE_SHELL_MEDIA_QUERY } from './mobileShellContract.js';
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
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}.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 {
constructor({ syncPanelCollapseButton, showToast }) {
this._syncPanelCollapseButton = syncPanelCollapseButton;
@@ -76,7 +73,9 @@ export class PanelPositionControls {
// keep it collapsed (their sheets present the rail); a share link decides
// for itself. A stored choice wins in both directions.
if (panelId === 'pp-toggles' && stored === null)
collapsed = !allowStored || isPhoneViewport();
collapsed =
!allowStored ||
globalThis.matchMedia?.(MOBILE_SHELL_MEDIA_QUERY)?.matches === true;
panelEl.classList.toggle('collapsed', collapsed);
// Bodies that expand themselves on first appearance must not override a
// 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 { displayPanelScroller } from './displayPanelScroll.js';
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);
});
const doc = makeDocument();
/** A rail panel (or any rail-side box) with its measured box and no content. */
function element(
id,
{ height = 42, top = 234, left = 20, width = 272, collapsed = false } = {},
) {
const classes = new Set(collapsed ? ['collapsed'] : []);
const properties = new Map();
const attributes = new Map();
const writes = [];
const node = {
id,
children: [],
dataset: {},
parentElement: null,
scrollHeight: height,
clientHeight: height,
scrollTop: 0,
computed: {
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,
};
const node = doc.createElement('div');
node.id = id;
node.dataset.panelId = id;
if (collapsed) node.classList.add('collapsed');
node.scrollHeight = node.clientHeight = height;
node.computed.rowGap = '12px';
Object.assign(node.rect, {
top,
left,
width,
height,
right: left + width,
bottom: top + height,
});
return node;
}
function fixture(
@@ -245,7 +199,7 @@ for (const side of ['left', 'right']) {
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.displayPanel = display;
const allocation = '--right-panel-allocated-height';
const measuring = () =>
f.stack.getAttribute('data-rail-measuring') !== undefined;
const measuring = () => f.stack.getAttribute('data-rail-measuring') !== null;
for (const panel of f.stack.children) {
panel.parentElement = f.stack;
panel.intrinsicHeight = panel.rect.height;
@@ -544,7 +497,7 @@ for (const focused of [false, true]) {
modes.push(f.stack.classList.contains('layout-focus'));
needs.push(f.stack.dataset.requiredHeight);
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));
// 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');
};
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', () => {
@@ -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.second.getAttribute('aria-hidden'), 'true');
assert.equal(weather.classList.contains('collapsed'), false);
assert.equal(weather.getAttribute('aria-hidden'), undefined);
assert.equal(weather.getAttribute('aria-hidden'), null);
assert.equal(
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'),
'',
);
assert.equal(imagery.getAttribute('aria-hidden'), undefined);
assert.equal(imagery.getAttribute('aria-hidden'), null);
const alone = parseFloat(
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.
* 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.parentElement !== parent) parent.append(panel);
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 (dock.parentElement !== slot) slot.append(dock);
} else if (dock.parentElement !== header) {
+2 -1
View File
@@ -2,6 +2,7 @@ import { allocatePanelStackHeights } from '../panelStackLayout.js';
import { resolveHudRailLayout } from './panelRailGeometry.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:
@@ -77,7 +78,7 @@ export function layoutRightPanelRail({
// presentation-collapsed; the marker is distinct from the left rail's
// space-based collapse. Phone sheets show several panels at once, so phones
// get every panel back.
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
const isMobile = windowRef.matchMedia(MOBILE_SHELL_MEDIA_QUERY).matches;
if (!isMobile) {
const expanded = panels.filter(
(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
* sheet shell (mobileShell.js).
*
* The open tab and its panel are one surface: the strip measures both, traces
* 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.
* The open tab and its panel are drawn as one surface by a join element the
* strip shapes from both (railJoinPath); styles/right-rail-tabs.css has the
* layers. A tool slot under the tabs holds the compact Radio launcher on
* desktop (radioPanelPlacement.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
* 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';
@@ -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
* 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
* Owner callback; the strip never touches panel classes itself.
* @param {Document} [input.documentRef]
* @returns {{ element: HTMLElement, column: HTMLElement, tools: HTMLElement,
* join: HTMLElement, sync: () => void, destroy: () => void }}
* @returns {{ element: HTMLElement, sync: () => void, destroy: () => void }}
*/
export function createRightRailTabs({
rail,
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');
column.id = RIGHT_RAIL_COLUMN_ID;
column.id = 'right-rail-column';
const strip = documentRef.createElement('div');
strip.id = RIGHT_RAIL_TABS_ID;
strip.id = 'right-rail-tabs';
strip.setAttribute('role', 'tablist');
strip.setAttribute('aria-orientation', 'vertical');
strip.setAttribute('aria-label', t('Panels'));
const tools = documentRef.createElement('div');
tools.id = RIGHT_RAIL_TOOLS_ID;
tools.id = 'right-rail-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.
const svg = (tag) =>
documentRef.createElementNS?.(SVG_NS, tag) ??
documentRef.createElement(tag);
const svg = (tag) => documentRef.createElementNS(SVG_NS, tag);
const join = documentRef.createElement('div');
join.className = 'right-rail-join';
join.setAttribute('aria-hidden', 'true');
@@ -377,7 +354,9 @@ export function createRightRailTabs({
// 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 panels = [...rail.children].filter(
(panel) => panel.matches?.('[data-panel-id]') && !panel.hidden,
);
const focusOwner =
panels.find((panel) => !panel.classList.contains('collapsed')) ||
panels[0];
@@ -436,9 +415,6 @@ export function createRightRailTabs({
return {
element: strip,
column,
tools,
join,
sync,
destroy() {
if (destroyed) return;
@@ -449,7 +425,7 @@ export function createRightRailTabs({
strip.removeEventListener('keydown', onKeydown);
column.remove();
join.remove();
rail.removeAttribute(RIGHT_RAIL_TABS_ATTRIBUTE);
rail.removeAttribute(RAIL_TABS_ATTRIBUTE);
placeJoin(null, null);
entries.clear();
},
+35 -201
View File
@@ -1,136 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { fire, makeDocument } from '../testSupport/fakeDom.mjs';
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,
railTabPanels,
} 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(
doc,
id,
@@ -175,6 +52,9 @@ function fixture() {
setPanelCollapsed: (...args) => calls.push(args),
});
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) =>
strip.children.find((tab) => tab.dataset.railTab === id);
const labelOf = (tab) =>
@@ -190,6 +70,9 @@ function fixture() {
context,
tabs,
strip,
column,
tools,
join,
calls,
tabFor,
labelOf,
@@ -200,21 +83,18 @@ 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.tabs.column,
f.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],
f.column.children,
[f.strip, f.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.deepEqual(f.tools.children, [], 'the slot starts empty');
assert.equal(f.strip.getAttribute('role'), 'tablist');
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(
f.strip.children.map((tab) => [
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.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', () => {
@@ -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', () => {
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;
assert.equal(f.tabs.join.parentElement, f.rail);
assert.equal(f.tabs.join.getAttribute('aria-hidden'), 'true');
assert.equal(f.tabs.join.hidden, true, 'nothing open, nothing to join');
assert.equal(f.join.parentElement, f.rail);
assert.equal(f.join.getAttribute('aria-hidden'), 'true');
assert.equal(f.join.hidden, true, 'nothing open, nothing to join');
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-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.tabs.sync();
assert.deepEqual([...props], [['--rail-tab-bottom', '142px']]);
assert.equal(f.tabs.join.hidden, false);
const [, glass] = f.tabs.join.children;
assert.equal(f.join.hidden, false);
const [, glass] = f.join.children;
assert.match(
glass.style.clipPath,
/^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.tabs.sync();
assert.deepEqual([...props], [], 'no open panel, no join');
assert.equal(f.tabs.join.hidden, true);
assert.equal(f.join.hidden, true);
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', () => {
@@ -504,35 +392,6 @@ test('a rail panel hidden at mount gets its tab when its owner unhides it, from
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', () => {
const base = {
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 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 },
@@ -650,26 +500,10 @@ test('destroy removes the column and stops listening', () => {
const f = fixture();
const tab = f.tabFor('cctv-panel');
f.tabs.destroy();
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);
assert.equal(f.column.parentElement, null);
assert.equal(f.rail.children.includes(f.column), false);
assert.equal(f.rail.getAttribute('data-rail-tabs'), null);
fire(tab, 'click');
assert.deepEqual(f.calls, []);
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);
}
#right-rail-tools .context-radio-toggle:focus-visible {
outline-offset: -3px;
}
#right-rail-tools .context-radio-toggle.active {
color: var(--accent);
}
@@ -229,23 +225,6 @@ body:not(.mobile-shell)
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 ──────────── */
.right-rail-join {
position: absolute;
@@ -303,14 +282,7 @@ body:not(.mobile-shell)
body:not(.mobile-shell)
#right-context-rail[data-rail-tabs]
> [data-panel-id]:not(.collapsed)
> :is(
.cctv-panel-inner,
.street-level-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.global-context-panel-inner,
[data-panel-surface]
),
> :is([data-rail-card], [data-panel-surface]),
body:not(.mobile-shell)
#right-context-rail[data-rail-tabs]
> [data-panel-id]:not(.collapsed)
@@ -337,14 +309,7 @@ body:not(.mobile-shell)
body:not(.mobile-shell)
#right-context-rail[data-rail-tabs]
> [data-panel-id]:not(.collapsed)
> :is(
.cctv-panel-inner,
.street-level-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.global-context-panel-inner,
[data-panel-surface]
) {
> :is([data-rail-card], [data-panel-surface]) {
min-height: var(--right-rail-panel-cover);
}
+3 -3
View File
@@ -2,7 +2,7 @@
<aside id="right-context-rail">
<div id="weather-panel" class="panel-collapsible" data-panel-id="weather-panel" hidden>
<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">
<span class="panel-title" data-i18n>WEATHER</span>
<span id="weather-panel-count" aria-label="Active weather products" data-i18n-aria-label>0</span>
@@ -14,7 +14,7 @@
</div>
<div id="recent-imagery-panel" class="panel-collapsible" data-panel-id="recent-imagery-panel" hidden>
<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">
<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>
@@ -26,7 +26,7 @@
</div>
<div id="global-context-panel" class="panel-collapsible collapsed" data-panel-id="global-context-panel">
<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">
<span class="panel-title" data-i18n>CONTEXT</span>
<span class="panel-divider"></span>
+2 -2
View File
@@ -15,7 +15,7 @@
<!-- CCTV Coverage Panel -->
<div id="cctv-panel" class="panel-collapsible collapsed" data-panel-id="cctv-panel">
<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">
<span class="panel-title" data-i18n>CCTV</span>
<span class="panel-divider"></span>
@@ -75,7 +75,7 @@
<!-- 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 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">
<span class="panel-title" data-i18n>STREET LEVEL</span>
<span class="panel-divider"></span>