mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
fix(ui): a short right rail strip is shared between the tabs at one height
Proportional shrinking clipped every label by the same fraction, so CCTV lost half its letters while STREET LEVEL kept most of them. The strip now divides the lane less the tool slot equally (fitTabs in rightRailTabs.js): 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; tabs that fit keep their natural height. A style observer on the rail re-fits on a new lane, and the measuring pass lifts the shared heights so the rail's climb still sees the natural column. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
f4285b8fb2
commit
48602e4400
5 files changed
+135
-9
No files matched your search
@@ -2,7 +2,7 @@
|
|||||||
Imagery while shown) is a vertical tab strip with sideways labels instead of
|
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
|
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
|
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
|
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
|
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
|
Escape inside one closes it and returns focus to its tab; the strip is a
|
||||||
|
|||||||
@@ -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
|
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 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`),
|
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
|
and a lane still shorter is shared equally between the tabs: a label short
|
||||||
labels at the end; the strip never scrolls. Under the tabs the strip's
|
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
|
column keeps a tool slot (`#right-rail-tools`) that holds the compact Radio
|
||||||
launcher on desktop: `src/ui/radioPanelPlacement.js` moves it there from
|
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
|
Context's header, and back for the phone sheet, so the compact player stays one
|
||||||
|
|||||||
+75
-1
@@ -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 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
|
||||||
@@ -385,8 +442,10 @@ 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');
|
||||||
@@ -405,6 +464,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,6 +490,19 @@ 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();
|
||||||
@@ -444,6 +517,7 @@ 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,6 +504,46 @@ 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.
|
||||||
@@ -596,7 +636,7 @@ test('the stylesheet keeps phones on border-box, folds the strip into narrow des
|
|||||||
css,
|
css,
|
||||||
/#right-rail-tabs \{[^}]*(overflow: [^;]*auto|scrollbar-width)/,
|
/#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(
|
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: 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,
|
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*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(
|
assert.match(
|
||||||
css,
|
css,
|
||||||
/#right-rail-tabs > \.right-rail-tab:focus-visible \{\s*outline-offset: -3px;/,
|
/#right-rail-tabs > \.right-rail-tab:focus-visible \{\s*outline-offset: -3px;/,
|
||||||
|
|||||||
@@ -72,12 +72,17 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The layout pass reads the column's natural height with the lane clamp
|
/* The layout pass reads the column's natural height with the lane clamp and
|
||||||
lifted, to climb when the lane is too short for it (rightPanelRail.js). */
|
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 {
|
#right-context-rail[data-rail-measuring] > #right-rail-column {
|
||||||
max-height: none !important;
|
max-height: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#right-context-rail[data-rail-measuring] #right-rail-tabs > .right-rail-tab {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
#right-rail-tabs {
|
#right-rail-tabs {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -169,8 +174,9 @@ body:not(.mobile-shell)
|
|||||||
outline-offset: -3px;
|
outline-offset: -3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A tab shrinks in proportion to its label when the strip is short, and
|
/* A short strip is shared between the tabs (rightRailTabs.js): the long
|
||||||
clips the label at its end, faded over the bottom padding. */
|
labels end up the same height. 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;
|
||||||
|
|||||||
Reference in new issue
Block a user