[feat](trx-frontend-http): compact single-row top bar #25

Merged
sjg merged 2 commits from feat/compact-top-bar into main 2026-08-02 18:50:53 +02:00
3 changed files with 234 additions and 16 deletions
@@ -747,6 +747,71 @@ function elementById(id) {
api.applyLayout(savedLayoutName(), { persist: false }); api.applyLayout(savedLayoutName(), { persist: false });
} }
} }
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
function installTopBarOverflow() {
const actions = document.querySelector(".top-bar-actions");
if (!actions || document.getElementById("top-bar-more")) return;
const wrap = document.createElement("div");
wrap.id = "top-bar-more";
wrap.className = "top-bar-more";
const button = document.createElement("button");
button.type = "button";
button.id = "top-bar-more-btn";
button.className = "header-bar-btn top-bar-more-btn";
button.textContent = "⋯";
button.setAttribute("aria-haspopup", "menu");
button.setAttribute("aria-expanded", "false");
button.setAttribute("aria-label", "More controls");
button.title = "More controls";
const menu = document.createElement("div");
menu.id = "top-bar-more-menu";
menu.className = "top-bar-more-menu";
menu.setAttribute("role", "menu");
button.setAttribute("aria-controls", menu.id);
wrap.append(button, menu);
actions.appendChild(wrap);
const closeMenu = () => {
menu.classList.remove("is-open");
button.setAttribute("aria-expanded", "false");
};
button.addEventListener("click", () => {
const open = menu.classList.toggle("is-open");
button.setAttribute("aria-expanded", String(open));
});
document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || !wrap.contains(event.target)) closeMenu();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMenu();
});
const barFits = () => {
const bar = actions.closest(".tab-bar");
if (!bar) return true;
const identity = bar.querySelector(".header-main");
const nav = bar.querySelector(".tab-bar-nav");
const gutters = 48;
const needed = (identity?.offsetWidth ?? 0) + (nav?.scrollWidth ?? 0) + actions.scrollWidth + gutters;
return needed <= bar.clientWidth;
};
const reflowOverflow = () => {
overflowOrder.forEach((selector) => {
const element = menu.querySelector(selector);
if (element) actions.insertBefore(element, wrap);
});
wrap.hidden = true;
for (const selector of overflowOrder) {
if (barFits()) break;
const element = actions.querySelector(selector);
if (!element) continue;
wrap.hidden = false;
menu.appendChild(element);
}
wrap.hidden = menu.children.length === 0;
if (wrap.hidden) closeMenu();
};
reflowOverflow();
window.addEventListener("resize", reflowOverflow);
}
function installMobileMore() { function installMobileMore() {
const nav = document.querySelector(".tab-bar-nav"); const nav = document.querySelector(".tab-bar-nav");
if (!nav || document.getElementById("mobile-more-btn")) return; if (!nav || document.getElementById("mobile-more-btn")) return;
@@ -853,6 +918,7 @@ function elementById(id) {
api.init = function init() { api.init = function init() {
ensureLiveRegions(); ensureLiveRegions();
installLayoutControls(); installLayoutControls();
installTopBarOverflow();
installMobileMore(); installMobileMore();
installDecoderPicker(); installDecoderPicker();
installDecoderBadges(); installDecoderBadges();
@@ -1229,12 +1229,20 @@ small { color: var(--text-muted); }
gap: 0; gap: 0;
flex-shrink: 0; flex-shrink: 0;
} }
/* One row, always. Controls that do not fit are moved into the overflow menu
* by ui-core rather than wrapping: wrapping made the header's height depend on
* the viewport width in a way that was not even monotonic (112px at 1440,
* 169px at 1100, 131px at 900), which is what made the bar feel unstable. */
.top-bar-actions { .top-bar-actions {
display: flex; display: flex;
align-items: flex-start; align-items: center;
gap: 0.45rem 0.6rem; gap: 0.4rem;
min-width: 0; min-width: 0;
flex-wrap: wrap; flex-wrap: nowrap;
}
/* Every control in the bar is the same height and none of them stretch. */
.top-bar-actions > * {
flex: 0 0 auto;
} }
.header-bar-btn.header-audio-btn { .header-bar-btn.header-audio-btn {
width: 2rem; width: 2rem;
@@ -1403,15 +1411,19 @@ small { color: var(--text-muted); }
gap: 0.8rem; gap: 0.8rem;
padding: 0.9rem 0.2rem 0; padding: 0.9rem 0.2rem 0;
} }
/* Inline, not stacked: the summary used to sit under the select, making this
* the only 54px control in a bar of 32px ones. */
.header-rig-switch { .header-rig-switch {
display: grid; display: flex;
grid-template-columns: minmax(0, 1fr); align-items: center;
align-items: start; gap: 0.4rem;
gap: 0.35rem; min-width: 0;
} }
.header-rig-summary { .header-rig-summary {
display: block; display: block;
max-width: 20rem; max-width: 12rem;
flex: 0 1 auto;
min-width: 0;
color: var(--text-muted); color: var(--text-muted);
font-size: var(--fs-xs); font-size: var(--fs-xs);
line-height: 1.35; line-height: 1.35;
@@ -1531,8 +1543,27 @@ small { color: var(--text-muted); }
align-items: center; align-items: center;
gap: 0.2rem; gap: 0.2rem;
min-width: 0; min-width: 0;
flex-wrap: wrap; /* Scroll rather than wrap: eight labels wrapped into a ragged block (four
* rows at 1100px) and made the header's height depend on the viewport. */
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 { .tab {
background: transparent; background: transparent;
border: none; border: none;
@@ -2964,7 +2995,10 @@ button.is-active {
text-transform: uppercase; text-transform: uppercase;
overflow: visible; overflow: visible;
} }
.operator-layout-picker::before { content: "Layout"; } /* The prefix made the least-used control the widest in the bar; the
* select carries its own aria-label and title instead. */
.operator-layout-picker { padding-left: 0; }
.operator-layout-picker select { max-width: 8rem; }
.operator-layout-picker select { .operator-layout-picker select {
width: auto; width: auto;
min-height: 2rem; min-height: 2rem;
@@ -3282,24 +3316,26 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.toast-region { bottom: calc(5.7rem + env(safe-area-inset-bottom)); } .toast-region { bottom: calc(5.7rem + env(safe-area-inset-bottom)); }
.top-bar-actions { .top-bar-actions {
width: 100%; width: 100%;
justify-content: flex-start; justify-content: flex-end;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 0.45rem; gap: 0.4rem;
} }
.operator-layout-picker { max-width: 100%; } .operator-layout-picker { max-width: 100%; }
.operator-layout-picker select { max-width: 10rem; } .operator-layout-picker select { max-width: 10rem; }
.header-rig-switch, .header-rig-switch,
.header-style-pick { .header-style-pick {
flex: 1 1 12rem; flex: 0 0 auto;
min-width: 0; min-width: 0;
} }
.header-rig-switch select, .header-rig-switch select,
.header-style-pick select { .header-style-pick select {
width: 100%; width: auto;
min-width: 0; min-width: 0;
} }
/* Rig hardware/feature detail is on the left subtitle and the About tab. */
.header-rig-summary { display: none; }
.header-bar-btn { .header-bar-btn {
flex: 1 1 calc(50% - 0.3rem); flex: 0 0 auto;
min-width: 0; min-width: 0;
} }
#tab-main, #tab-main,
@@ -5379,3 +5415,34 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
font-size: max(0.75rem, 12px); font-size: max(0.75rem, 12px);
} }
} }
/* --- Top bar overflow menu ------------------------------------------------ */
.top-bar-more { position: relative; }
.top-bar-more[hidden] { display: none; }
.top-bar-more-btn {
width: 2rem;
height: 2rem;
min-height: 2rem;
padding: 0;
font-size: 1rem;
line-height: 1;
}
.top-bar-more-menu {
position: absolute;
top: calc(100% + 0.35rem);
right: 0;
z-index: 40;
display: none;
flex-direction: column;
gap: 0.4rem;
min-width: 12rem;
padding: 0.5rem;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: var(--surface);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
}
.top-bar-more-menu.is-open { display: flex; }
.top-bar-more-menu > * { width: 100%; }
.top-bar-more-menu select { width: 100%; max-width: none; }
@@ -322,6 +322,90 @@ function elementById<T extends HTMLElement>(id: string): T {
} }
} }
// Secondary controls, in the order they leave the bar when it gets tight.
// Audio, record and the rig picker are the operating controls and stay.
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
function installTopBarOverflow() {
const actions = document.querySelector<HTMLElement>(".top-bar-actions");
if (!actions || document.getElementById("top-bar-more")) return;
const wrap = document.createElement("div");
wrap.id = "top-bar-more";
wrap.className = "top-bar-more";
const button = document.createElement("button");
button.type = "button";
button.id = "top-bar-more-btn";
button.className = "header-bar-btn top-bar-more-btn";
button.textContent = "⋯";
button.setAttribute("aria-haspopup", "menu");
button.setAttribute("aria-expanded", "false");
button.setAttribute("aria-label", "More controls");
button.title = "More controls";
const menu = document.createElement("div");
menu.id = "top-bar-more-menu";
menu.className = "top-bar-more-menu";
menu.setAttribute("role", "menu");
button.setAttribute("aria-controls", menu.id);
wrap.append(button, menu);
actions.appendChild(wrap);
const closeMenu = () => {
menu.classList.remove("is-open");
button.setAttribute("aria-expanded", "false");
};
button.addEventListener("click", () => {
const open = menu.classList.toggle("is-open");
button.setAttribute("aria-expanded", String(open));
});
document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || !wrap.contains(event.target)) closeMenu();
});
document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMenu(); });
// Measured against the bar, not the actions container: the actions are
// sized by their content, so their own scrollWidth never exceeds their
// clientWidth. A viewport-width threshold is not enough either — how much
// fits depends on the rig name and the translated labels, so a bar that is
// wide enough on one rig clips a control on another.
// `bar.scrollWidth > bar.clientWidth` is true even when nothing is clipped,
// so it cannot be the test. What actually matters is that the controls stay
// inside the bar and the page tabs are not squeezed into a scroller: seeing
// every tab beats keeping the style picker inline.
// Compare natural widths against the space available. Rendered widths
// cannot answer this: the nav has min-width 0 and scrolls, so it always
// shrinks to the leftover space and always reports "scrolling", while the
// bar reports overflow even when nothing is clipped. scrollWidth on a
// scroll container is its unconstrained content width, which is what a fit
// test needs.
const barFits = () => {
const bar = actions.closest<HTMLElement>(".tab-bar");
if (!bar) return true;
const identity = bar.querySelector<HTMLElement>(".header-main");
const nav = bar.querySelector<HTMLElement>(".tab-bar-nav");
const gutters = 48;
const needed = (identity?.offsetWidth ?? 0) + (nav?.scrollWidth ?? 0) + actions.scrollWidth + gutters;
return needed <= bar.clientWidth;
};
const reflowOverflow = () => {
overflowOrder.forEach((selector) => {
const element = menu.querySelector<HTMLElement>(selector);
if (element) actions.insertBefore(element, wrap);
});
wrap.hidden = true;
for (const selector of overflowOrder) {
if (barFits()) break;
const element = actions.querySelector<HTMLElement>(selector);
if (!element) continue;
wrap.hidden = false;
menu.appendChild(element);
}
wrap.hidden = menu.children.length === 0;
if (wrap.hidden) closeMenu();
};
reflowOverflow();
window.addEventListener("resize", reflowOverflow);
}
function installMobileMore() { function installMobileMore() {
const nav = document.querySelector(".tab-bar-nav"); const nav = document.querySelector(".tab-bar-nav");
if (!nav || document.getElementById("mobile-more-btn")) return; if (!nav || document.getElementById("mobile-more-btn")) return;
@@ -426,6 +510,7 @@ function elementById<T extends HTMLElement>(id: string): T {
api.init = function init() { api.init = function init() {
ensureLiveRegions(); ensureLiveRegions();
installLayoutControls(); installLayoutControls();
installTopBarOverflow();
installMobileMore(); installMobileMore();
installDecoderPicker(); installDecoderPicker();
installDecoderBadges(); installDecoderBadges();