refactor: convert shared UI core to TypeScript
This commit is contained in:
@@ -1,6 +1,14 @@
|
|||||||
"use strict";
|
"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() {
|
(function initUiCore() {
|
||||||
const api = window.trxUi = window.trxUi || {};
|
const api = browserWindow.trxUi ?? {};
|
||||||
|
browserWindow.trxUi = api;
|
||||||
function ensureLiveRegions() {
|
function ensureLiveRegions() {
|
||||||
if (!document.getElementById("toast-region")) {
|
if (!document.getElementById("toast-region")) {
|
||||||
const region = document.createElement("div");
|
const region = document.createElement("div");
|
||||||
@@ -45,21 +53,28 @@
|
|||||||
});
|
});
|
||||||
toast.appendChild(button);
|
toast.appendChild(button);
|
||||||
}
|
}
|
||||||
document.getElementById("toast-region").appendChild(toast);
|
elementById("toast-region").appendChild(toast);
|
||||||
requestAnimationFrame(() => toast.classList.add("toast-visible"));
|
requestAnimationFrame(() => {
|
||||||
if (duration > 0) setTimeout(() => toast.remove(), duration);
|
toast.classList.add("toast-visible");
|
||||||
|
});
|
||||||
|
if (duration > 0) setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, duration);
|
||||||
return toast;
|
return toast;
|
||||||
};
|
};
|
||||||
api.confirm = function confirmAction(options = {}) {
|
api.confirm = function confirmAction(options = {}) {
|
||||||
ensureLiveRegions();
|
ensureLiveRegions();
|
||||||
const dialog = document.getElementById("ui-confirm-dialog");
|
const dialog = elementById("ui-confirm-dialog");
|
||||||
document.getElementById("ui-confirm-title").textContent = options.title || "Confirm action";
|
elementById("ui-confirm-title").textContent = options.title || "Confirm action";
|
||||||
document.getElementById("ui-confirm-message").textContent = options.message || "Continue?";
|
elementById("ui-confirm-message").textContent = options.message || "Continue?";
|
||||||
const confirmButton = dialog.querySelector('[value="confirm"]');
|
const confirmButton = dialog.querySelector('[value="confirm"]');
|
||||||
|
if (!confirmButton) throw new Error("Confirmation dialog has no confirm button");
|
||||||
confirmButton.textContent = options.confirmLabel || "Confirm";
|
confirmButton.textContent = options.confirmLabel || "Confirm";
|
||||||
confirmButton.classList.toggle("danger", options.danger !== false);
|
confirmButton.classList.toggle("danger", options.danger !== false);
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const finish = () => resolve(dialog.returnValue === "confirm");
|
const finish = () => {
|
||||||
|
resolve(dialog.returnValue === "confirm");
|
||||||
|
};
|
||||||
dialog.addEventListener("close", finish, { once: true });
|
dialog.addEventListener("close", finish, { once: true });
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
});
|
});
|
||||||
@@ -77,8 +92,8 @@
|
|||||||
};
|
};
|
||||||
api.prepareTabList = function prepareTabList(bar, kind = "primary") {
|
api.prepareTabList = function prepareTabList(bar, kind = "primary") {
|
||||||
if (!bar) return;
|
if (!bar) return;
|
||||||
if (bar._accessibleTabsPrepared) return;
|
if (preparedTabLists.has(bar)) return;
|
||||||
bar._accessibleTabsPrepared = true;
|
preparedTabLists.add(bar);
|
||||||
const selector = kind === "primary" ? ".tab[data-tab]" : ".sub-tab[data-subtab]";
|
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");
|
bar.setAttribute("role", "tablist");
|
||||||
@@ -87,6 +102,7 @@
|
|||||||
button.setAttribute("aria-selected", String(button.classList.contains("active")));
|
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;
|
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;
|
const key = button.dataset.tab || button.dataset.subtab;
|
||||||
|
if (!key) return;
|
||||||
button.setAttribute("aria-controls", `${kind === "primary" ? "tab-" : "subtab-"}${key}`);
|
button.setAttribute("aria-controls", `${kind === "primary" ? "tab-" : "subtab-"}${key}`);
|
||||||
const panel = document.getElementById(`${kind === "primary" ? "tab-" : "subtab-"}${key}`);
|
const panel = document.getElementById(`${kind === "primary" ? "tab-" : "subtab-"}${key}`);
|
||||||
if (panel) {
|
if (panel) {
|
||||||
@@ -96,11 +112,12 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
bar.addEventListener("keydown", (event) => {
|
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;
|
const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
|
||||||
if (!direction) return;
|
if (!direction) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const next = buttons[(buttons.indexOf(event.target) + direction + buttons.length) % buttons.length];
|
const next = buttons[(buttons.indexOf(event.target) + direction + buttons.length) % buttons.length];
|
||||||
|
if (!next) return;
|
||||||
next.focus();
|
next.focus();
|
||||||
next.click();
|
next.click();
|
||||||
});
|
});
|
||||||
@@ -128,7 +145,7 @@
|
|||||||
return localStorage.getItem(layoutStorageKey()) || localStorage.getItem("trxOperatorLayout") || "compact";
|
return localStorage.getItem(layoutStorageKey()) || localStorage.getItem("trxOperatorLayout") || "compact";
|
||||||
}
|
}
|
||||||
function layoutAvailable(layout) {
|
function layoutAvailable(layout) {
|
||||||
return !layout.capability || layoutCapabilities[layout.capability] === true;
|
return !layout.capability || layoutCapabilities[layout.capability];
|
||||||
}
|
}
|
||||||
function unavailableLayoutMessage() {
|
function unavailableLayoutMessage() {
|
||||||
const unavailable = Object.values(layouts).filter((layout) => !layoutAvailable(layout) && layout.unavailable);
|
const unavailable = Object.values(layouts).filter((layout) => !layoutAvailable(layout) && layout.unavailable);
|
||||||
@@ -168,19 +185,20 @@
|
|||||||
api.applyLayout(select?.value || saved, { persist: false });
|
api.applyLayout(select?.value || saved, { persist: false });
|
||||||
};
|
};
|
||||||
api.applyLayout = function applyLayout(name, options = {}) {
|
api.applyLayout = function applyLayout(name, options = {}) {
|
||||||
const requestedLayout = layouts[name];
|
const requestedName = name in layouts ? name : "compact";
|
||||||
const permittedName = requestedLayout && layoutAvailable(requestedLayout) ? name : "compact";
|
const requestedLayout = layouts[requestedName];
|
||||||
|
const permittedName = layoutAvailable(requestedLayout) ? requestedName : "compact";
|
||||||
const layout = layouts[permittedName];
|
const layout = layouts[permittedName];
|
||||||
document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact";
|
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");
|
const details = document.getElementById("advanced-radio-controls");
|
||||||
if (details) details.open = layout.advanced;
|
if (details) details.open = layout.advanced;
|
||||||
const audioDetails = document.getElementById("audio-controls");
|
const audioDetails = document.getElementById("audio-controls");
|
||||||
if (audioDetails) audioDetails.open = layout.audio;
|
if (audioDetails) audioDetails.open = layout.audio;
|
||||||
const schedulerDetails = document.getElementById("scheduler-controls");
|
const schedulerDetails = document.getElementById("scheduler-controls");
|
||||||
if (schedulerDetails) schedulerDetails.open = layout.scheduler;
|
if (schedulerDetails) schedulerDetails.open = layout.scheduler;
|
||||||
if (options.navigate && typeof window.navigateToTab === "function") {
|
if (options.navigate && typeof browserWindow.navigateToTab === "function") {
|
||||||
window.navigateToTab(layout.preferredTab);
|
browserWindow.navigateToTab(layout.preferredTab);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
function installLayoutControls() {
|
function installLayoutControls() {
|
||||||
@@ -190,12 +208,15 @@
|
|||||||
label.className = "operator-layout-picker";
|
label.className = "operator-layout-picker";
|
||||||
label.innerHTML = '<span class="visually-hidden">Operator layout</span><select id="operator-layout-select" aria-label="Operator layout"></select>';
|
label.innerHTML = '<span class="visually-hidden">Operator layout</span><select id="operator-layout-select" aria-label="Operator layout"></select>';
|
||||||
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();
|
const savedLayout = savedLayoutName();
|
||||||
actions.insertBefore(label, actions.firstChild);
|
actions.insertBefore(label, actions.firstChild);
|
||||||
select.value = savedLayout;
|
select.value = savedLayout;
|
||||||
refreshLayoutOptions();
|
refreshLayoutOptions();
|
||||||
if (savedLayout !== "broadcast" && layouts[savedLayout]) select.value = savedLayout;
|
if (savedLayout !== "broadcast" && savedLayout in layouts) select.value = savedLayout;
|
||||||
select.addEventListener("change", () => api.applyLayout(select.value, { navigate: true }));
|
select.addEventListener("change", () => {
|
||||||
|
api.applyLayout(select.value, { navigate: true });
|
||||||
|
});
|
||||||
api.applyLayout(select.value);
|
api.applyLayout(select.value);
|
||||||
}
|
}
|
||||||
const tray = document.querySelector(".controls-tray");
|
const tray = document.querySelector(".controls-tray");
|
||||||
@@ -205,6 +226,7 @@
|
|||||||
details.className = "advanced-radio-controls";
|
details.className = "advanced-radio-controls";
|
||||||
details.innerHTML = '<summary>Advanced radio controls</summary><div class="advanced-radio-body"></div>';
|
details.innerHTML = '<summary>Advanced radio controls</summary><div class="advanced-radio-body"></div>';
|
||||||
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) => {
|
["sdr-settings-row", "vchan-row", "tx-limit-row"].forEach((id) => {
|
||||||
const element = document.getElementById(id);
|
const element = document.getElementById(id);
|
||||||
if (element) body.appendChild(element);
|
if (element) body.appendChild(element);
|
||||||
@@ -244,7 +266,7 @@
|
|||||||
item.dataset.navigateTab = tabName;
|
item.dataset.navigateTab = tabName;
|
||||||
item.textContent = source.textContent.trim();
|
item.textContent = source.textContent.trim();
|
||||||
item.addEventListener("click", () => {
|
item.addEventListener("click", () => {
|
||||||
if (typeof window.navigateToTab === "function") window.navigateToTab(tabName);
|
if (typeof browserWindow.navigateToTab === "function") browserWindow.navigateToTab(tabName);
|
||||||
closeMore();
|
closeMore();
|
||||||
});
|
});
|
||||||
menu.appendChild(item);
|
menu.appendChild(item);
|
||||||
@@ -255,13 +277,17 @@
|
|||||||
if (open) menu.querySelector('[role="menuitem"]')?.focus();
|
if (open) menu.querySelector('[role="menuitem"]')?.focus();
|
||||||
});
|
});
|
||||||
document.addEventListener("click", (event) => {
|
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) => {
|
document.addEventListener("keydown", (event) => {
|
||||||
if (event.key === "Escape") closeMore(true);
|
if (event.key === "Escape") closeMore(true);
|
||||||
});
|
});
|
||||||
window.addEventListener("resize", () => closeMore());
|
window.addEventListener("resize", () => {
|
||||||
window.addEventListener("popstate", () => closeMore());
|
closeMore();
|
||||||
|
});
|
||||||
|
window.addEventListener("popstate", () => {
|
||||||
|
closeMore();
|
||||||
|
});
|
||||||
nav.append(more, menu);
|
nav.append(more, menu);
|
||||||
}
|
}
|
||||||
function installDecoderPicker() {
|
function installDecoderPicker() {
|
||||||
@@ -294,6 +320,7 @@
|
|||||||
if (!bar) return;
|
if (!bar) return;
|
||||||
bar.querySelectorAll(".sub-tab[data-subtab]").forEach((button) => {
|
bar.querySelectorAll(".sub-tab[data-subtab]").forEach((button) => {
|
||||||
const id = button.dataset.subtab;
|
const id = button.dataset.subtab;
|
||||||
|
if (!id) return;
|
||||||
if (id === "overview" || button.querySelector(".decoder-state-dot")) return;
|
if (id === "overview" || button.querySelector(".decoder-state-dot")) return;
|
||||||
const dot = document.createElement("span");
|
const dot = document.createElement("span");
|
||||||
dot.className = "decoder-state-dot";
|
dot.className = "decoder-state-dot";
|
||||||
@@ -318,12 +345,16 @@
|
|||||||
installDecoderPicker();
|
installDecoderPicker();
|
||||||
installDecoderBadges();
|
installDecoderBadges();
|
||||||
api.prepareTabList(document.querySelector(".tab-bar-nav"), "primary");
|
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) => {
|
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" });
|
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();
|
else api.init();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ await build({
|
|||||||
entryPoints: {
|
entryPoints: {
|
||||||
"api-client": path.join(sourceDir, "api", "client.ts"),
|
"api-client": path.join(sourceDir, "api", "client.ts"),
|
||||||
app: path.join(sourceDir, "app.js"),
|
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"),
|
"map-core": path.join(sourceDir, "map-core.js"),
|
||||||
screenshot: path.join(sourceDir, "screenshot.js"),
|
screenshot: path.join(sourceDir, "screenshot.js"),
|
||||||
"webgl-renderer": path.join(sourceDir, "webgl-renderer.js"),
|
"webgl-renderer": path.join(sourceDir, "webgl-renderer.js"),
|
||||||
|
|||||||
+120
-55
@@ -4,10 +4,68 @@
|
|||||||
|
|
||||||
"use strict";
|
"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<boolean>;
|
||||||
|
setButtonState(button: HTMLButtonElement | null, options?: ButtonStateOptions): void;
|
||||||
|
prepareTabList(bar: HTMLElement | null, kind?: TabKind): void;
|
||||||
|
syncSelectedTab(bar: HTMLElement | null, selected: Element): void;
|
||||||
|
setLayoutCapabilities(capabilities?: Partial<Record<LayoutCapability, boolean>>): 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<HTMLElement>();
|
||||||
|
|
||||||
|
function elementById<T extends HTMLElement>(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,
|
// Shared UI primitives. Keeping these outside app.js prevents navigation,
|
||||||
// feedback, dialogs, and layout preferences from growing separate state models.
|
// feedback, dialogs, and layout preferences from growing separate state models.
|
||||||
(function initUiCore() {
|
(function initUiCore() {
|
||||||
const api = window.trxUi = window.trxUi || {};
|
const api = (browserWindow.trxUi ?? {}) as TrxUi;
|
||||||
|
browserWindow.trxUi = api;
|
||||||
|
|
||||||
function ensureLiveRegions() {
|
function ensureLiveRegions() {
|
||||||
if (!document.getElementById("toast-region")) {
|
if (!document.getElementById("toast-region")) {
|
||||||
@@ -35,7 +93,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
api.notify = function notify(message, options = {}) {
|
api.notify = function notify(message: string, options: NoticeOptions = {}) {
|
||||||
ensureLiveRegions();
|
ensureLiveRegions();
|
||||||
const { kind = "info", duration = kind === "error" ? 7000 : 3200, action = null } = options;
|
const { kind = "info", duration = kind === "error" ? 7000 : 3200, action = null } = options;
|
||||||
const toast = document.createElement("div");
|
const toast = document.createElement("div");
|
||||||
@@ -51,28 +109,29 @@
|
|||||||
button.addEventListener("click", () => { action.run(); toast.remove(); });
|
button.addEventListener("click", () => { action.run(); toast.remove(); });
|
||||||
toast.appendChild(button);
|
toast.appendChild(button);
|
||||||
}
|
}
|
||||||
document.getElementById("toast-region").appendChild(toast);
|
elementById("toast-region").appendChild(toast);
|
||||||
requestAnimationFrame(() => toast.classList.add("toast-visible"));
|
requestAnimationFrame(() => { toast.classList.add("toast-visible"); });
|
||||||
if (duration > 0) setTimeout(() => toast.remove(), duration);
|
if (duration > 0) setTimeout(() => { toast.remove(); }, duration);
|
||||||
return toast;
|
return toast;
|
||||||
};
|
};
|
||||||
|
|
||||||
api.confirm = function confirmAction(options = {}) {
|
api.confirm = function confirmAction(options: ConfirmOptions = {}) {
|
||||||
ensureLiveRegions();
|
ensureLiveRegions();
|
||||||
const dialog = document.getElementById("ui-confirm-dialog");
|
const dialog = elementById<HTMLDialogElement>("ui-confirm-dialog");
|
||||||
document.getElementById("ui-confirm-title").textContent = options.title || "Confirm action";
|
elementById("ui-confirm-title").textContent = options.title || "Confirm action";
|
||||||
document.getElementById("ui-confirm-message").textContent = options.message || "Continue?";
|
elementById("ui-confirm-message").textContent = options.message || "Continue?";
|
||||||
const confirmButton = dialog.querySelector('[value="confirm"]');
|
const confirmButton = dialog.querySelector<HTMLButtonElement>('[value="confirm"]');
|
||||||
|
if (!confirmButton) throw new Error("Confirmation dialog has no confirm button");
|
||||||
confirmButton.textContent = options.confirmLabel || "Confirm";
|
confirmButton.textContent = options.confirmLabel || "Confirm";
|
||||||
confirmButton.classList.toggle("danger", options.danger !== false);
|
confirmButton.classList.toggle("danger", options.danger !== false);
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const finish = () => resolve(dialog.returnValue === "confirm");
|
const finish = () => { resolve(dialog.returnValue === "confirm"); };
|
||||||
dialog.addEventListener("close", finish, { once: true });
|
dialog.addEventListener("close", finish, { once: true });
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
api.setButtonState = function setButtonState(button, options = {}) {
|
api.setButtonState = function setButtonState(button: HTMLButtonElement | null, options: ButtonStateOptions = {}) {
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
const { active = false, activeLabel, inactiveLabel, busy = false, disabled = false } = options;
|
const { active = false, activeLabel, inactiveLabel, busy = false, disabled = false } = options;
|
||||||
button.classList.toggle("is-active", active);
|
button.classList.toggle("is-active", active);
|
||||||
@@ -84,18 +143,19 @@
|
|||||||
if (label) button.textContent = label;
|
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) return;
|
||||||
if (bar._accessibleTabsPrepared) return;
|
if (preparedTabLists.has(bar)) return;
|
||||||
bar._accessibleTabsPrepared = true;
|
preparedTabLists.add(bar);
|
||||||
const selector = kind === "primary" ? ".tab[data-tab]" : ".sub-tab[data-subtab]";
|
const selector = kind === "primary" ? ".tab[data-tab]" : ".sub-tab[data-subtab]";
|
||||||
const buttons = Array.from(bar.querySelectorAll(selector));
|
const buttons = Array.from(bar.querySelectorAll<HTMLElement>(selector));
|
||||||
bar.setAttribute("role", "tablist");
|
bar.setAttribute("role", "tablist");
|
||||||
buttons.forEach((button, index) => {
|
buttons.forEach((button, index) => {
|
||||||
button.setAttribute("role", "tab");
|
button.setAttribute("role", "tab");
|
||||||
button.setAttribute("aria-selected", String(button.classList.contains("active")));
|
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;
|
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;
|
const key = button.dataset.tab || button.dataset.subtab;
|
||||||
|
if (!key) return;
|
||||||
button.setAttribute("aria-controls", `${kind === "primary" ? "tab-" : "subtab-"}${key}`);
|
button.setAttribute("aria-controls", `${kind === "primary" ? "tab-" : "subtab-"}${key}`);
|
||||||
const panel = document.getElementById(`${kind === "primary" ? "tab-" : "subtab-"}${key}`);
|
const panel = document.getElementById(`${kind === "primary" ? "tab-" : "subtab-"}${key}`);
|
||||||
if (panel) {
|
if (panel) {
|
||||||
@@ -105,34 +165,35 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
bar.addEventListener("keydown", (event) => {
|
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
|
const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1
|
||||||
: event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
|
: event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
|
||||||
if (!direction) return;
|
if (!direction) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const next = buttons[(buttons.indexOf(event.target) + direction + buttons.length) % buttons.length];
|
const next = buttons[(buttons.indexOf(event.target) + direction + buttons.length) % buttons.length];
|
||||||
|
if (!next) return;
|
||||||
next.focus();
|
next.focus();
|
||||||
next.click();
|
next.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
api.syncSelectedTab = function syncSelectedTab(bar, selected) {
|
api.syncSelectedTab = function syncSelectedTab(bar: HTMLElement | null, selected: Element) {
|
||||||
if (!bar) return;
|
if (!bar) return;
|
||||||
bar.querySelectorAll('[role="tab"]').forEach((tab) => {
|
bar.querySelectorAll<HTMLElement>('[role="tab"]').forEach((tab) => {
|
||||||
const active = tab === selected;
|
const active = tab === selected;
|
||||||
tab.setAttribute("aria-selected", String(active));
|
tab.setAttribute("aria-selected", String(active));
|
||||||
tab.tabIndex = active ? 0 : -1;
|
tab.tabIndex = active ? 0 : -1;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const layouts = {
|
const layouts: Record<LayoutName, OperatorLayout> = {
|
||||||
compact: { label: "Compact", advanced: false, audio: false, scheduler: false, preferredTab: "main" },
|
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" },
|
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" },
|
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" },
|
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" },
|
||||||
};
|
};
|
||||||
const layoutCapabilities = { broadcast: false, digital: false };
|
const layoutCapabilities: Record<LayoutCapability, boolean> = { broadcast: false, digital: false };
|
||||||
let activeRigId = null;
|
let activeRigId: string | null = null;
|
||||||
|
|
||||||
function layoutStorageKey() {
|
function layoutStorageKey() {
|
||||||
return activeRigId ? `trxOperatorLayout:${activeRigId}` : "trxOperatorLayout";
|
return activeRigId ? `trxOperatorLayout:${activeRigId}` : "trxOperatorLayout";
|
||||||
@@ -142,8 +203,8 @@
|
|||||||
return localStorage.getItem(layoutStorageKey()) || localStorage.getItem("trxOperatorLayout") || "compact";
|
return localStorage.getItem(layoutStorageKey()) || localStorage.getItem("trxOperatorLayout") || "compact";
|
||||||
}
|
}
|
||||||
|
|
||||||
function layoutAvailable(layout) {
|
function layoutAvailable(layout: OperatorLayout): boolean {
|
||||||
return !layout.capability || layoutCapabilities[layout.capability] === true;
|
return !layout.capability || layoutCapabilities[layout.capability];
|
||||||
}
|
}
|
||||||
|
|
||||||
function unavailableLayoutMessage() {
|
function unavailableLayoutMessage() {
|
||||||
@@ -152,7 +213,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshLayoutOptions() {
|
function refreshLayoutOptions() {
|
||||||
const select = document.getElementById("operator-layout-select");
|
const select = document.getElementById("operator-layout-select") as HTMLSelectElement | null;
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
const previous = select.value || document.body.dataset.operatorLayout || "compact";
|
const previous = select.value || document.body.dataset.operatorLayout || "compact";
|
||||||
select.replaceChildren();
|
select.replaceChildren();
|
||||||
@@ -166,12 +227,12 @@
|
|||||||
select.title = unavailableLayoutMessage();
|
select.title = unavailableLayoutMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
api.setLayoutCapabilities = function setLayoutCapabilities(capabilities = {}) {
|
api.setLayoutCapabilities = function setLayoutCapabilities(capabilities: Partial<Record<LayoutCapability, boolean>> = {}) {
|
||||||
Object.keys(layoutCapabilities).forEach((name) => {
|
(Object.keys(layoutCapabilities) as LayoutCapability[]).forEach((name) => {
|
||||||
if (name in capabilities) layoutCapabilities[name] = Boolean(capabilities[name]);
|
if (name in capabilities) layoutCapabilities[name] = Boolean(capabilities[name]);
|
||||||
});
|
});
|
||||||
refreshLayoutOptions();
|
refreshLayoutOptions();
|
||||||
const select = document.getElementById("operator-layout-select");
|
const select = document.getElementById("operator-layout-select") as HTMLSelectElement | null;
|
||||||
const saved = savedLayoutName();
|
const saved = savedLayoutName();
|
||||||
if (select && Array.from(select.options).some(option => option.value === saved)) {
|
if (select && Array.from(select.options).some(option => option.value === saved)) {
|
||||||
select.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;
|
activeRigId = typeof rigId === "string" && rigId ? rigId : null;
|
||||||
const saved = savedLayoutName();
|
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";
|
if (select) select.value = Array.from(select.options).some(option => option.value === saved) ? saved : "compact";
|
||||||
api.applyLayout(select?.value || saved, { persist: false });
|
api.applyLayout(select?.value || saved, { persist: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
api.applyLayout = function applyLayout(name, options = {}) {
|
api.applyLayout = function applyLayout(name: string, options: LayoutOptions = {}) {
|
||||||
const requestedLayout = layouts[name];
|
const requestedName = name in layouts ? name as LayoutName : "compact";
|
||||||
const permittedName = requestedLayout && layoutAvailable(requestedLayout) ? name : "compact";
|
const requestedLayout = layouts[requestedName];
|
||||||
|
const permittedName: LayoutName = layoutAvailable(requestedLayout) ? requestedName : "compact";
|
||||||
const layout = layouts[permittedName];
|
const layout = layouts[permittedName];
|
||||||
document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact";
|
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");
|
const details = document.getElementById("advanced-radio-controls") as HTMLDetailsElement | null;
|
||||||
if (details) details.open = layout.advanced;
|
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;
|
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 (schedulerDetails) schedulerDetails.open = layout.scheduler;
|
||||||
if (options.navigate && typeof window.navigateToTab === "function") {
|
if (options.navigate && typeof browserWindow.navigateToTab === "function") {
|
||||||
window.navigateToTab(layout.preferredTab);
|
browserWindow.navigateToTab(layout.preferredTab);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -210,13 +272,14 @@
|
|||||||
const label = document.createElement("label");
|
const label = document.createElement("label");
|
||||||
label.className = "operator-layout-picker";
|
label.className = "operator-layout-picker";
|
||||||
label.innerHTML = '<span class="visually-hidden">Operator layout</span><select id="operator-layout-select" aria-label="Operator layout"></select>';
|
label.innerHTML = '<span class="visually-hidden">Operator layout</span><select id="operator-layout-select" aria-label="Operator layout"></select>';
|
||||||
const select = label.querySelector("select");
|
const select = label.querySelector<HTMLSelectElement>("select");
|
||||||
|
if (!select) throw new Error("Operator layout picker has no select element");
|
||||||
const savedLayout = savedLayoutName();
|
const savedLayout = savedLayoutName();
|
||||||
actions.insertBefore(label, actions.firstChild);
|
actions.insertBefore(label, actions.firstChild);
|
||||||
select.value = savedLayout;
|
select.value = savedLayout;
|
||||||
refreshLayoutOptions();
|
refreshLayoutOptions();
|
||||||
if (savedLayout !== "broadcast" && layouts[savedLayout]) select.value = savedLayout;
|
if (savedLayout !== "broadcast" && savedLayout in layouts) select.value = savedLayout;
|
||||||
select.addEventListener("change", () => api.applyLayout(select.value, { navigate: true }));
|
select.addEventListener("change", () => { api.applyLayout(select.value, { navigate: true }); });
|
||||||
api.applyLayout(select.value);
|
api.applyLayout(select.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +289,8 @@
|
|||||||
details.id = "advanced-radio-controls";
|
details.id = "advanced-radio-controls";
|
||||||
details.className = "advanced-radio-controls";
|
details.className = "advanced-radio-controls";
|
||||||
details.innerHTML = '<summary>Advanced radio controls</summary><div class="advanced-radio-body"></div>';
|
details.innerHTML = '<summary>Advanced radio controls</summary><div class="advanced-radio-body"></div>';
|
||||||
const body = details.querySelector(".advanced-radio-body");
|
const body = details.querySelector<HTMLElement>(".advanced-radio-body");
|
||||||
|
if (!body) throw new Error("Advanced controls have no body");
|
||||||
["sdr-settings-row", "vchan-row", "tx-limit-row"].forEach((id) => {
|
["sdr-settings-row", "vchan-row", "tx-limit-row"].forEach((id) => {
|
||||||
const element = document.getElementById(id);
|
const element = document.getElementById(id);
|
||||||
if (element) body.appendChild(element);
|
if (element) body.appendChild(element);
|
||||||
@@ -267,7 +331,7 @@
|
|||||||
item.dataset.navigateTab = tabName;
|
item.dataset.navigateTab = tabName;
|
||||||
item.textContent = source.textContent.trim();
|
item.textContent = source.textContent.trim();
|
||||||
item.addEventListener("click", () => {
|
item.addEventListener("click", () => {
|
||||||
if (typeof window.navigateToTab === "function") window.navigateToTab(tabName);
|
if (typeof browserWindow.navigateToTab === "function") browserWindow.navigateToTab(tabName);
|
||||||
closeMore();
|
closeMore();
|
||||||
});
|
});
|
||||||
menu.appendChild(item);
|
menu.appendChild(item);
|
||||||
@@ -275,16 +339,16 @@
|
|||||||
more.addEventListener("click", () => {
|
more.addEventListener("click", () => {
|
||||||
const open = menu.classList.toggle("is-open");
|
const open = menu.classList.toggle("is-open");
|
||||||
more.setAttribute("aria-expanded", String(open));
|
more.setAttribute("aria-expanded", String(open));
|
||||||
if (open) menu.querySelector('[role="menuitem"]')?.focus();
|
if (open) menu.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
|
||||||
});
|
});
|
||||||
document.addEventListener("click", (event) => {
|
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) => {
|
document.addEventListener("keydown", (event) => {
|
||||||
if (event.key === "Escape") closeMore(true);
|
if (event.key === "Escape") closeMore(true);
|
||||||
});
|
});
|
||||||
window.addEventListener("resize", () => closeMore());
|
window.addEventListener("resize", () => { closeMore(); });
|
||||||
window.addEventListener("popstate", () => closeMore());
|
window.addEventListener("popstate", () => { closeMore(); });
|
||||||
nav.append(more, menu);
|
nav.append(more, menu);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +359,7 @@
|
|||||||
select.id = "decoder-tab-select";
|
select.id = "decoder-tab-select";
|
||||||
select.className = "decoder-tab-select";
|
select.className = "decoder-tab-select";
|
||||||
select.setAttribute("aria-label", "Decoder view");
|
select.setAttribute("aria-label", "Decoder view");
|
||||||
const groups = [
|
const groups: Array<[string, string[]]> = [
|
||||||
["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
||||||
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax"]],
|
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax"]],
|
||||||
];
|
];
|
||||||
@@ -303,20 +367,21 @@
|
|||||||
const group = document.createElement("optgroup");
|
const group = document.createElement("optgroup");
|
||||||
group.label = label;
|
group.label = label;
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
const button = bar.querySelector(`[data-subtab="${id}"]`);
|
const button = bar.querySelector<HTMLButtonElement>(`[data-subtab="${id}"]`);
|
||||||
if (button) group.appendChild(new Option(button.textContent.trim(), id));
|
if (button) group.appendChild(new Option(button.textContent.trim(), id));
|
||||||
});
|
});
|
||||||
select.appendChild(group);
|
select.appendChild(group);
|
||||||
});
|
});
|
||||||
select.addEventListener("change", () => bar.querySelector(`[data-subtab="${select.value}"]`)?.click());
|
select.addEventListener("change", () => bar.querySelector<HTMLElement>(`[data-subtab="${select.value}"]`)?.click());
|
||||||
bar.insertAdjacentElement("afterend", select);
|
bar.insertAdjacentElement("afterend", select);
|
||||||
}
|
}
|
||||||
|
|
||||||
function installDecoderBadges() {
|
function installDecoderBadges() {
|
||||||
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
|
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
|
||||||
if (!bar) return;
|
if (!bar) return;
|
||||||
bar.querySelectorAll(".sub-tab[data-subtab]").forEach((button) => {
|
bar.querySelectorAll<HTMLElement>(".sub-tab[data-subtab]").forEach((button) => {
|
||||||
const id = button.dataset.subtab;
|
const id = button.dataset.subtab;
|
||||||
|
if (!id) return;
|
||||||
if (id === "overview" || button.querySelector(".decoder-state-dot")) return;
|
if (id === "overview" || button.querySelector(".decoder-state-dot")) return;
|
||||||
const dot = document.createElement("span");
|
const dot = document.createElement("span");
|
||||||
dot.className = "decoder-state-dot";
|
dot.className = "decoder-state-dot";
|
||||||
@@ -343,13 +408,13 @@
|
|||||||
installDecoderPicker();
|
installDecoderPicker();
|
||||||
installDecoderBadges();
|
installDecoderBadges();
|
||||||
api.prepareTabList(document.querySelector(".tab-bar-nav"), "primary");
|
api.prepareTabList(document.querySelector(".tab-bar-nav"), "primary");
|
||||||
document.querySelectorAll(".sub-tab-bar").forEach(bar => api.prepareTabList(bar, "secondary"));
|
document.querySelectorAll<HTMLElement>(".sub-tab-bar").forEach(bar => { api.prepareTabList(bar, "secondary"); });
|
||||||
window.addEventListener("unhandledrejection", (event) => {
|
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" });
|
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();
|
else api.init();
|
||||||
})();
|
})();
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// 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");
|
||||||
Reference in New Issue
Block a user