refactor(ui): share a short right rail strip in CSS alone

The fitTabs pass and its lane observer go: the column stands the lane's full height, which makes the strip's size definite, and the tabs take flex 1 1 0% with max-height max-content, so flexbox hands the strip's height out in equal parts, a tab's own length caps its part, and what a short label leaves goes to the longer ones. The basis is 0%, not 0, so the strip's content size, which sizes the column, still comes from the labels. Only the strip and the tool slot take the pointer; the measuring pass sets the column back to height auto so the rail's climb sees the natural column. Same heights as the JS pass at every viewport checked.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Fable 5.1 committed 2026-10-28 12:35:16 +04:00
1 parent 48602e4400
commit 5cc63fefb0
3 files changed
+32 -136

No files matched your search

+1 -75
View File
@@ -296,69 +296,12 @@ export function createRightRailTabs({
}); });
}; };
/** A length from a computed style, in pixels. */ /** A join token from the stylesheet (--right-rail-*), in pixels. */
const token = (computed, name, fallback) => { const token = (computed, name, fallback) => {
const value = parseFloat(computed?.getPropertyValue?.(name)); const value = parseFloat(computed?.getPropertyValue?.(name));
return Number.isFinite(value) ? value : fallback; 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, * 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 * rail pixels; the stylesheet sets the panel's minimum height from it) and
@@ -442,10 +385,8 @@ export function createRightRailTabs({
// would drop keyboard focus on every class change. // would drop keyboard focus on every class change.
let cursor = strip.firstElementChild; let cursor = strip.firstElementChild;
let open = null; let open = null;
const tabs = [];
for (const panel of panels) { for (const panel of panels) {
const { tab, label } = entryFor(panel); const { tab, label } = entryFor(panel);
tabs.push(tab);
if (tab !== cursor) strip.insertBefore(tab, cursor); if (tab !== cursor) strip.insertBefore(tab, cursor);
else cursor = cursor.nextElementSibling; else cursor = cursor.nextElementSibling;
const collapsed = panel.classList.contains('collapsed'); const collapsed = panel.classList.contains('collapsed');
@@ -464,7 +405,6 @@ export function createRightRailTabs({
entry.tab.remove(); entry.tab.remove();
entries.delete(id); entries.delete(id);
} }
fitTabs(tabs);
placeJoin(open?.tab, open?.panel); placeJoin(open?.tab, open?.panel);
} }
@@ -490,19 +430,6 @@ export function createRightRailTabs({
strip.addEventListener('click', onClick); strip.addEventListener('click', onClick);
strip.addEventListener('keydown', onKeydown); strip.addEventListener('keydown', onKeydown);
observer?.observe(rail, { childList: true }); 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.prepend(column);
rail.append(join); rail.append(join);
sync(); sync();
@@ -517,7 +444,6 @@ export function createRightRailTabs({
if (destroyed) return; if (destroyed) return;
destroyed = true; destroyed = true;
observer?.disconnect(); observer?.disconnect();
laneObserver?.disconnect();
resizer?.disconnect(); resizer?.disconnect();
strip.removeEventListener('click', onClick); strip.removeEventListener('click', onClick);
strip.removeEventListener('keydown', onKeydown); strip.removeEventListener('keydown', onKeydown);
+14 -47
View File
@@ -504,46 +504,6 @@ test('a rail panel hidden at mount gets its tab when its owner unhides it, from
tabs.destroy(); 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', () => { test('a tab shrunk by a short strip joins with corners no larger than half its height', () => {
const f = fixture(); const f = fixture();
// The strip's offsets are relative to its column, the column's to the rail. // The strip's offsets are relative to its column, the column's to the rail.
@@ -639,18 +599,25 @@ test('the stylesheet keeps phones on border-box, folds the strip into narrow des
// The tabs give way to a short strip and clip their labels at the end. // The tabs give way to a short strip and clip their labels at the end.
assert.match( assert.match(
css, 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\);/, /\.right-rail-tab \{[^}]*flex: 1 1 0%;[^}]*align-items: flex-start;[^}]*min-height: 0;[^}]*max-height: max-content;[^}]*overflow: hidden;[^}]*mask-image: linear-gradient\(to bottom, #000 calc\(100% - 6px\), transparent\);/,
);
// The column stands the lane's full height so the strip's size is definite
// and a zero basis shares it; only the strip and the slot take the pointer.
assert.match(
css,
/#right-rail-column \{[^}]*height: var\(--right-stack-max-height, 70vh\);[^}]*pointer-events: none;/,
);
assert.match(css, /#right-rail-tabs \{[^}]*pointer-events: auto;/);
assert.match(css, /#right-rail-tools \{[^}]*pointer-events: auto;/);
assert.doesNotMatch(
css,
/#right-rail-column \{[^}]*max-height: var\(--right-stack-max-height/,
); );
// The rail layout pass measures the column's natural height to climb above // The rail layout pass measures the column's natural height to climb above
// its baseline before the strip has to scroll. // its baseline before the strip has to scroll.
assert.match( assert.match(
css, css,
/#right-context-rail\[data-rail-measuring\] > #right-rail-column \{\s*max-height: none !important;/, /#right-context-rail\[data-rail-measuring\] > #right-rail-column \{\s*height: auto !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( assert.match(
css, css,
+17 -14
View File
@@ -58,7 +58,9 @@
} }
/* The strip's column: the tablist, then a tool slot that holds the compact /* The strip's column: the tablist, then a tool slot that holds the compact
Radio launcher on desktop (radioPanelPlacement.js). */ Radio launcher on desktop (radioPanelPlacement.js). It stands the lane's
full height, which makes the strip's size definite so its tabs can share
it (see .right-rail-tab); only the strip and the slot take the pointer. */
#right-rail-column { #right-rail-column {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -68,18 +70,13 @@
flex-direction: column; flex-direction: column;
box-sizing: border-box; box-sizing: border-box;
width: var(--right-rail-tabs-inset); width: var(--right-rail-tabs-inset);
max-height: var(--right-stack-max-height, 70vh); height: var(--right-stack-max-height, 70vh);
pointer-events: auto; pointer-events: none;
} }
/* The layout pass reads the column's natural height with the lane clamp and /* The layout pass reads the column's natural height, to climb when the lane
the shared tab heights lifted, to climb when the lane is too short for it is too short for it (rightPanelRail.js). */
(rightPanelRail.js). */
#right-context-rail[data-rail-measuring] > #right-rail-column { #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; height: auto !important;
} }
@@ -96,6 +93,7 @@
/* The rail climbs above its baseline to fit the column (rightPanelRail.js); /* The rail climbs above its baseline to fit the column (rightPanelRail.js);
a lane still shorter shrinks the tabs. The strip never scrolls. */ a lane still shorter shrinks the tabs. The strip never scrolls. */
overflow: hidden; overflow: hidden;
pointer-events: auto;
} }
#right-rail-tools { #right-rail-tools {
@@ -106,6 +104,7 @@
box-sizing: border-box; box-sizing: border-box;
margin-top: 6px; margin-top: 6px;
padding-left: var(--right-rail-tabs-gap); padding-left: var(--right-rail-tabs-gap);
pointer-events: auto;
} }
#right-rail-tools:empty { #right-rail-tools:empty {
@@ -174,18 +173,22 @@ body:not(.mobile-shell)
outline-offset: -3px; outline-offset: -3px;
} }
/* A short strip is shared between the tabs (rightRailTabs.js): the long /* A short strip is shared between the tabs: a zero basis hands the strip's
labels end up the same height. A tab clips its label at the end, faded over height out in equal parts, a tab's own length caps its part, and what a
the bottom padding. */ short label leaves goes to the longer ones, which end up the same height. A
strip with room gives every tab its own length. The basis is 0%, not 0:
the strip's content size, which sizes the column, comes from the labels. A
tab clips its label at the end, faded over the bottom padding. */
.right-rail-tab { .right-rail-tab {
position: relative; position: relative;
display: flex; display: flex;
flex: 0 1 auto; flex: 1 1 0%;
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: center;
box-sizing: border-box; box-sizing: border-box;
width: var(--right-rail-tabs-width); width: var(--right-rail-tabs-width);
min-height: 0; min-height: 0;
max-height: max-content;
padding: 6px 0; padding: 6px 0;
overflow: hidden; overflow: hidden;
mask-image: linear-gradient(to bottom, #000 calc(100% - 6px), transparent); mask-image: linear-gradient(to bottom, #000 calc(100% - 6px), transparent);