mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
e05d6a29b4
commit
8c788474db
19 files changed
+273
-396
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'\)/);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 \{/);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
@@ -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
@@ -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'),
|
||||
);
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user