mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
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:
1 parent
48602e4400
commit
5cc63fefb0
3 files changed
+32
-136
No files matched your search
+1
-75
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user