refactor: extract typed navigation routes
This commit is contained in:
@@ -80,7 +80,7 @@
|
|||||||
if (decoderRegistry.length > 0) callback();
|
if (decoderRegistry.length > 0) callback();
|
||||||
else readyCallbacks.push(callback);
|
else readyCallbacks.push(callback);
|
||||||
}
|
}
|
||||||
function hideUnsupportedDecoderUi() {
|
function applyDecoderRegistryVisibility() {
|
||||||
const knownIds = new Set(decoderRegistry.map(({ id }) => id));
|
const knownIds = new Set(decoderRegistry.map(({ id }) => id));
|
||||||
const alwaysShow = /* @__PURE__ */ new Set(["overview", "rds", "sat"]);
|
const alwaysShow = /* @__PURE__ */ new Set(["overview", "rds", "sat"]);
|
||||||
document.querySelectorAll(
|
document.querySelectorAll(
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
readyCallbacks.splice(0).forEach((callback) => {
|
readyCallbacks.splice(0).forEach((callback) => {
|
||||||
callback();
|
callback();
|
||||||
});
|
});
|
||||||
hideUnsupportedDecoderUi();
|
applyDecoderRegistryVisibility();
|
||||||
onLoaded();
|
onLoaded();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch decoder registry:", error);
|
console.error("Failed to fetch decoder registry:", error);
|
||||||
@@ -352,6 +352,44 @@
|
|||||||
return value;
|
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
|
// src/app.js
|
||||||
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
|
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
|
||||||
var authRole = null;
|
var authRole = null;
|
||||||
@@ -420,7 +458,7 @@
|
|||||||
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
|
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
|
||||||
btn.classList.remove("active");
|
btn.classList.remove("active");
|
||||||
});
|
});
|
||||||
navigateToTab(tabFromPath(), { updateHistory: false, replaceHistory: true });
|
navigateToTab(tabFromPath2(), { updateHistory: false, replaceHistory: true });
|
||||||
syncTopBarAccess();
|
syncTopBarAccess();
|
||||||
}
|
}
|
||||||
function showAuthError(msg) {
|
function showAuthError(msg) {
|
||||||
@@ -4184,34 +4222,8 @@ ${unsupportedBandSummary()}`;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
var _activeTab = "main";
|
var _activeTab = "main";
|
||||||
var TAB_ORDER = ["main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about"];
|
function tabFromPath2(pathname = window.location.pathname) {
|
||||||
var TAB_PATHS = {
|
return tabFromPath(pathname);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
var _mapInitTimer = null;
|
var _mapInitTimer = null;
|
||||||
function _initMapWhenReady() {
|
function _initMapWhenReady() {
|
||||||
@@ -4265,7 +4277,7 @@ ${unsupportedBandSummary()}`;
|
|||||||
panel.appendChild(tmpl.content.cloneNode(true));
|
panel.appendChild(tmpl.content.cloneNode(true));
|
||||||
tmpl.remove();
|
tmpl.remove();
|
||||||
panel.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
|
panel.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
|
||||||
if (decoderRegistry.length) hideUnsupportedDecoderTabs();
|
if (decoderRegistry.length) applyDecoderRegistryVisibility();
|
||||||
}
|
}
|
||||||
if (updateHistory) {
|
if (updateHistory) {
|
||||||
updateTabHistory(name, replaceHistory);
|
updateTabHistory(name, replaceHistory);
|
||||||
@@ -4289,7 +4301,7 @@ ${unsupportedBandSummary()}`;
|
|||||||
navigateToTab(btn.dataset.tab);
|
navigateToTab(btn.dataset.tab);
|
||||||
});
|
});
|
||||||
window.addEventListener("popstate", () => {
|
window.addEventListener("popstate", () => {
|
||||||
navigateToTab(tabFromPath(), { updateHistory: false });
|
navigateToTab(tabFromPath2(), { updateHistory: false });
|
||||||
});
|
});
|
||||||
(function() {
|
(function() {
|
||||||
let tx = 0, ty = 0;
|
let tx = 0, ty = 0;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { escapeHtml as escapeMapHtml } from "./core/dom.js";
|
import { escapeHtml as escapeMapHtml } from "./core/dom.js";
|
||||||
import { loadSetting, saveSetting } from "./core/settings.js";
|
import { loadSetting, saveSetting } from "./core/settings.js";
|
||||||
import {
|
import {
|
||||||
|
applyDecoderRegistryVisibility,
|
||||||
decoderRegistry,
|
decoderRegistry,
|
||||||
loadDecoderRegistry,
|
loadDecoderRegistry,
|
||||||
} from "./core/decoder-registry.js";
|
} from "./core/decoder-registry.js";
|
||||||
@@ -30,6 +31,11 @@ import {
|
|||||||
parseFrequencyInput,
|
parseFrequencyInput,
|
||||||
} from "./core/format.js";
|
} from "./core/format.js";
|
||||||
import { decodeCbor as decodeCborPayload } from "./core/cbor.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) ---
|
// --- Decoder registry (fetched from /decoders on load) ---
|
||||||
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
|
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
|
||||||
@@ -4258,37 +4264,8 @@ if (spectrumBwSweetBtn) {
|
|||||||
|
|
||||||
// --- Tab navigation ---
|
// --- Tab navigation ---
|
||||||
let _activeTab = "main"; // tracked for render-path tab awareness
|
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) {
|
function tabFromPath(pathname = window.location.pathname) {
|
||||||
const normalized = normalizeTabPath(pathname);
|
return tabFromPathname(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialise the Leaflet map, waiting for both Leaflet (L) and map-core.js
|
// 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.
|
// Wire sub-tab bars inside the freshly cloned content.
|
||||||
panel.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
|
panel.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
|
||||||
// Re-run decoder visibility for about-tab elements now in the DOM.
|
// Re-run decoder visibility for about-tab elements now in the DOM.
|
||||||
if (decoderRegistry.length) hideUnsupportedDecoderTabs();
|
if (decoderRegistry.length) applyDecoderRegistryVisibility();
|
||||||
}
|
}
|
||||||
if (updateHistory) {
|
if (updateHistory) {
|
||||||
updateTabHistory(name, replaceHistory);
|
updateTabHistory(name, replaceHistory);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function onDecoderRegistryReady(callback: () => void): void {
|
|||||||
else readyCallbacks.push(callback);
|
else readyCallbacks.push(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideUnsupportedDecoderUi(): void {
|
export function applyDecoderRegistryVisibility(): void {
|
||||||
const knownIds = new Set(decoderRegistry.map(({ id }) => id));
|
const knownIds = new Set(decoderRegistry.map(({ id }) => id));
|
||||||
const alwaysShow = new Set(["overview", "rds", "sat"]);
|
const alwaysShow = new Set(["overview", "rds", "sat"]);
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ export async function loadDecoderRegistry(onLoaded: () => void): Promise<void> {
|
|||||||
decoderRegistry = decodeRegistry(await response.json());
|
decoderRegistry = decodeRegistry(await response.json());
|
||||||
bridge.decoderRegistry = decoderRegistry;
|
bridge.decoderRegistry = decoderRegistry;
|
||||||
readyCallbacks.splice(0).forEach((callback) => { callback(); });
|
readyCallbacks.splice(0).forEach((callback) => { callback(); });
|
||||||
hideUnsupportedDecoderUi();
|
applyDecoderRegistryVisibility();
|
||||||
onLoaded();
|
onLoaded();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error("Failed to fetch decoder registry:", error);
|
console.error("Failed to fetch decoder registry:", error);
|
||||||
|
|||||||
+39
@@ -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);
|
||||||
|
}
|
||||||
+40
@@ -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");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user