/,
);
assert.doesNotMatch(
html,
diff --git a/src/testSupport/fakeDom.mjs b/src/testSupport/fakeDom.mjs
new file mode 100644
index 000000000..6191c5665
--- /dev/null
+++ b/src/testSupport/fakeDom.mjs
@@ -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;
+}
diff --git a/src/ui/mobileShell.js b/src/ui/mobileShell.js
index efd1acedd..017621ae0 100644
--- a/src/ui/mobileShell.js
+++ b/src/ui/mobileShell.js
@@ -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';
/**
diff --git a/src/ui/mobileShellContract.js b/src/ui/mobileShellContract.js
new file mode 100644
index 000000000..6e17a3157
--- /dev/null
+++ b/src/ui/mobileShellContract.js
@@ -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';
diff --git a/src/ui/panelPositionControls.js b/src/ui/panelPositionControls.js
index 52cf20526..3cd956031 100644
--- a/src/ui/panelPositionControls.js
+++ b/src/ui/panelPositionControls.js
@@ -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.
diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs
index e9b0d2db4..b8fcec3ec 100644
--- a/src/ui/panelRails.test.mjs
+++ b/src/ui/panelRails.test.mjs
@@ -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'),
);
diff --git a/src/ui/radioPanelPlacement.js b/src/ui/radioPanelPlacement.js
index 37b066b16..37b289474 100644
--- a/src/ui/radioPanelPlacement.js
+++ b/src/ui/radioPanelPlacement.js
@@ -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) {
diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js
index 71149773e..c6bbf2a77 100644
--- a/src/ui/rightPanelRail.js
+++ b/src/ui/rightPanelRail.js
@@ -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'),
diff --git a/src/ui/rightRailTabs.js b/src/ui/rightRailTabs.js
index 15687b83c..da9a39bef 100644
--- a/src/ui/rightRailTabs.js
+++ b/src/ui/rightRailTabs.js
@@ -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();
},
diff --git a/src/ui/rightRailTabs.test.mjs b/src/ui/rightRailTabs.test.mjs
index 972e298b4..ddaf2944a 100644
--- a/src/ui/rightRailTabs.test.mjs
+++ b/src/ui/rightRailTabs.test.mjs
@@ -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,
- );
-});
diff --git a/src/ui/styles/right-rail-tabs.css b/src/ui/styles/right-rail-tabs.css
index 0bace3e13..22d772e91 100644
--- a/src/ui/styles/right-rail-tabs.css
+++ b/src/ui/styles/right-rail-tabs.css
@@ -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);
}
diff --git a/src/ui/templates/context.html b/src/ui/templates/context.html
index 9e88d20f4..0b802dc4b 100644
--- a/src/ui/templates/context.html
+++ b/src/ui/templates/context.html
@@ -2,7 +2,7 @@