refactor: extract typed navigation routes

This commit is contained in:
sjg
2026-08-01 17:18:41 +02:00
parent c8cc235e88
commit 1fb196a64e
5 changed files with 134 additions and 66 deletions
@@ -80,7 +80,7 @@
if (decoderRegistry.length > 0) callback();
else readyCallbacks.push(callback);
}
function hideUnsupportedDecoderUi() {
function applyDecoderRegistryVisibility() {
const knownIds = new Set(decoderRegistry.map(({ id }) => id));
const alwaysShow = /* @__PURE__ */ new Set(["overview", "rds", "sat"]);
document.querySelectorAll(
@@ -128,7 +128,7 @@
readyCallbacks.splice(0).forEach((callback) => {
callback();
});
hideUnsupportedDecoderUi();
applyDecoderRegistryVisibility();
onLoaded();
} catch (error) {
console.error("Failed to fetch decoder registry:", error);
@@ -352,6 +352,44 @@
return value;
}
// src/features/navigation/routes.ts
var TAB_ORDER = [
"main",
"bookmarks",
"digital-modes",
"map",
"statistics",
"recorder",
"settings",
"about"
];
var TAB_PATHS = {
main: "/",
bookmarks: "/bookmarks",
"digital-modes": "/digital-modes",
map: "/map",
statistics: "/statistics",
recorder: "/recorder",
settings: "/settings",
about: "/about"
};
function normalizeTabPath(pathname) {
const raw = pathname.length > 0 ? pathname : "/";
return raw === "/" ? "/" : raw.replace(/\/+$/, "") || "/";
}
function tabFromPath(pathname) {
const normalized = normalizeTabPath(pathname);
const match = Object.entries(TAB_PATHS).find(([, path]) => path === normalized);
return match ? match[0] : "main";
}
function updateTabHistory(name, replace = false) {
const targetPath = TAB_PATHS[name];
if (normalizeTabPath(window.location.pathname) === targetPath) return;
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
if (replace) window.history.replaceState({}, "", nextUrl);
else window.history.pushState({}, "", nextUrl);
}
// src/app.js
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
var authRole = null;
@@ -420,7 +458,7 @@
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
btn.classList.remove("active");
});
navigateToTab(tabFromPath(), { updateHistory: false, replaceHistory: true });
navigateToTab(tabFromPath2(), { updateHistory: false, replaceHistory: true });
syncTopBarAccess();
}
function showAuthError(msg) {
@@ -4184,34 +4222,8 @@ ${unsupportedBandSummary()}`;
});
}
var _activeTab = "main";
var TAB_ORDER = ["main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about"];
var TAB_PATHS = {
main: "/",
bookmarks: "/bookmarks",
"digital-modes": "/digital-modes",
map: "/map",
recorder: "/recorder",
settings: "/settings",
about: "/about"
};
function normalizeTabPath(pathname) {
const raw = typeof pathname === "string" && pathname.length > 0 ? pathname : "/";
if (raw === "/") return "/";
return raw.replace(/\/+$/, "") || "/";
}
function tabFromPath(pathname = window.location.pathname) {
const normalized = normalizeTabPath(pathname);
for (const [tabName, tabPath] of Object.entries(TAB_PATHS)) {
if (normalized === tabPath) return tabName;
}
return "main";
}
function updateTabHistory(name, replaceHistory = false) {
const targetPath = TAB_PATHS[name] || "/";
if (normalizeTabPath(window.location.pathname) === targetPath) return;
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
const method = replaceHistory ? "replaceState" : "pushState";
window.history[method]({}, "", nextUrl);
function tabFromPath2(pathname = window.location.pathname) {
return tabFromPath(pathname);
}
var _mapInitTimer = null;
function _initMapWhenReady() {
@@ -4265,7 +4277,7 @@ ${unsupportedBandSummary()}`;
panel.appendChild(tmpl.content.cloneNode(true));
tmpl.remove();
panel.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
if (decoderRegistry.length) hideUnsupportedDecoderTabs();
if (decoderRegistry.length) applyDecoderRegistryVisibility();
}
if (updateHistory) {
updateTabHistory(name, replaceHistory);
@@ -4289,7 +4301,7 @@ ${unsupportedBandSummary()}`;
navigateToTab(btn.dataset.tab);
});
window.addEventListener("popstate", () => {
navigateToTab(tabFromPath(), { updateHistory: false });
navigateToTab(tabFromPath2(), { updateHistory: false });
});
(function() {
let tx = 0, ty = 0;
@@ -12,6 +12,7 @@ import {
import { escapeHtml as escapeMapHtml } from "./core/dom.js";
import { loadSetting, saveSetting } from "./core/settings.js";
import {
applyDecoderRegistryVisibility,
decoderRegistry,
loadDecoderRegistry,
} from "./core/decoder-registry.js";
@@ -30,6 +31,11 @@ import {
parseFrequencyInput,
} from "./core/format.js";
import { decodeCbor as decodeCborPayload } from "./core/cbor.js";
import {
TAB_ORDER,
tabFromPath as tabFromPathname,
updateTabHistory,
} from "./features/navigation/routes.js";
// --- Decoder registry (fetched from /decoders on load) ---
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
@@ -4258,37 +4264,8 @@ if (spectrumBwSweetBtn) {
// --- Tab navigation ---
let _activeTab = "main"; // tracked for render-path tab awareness
const TAB_ORDER = ["main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about"];
const TAB_PATHS = {
main: "/",
bookmarks: "/bookmarks",
"digital-modes": "/digital-modes",
map: "/map",
recorder: "/recorder",
settings: "/settings",
about: "/about",
};
function normalizeTabPath(pathname) {
const raw = typeof pathname === "string" && pathname.length > 0 ? pathname : "/";
if (raw === "/") return "/";
return raw.replace(/\/+$/, "") || "/";
}
function tabFromPath(pathname = window.location.pathname) {
const normalized = normalizeTabPath(pathname);
for (const [tabName, tabPath] of Object.entries(TAB_PATHS)) {
if (normalized === tabPath) return tabName;
}
return "main";
}
function updateTabHistory(name, replaceHistory = false) {
const targetPath = TAB_PATHS[name] || "/";
if (normalizeTabPath(window.location.pathname) === targetPath) return;
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
const method = replaceHistory ? "replaceState" : "pushState";
window.history[method]({}, "", nextUrl);
return tabFromPathname(pathname);
}
// Initialise the Leaflet map, waiting for both Leaflet (L) and map-core.js
@@ -4347,7 +4324,7 @@ function navigateToTab(name, options = {}) {
// Wire sub-tab bars inside the freshly cloned content.
panel.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
// Re-run decoder visibility for about-tab elements now in the DOM.
if (decoderRegistry.length) hideUnsupportedDecoderTabs();
if (decoderRegistry.length) applyDecoderRegistryVisibility();
}
if (updateHistory) {
updateTabHistory(name, replaceHistory);
@@ -26,7 +26,7 @@ export function onDecoderRegistryReady(callback: () => void): void {
else readyCallbacks.push(callback);
}
function hideUnsupportedDecoderUi(): void {
export function applyDecoderRegistryVisibility(): void {
const knownIds = new Set(decoderRegistry.map(({ id }) => id));
const alwaysShow = new Set(["overview", "rds", "sat"]);
@@ -87,7 +87,7 @@ export async function loadDecoderRegistry(onLoaded: () => void): Promise<void> {
decoderRegistry = decodeRegistry(await response.json());
bridge.decoderRegistry = decoderRegistry;
readyCallbacks.splice(0).forEach((callback) => { callback(); });
hideUnsupportedDecoderUi();
applyDecoderRegistryVisibility();
onLoaded();
} catch (error: unknown) {
console.error("Failed to fetch decoder registry:", error);
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
export const TAB_ORDER = [
"main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about",
] as const;
export type TabName = typeof TAB_ORDER[number];
export const TAB_PATHS: Readonly<Record<TabName, string>> = {
main: "/",
bookmarks: "/bookmarks",
"digital-modes": "/digital-modes",
map: "/map",
statistics: "/statistics",
recorder: "/recorder",
settings: "/settings",
about: "/about",
};
export function normalizeTabPath(pathname: string): string {
const raw = pathname.length > 0 ? pathname : "/";
return raw === "/" ? "/" : raw.replace(/\/+$/, "") || "/";
}
export function tabFromPath(pathname: string): TabName {
const normalized = normalizeTabPath(pathname);
const match = Object.entries(TAB_PATHS).find(([, path]) => path === normalized);
return match ? match[0] as TabName : "main";
}
export function updateTabHistory(name: TabName, replace = false): void {
const targetPath = TAB_PATHS[name];
if (normalizeTabPath(window.location.pathname) === targetPath) return;
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
if (replace) window.history.replaceState({}, "", nextUrl);
else window.history.pushState({}, "", nextUrl);
}
@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { build } from "esbuild";
async function loadRoutes(window) {
const result = await build({
entryPoints: [new URL("../src/features/navigation/routes.ts", import.meta.url).pathname],
bundle: true,
format: "cjs",
platform: "browser",
target: "es2022",
write: false,
});
const module = { exports: {} };
vm.runInNewContext(result.outputFiles[0].text, { module, exports: module.exports, window });
return module.exports;
}
test("all top-level tabs have stable round-trip routes", async () => {
const calls = [];
const window = {
location: { pathname: "/", search: "?rig=one", hash: "#panel" },
history: {
pushState: (...args) => calls.push(["push", ...args]),
replaceState: (...args) => calls.push(["replace", ...args]),
},
};
const routes = await loadRoutes(window);
for (const name of routes.TAB_ORDER) {
assert.equal(routes.tabFromPath(routes.TAB_PATHS[name]), name);
}
routes.updateTabHistory("statistics");
assert.equal(calls[0][0], "push");
assert.equal(calls[0][3], "/statistics?rig=one#panel");
});