diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index 98c621c6..b851bc7d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -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(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index 480ba033..813976ba 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -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,18 @@ 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; } +.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 +2986,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 +3307,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 +5406,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; } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts index 5b660105..c1da0358 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts @@ -322,6 +322,90 @@ function elementById(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(".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(".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; @@ -426,6 +510,7 @@ function elementById(id: string): T { api.init = function init() { ensureLiveRegions(); installLayoutControls(); + installTopBarOverflow(); installMobileMore(); installDecoderPicker(); installDecoderBadges();