[fix](trx-frontend-http): render the header menus above the page #27

Merged
sjg merged 1 commits from fix/more-menu-clipping into main 2026-08-02 19:20:31 +02:00
3 changed files with 58 additions and 20 deletions
@@ -748,6 +748,24 @@ function elementById(id) {
} }
} }
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"]; const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
function anchorMenu(button, menu) {
const rect = button.getBoundingClientRect();
menu.style.position = "fixed";
menu.style.zIndex = "200";
menu.style.top = "0px";
menu.style.bottom = "auto";
menu.style.right = "auto";
const width = menu.offsetWidth || 200;
const height = menu.offsetHeight || 0;
menu.style.left = `${String(Math.round(Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8)))}px`;
const below = rect.bottom + 8;
if (below + height <= window.innerHeight - 8) {
menu.style.top = `${String(Math.round(below))}px`;
} else {
menu.style.top = "auto";
menu.style.bottom = `${String(Math.round(window.innerHeight - rect.top + 8))}px`;
}
}
function installTopBarOverflow() { function installTopBarOverflow() {
const actions = document.querySelector(".top-bar-actions"); const actions = document.querySelector(".top-bar-actions");
if (!actions || document.getElementById("top-bar-more")) return; if (!actions || document.getElementById("top-bar-more")) return;
@@ -777,6 +795,7 @@ function elementById(id) {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const open = menu.classList.toggle("is-open"); const open = menu.classList.toggle("is-open");
button.setAttribute("aria-expanded", String(open)); button.setAttribute("aria-expanded", String(open));
if (open) anchorMenu(button, menu);
}); });
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || !wrap.contains(event.target)) closeMenu(); if (!(event.target instanceof Node) || !wrap.contains(event.target)) closeMenu();
@@ -851,7 +870,10 @@ function elementById(id) {
more.addEventListener("click", () => { more.addEventListener("click", () => {
const open = menu.classList.toggle("is-open"); const open = menu.classList.toggle("is-open");
more.setAttribute("aria-expanded", String(open)); more.setAttribute("aria-expanded", String(open));
if (open) menu.querySelector('[role="menuitem"]')?.focus(); if (open) {
anchorMenu(more, menu);
menu.querySelector('[role="menuitem"]')?.focus();
}
}); });
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || !menu.contains(event.target) && !more.contains(event.target)) closeMore(); if (!(event.target instanceof Node) || !menu.contains(event.target) && !more.contains(event.target)) closeMore();
@@ -1531,25 +1531,10 @@ small { color: var(--text-muted); }
align-items: center; align-items: center;
gap: 0.2rem; gap: 0.2rem;
min-width: 0; min-width: 0;
/* Scroll rather than wrap: eight labels wrapped into a ragged block (four /* Never wraps and never scrolls: the occasional destinations live behind
* rows at 1100px) and made the header's height depend on the viewport. */ * More, so four tabs plus More always fit. overflow-x here would clip the
* dropdown this element anchors. */
flex-wrap: nowrap; flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: thin;
}
/* Fade the trailing edge so a tab scrolled out of view reads as "there is more"
* rather than as a label cut mid-word. A mask rather than a colour-matched
* cover gradient: the card is transparent, so a cover would have to track the
* page background across both themes and all nine styles. Only the trailing
* edge is faded, so the first tab stays crisp when the strip is at rest. */
.tab-bar-nav {
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 2rem), transparent 100%);
mask-image: linear-gradient(to right, #000 calc(100% - 2rem), transparent 100%);
}
.tab-bar-nav::-webkit-scrollbar { height: 3px; }
.tab-bar-nav::-webkit-scrollbar-thumb {
border-radius: 3px;
background: color-mix(in srgb, var(--border-light) 70%, transparent);
} }
.tab-bar-nav .tab { flex: 0 0 auto; } .tab-bar-nav .tab { flex: 0 0 auto; }
/* Icons before scrolling: every tab already carries one, and four icons plus /* Icons before scrolling: every tab already carries one, and four icons plus
@@ -326,6 +326,33 @@ function elementById<T extends HTMLElement>(id: string): T {
// Audio, record and the rig picker are the operating controls and stay. // Audio, record and the rig picker are the operating controls and stay.
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"]; const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
// Anchored at paint time in fixed coordinates. An absolutely positioned
// dropdown is clipped by any scrolling ancestor and trapped inside the
// stacking context its ancestors create, so it can end up squashed inside
// the bar or painted underneath page content. Fixed positioning answers to
// the viewport instead, and the menu flips above its button near the bottom
// edge.
function anchorMenu(button: HTMLElement, menu: HTMLElement) {
const rect = button.getBoundingClientRect();
menu.style.position = "fixed";
menu.style.zIndex = "200";
menu.style.top = "0px";
menu.style.bottom = "auto";
// The stylesheet pins these menus to the right of their anchor; leaving
// that in place while setting a left would stretch them across the bar.
menu.style.right = "auto";
const width = menu.offsetWidth || 200;
const height = menu.offsetHeight || 0;
menu.style.left = `${String(Math.round(Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8)))}px`;
const below = rect.bottom + 8;
if (below + height <= window.innerHeight - 8) {
menu.style.top = `${String(Math.round(below))}px`;
} else {
menu.style.top = "auto";
menu.style.bottom = `${String(Math.round(window.innerHeight - rect.top + 8))}px`;
}
}
function installTopBarOverflow() { function installTopBarOverflow() {
const actions = document.querySelector<HTMLElement>(".top-bar-actions"); const actions = document.querySelector<HTMLElement>(".top-bar-actions");
if (!actions || document.getElementById("top-bar-more")) return; if (!actions || document.getElementById("top-bar-more")) return;
@@ -356,6 +383,7 @@ function elementById<T extends HTMLElement>(id: string): T {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const open = menu.classList.toggle("is-open"); const open = menu.classList.toggle("is-open");
button.setAttribute("aria-expanded", String(open)); button.setAttribute("aria-expanded", String(open));
if (open) anchorMenu(button, menu);
}); });
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || !wrap.contains(event.target)) closeMenu(); if (!(event.target instanceof Node) || !wrap.contains(event.target)) closeMenu();
@@ -445,7 +473,10 @@ function elementById<T extends HTMLElement>(id: string): T {
more.addEventListener("click", () => { more.addEventListener("click", () => {
const open = menu.classList.toggle("is-open"); const open = menu.classList.toggle("is-open");
more.setAttribute("aria-expanded", String(open)); more.setAttribute("aria-expanded", String(open));
if (open) menu.querySelector<HTMLElement>('[role="menuitem"]')?.focus(); if (open) {
anchorMenu(more, menu);
menu.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
}
}); });
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || (!menu.contains(event.target) && !more.contains(event.target))) closeMore(); if (!(event.target instanceof Node) || (!menu.contains(event.target) && !more.contains(event.target))) closeMore();