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 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
@@ -442,10 +385,8 @@ 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');
@@ -464,7 +405,6 @@ export function createRightRailTabs({
entry.tab.remove();
entries.delete(id);
}
fitTabs(tabs);
placeJoin(open?.tab, open?.panel);
}
@@ -490,19 +430,6 @@ 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();
@@ -517,7 +444,6 @@ export function createRightRailTabs({
if (destroyed) return;
destroyed = true;
observer?.disconnect();
laneObserver?.disconnect();
resizer?.disconnect();
strip.removeEventListener('click', onClick);
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();
});
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.
@@ -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.
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\);/,
/\.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
// its baseline before the strip has to scroll.
assert.match(
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',
/#right-context-rail\[data-rail-measuring\] > #right-rail-column \{\s*height: auto !important;/,
);
assert.match(
css,
+17 -14
View File
@@ -58,7 +58,9 @@
}
/* 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 {
position: absolute;
top: 0;
@@ -68,18 +70,13 @@
flex-direction: column;
box-sizing: border-box;
width: var(--right-rail-tabs-inset);
max-height: var(--right-stack-max-height, 70vh);
pointer-events: auto;
height: var(--right-stack-max-height, 70vh);
pointer-events: none;
}
/* 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). */
/* The layout pass reads the column's natural height, 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;
}
@@ -96,6 +93,7 @@
/* The rail climbs above its baseline to fit the column (rightPanelRail.js);
a lane still shorter shrinks the tabs. The strip never scrolls. */
overflow: hidden;
pointer-events: auto;
}
#right-rail-tools {
@@ -106,6 +104,7 @@
box-sizing: border-box;
margin-top: 6px;
padding-left: var(--right-rail-tabs-gap);
pointer-events: auto;
}
#right-rail-tools:empty {
@@ -174,18 +173,22 @@ body:not(.mobile-shell)
outline-offset: -3px;
}
/* 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. */
/* A short strip is shared between the tabs: a zero basis 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, 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 {
position: relative;
display: flex;
flex: 0 1 auto;
flex: 1 1 0%;
align-items: flex-start;
justify-content: center;
box-sizing: border-box;
width: var(--right-rail-tabs-width);
min-height: 0;
max-height: max-content;
padding: 6px 0;
overflow: hidden;
mask-image: linear-gradient(to bottom, #000 calc(100% - 6px), transparent);