From 7442437757c3b099e1d89bbe33a65957d3175de1 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 1 Aug 2026 12:01:07 +0200 Subject: [PATCH] refactor: convert shared UI core to TypeScript --- .../assets/web/generated/ui-core.js | 83 ++++++--- .../trx-frontend-http/frontend/build.mjs | 2 +- .../frontend/src/{ui-core.js => ui-core.ts} | 175 ++++++++++++------ .../frontend/tests/ui-core.test.mjs | 146 +++++++++++++++ 4 files changed, 324 insertions(+), 82 deletions(-) rename src/trx-client/trx-frontend/trx-frontend-http/frontend/src/{ui-core.js => ui-core.ts} (68%) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ui-core.test.mjs diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ui-core.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ui-core.js index 4f9acdea..7f1dc89a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ui-core.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ui-core.js @@ -1,6 +1,14 @@ "use strict"; +const browserWindow = window; +const preparedTabLists = /* @__PURE__ */ new WeakSet(); +function elementById(id) { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing required UI element #${id}`); + return element; +} (function initUiCore() { - const api = window.trxUi = window.trxUi || {}; + const api = browserWindow.trxUi ?? {}; + browserWindow.trxUi = api; function ensureLiveRegions() { if (!document.getElementById("toast-region")) { const region = document.createElement("div"); @@ -45,21 +53,28 @@ }); toast.appendChild(button); } - document.getElementById("toast-region").appendChild(toast); - requestAnimationFrame(() => toast.classList.add("toast-visible")); - if (duration > 0) setTimeout(() => toast.remove(), duration); + elementById("toast-region").appendChild(toast); + requestAnimationFrame(() => { + toast.classList.add("toast-visible"); + }); + if (duration > 0) setTimeout(() => { + toast.remove(); + }, duration); return toast; }; api.confirm = function confirmAction(options = {}) { ensureLiveRegions(); - const dialog = document.getElementById("ui-confirm-dialog"); - document.getElementById("ui-confirm-title").textContent = options.title || "Confirm action"; - document.getElementById("ui-confirm-message").textContent = options.message || "Continue?"; + const dialog = elementById("ui-confirm-dialog"); + elementById("ui-confirm-title").textContent = options.title || "Confirm action"; + elementById("ui-confirm-message").textContent = options.message || "Continue?"; const confirmButton = dialog.querySelector('[value="confirm"]'); + if (!confirmButton) throw new Error("Confirmation dialog has no confirm button"); confirmButton.textContent = options.confirmLabel || "Confirm"; confirmButton.classList.toggle("danger", options.danger !== false); return new Promise((resolve) => { - const finish = () => resolve(dialog.returnValue === "confirm"); + const finish = () => { + resolve(dialog.returnValue === "confirm"); + }; dialog.addEventListener("close", finish, { once: true }); dialog.showModal(); }); @@ -77,8 +92,8 @@ }; api.prepareTabList = function prepareTabList(bar, kind = "primary") { if (!bar) return; - if (bar._accessibleTabsPrepared) return; - bar._accessibleTabsPrepared = true; + if (preparedTabLists.has(bar)) return; + preparedTabLists.add(bar); const selector = kind === "primary" ? ".tab[data-tab]" : ".sub-tab[data-subtab]"; const buttons = Array.from(bar.querySelectorAll(selector)); bar.setAttribute("role", "tablist"); @@ -87,6 +102,7 @@ button.setAttribute("aria-selected", String(button.classList.contains("active"))); button.tabIndex = button.classList.contains("active") || !buttons.some((b) => b.classList.contains("active")) && index === 0 ? 0 : -1; const key = button.dataset.tab || button.dataset.subtab; + if (!key) return; button.setAttribute("aria-controls", `${kind === "primary" ? "tab-" : "subtab-"}${key}`); const panel = document.getElementById(`${kind === "primary" ? "tab-" : "subtab-"}${key}`); if (panel) { @@ -96,11 +112,12 @@ } }); bar.addEventListener("keydown", (event) => { - if (!buttons.includes(event.target)) return; + if (!(event.target instanceof HTMLElement) || !buttons.includes(event.target)) return; const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0; if (!direction) return; event.preventDefault(); const next = buttons[(buttons.indexOf(event.target) + direction + buttons.length) % buttons.length]; + if (!next) return; next.focus(); next.click(); }); @@ -128,7 +145,7 @@ return localStorage.getItem(layoutStorageKey()) || localStorage.getItem("trxOperatorLayout") || "compact"; } function layoutAvailable(layout) { - return !layout.capability || layoutCapabilities[layout.capability] === true; + return !layout.capability || layoutCapabilities[layout.capability]; } function unavailableLayoutMessage() { const unavailable = Object.values(layouts).filter((layout) => !layoutAvailable(layout) && layout.unavailable); @@ -168,19 +185,20 @@ api.applyLayout(select?.value || saved, { persist: false }); }; api.applyLayout = function applyLayout(name, options = {}) { - const requestedLayout = layouts[name]; - const permittedName = requestedLayout && layoutAvailable(requestedLayout) ? name : "compact"; + const requestedName = name in layouts ? name : "compact"; + const requestedLayout = layouts[requestedName]; + const permittedName = layoutAvailable(requestedLayout) ? requestedName : "compact"; const layout = layouts[permittedName]; document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact"; - if (options.persist !== false) localStorage.setItem(layoutStorageKey(), document.body.dataset.operatorLayout); + if (options.persist !== false) localStorage.setItem(layoutStorageKey(), permittedName); const details = document.getElementById("advanced-radio-controls"); if (details) details.open = layout.advanced; const audioDetails = document.getElementById("audio-controls"); if (audioDetails) audioDetails.open = layout.audio; const schedulerDetails = document.getElementById("scheduler-controls"); if (schedulerDetails) schedulerDetails.open = layout.scheduler; - if (options.navigate && typeof window.navigateToTab === "function") { - window.navigateToTab(layout.preferredTab); + if (options.navigate && typeof browserWindow.navigateToTab === "function") { + browserWindow.navigateToTab(layout.preferredTab); } }; function installLayoutControls() { @@ -190,12 +208,15 @@ label.className = "operator-layout-picker"; label.innerHTML = 'Operator layout'; const select = label.querySelector("select"); + if (!select) throw new Error("Operator layout picker has no select element"); const savedLayout = savedLayoutName(); actions.insertBefore(label, actions.firstChild); select.value = savedLayout; refreshLayoutOptions(); - if (savedLayout !== "broadcast" && layouts[savedLayout]) select.value = savedLayout; - select.addEventListener("change", () => api.applyLayout(select.value, { navigate: true })); + if (savedLayout !== "broadcast" && savedLayout in layouts) select.value = savedLayout; + select.addEventListener("change", () => { + api.applyLayout(select.value, { navigate: true }); + }); api.applyLayout(select.value); } const tray = document.querySelector(".controls-tray"); @@ -205,6 +226,7 @@ details.className = "advanced-radio-controls"; details.innerHTML = 'Advanced radio controls
'; const body = details.querySelector(".advanced-radio-body"); + if (!body) throw new Error("Advanced controls have no body"); ["sdr-settings-row", "vchan-row", "tx-limit-row"].forEach((id) => { const element = document.getElementById(id); if (element) body.appendChild(element); @@ -244,7 +266,7 @@ item.dataset.navigateTab = tabName; item.textContent = source.textContent.trim(); item.addEventListener("click", () => { - if (typeof window.navigateToTab === "function") window.navigateToTab(tabName); + if (typeof browserWindow.navigateToTab === "function") browserWindow.navigateToTab(tabName); closeMore(); }); menu.appendChild(item); @@ -255,13 +277,17 @@ if (open) menu.querySelector('[role="menuitem"]')?.focus(); }); document.addEventListener("click", (event) => { - if (!menu.contains(event.target) && !more.contains(event.target)) closeMore(); + if (!(event.target instanceof Node) || !menu.contains(event.target) && !more.contains(event.target)) closeMore(); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMore(true); }); - window.addEventListener("resize", () => closeMore()); - window.addEventListener("popstate", () => closeMore()); + window.addEventListener("resize", () => { + closeMore(); + }); + window.addEventListener("popstate", () => { + closeMore(); + }); nav.append(more, menu); } function installDecoderPicker() { @@ -294,6 +320,7 @@ if (!bar) return; bar.querySelectorAll(".sub-tab[data-subtab]").forEach((button) => { const id = button.dataset.subtab; + if (!id) return; if (id === "overview" || button.querySelector(".decoder-state-dot")) return; const dot = document.createElement("span"); dot.className = "decoder-state-dot"; @@ -318,12 +345,16 @@ installDecoderPicker(); installDecoderBadges(); api.prepareTabList(document.querySelector(".tab-bar-nav"), "primary"); - document.querySelectorAll(".sub-tab-bar").forEach((bar) => api.prepareTabList(bar, "secondary")); + document.querySelectorAll(".sub-tab-bar").forEach((bar) => { + api.prepareTabList(bar, "secondary"); + }); window.addEventListener("unhandledrejection", (event) => { - const message = event.reason?.message || "An operation failed unexpectedly"; + const message = event.reason instanceof Error ? event.reason.message : "An operation failed unexpectedly"; api.notify(message, { kind: "error" }); }); }; - if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", api.init, { once: true }); + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", () => { + api.init(); + }, { once: true }); else api.init(); })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs index 0743fb67..ca45c216 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs @@ -17,7 +17,7 @@ await build({ entryPoints: { "api-client": path.join(sourceDir, "api", "client.ts"), app: path.join(sourceDir, "app.js"), - "ui-core": path.join(sourceDir, "ui-core.js"), + "ui-core": path.join(sourceDir, "ui-core.ts"), "map-core": path.join(sourceDir, "map-core.js"), screenshot: path.join(sourceDir, "screenshot.js"), "webgl-renderer": path.join(sourceDir, "webgl-renderer.js"), diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts similarity index 68% rename from src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.js rename to src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts index 9c01455b..899e13ef 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts @@ -4,10 +4,68 @@ "use strict"; +type NoticeKind = "info" | "error" | "success" | "warning"; +interface NoticeOptions { + kind?: NoticeKind; + duration?: number; + action?: { label?: string; run: () => void } | null; +} +interface ConfirmOptions { + title?: string; + message?: string; + confirmLabel?: string; + danger?: boolean; +} +interface ButtonStateOptions { + active?: boolean; + activeLabel?: string; + inactiveLabel?: string; + busy?: boolean; + disabled?: boolean; +} +type TabKind = "primary" | "secondary"; +type LayoutCapability = "broadcast" | "digital"; +type LayoutName = "compact" | "broadcast" | "digital" | "full"; +interface OperatorLayout { + label: string; + unavailable?: string; + advanced: boolean; + audio: boolean; + scheduler: boolean; + preferredTab: string; + capability?: LayoutCapability; +} +interface LayoutOptions { persist?: boolean; navigate?: boolean } +interface TrxUi { + notify(message: string, options?: NoticeOptions): HTMLDivElement; + confirm(options?: ConfirmOptions): Promise; + setButtonState(button: HTMLButtonElement | null, options?: ButtonStateOptions): void; + prepareTabList(bar: HTMLElement | null, kind?: TabKind): void; + syncSelectedTab(bar: HTMLElement | null, selected: Element): void; + setLayoutCapabilities(capabilities?: Partial>): void; + setActiveRig(rigId: string | null): void; + applyLayout(name: string, options?: LayoutOptions): void; + closeMobileOverlays?: (restoreFocus?: boolean) => void; + init(): void; +} + +const browserWindow = window as typeof window & { + trxUi?: TrxUi; + navigateToTab?: (tab: string) => void; +}; +const preparedTabLists = new WeakSet(); + +function elementById(id: string): T { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing required UI element #${id}`); + return element as T; +} + // Shared UI primitives. Keeping these outside app.js prevents navigation, // feedback, dialogs, and layout preferences from growing separate state models. (function initUiCore() { - const api = window.trxUi = window.trxUi || {}; + const api = (browserWindow.trxUi ?? {}) as TrxUi; + browserWindow.trxUi = api; function ensureLiveRegions() { if (!document.getElementById("toast-region")) { @@ -35,7 +93,7 @@ } } - api.notify = function notify(message, options = {}) { + api.notify = function notify(message: string, options: NoticeOptions = {}) { ensureLiveRegions(); const { kind = "info", duration = kind === "error" ? 7000 : 3200, action = null } = options; const toast = document.createElement("div"); @@ -51,28 +109,29 @@ button.addEventListener("click", () => { action.run(); toast.remove(); }); toast.appendChild(button); } - document.getElementById("toast-region").appendChild(toast); - requestAnimationFrame(() => toast.classList.add("toast-visible")); - if (duration > 0) setTimeout(() => toast.remove(), duration); + elementById("toast-region").appendChild(toast); + requestAnimationFrame(() => { toast.classList.add("toast-visible"); }); + if (duration > 0) setTimeout(() => { toast.remove(); }, duration); return toast; }; - api.confirm = function confirmAction(options = {}) { + api.confirm = function confirmAction(options: ConfirmOptions = {}) { ensureLiveRegions(); - const dialog = document.getElementById("ui-confirm-dialog"); - document.getElementById("ui-confirm-title").textContent = options.title || "Confirm action"; - document.getElementById("ui-confirm-message").textContent = options.message || "Continue?"; - const confirmButton = dialog.querySelector('[value="confirm"]'); + const dialog = elementById("ui-confirm-dialog"); + elementById("ui-confirm-title").textContent = options.title || "Confirm action"; + elementById("ui-confirm-message").textContent = options.message || "Continue?"; + const confirmButton = dialog.querySelector('[value="confirm"]'); + if (!confirmButton) throw new Error("Confirmation dialog has no confirm button"); confirmButton.textContent = options.confirmLabel || "Confirm"; confirmButton.classList.toggle("danger", options.danger !== false); return new Promise((resolve) => { - const finish = () => resolve(dialog.returnValue === "confirm"); + const finish = () => { resolve(dialog.returnValue === "confirm"); }; dialog.addEventListener("close", finish, { once: true }); dialog.showModal(); }); }; - api.setButtonState = function setButtonState(button, options = {}) { + api.setButtonState = function setButtonState(button: HTMLButtonElement | null, options: ButtonStateOptions = {}) { if (!button) return; const { active = false, activeLabel, inactiveLabel, busy = false, disabled = false } = options; button.classList.toggle("is-active", active); @@ -84,18 +143,19 @@ if (label) button.textContent = label; }; - api.prepareTabList = function prepareTabList(bar, kind = "primary") { + api.prepareTabList = function prepareTabList(bar: HTMLElement | null, kind: TabKind = "primary") { if (!bar) return; - if (bar._accessibleTabsPrepared) return; - bar._accessibleTabsPrepared = true; + if (preparedTabLists.has(bar)) return; + preparedTabLists.add(bar); const selector = kind === "primary" ? ".tab[data-tab]" : ".sub-tab[data-subtab]"; - const buttons = Array.from(bar.querySelectorAll(selector)); + const buttons = Array.from(bar.querySelectorAll(selector)); bar.setAttribute("role", "tablist"); buttons.forEach((button, index) => { button.setAttribute("role", "tab"); button.setAttribute("aria-selected", String(button.classList.contains("active"))); button.tabIndex = button.classList.contains("active") || (!buttons.some(b => b.classList.contains("active")) && index === 0) ? 0 : -1; const key = button.dataset.tab || button.dataset.subtab; + if (!key) return; button.setAttribute("aria-controls", `${kind === "primary" ? "tab-" : "subtab-"}${key}`); const panel = document.getElementById(`${kind === "primary" ? "tab-" : "subtab-"}${key}`); if (panel) { @@ -105,34 +165,35 @@ } }); bar.addEventListener("keydown", (event) => { - if (!buttons.includes(event.target)) return; + if (!(event.target instanceof HTMLElement) || !buttons.includes(event.target)) return; const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0; if (!direction) return; event.preventDefault(); const next = buttons[(buttons.indexOf(event.target) + direction + buttons.length) % buttons.length]; + if (!next) return; next.focus(); next.click(); }); }; - api.syncSelectedTab = function syncSelectedTab(bar, selected) { + api.syncSelectedTab = function syncSelectedTab(bar: HTMLElement | null, selected: Element) { if (!bar) return; - bar.querySelectorAll('[role="tab"]').forEach((tab) => { + bar.querySelectorAll('[role="tab"]').forEach((tab) => { const active = tab === selected; tab.setAttribute("aria-selected", String(active)); tab.tabIndex = active ? 0 : -1; }); }; - const layouts = { + const layouts: Record = { compact: { label: "Compact", advanced: false, audio: false, scheduler: false, preferredTab: "main" }, broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" }, digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" }, full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" }, }; - const layoutCapabilities = { broadcast: false, digital: false }; - let activeRigId = null; + const layoutCapabilities: Record = { broadcast: false, digital: false }; + let activeRigId: string | null = null; function layoutStorageKey() { return activeRigId ? `trxOperatorLayout:${activeRigId}` : "trxOperatorLayout"; @@ -142,8 +203,8 @@ return localStorage.getItem(layoutStorageKey()) || localStorage.getItem("trxOperatorLayout") || "compact"; } - function layoutAvailable(layout) { - return !layout.capability || layoutCapabilities[layout.capability] === true; + function layoutAvailable(layout: OperatorLayout): boolean { + return !layout.capability || layoutCapabilities[layout.capability]; } function unavailableLayoutMessage() { @@ -152,7 +213,7 @@ } function refreshLayoutOptions() { - const select = document.getElementById("operator-layout-select"); + const select = document.getElementById("operator-layout-select") as HTMLSelectElement | null; if (!select) return; const previous = select.value || document.body.dataset.operatorLayout || "compact"; select.replaceChildren(); @@ -166,12 +227,12 @@ select.title = unavailableLayoutMessage(); } - api.setLayoutCapabilities = function setLayoutCapabilities(capabilities = {}) { - Object.keys(layoutCapabilities).forEach((name) => { + api.setLayoutCapabilities = function setLayoutCapabilities(capabilities: Partial> = {}) { + (Object.keys(layoutCapabilities) as LayoutCapability[]).forEach((name) => { if (name in capabilities) layoutCapabilities[name] = Boolean(capabilities[name]); }); refreshLayoutOptions(); - const select = document.getElementById("operator-layout-select"); + const select = document.getElementById("operator-layout-select") as HTMLSelectElement | null; const saved = savedLayoutName(); if (select && Array.from(select.options).some(option => option.value === saved)) { select.value = saved; @@ -179,28 +240,29 @@ } }; - api.setActiveRig = function setActiveRig(rigId) { + api.setActiveRig = function setActiveRig(rigId: string | null) { activeRigId = typeof rigId === "string" && rigId ? rigId : null; const saved = savedLayoutName(); - const select = document.getElementById("operator-layout-select"); + const select = document.getElementById("operator-layout-select") as HTMLSelectElement | null; if (select) select.value = Array.from(select.options).some(option => option.value === saved) ? saved : "compact"; api.applyLayout(select?.value || saved, { persist: false }); }; - api.applyLayout = function applyLayout(name, options = {}) { - const requestedLayout = layouts[name]; - const permittedName = requestedLayout && layoutAvailable(requestedLayout) ? name : "compact"; + api.applyLayout = function applyLayout(name: string, options: LayoutOptions = {}) { + const requestedName = name in layouts ? name as LayoutName : "compact"; + const requestedLayout = layouts[requestedName]; + const permittedName: LayoutName = layoutAvailable(requestedLayout) ? requestedName : "compact"; const layout = layouts[permittedName]; document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact"; - if (options.persist !== false) localStorage.setItem(layoutStorageKey(), document.body.dataset.operatorLayout); - const details = document.getElementById("advanced-radio-controls"); + if (options.persist !== false) localStorage.setItem(layoutStorageKey(), permittedName); + const details = document.getElementById("advanced-radio-controls") as HTMLDetailsElement | null; if (details) details.open = layout.advanced; - const audioDetails = document.getElementById("audio-controls"); + const audioDetails = document.getElementById("audio-controls") as HTMLDetailsElement | null; if (audioDetails) audioDetails.open = layout.audio; - const schedulerDetails = document.getElementById("scheduler-controls"); + const schedulerDetails = document.getElementById("scheduler-controls") as HTMLDetailsElement | null; if (schedulerDetails) schedulerDetails.open = layout.scheduler; - if (options.navigate && typeof window.navigateToTab === "function") { - window.navigateToTab(layout.preferredTab); + if (options.navigate && typeof browserWindow.navigateToTab === "function") { + browserWindow.navigateToTab(layout.preferredTab); } }; @@ -210,13 +272,14 @@ const label = document.createElement("label"); label.className = "operator-layout-picker"; label.innerHTML = 'Operator layout'; - const select = label.querySelector("select"); + const select = label.querySelector("select"); + if (!select) throw new Error("Operator layout picker has no select element"); const savedLayout = savedLayoutName(); actions.insertBefore(label, actions.firstChild); select.value = savedLayout; refreshLayoutOptions(); - if (savedLayout !== "broadcast" && layouts[savedLayout]) select.value = savedLayout; - select.addEventListener("change", () => api.applyLayout(select.value, { navigate: true })); + if (savedLayout !== "broadcast" && savedLayout in layouts) select.value = savedLayout; + select.addEventListener("change", () => { api.applyLayout(select.value, { navigate: true }); }); api.applyLayout(select.value); } @@ -226,7 +289,8 @@ details.id = "advanced-radio-controls"; details.className = "advanced-radio-controls"; details.innerHTML = 'Advanced radio controls
'; - const body = details.querySelector(".advanced-radio-body"); + const body = details.querySelector(".advanced-radio-body"); + if (!body) throw new Error("Advanced controls have no body"); ["sdr-settings-row", "vchan-row", "tx-limit-row"].forEach((id) => { const element = document.getElementById(id); if (element) body.appendChild(element); @@ -267,7 +331,7 @@ item.dataset.navigateTab = tabName; item.textContent = source.textContent.trim(); item.addEventListener("click", () => { - if (typeof window.navigateToTab === "function") window.navigateToTab(tabName); + if (typeof browserWindow.navigateToTab === "function") browserWindow.navigateToTab(tabName); closeMore(); }); menu.appendChild(item); @@ -275,16 +339,16 @@ 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) menu.querySelector('[role="menuitem"]')?.focus(); }); document.addEventListener("click", (event) => { - if (!menu.contains(event.target) && !more.contains(event.target)) closeMore(); + if (!(event.target instanceof Node) || (!menu.contains(event.target) && !more.contains(event.target))) closeMore(); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMore(true); }); - window.addEventListener("resize", () => closeMore()); - window.addEventListener("popstate", () => closeMore()); + window.addEventListener("resize", () => { closeMore(); }); + window.addEventListener("popstate", () => { closeMore(); }); nav.append(more, menu); } @@ -295,7 +359,7 @@ select.id = "decoder-tab-select"; select.className = "decoder-tab-select"; select.setAttribute("aria-label", "Decoder view"); - const groups = [ + const groups: Array<[string, string[]]> = [ ["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]], ["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax"]], ]; @@ -303,20 +367,21 @@ const group = document.createElement("optgroup"); group.label = label; ids.forEach((id) => { - const button = bar.querySelector(`[data-subtab="${id}"]`); + const button = bar.querySelector(`[data-subtab="${id}"]`); if (button) group.appendChild(new Option(button.textContent.trim(), id)); }); select.appendChild(group); }); - select.addEventListener("change", () => bar.querySelector(`[data-subtab="${select.value}"]`)?.click()); + select.addEventListener("change", () => bar.querySelector(`[data-subtab="${select.value}"]`)?.click()); bar.insertAdjacentElement("afterend", select); } function installDecoderBadges() { const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar"); if (!bar) return; - bar.querySelectorAll(".sub-tab[data-subtab]").forEach((button) => { + bar.querySelectorAll(".sub-tab[data-subtab]").forEach((button) => { const id = button.dataset.subtab; + if (!id) return; if (id === "overview" || button.querySelector(".decoder-state-dot")) return; const dot = document.createElement("span"); dot.className = "decoder-state-dot"; @@ -343,13 +408,13 @@ installDecoderPicker(); installDecoderBadges(); api.prepareTabList(document.querySelector(".tab-bar-nav"), "primary"); - document.querySelectorAll(".sub-tab-bar").forEach(bar => api.prepareTabList(bar, "secondary")); + document.querySelectorAll(".sub-tab-bar").forEach(bar => { api.prepareTabList(bar, "secondary"); }); window.addEventListener("unhandledrejection", (event) => { - const message = event.reason?.message || "An operation failed unexpectedly"; + const message = event.reason instanceof Error ? event.reason.message : "An operation failed unexpectedly"; api.notify(message, { kind: "error" }); }); }; - if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", api.init, { once: true }); + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", () => { api.init(); }, { once: true }); else api.init(); })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ui-core.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ui-core.test.mjs new file mode 100644 index 00000000..90a4436f --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ui-core.test.mjs @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import vm from "node:vm"; + +class ClassList { + constructor() { this.values = new Set(); } + add(...names) { names.forEach(name => this.values.add(name)); } + remove(...names) { names.forEach(name => this.values.delete(name)); } + contains(name) { return this.values.has(name); } + toggle(name, force) { + const enabled = force === undefined ? !this.contains(name) : force; + if (enabled) this.add(name); else this.remove(name); + return enabled; + } +} + +class Element { + constructor(tagName, document) { + this.tagName = tagName.toUpperCase(); + this.ownerDocument = document; + this.children = []; + this.dataset = {}; + this.attributes = new Map(); + this.classList = new ClassList(); + this.listeners = new Map(); + this.style = {}; + this.options = []; + this.value = ""; + this.textContent = ""; + } + set id(value) { this._id = value; if (value) this.ownerDocument.elements.set(value, this); } + get id() { return this._id || ""; } + set className(value) { this.classList = new ClassList(); value.split(/\s+/).filter(Boolean).forEach(name => this.classList.add(name)); } + set innerHTML(value) { + this._innerHTML = value; + if (value.includes('value="confirm"')) { + const title = new Element("h2", this.ownerDocument); title.id = "ui-confirm-title"; + const message = new Element("p", this.ownerDocument); message.id = "ui-confirm-message"; + const confirm = new Element("button", this.ownerDocument); confirm.value = "confirm"; + this.append(title, message, confirm); + this._confirmButton = confirm; + } + } + get innerHTML() { return this._innerHTML || ""; } + appendChild(child) { this.children.push(child); child.parentElement = this; return child; } + append(...children) { children.forEach(child => this.appendChild(child)); } + insertBefore(child) { return this.appendChild(child); } + remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter(child => child !== this); } + replaceChildren(...children) { this.children = []; this.options = []; this.append(...children); } + setAttribute(name, value) { this.attributes.set(name, String(value)); } + getAttribute(name) { return this.attributes.get(name); } + addEventListener(type, listener) { this.listeners.set(type, listener); } + dispatch(type, event = {}) { this.listeners.get(type)?.({ target: this, preventDefault() {}, ...event }); } + querySelector(selector) { + if (selector === '[value="confirm"]') return this._confirmButton || null; + return null; + } + querySelectorAll(selector) { + if (selector === ".tab[data-tab]") return this.children.filter(child => child.dataset.tab); + if (selector === ".sub-tab[data-subtab]") return this.children.filter(child => child.dataset.subtab); + if (selector === '[role="tab"]') return this.children.filter(child => child.getAttribute("role") === "tab"); + return []; + } + add(option) { this.options.push(option); if (!this.value) this.value = option.value; } + showModal() { this.open = true; } + close(value) { this.returnValue = value; this.open = false; this.dispatch("close"); } + focus() { this.focused = true; } + click() { this.clicked = true; } +} + +class DocumentFixture { + constructor() { + this.readyState = "loading"; + this.elements = new Map(); + this.body = new Element("body", this); + } + createElement(tagName) { return new Element(tagName, this); } + getElementById(id) { return this.elements.get(id) || null; } + querySelector() { return null; } + querySelectorAll() { return []; } + addEventListener() {} +} + +const document = new DocumentFixture(); +const storage = new Map(); +const localStorage = { + getItem(key) { return storage.has(key) ? storage.get(key) : null; }, + setItem(key, value) { storage.set(key, String(value)); }, +}; +const window = { document, localStorage, addEventListener() {} }; +const context = vm.createContext({ + window, document, localStorage, + HTMLElement: Element, + Node: Element, + Option: class Option { constructor(label, value) { this.label = label; this.value = value; } }, + MutationObserver: class MutationObserver { observe() {} }, + requestAnimationFrame(callback) { callback(); }, + setTimeout() { return 1; }, + clearTimeout() {}, + console, +}); + +const source = await readFile(new URL("../../assets/web/generated/ui-core.js", import.meta.url), "utf8"); +new vm.Script(source, { filename: "ui-core.js" }).runInContext(context); +const ui = window.trxUi; + +const toast = ui.notify("Saved", { kind: "success" }); +assert.equal(toast.getAttribute("role"), "status"); +assert.equal(toast.classList.contains("toast-success"), true); +assert.equal(document.getElementById("toast-region").children.length, 1); + +const confirmation = ui.confirm({ title: "Delete?", message: "Permanent", confirmLabel: "Delete" }); +const dialog = document.getElementById("ui-confirm-dialog"); +assert.equal(dialog.open, true); +assert.equal(document.getElementById("ui-confirm-title").textContent, "Delete?"); +dialog.close("confirm"); +assert.equal(await confirmation, true); + +const tabBar = new Element("div", document); +const firstTab = new Element("button", document); firstTab.dataset.tab = "main"; firstTab.classList.add("active"); +const secondTab = new Element("button", document); secondTab.dataset.tab = "map"; +tabBar.append(firstTab, secondTab); +const mainPanel = new Element("section", document); mainPanel.id = "tab-main"; +const mapPanel = new Element("section", document); mapPanel.id = "tab-map"; +ui.prepareTabList(tabBar, "primary"); +assert.equal(firstTab.getAttribute("aria-selected"), "true"); +tabBar.dispatch("keydown", { target: firstTab, key: "ArrowRight" }); +assert.equal(secondTab.focused, true); +assert.equal(secondTab.clicked, true); + +localStorage.setItem("trxOperatorLayout:rig-a", "broadcast"); +ui.setActiveRig("rig-a"); +assert.equal(document.body.dataset.operatorLayout, "compact"); +ui.setLayoutCapabilities({ broadcast: true }); +ui.setActiveRig("rig-a"); +assert.equal(document.body.dataset.operatorLayout, "broadcast"); +ui.setActiveRig("rig-b"); +ui.applyLayout("digital"); +assert.equal(document.body.dataset.operatorLayout, "compact"); +assert.equal(localStorage.getItem("trxOperatorLayout:rig-b"), "compact"); + +console.log("ui-core component tests passed");