[feat](trx-frontend-http): compact single-row top bar #25
@@ -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,18 @@ 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;
|
||||||
}
|
}
|
||||||
|
.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 +2986,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 +3307,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 +5406,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();
|
||||||
|
|||||||
Reference in New Issue
Block a user