diff --git a/changelog.d/1425-right-rail-vertical-tabs.md b/changelog.d/1425-right-rail-vertical-tabs.md index c0d87086c..459b8546e 100644 --- a/changelog.d/1425-right-rail-vertical-tabs.md +++ b/changelog.d/1425-right-rail-vertical-tabs.md @@ -2,7 +2,7 @@ Imagery while shown) is a vertical tab strip with sideways labels instead of an accordion of collapsed launcher cards: at most one panel is open in every desktop HUD layout, and the open tab and its panel share one surface and - outline, like a folder tab. On a short window the rail rises toward the HUD corner above it and the tabs shrink, clipping their labels, rather than scroll. A first-time visitor on a + outline, like a folder tab. On a short window the rail rises toward the HUD corner above it and the tabs share the room equally, clipping the longer labels, rather than scroll. A first-time visitor on a desktop finds Display open; a stored choice still wins. The panels' own collapse arrows are gone on desktop: the tab opens and closes a panel, and Escape inside one closes it and returns focus to its tab; the strip is a diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bb115f2ab..da8d8b768 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1513,8 +1513,9 @@ the DOM so Tab from the open tab reaches the tool slot and then the panel. Labels are the panels' own translated header titles. The rail's lane shares the left rail's top; when it is too short for the strip, the rail climbs toward the HUD corner above as far as the strip needs (`src/ui/rightPanelRail.js`), -and a lane still shorter shrinks the tabs in proportion, clipping their -labels at the end; the strip never scrolls. Under the tabs the strip's +and a lane still shorter is shared equally between the tabs: a label short +enough keeps its height, the long ones end up the same height and clip at the +end; the strip never scrolls. Under the tabs the strip's column keeps a tool slot (`#right-rail-tools`) that holds the compact Radio launcher on desktop: `src/ui/radioPanelPlacement.js` moves it there from Context's header, and back for the phone sheet, so the compact player stays one diff --git a/src/ui/rightRailTabs.js b/src/ui/rightRailTabs.js index 15687b83c..11670f57f 100644 --- a/src/ui/rightRailTabs.js +++ b/src/ui/rightRailTabs.js @@ -296,12 +296,69 @@ export function createRightRailTabs({ }); }; - /** A join token from the stylesheet (--right-rail-*), in pixels. */ + /** A length from a computed style, in pixels. */ const token = (computed, name, fallback) => { const value = parseFloat(computed?.getPropertyValue?.(name)); return Number.isFinite(value) ? value : fallback; }; + /** + * Share a strip shorter than its tabs. The lane (--right-stack-max-height) + * less the tool slot and the strip's gaps is divided equally; a tab shorter + * than its share keeps its height and the rest is shared again, so the long + * labels end up the same height and clip alike (styles/right-rail-tabs.css). + * Tabs that fit keep their natural height. + */ + const heights = new WeakMap(); + const setHeight = (tab, value) => { + if (heights.get(tab) === value) return; + heights.set(tab, value); + if (value) tab.style.setProperty('height', value); + else tab.style.removeProperty('height'); + }; + function fitTabs(tabs) { + const styleOf = (el) => documentRef.defaultView?.getComputedStyle?.(el); + const stripStyle = styleOf(strip); + const lane = token(styleOf(rail), '--right-stack-max-height', NaN); + const chrome = + token(stripStyle, 'row-gap', 0) * Math.max(0, tabs.length - 1) + + token(stripStyle, 'padding-top', 0) + + token(stripStyle, 'padding-bottom', 0); + const overhead = (column.offsetHeight || 0) - (strip.offsetHeight || 0); + const room = lane - overhead - chrome; + const entries = tabs.map((tab) => { + const tabStyle = styleOf(tab); + return { + tab, + natural: + (tab.firstElementChild?.offsetHeight || 0) + + token(tabStyle, 'padding-top', 0) + + token(tabStyle, 'padding-bottom', 0), + }; + }); + const natural = entries.reduce((sum, entry) => sum + entry.natural, 0); + if ( + !(room > 0) || + natural <= room + 0.5 || + entries.some((entry) => !(entry.natural > 0)) + ) { + for (const entry of entries) setHeight(entry.tab, ''); + return; + } + let remaining = room; + let count = entries.length; + for (const entry of [...entries].sort((a, b) => a.natural - b.natural)) { + const share = remaining / count; + if (entry.natural <= share) { + remaining -= entry.natural; + count -= 1; + setHeight(entry.tab, ''); + } else { + setHeight(entry.tab, `${Math.round(share * 100) / 100}px`); + } + } + } + /** * Publish the join: the open tab's bottom on the rail (--rail-tab-bottom, * rail pixels; the stylesheet sets the panel's minimum height from it) and @@ -385,8 +442,10 @@ export function createRightRailTabs({ // would drop keyboard focus on every class change. let cursor = strip.firstElementChild; let open = null; + const tabs = []; for (const panel of panels) { const { tab, label } = entryFor(panel); + tabs.push(tab); if (tab !== cursor) strip.insertBefore(tab, cursor); else cursor = cursor.nextElementSibling; const collapsed = panel.classList.contains('collapsed'); @@ -405,6 +464,7 @@ export function createRightRailTabs({ entry.tab.remove(); entries.delete(id); } + fitTabs(tabs); placeJoin(open?.tab, open?.panel); } @@ -430,6 +490,19 @@ export function createRightRailTabs({ strip.addEventListener('click', onClick); strip.addEventListener('keydown', onKeydown); observer?.observe(rail, { childList: true }); + // Tabs holding shared heights do not resize with the lane: watch the rail's + // style for a new lane. The join's own writes to that style change no lane. + let seenLane = rail.style?.getPropertyValue?.('--right-stack-max-height'); + const laneObserver = + typeof Observer === 'function' + ? new Observer(() => { + const lane = rail.style.getPropertyValue('--right-stack-max-height'); + if (lane === seenLane) return; + seenLane = lane; + sync(); + }) + : null; + laneObserver?.observe(rail, { attributes: true, attributeFilter: ['style'] }); rail.prepend(column); rail.append(join); sync(); @@ -444,6 +517,7 @@ export function createRightRailTabs({ if (destroyed) return; destroyed = true; observer?.disconnect(); + laneObserver?.disconnect(); resizer?.disconnect(); strip.removeEventListener('click', onClick); strip.removeEventListener('keydown', onKeydown); diff --git a/src/ui/rightRailTabs.test.mjs b/src/ui/rightRailTabs.test.mjs index 785cd6fda..2b713ab7c 100644 --- a/src/ui/rightRailTabs.test.mjs +++ b/src/ui/rightRailTabs.test.mjs @@ -504,6 +504,46 @@ test('a rail panel hidden at mount gets its tab when its owner unhides it, from tabs.destroy(); }); +test('a strip shorter than its tabs is shared: short labels keep their height, the long ones get equal heights', () => { + const f = fixture(); + f.doc.defaultView = { + getComputedStyle: (el) => ({ + getPropertyValue: (name) => el.style.props.get(name) ?? '', + }), + }; + f.strip.style.setProperty('row-gap', '4px'); + const labels = { + 'pp-toggles': 49, + 'cctv-panel': 28, + 'global-context-panel': 49, + }; + for (const [id, height] of Object.entries(labels)) { + const tab = f.tabFor(id); + tab.style.setProperty('padding-top', '6px'); + tab.style.setProperty('padding-bottom', '6px'); + tab.children[0].offsetHeight = height; + } + // The column holds the strip (160px) and a 40px tool slot; the lane is 200. + Object.assign(f.tabs.column, { offsetHeight: 200 }); + Object.assign(f.strip, { offsetHeight: 160 }); + f.rail.style.setProperty('--right-stack-max-height', '200px'); + f.tabs.sync(); + // Room 152 for naturals 61, 40, 61: CCTV keeps 40, the other two share 112. + assert.equal(f.tabFor('cctv-panel').style.props.has('height'), false); + assert.equal(f.tabFor('pp-toggles').style.props.get('height'), '56px'); + assert.equal( + f.tabFor('global-context-panel').style.props.get('height'), + '56px', + ); + f.rail.style.setProperty('--right-stack-max-height', '400px'); + f.tabs.sync(); + assert.equal( + f.strip.children.some((tab) => tab.style.props.has('height')), + false, + 'a lane with room gives every tab its natural height back', + ); +}); + 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. @@ -596,7 +636,7 @@ test('the stylesheet keeps phones on border-box, folds the strip into narrow des css, /#right-rail-tabs \{[^}]*(overflow: [^;]*auto|scrollbar-width)/, ); - // The tabs give way in proportion and clip their labels at the end. + // The tabs give way to a short strip and clip their labels at the end. assert.match( css, /\.right-rail-tab \{[^}]*flex: 0 1 auto;[^}]*align-items: flex-start;[^}]*min-height: 0;[^}]*overflow: hidden;[^}]*mask-image: linear-gradient\(to bottom, #000 calc\(100% - 6px\), transparent\);/, @@ -607,6 +647,11 @@ test('the stylesheet keeps phones on border-box, folds the strip into narrow des css, /#right-context-rail\[data-rail-measuring\] > #right-rail-column \{\s*max-height: none !important;/, ); + assert.match( + css, + /#right-context-rail\[data-rail-measuring\] #right-rail-tabs > \.right-rail-tab \{\s*height: auto !important;/, + 'shared tab heights are lifted for the measurement, or the climb would see a column that already fits', + ); assert.match( css, /#right-rail-tabs > \.right-rail-tab:focus-visible \{\s*outline-offset: -3px;/, diff --git a/src/ui/styles/right-rail-tabs.css b/src/ui/styles/right-rail-tabs.css index 8348344f8..86f44bd9f 100644 --- a/src/ui/styles/right-rail-tabs.css +++ b/src/ui/styles/right-rail-tabs.css @@ -72,12 +72,17 @@ pointer-events: auto; } -/* The layout pass reads the column's natural height with the lane clamp - lifted, to climb when the lane is too short for it (rightPanelRail.js). */ +/* The layout pass reads the column's natural height with the lane clamp and + the shared tab heights lifted, to climb when the lane is too short for it + (rightPanelRail.js). */ #right-context-rail[data-rail-measuring] > #right-rail-column { max-height: none !important; } +#right-context-rail[data-rail-measuring] #right-rail-tabs > .right-rail-tab { + height: auto !important; +} + #right-rail-tabs { position: relative; display: flex; @@ -169,8 +174,9 @@ body:not(.mobile-shell) outline-offset: -3px; } -/* A tab shrinks in proportion to its label when the strip is short, and - clips the label at its end, faded over the bottom padding. */ +/* A short strip is shared between the tabs (rightRailTabs.js): the long + labels end up the same height. A tab clips its label at the end, faded over + the bottom padding. */ .right-rail-tab { position: relative; display: flex;