[feat](trx-frontend-http): compact single-row top bar #25
@@ -747,6 +747,71 @@ function elementById(id) {
|
||||
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() {
|
||||
const nav = document.querySelector(".tab-bar-nav");
|
||||
if (!nav || document.getElementById("mobile-more-btn")) return;
|
||||
@@ -853,6 +918,7 @@ function elementById(id) {
|
||||
api.init = function init() {
|
||||
ensureLiveRegions();
|
||||
installLayoutControls();
|
||||
installTopBarOverflow();
|
||||
installMobileMore();
|
||||
installDecoderPicker();
|
||||
installDecoderBadges();
|
||||
|
||||
@@ -1229,12 +1229,20 @@ small { color: var(--text-muted); }
|
||||
gap: 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 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.45rem 0.6rem;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
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 {
|
||||
width: 2rem;
|
||||
@@ -1403,15 +1411,19 @@ small { color: var(--text-muted); }
|
||||
gap: 0.8rem;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.35rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.header-rig-summary {
|
||||
display: block;
|
||||
max-width: 20rem;
|
||||
max-width: 12rem;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.35;
|
||||
@@ -1531,8 +1543,27 @@ small { color: var(--text-muted); }
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
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 {
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -2964,7 +2995,10 @@ button.is-active {
|
||||
text-transform: uppercase;
|
||||
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 {
|
||||
width: auto;
|
||||
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)); }
|
||||
.top-bar-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.operator-layout-picker { max-width: 100%; }
|
||||
.operator-layout-picker select { max-width: 10rem; }
|
||||
.header-rig-switch,
|
||||
.header-style-pick {
|
||||
flex: 1 1 12rem;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.header-rig-switch select,
|
||||
.header-style-pick select {
|
||||
width: 100%;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Rig hardware/feature detail is on the left subtitle and the About tab. */
|
||||
.header-rig-summary { display: none; }
|
||||
.header-bar-btn {
|
||||
flex: 1 1 calc(50% - 0.3rem);
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
#tab-main,
|
||||
@@ -5379,3 +5415,34 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
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() {
|
||||
const nav = document.querySelector(".tab-bar-nav");
|
||||
if (!nav || document.getElementById("mobile-more-btn")) return;
|
||||
@@ -426,6 +510,7 @@ function elementById<T extends HTMLElement>(id: string): T {
|
||||
api.init = function init() {
|
||||
ensureLiveRegions();
|
||||
installLayoutControls();
|
||||
installTopBarOverflow();
|
||||
installMobileMore();
|
||||
installDecoderPicker();
|
||||
installDecoderBadges();
|
||||
|
||||
Reference in New Issue
Block a user