Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00191c8d7a | ||
|
|
56c363a517 | ||
|
|
5ad91b4ab6 | ||
|
|
1843522b45 | ||
|
|
41ddecf5a7 | ||
|
|
b06c37affa | ||
|
|
b4912f5879 | ||
|
|
27f2558193 | ||
|
|
3a9bf1b7ce |
@@ -748,6 +748,25 @@ function elementById(id) {
|
||||
}
|
||||
}
|
||||
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
|
||||
function anchorMenu(button, menu) {
|
||||
const rect = button.getBoundingClientRect();
|
||||
if (menu.parentElement !== document.body) document.body.appendChild(menu);
|
||||
menu.style.position = "fixed";
|
||||
menu.style.zIndex = "9700";
|
||||
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() {
|
||||
const actions = document.querySelector(".top-bar-actions");
|
||||
if (!actions || document.getElementById("top-bar-more")) return;
|
||||
@@ -777,9 +796,14 @@ function elementById(id) {
|
||||
button.addEventListener("click", () => {
|
||||
const open = menu.classList.toggle("is-open");
|
||||
button.setAttribute("aria-expanded", String(open));
|
||||
if (open) anchorMenu(button, menu);
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!(event.target instanceof Node) || !wrap.contains(event.target)) closeMenu();
|
||||
if (!(event.target instanceof Node)) {
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
if (!wrap.contains(event.target) && !menu.contains(event.target)) closeMenu();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeMenu();
|
||||
@@ -819,7 +843,8 @@ function elementById(id) {
|
||||
more.id = "mobile-more-btn";
|
||||
more.className = "tab mobile-more-btn";
|
||||
more.type = "button";
|
||||
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">More</span>';
|
||||
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">Tools</span>';
|
||||
more.setAttribute("aria-label", "Tools and settings");
|
||||
more.setAttribute("aria-haspopup", "menu");
|
||||
more.setAttribute("aria-expanded", "false");
|
||||
const menu = document.createElement("div");
|
||||
@@ -851,7 +876,10 @@ function elementById(id) {
|
||||
more.addEventListener("click", () => {
|
||||
const open = menu.classList.toggle("is-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) => {
|
||||
if (!(event.target instanceof Node) || !menu.contains(event.target) && !more.contains(event.target)) closeMore();
|
||||
@@ -2013,7 +2041,6 @@ var signalSplitValueEl = document.getElementById("signal-split-value");
|
||||
var overviewPeakHoldEl = document.getElementById("overview-peak-hold");
|
||||
var themeToggleBtn = document.getElementById("theme-toggle");
|
||||
var headerRigSwitchSelect = document.getElementById("header-rig-switch-select");
|
||||
var headerRigSummary = document.getElementById("header-rig-summary");
|
||||
var headerStylePickSelect = document.getElementById("header-style-pick-select");
|
||||
var rdsPsOverlay = document.getElementById("rds-ps-overlay");
|
||||
var tabMainEl = document.getElementById("tab-main");
|
||||
@@ -2730,19 +2757,6 @@ function populateRigPicker(selectEl, rigIds, activeRigId, disabled) {
|
||||
}
|
||||
selectEl.disabled = disabled;
|
||||
}
|
||||
function updateRigIdentitySummary(rigId, pending = false) {
|
||||
if (!headerRigSummary) return;
|
||||
const rig = serverRigs.find((entry) => entry?.remote === rigId);
|
||||
if (!rig) {
|
||||
headerRigSummary.textContent = pending ? "Switching rigs…" : "No rig details available";
|
||||
return;
|
||||
}
|
||||
const hardware = [rig.manufacturer, rig.model].map((value) => String(value || "").trim()).filter(Boolean).join(" ") || rig.remote;
|
||||
const modes = Array.isArray(rig.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : [];
|
||||
const features = [rig.tx ? "TX" : "RX", rig.filter_controls ? "SDR filters" : null, ...modes.slice(0, 5)];
|
||||
if (modes.length > 5) features.push(`+${modes.length - 5} modes`);
|
||||
headerRigSummary.textContent = `${pending ? "Switching to " : ""}${hardware} · ${features.filter(Boolean).join(" · ")}`;
|
||||
}
|
||||
function updateRigSubtitle(activeRigId) {
|
||||
if (!rigSubtitle) return;
|
||||
const name = activeRigId && lastRigDisplayNames[activeRigId] || activeRigId || "--";
|
||||
@@ -2773,7 +2787,6 @@ function applyRigList(activeRigId, rigIds, displayNames = {}) {
|
||||
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
|
||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||||
updateRigSubtitle(lastActiveRigId);
|
||||
updateRigIdentitySummary(lastActiveRigId);
|
||||
window.trxUi?.setActiveRig(lastActiveRigId);
|
||||
if (rigListChanged) {
|
||||
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
||||
@@ -5034,7 +5047,7 @@ async function switchRigFromSelect(selectEl) {
|
||||
rigSwitchInProgress = true;
|
||||
setControlPending(selectEl, true);
|
||||
selectEl.closest(".header-rig-switch")?.classList.add("is-switching");
|
||||
updateRigIdentitySummary(nextRig, true);
|
||||
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}…`);
|
||||
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}…`);
|
||||
try {
|
||||
const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : "";
|
||||
@@ -5042,7 +5055,6 @@ async function switchRigFromSelect(selectEl) {
|
||||
lastActiveRigId = nextRig;
|
||||
resetDecoderStateOnRigSwitch();
|
||||
updateRigSubtitle(lastActiveRigId);
|
||||
updateRigIdentitySummary(lastActiveRigId);
|
||||
window.trxUi?.setActiveRig(lastActiveRigId);
|
||||
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
||||
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
||||
@@ -5061,7 +5073,6 @@ async function switchRigFromSelect(selectEl) {
|
||||
} catch (err) {
|
||||
console.error("select_rig failed:", err);
|
||||
selectEl.value = prevRig || "";
|
||||
updateRigIdentitySummary(prevRig);
|
||||
window.trxUi?.notify("Rig could not be switched", { kind: "error" });
|
||||
} finally {
|
||||
rigSwitchInProgress = false;
|
||||
|
||||
@@ -46,7 +46,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="tab-bar-nav" aria-label="Primary navigation">
|
||||
<button class="tab active" data-tab="main">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-home"/></svg>
|
||||
<span class="tab-label">Main</span>
|
||||
<span class="tab-label">Radio</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="bookmarks">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
|
||||
@@ -85,7 +85,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<button id="header-rec-btn" class="header-bar-btn header-rec-btn" type="button" aria-label="Toggle recording" title="Toggle recording">REC</button>
|
||||
<div class="header-rig-switch">
|
||||
<select id="header-rig-switch-select" aria-label="Select active rig"></select>
|
||||
<span id="header-rig-summary" class="header-rig-summary" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="header-style-pick">
|
||||
<select id="header-style-pick-select" aria-label="Select UI style">
|
||||
@@ -1052,6 +1051,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</template>
|
||||
</div>
|
||||
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
||||
<h2 class="section-heading">Statistics</h2>
|
||||
<template id="tmpl-statistics">
|
||||
<div class="stats-controls">
|
||||
<div class="stats-control-group">
|
||||
@@ -1192,6 +1192,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</section>
|
||||
</div>
|
||||
<div id="tab-settings" class="tab-panel" style="display:none;">
|
||||
<h2 class="section-heading">Settings</h2>
|
||||
<div class="sub-tab-bar">
|
||||
<button class="sub-tab active" data-subtab="settings-scheduler">Scheduler</button>
|
||||
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
|
||||
@@ -1487,6 +1488,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-about" class="tab-panel" style="display:none;">
|
||||
<h2 class="section-heading">About</h2>
|
||||
<div id="auth-badge" style="display:none; margin-bottom: 1rem; padding: 0.5rem; background: var(--bg-secondary); border-radius: 0.25rem; color: var(--text-muted); font-size: 0.85rem;">Authenticated as: <strong id="auth-role-badge">--</strong></div>
|
||||
<template id="tmpl-about">
|
||||
<div class="sub-tab-bar">
|
||||
|
||||
@@ -1419,18 +1419,6 @@ small { color: var(--text-muted); }
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.header-rig-summary {
|
||||
display: block;
|
||||
max-width: 12rem;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.header-rig-switch select {
|
||||
min-width: 8rem;
|
||||
height: 2rem;
|
||||
@@ -1543,27 +1531,20 @@ small { color: var(--text-muted); }
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
/* 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. */
|
||||
/* Never wraps. It must still be able to shrink below its content, or it
|
||||
* overlaps the controls; the menus it anchors are reparented to the body on
|
||||
* open, so clipping here no longer reaches them. */
|
||||
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);
|
||||
overflow: hidden;
|
||||
}
|
||||
.tab-bar-nav .tab { flex: 0 0 auto; }
|
||||
/* Icons before scrolling: every tab already carries one, and four icons plus
|
||||
* More always fit, so the strip never has to hide a destination. */
|
||||
@media (max-width: 1360px) and (min-width: 701px) {
|
||||
.tab-bar-nav .tab .tab-label { display: none; }
|
||||
.tab-bar-nav .tab .tab-icon, .tab-bar-nav .tab .tab-more-icon { display: block; }
|
||||
.tab-bar-nav .tab { padding: 0.5rem 0.6rem; }
|
||||
}
|
||||
.tab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -1585,6 +1566,7 @@ small { color: var(--text-muted); }
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tab-label { display: block; }
|
||||
.tab-more-icon { display: none; }
|
||||
.about-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
|
||||
.about-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 0.5rem; padding: 0; overflow: hidden; }
|
||||
.about-card-title { display: flex; align-items: center; gap: 0.5rem; padding: 0.6rem 0.75rem; font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-heading); border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--card-bg) 50%, var(--bg)); }
|
||||
@@ -3094,9 +3076,22 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
body[data-operator-layout="broadcast"] .controls-row { grid-template-columns: 1fr auto; }
|
||||
body[data-operator-layout="broadcast"] #wfm-controls-col { grid-column: 1 / -1; }
|
||||
}
|
||||
.mobile-more-btn, .mobile-more-menu, .decoder-tab-select { display: none; }
|
||||
/* One navigation model at every width. Statistics, Recorder, Settings and
|
||||
* About are occasional destinations: they live behind More rather than
|
||||
* competing with the operating tabs for the row and then scrolling out of
|
||||
* reach. The mobile layout already grouped them this way; the desktop strip
|
||||
* now matches it, which is why the tab strip no longer needs to scroll. */
|
||||
.tab-bar-nav .tab[data-tab="statistics"],
|
||||
.tab-bar-nav .tab[data-tab="recorder"],
|
||||
.tab-bar-nav .tab[data-tab="settings"],
|
||||
.tab-bar-nav .tab[data-tab="about"] { display: none; }
|
||||
.mobile-more-btn { display: flex; flex: 0 0 auto; }
|
||||
.mobile-more-menu, .decoder-tab-select { display: none; }
|
||||
.tab-bar-nav { position: relative; }
|
||||
.mobile-more-menu {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
top: calc(100% + 0.4rem);
|
||||
right: 0;
|
||||
right: max(0.75rem, env(safe-area-inset-right));
|
||||
bottom: calc(5.4rem + env(safe-area-inset-bottom));
|
||||
z-index: 80;
|
||||
@@ -3285,7 +3280,7 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
color: var(--text);
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
|
||||
}
|
||||
.tab-icon { display: block; }
|
||||
.tab-icon, .tab-more-icon { display: block; }
|
||||
/* Shorten long tab labels to keep bottom nav compact */
|
||||
.tab[data-tab="bookmarks"] .tab-label { font-size: 0.6rem; }
|
||||
.tab[data-tab="digital-modes"] .tab-label { font-size: 0.6rem; }
|
||||
@@ -3293,6 +3288,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
.tab[data-tab="statistics"], .tab[data-tab="recorder"],
|
||||
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
|
||||
.mobile-more-btn { display: flex; }
|
||||
.mobile-more-menu {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
right: max(0.75rem, env(safe-area-inset-right));
|
||||
bottom: calc(5.4rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
.mobile-more-btn[aria-expanded="true"] {
|
||||
color: var(--accent-text);
|
||||
background: color-mix(in srgb, var(--accent-green) 10%, transparent);
|
||||
@@ -3332,8 +3333,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
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: 0 0 auto;
|
||||
min-width: 0;
|
||||
@@ -5189,7 +5188,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.stats-controls {
|
||||
display: flex;
|
||||
@@ -5446,3 +5444,20 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
.top-bar-more-menu.is-open { display: flex; }
|
||||
.top-bar-more-menu > * { width: 100%; }
|
||||
.top-bar-more-menu select { width: 100%; max-width: none; }
|
||||
|
||||
|
||||
/* Page title for destinations reached through the Tools menu. The tab strip
|
||||
* cannot show which of them is active, so each states its own name.
|
||||
*
|
||||
* The margin is the spacing on the plain block panels; #tab-statistics is a
|
||||
* flex column with its own `gap`, where the margin lands on top of that gap
|
||||
* and doubles the space under the title. */
|
||||
.section-heading {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#tab-statistics > .section-heading { margin-bottom: 0; }
|
||||
|
||||
@@ -724,7 +724,6 @@ const signalSplitValueEl = document.getElementById("signal-split-value");
|
||||
const overviewPeakHoldEl = document.getElementById("overview-peak-hold") as HTMLInputElement | null;
|
||||
const themeToggleBtn = document.getElementById("theme-toggle") as HTMLButtonElement | null;
|
||||
const headerRigSwitchSelect = document.getElementById("header-rig-switch-select") as HTMLSelectElement | null;
|
||||
const headerRigSummary = document.getElementById("header-rig-summary");
|
||||
const headerStylePickSelect = document.getElementById("header-style-pick-select") as HTMLSelectElement | null;
|
||||
const rdsPsOverlay = document.getElementById("rds-ps-overlay");
|
||||
const tabMainEl = document.getElementById("tab-main");
|
||||
@@ -1371,20 +1370,6 @@ function populateRigPicker(selectEl: HTMLSelectElement | null, rigIds: string[],
|
||||
selectEl.disabled = disabled;
|
||||
}
|
||||
|
||||
function updateRigIdentitySummary(rigId: string | null, pending = false) {
|
||||
if (!headerRigSummary) return;
|
||||
const rig = serverRigs.find((entry) => entry?.remote === rigId);
|
||||
if (!rig) {
|
||||
headerRigSummary.textContent = pending ? "Switching rigs…" : "No rig details available";
|
||||
return;
|
||||
}
|
||||
const hardware = [rig.manufacturer, rig.model].map(value => String(value || "").trim()).filter(Boolean).join(" ") || rig.remote;
|
||||
const modes = Array.isArray(rig.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : [];
|
||||
const features = [rig.tx ? "TX" : "RX", rig.filter_controls ? "SDR filters" : null, ...modes.slice(0, 5)];
|
||||
if (modes.length > 5) features.push(`+${modes.length - 5} modes`);
|
||||
headerRigSummary.textContent = `${pending ? "Switching to " : ""}${hardware} · ${features.filter(Boolean).join(" · ")}`;
|
||||
}
|
||||
|
||||
function updateRigSubtitle(activeRigId: string | null) {
|
||||
if (!rigSubtitle) return;
|
||||
const name = (activeRigId && lastRigDisplayNames[activeRigId]) || activeRigId || "--";
|
||||
@@ -1421,7 +1406,6 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
|
||||
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
|
||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||||
updateRigSubtitle(lastActiveRigId);
|
||||
updateRigIdentitySummary(lastActiveRigId);
|
||||
window.trxUi?.setActiveRig(lastActiveRigId);
|
||||
if (rigListChanged) {
|
||||
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
||||
@@ -4023,7 +4007,7 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
|
||||
rigSwitchInProgress = true;
|
||||
setControlPending(selectEl, true);
|
||||
selectEl.closest(".header-rig-switch")?.classList.add("is-switching");
|
||||
updateRigIdentitySummary(nextRig, true);
|
||||
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}…`);
|
||||
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}…`);
|
||||
try {
|
||||
const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : "";
|
||||
@@ -4031,7 +4015,6 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
|
||||
lastActiveRigId = nextRig;
|
||||
resetDecoderStateOnRigSwitch();
|
||||
updateRigSubtitle(lastActiveRigId);
|
||||
updateRigIdentitySummary(lastActiveRigId);
|
||||
window.trxUi?.setActiveRig(lastActiveRigId);
|
||||
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
||||
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
||||
@@ -4050,7 +4033,6 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
|
||||
} catch (err) {
|
||||
console.error("select_rig failed:", err);
|
||||
selectEl.value = prevRig || "";
|
||||
updateRigIdentitySummary(prevRig);
|
||||
window.trxUi?.notify("Rig could not be switched", { kind: "error" });
|
||||
} finally {
|
||||
rigSwitchInProgress = false;
|
||||
|
||||
@@ -326,6 +326,39 @@ function elementById<T extends HTMLElement>(id: string): T {
|
||||
// Audio, record and the rig picker are the operating controls and stay.
|
||||
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();
|
||||
// Reparent to the body. Fixed positioning escapes clipping but not
|
||||
// stacking: the header sets `z-index: 2`, which makes it a stacking
|
||||
// context, so any z-index the menu carries is confined below every panel
|
||||
// that paints above level 2 — the spectrum overlays reach 9600. Only
|
||||
// leaving that subtree lets the menu sit above the page.
|
||||
if (menu.parentElement !== document.body) document.body.appendChild(menu);
|
||||
menu.style.position = "fixed";
|
||||
menu.style.zIndex = "9700";
|
||||
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() {
|
||||
const actions = document.querySelector<HTMLElement>(".top-bar-actions");
|
||||
if (!actions || document.getElementById("top-bar-more")) return;
|
||||
@@ -356,9 +389,11 @@ function elementById<T extends HTMLElement>(id: string): T {
|
||||
button.addEventListener("click", () => {
|
||||
const open = menu.classList.toggle("is-open");
|
||||
button.setAttribute("aria-expanded", String(open));
|
||||
if (open) anchorMenu(button, menu);
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!(event.target instanceof Node) || !wrap.contains(event.target)) closeMenu();
|
||||
if (!(event.target instanceof Node)) { closeMenu(); return; }
|
||||
if (!wrap.contains(event.target) && !menu.contains(event.target)) closeMenu();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMenu(); });
|
||||
|
||||
@@ -413,7 +448,8 @@ function elementById<T extends HTMLElement>(id: string): T {
|
||||
more.id = "mobile-more-btn";
|
||||
more.className = "tab mobile-more-btn";
|
||||
more.type = "button";
|
||||
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">More</span>';
|
||||
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">Tools</span>';
|
||||
more.setAttribute("aria-label", "Tools and settings");
|
||||
more.setAttribute("aria-haspopup", "menu");
|
||||
more.setAttribute("aria-expanded", "false");
|
||||
const menu = document.createElement("div");
|
||||
@@ -445,7 +481,10 @@ function elementById<T extends HTMLElement>(id: string): T {
|
||||
more.addEventListener("click", () => {
|
||||
const open = menu.classList.toggle("is-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) => {
|
||||
if (!(event.target instanceof Node) || (!menu.contains(event.target) && !more.contains(event.target))) closeMore();
|
||||
|
||||
@@ -184,7 +184,10 @@ try {
|
||||
await page.locator('.tab[data-tab="main"]').click();
|
||||
assert.equal(new URL(page.url()).pathname, "/");
|
||||
|
||||
await page.locator('.tab[data-tab="about"]').click();
|
||||
// About is an occasional destination, so it lives behind More at every
|
||||
// width rather than in the operating tab strip.
|
||||
await page.locator("#mobile-more-btn").click();
|
||||
await page.locator('[data-navigate-tab="about"]').click();
|
||||
await page.locator("#tab-about").waitFor({ state: "visible" });
|
||||
assert.equal(new URL(page.url()).pathname, "/about");
|
||||
|
||||
|
||||
@@ -95,6 +95,18 @@ pub(crate) async fn about_index(req: HttpRequest) -> impl Responder {
|
||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/statistics")]
|
||||
pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/bookmarks")]
|
||||
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Favicon & logo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -637,6 +637,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(assets::recorder_index)
|
||||
.service(assets::settings_index)
|
||||
.service(assets::about_index)
|
||||
.service(assets::statistics_index)
|
||||
.service(assets::bookmarks_index)
|
||||
.service(assets::favicon)
|
||||
.service(assets::favicon_png)
|
||||
.service(assets::logo)
|
||||
|
||||
Reference in New Issue
Block a user