diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d836efd75..61e7ff232 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 64f1c430f..a0a8bb7ed 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index b6c185361..547c70786 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -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), diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs index 3032a25fe..e83a8aa43 100644 --- a/src/creditAttribution.test.mjs +++ b/src/creditAttribution.test.mjs @@ -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'\)/); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 5bef25a92..b00237e2e 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -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( diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index 560b6f50f..ed8e48d06 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -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 \{/); diff --git a/src/streetLevelMarkup.test.mjs b/src/streetLevelMarkup.test.mjs index 8d316c120..892dcbd96 100644 --- a/src/streetLevelMarkup.test.mjs +++ b/src/streetLevelMarkup.test.mjs @@ -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, - /
/, + /
/, ); 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 @@