// ARS Page Router Component
// Provides routing functionality for web applications with URL integration
//
// usage:
//
// Dashboard page content
// Bots page content
// Settings page content
//
//
// Remote call methods:
// - showPage(pageId): Shows the page with the specified ID
// - hidePage(pageId): Hides the page with the specified ID
// - showAllPages(): Shows all pages
// - hideAllPages(): Hides all pages
// - getCurrentPage(): Returns the ID of the currently visible page
// - getPageInfo(): Returns information about the current page and available pages
// - navigateToRoute(route): Navigate to a specific route
// - getCurrentRoute(): Get the current route
import WebComponentBase from "../web-component-base/web-component-base.js";
class ArsPage extends WebComponentBase {
[key: string]: any;
constructor() {
super();
this._currentPage = null;
this._pages = new Map();
this._defaultPage = null;
this._routes = {};
this._routeToPageMap = new Map();
this._pageToRouteMap = new Map();
this._currentRoute = null;
this._routingMode = "browser";
this._popstateHandler = this._handlePopState.bind(this);
}
static get observedAttributes() {
return ["default-page", "routes", "base-path", "routing-mode"];
}
static defaultAttributeValue(name: string) {
if (name === "routes") {
return "{}";
}
if (name === "routing-mode") {
return "browser";
}
return null;
}
static parseAttributeValue(name: string, value: string) {
if (name === "routes") {
try {
return JSON.parse(value);
} catch (e) {
console.error("Failed to parse routes attribute:", e);
return {};
}
}
return WebComponentBase.parseAttributeValue(name, value);
}
allAttributesChangedCallback(attributes: Record) {
if (attributes.routes) {
this._routes = attributes.routes;
this._buildRouteMaps();
}
if (attributes["default-page"]) {
this._defaultPage = attributes["default-page"];
}
this._basePath = attributes["base-path"] || "";
this._routingMode = attributes["routing-mode"] || "browser";
}
connectedCallback() {
super.connectedCallback();
if (this._usesBrowserRouting()) {
this._getBrowserWindow()?.addEventListener("popstate", this._popstateHandler);
}
this._initializePages();
this._buildRouteMaps();
// Browser routing uses the current URL; internal routing stays local to the component.
const currentPath = this._usesBrowserRouting()
? this._getBrowserWindow()?.location.pathname || null
: this._currentRoute;
const pageId = currentPath ? this._getPageIdFromRoute(currentPath) : null;
if (pageId && this._pages.has(pageId)) {
this.showPage(pageId);
} else if (this._defaultPage) {
this.showPage(this._defaultPage);
} else if (this._pages.size > 0) {
const firstPageId = this._pages.keys().next().value;
this.showPage(firstPageId);
}
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._usesBrowserRouting()) {
this._getBrowserWindow()?.removeEventListener("popstate", this._popstateHandler);
}
this._pages.clear();
}
// Resolves the window object from the current ownerDocument.
_getBrowserWindow() {
return this.ownerDocument?.defaultView || null;
}
// Internal mode skips global history/location integration so the component can be embedded safely.
_usesBrowserRouting() {
return this._routingMode !== "internal";
}
// Private methods
_initializePages() {
this._pages.clear();
const pageElements = Array.from(this.children).filter((child) => child.id);
pageElements.forEach((element: Element) => {
this._pages.set(element.id, element);
(element as HTMLElement).style.display = "none";
});
}
_buildRouteMaps() {
this._routeToPageMap.clear();
this._pageToRouteMap.clear();
const processRoutes = (routes: Record, parentKey: string | null = null) => {
Object.entries(routes).forEach(([key, value]) => {
if (typeof value === "string") {
// The key is the pageId (e.g., "demo-ars-calendar")
// The value is the route (e.g., "/demos/ars-calendar")
this._routeToPageMap.set(value, key); // Maps route -> pageId
this._pageToRouteMap.set(key, value); // Maps pageId -> route
} else if (typeof value === "object" && value !== null) {
const parentPageId = parentKey || key;
if (!this._routeToPageMap.has(`/${parentPageId}`)) {
this._routeToPageMap.set(`/${parentPageId}`, parentPageId);
this._pageToRouteMap.set(parentPageId, `/${parentPageId}`);
}
Object.entries(value as Record).forEach(([nestedKey, nestedValue]) => {
if (typeof nestedValue === "string") {
this._routeToPageMap.set(nestedValue, parentPageId);
} else if (typeof nestedValue === "object" && nestedValue !== null) {
processRoutes({ [nestedKey]: nestedValue }, parentPageId);
}
});
}
});
};
processRoutes(this._routes);
}
_getPageIdFromRoute(route: string) {
const rel = route.startsWith(this._basePath)
? route.slice(this._basePath.length)
: route;
// Try exact match first
if (this._routeToPageMap.has(rel)) {
const pageId = this._routeToPageMap.get(rel);
return pageId;
}
// Try partial matches for nested routes
for (const [routePath, pageId] of this._routeToPageMap) {
if (rel.startsWith(routePath)) {
return pageId;
}
}
return null;
}
_getRouteFromPageId(pageId: string) {
// Get the direct route mapping
const directRoute = this._pageToRouteMap.get(pageId);
if (directRoute) {
return this._basePath + directRoute;
}
// If no direct route, return null
return null;
}
_isNestedRouteForPage(path: string, pageId: string) {
const directRoute = this._getRouteFromPageId(pageId);
if (!directRoute) return false;
// Check if the path is a nested route for this page
// For example, if pageId is 'configs' and directRoute is '/configs',
// then '/configs/system' would be a nested route
const directRoutePath = directRoute.replace(this._basePath, '');
return path.startsWith(directRoutePath) && path !== directRoutePath;
}
_updateBrowserUrl(route: string | null) {
if (!route) {
return;
}
if (!this._usesBrowserRouting()) {
this._currentRoute = route;
return;
}
const hostWindow = this._getBrowserWindow();
const abs = this._basePath + route;
if (hostWindow && abs && abs !== hostWindow.location.pathname) {
hostWindow.history.pushState({ pageId: this._currentPage }, "", abs);
this._currentRoute = abs;
}
}
_handlePopState(_event: PopStateEvent) {
if (!this._usesBrowserRouting()) {
return;
}
const currentPath = this._getBrowserWindow()?.location.pathname || "";
const pageId = this._getPageIdFromRoute(currentPath);
if (pageId && this._pages.has(pageId)) {
// Update the current route first so the event has the correct route
this._currentRoute = currentPath;
this._showPage(pageId, false); // Don't update URL since we're responding to URL change
}
}
_showPage(pageId: string, updateUrl: boolean = true) {
if (!this._pages.has(pageId)) {
console.error(`ARS Page: Page with ID '${pageId}' not found`);
return false;
}
const previousPage = this._currentPage;
if (previousPage && this._pages.has(previousPage)) {
this._pages.get(previousPage).style.display = "none";
}
const pageElement = this._pages.get(pageId);
pageElement.style.display = "";
this._currentPage = pageId;
// Update browser URL if requested
if (updateUrl) {
// For nested routes, preserve the original route if it's more specific
const currentPath = this._usesBrowserRouting()
? this._getBrowserWindow()?.location.pathname || ""
: (this._currentRoute || "");
const directRoute = this._getRouteFromPageId(pageId);
// Check if current path is a nested route for this page
const isNestedRoute = this._isNestedRouteForPage(currentPath, pageId);
if (isNestedRoute) {
// Preserve the nested route
this._updateBrowserUrl(currentPath);
} else {
// Use the direct route
this._updateBrowserUrl(directRoute);
}
}
this.dispatchEvent(
new CustomEvent("ars-page:page-changed", {
detail: {
previousPage,
currentPage: pageId,
pageElement: pageElement,
route: this._currentRoute,
},
bubbles: true,
composed: true,
}),
);
return true;
}
_hidePage(pageId: string) {
if (!this._pages.has(pageId)) {
console.error(`ARS Page: Page with ID '${pageId}' not found`);
return false;
}
const pageElement = this._pages.get(pageId);
pageElement.style.display = "none";
if (this._currentPage === pageId) {
this._currentPage = null;
}
return true;
}
// Public methods (called by ars-page-controller via _callRemote)
showPage(pageId: string) {
const success = this._showPage(pageId);
return {
success,
pageId,
currentPage: this._currentPage,
route: this._currentRoute,
};
}
hidePage(pageId: string) {
const success = this._hidePage(pageId);
return { success, pageId, currentPage: this._currentPage };
}
showAllPages() {
this._pages.forEach((element: Element) => {
(element as HTMLElement).style.display = "";
});
return { success: true, pagesShown: this._pages.size };
}
hideAllPages() {
this._pages.forEach((element: Element) => {
(element as HTMLElement).style.display = "none";
});
this._currentPage = null;
return { success: true, pagesHidden: this._pages.size };
}
getCurrentPage() {
return {
currentPage: this._currentPage,
availablePages: Array.from(this._pages.keys()),
currentRoute: this._currentRoute,
};
}
getPageInfo() {
return {
currentPage: this._currentPage,
availablePages: Array.from(this._pages.keys()),
totalPages: this._pages.size,
defaultPage: this._defaultPage,
currentRoute: this._currentRoute,
routes: this._routes,
};
}
// New methods for route-based navigation
navigateToRoute(route: string) {
const pageId = this._getPageIdFromRoute(route);
if (pageId) {
// Update the current route first so the event has the correct route
this._currentRoute = route;
// Show the page without updating URL (we'll do it manually)
const success = this._showPage(pageId, false);
if (success) {
// Internal mode keeps route state local; browser mode updates history.
this._updateBrowserUrl(route);
return {
success: true,
pageId,
currentPage: this._currentPage,
route: this._currentRoute,
};
}
return {
success: false,
pageId,
currentPage: this._currentPage,
route: this._currentRoute,
};
}
console.error(`ARS Page: No page found for route '${route}'`);
return { success: false, route, error: "Route not found" };
}
getCurrentRoute() {
return {
currentRoute: this._currentRoute,
currentPage: this._currentPage,
availableRoutes: Array.from(this._routeToPageMap.keys()),
};
}
}
// Register the custom element
export { ArsPage, ArsPage as default };