Angular Route Switcher

Automatically detects Angular routes and provides a floating UI to switch between them. Works only in Dev Mode (requires window.ng).

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Angular Route Switcher
// @namespace    https://github.com/junyou1998/angular-route-switcher
// @version      1.4.7
// @description      Automatically detects Angular routes and provides a floating UI to switch between them. Works only in Dev Mode (requires window.ng).
// @description:zh-TW 自動偵測 Angular 路由並提供浮動介面進行切換。僅適用於開發模式 (需要 window.ng)。
// @author       junyou
// @match        http://localhost:*/*
// @match        http://127.0.0.1:*/*
// @grant        none
// @icon         https://cdn.jsdelivr.net/gh/junyou1998/angular-route-switcher@main/icon.png
// @license      MIT
// @homepageURL  https://github.com/junyou1998/angular-route-switcher
// @supportURL   https://github.com/junyou1998/angular-route-switcher/issues
// @run-at       document-idle
// @noframes
// ==/UserScript==

(function () {
    "use strict";

    // Configuration
    const CONFIG = {
        pollInterval: 1000,
        maxPollAttempts: 60,
        uiZIndex: 99999,
        colors: {
            primary: "#006D50",
            primaryHover: "#004D38",
            primaryLight: "rgba(0, 109, 80, 0.08)",
            background: "#ffffff",
            text: "#333333",
            border: "#e0e0e0",
            scrollThumb: "#cccccc",
            scrollThumbHover: "#aaaaaa",
            scrollTrack: "#f5f5f5",
        },
    };

    // Localization
    const I18N = {
        en: {
            title: "Available Routes",
            refresh: "↻ Refresh",
            searchPlaceholder: "Search routes...",
            noRoutes: "No routes found",
            dynamicTitle: "(Dynamic Title)",
            paramPrompt: "Route contains parameters. Please edit:",
            tooltip: "Angular Routes",
        },
        "zh-TW": {
            title: "可用路由",
            refresh: "↻ 重新整理",
            searchPlaceholder: "搜尋路由...",
            noRoutes: "未找到路由",
            dynamicTitle: "(動態標題)",
            paramPrompt: "此路由包含參數,請編輯路徑:",
            tooltip: "Angular 路由切換",
        },
    };

    // Detect Language
    let currentLang = "en";
    const languages = navigator.languages || [
        navigator.language || navigator.userLanguage,
    ];

    for (const l of languages) {
        if (!l) continue;
        const lower = l.toLowerCase();

        if (lower.startsWith("zh")) {
            if (
                lower.includes("tw") ||
                lower.includes("hk") ||
                lower === "zh"
            ) {
                currentLang = "zh-TW";
                break;
            }
        } else if (lower.startsWith("en")) {
            currentLang = "en";
            break;
        }
    }

    const TEXT = I18N[currentLang] || I18N["en"];

    let pollAttempts = 0;
    let routes = [];
    let router = null;

    // --- Core Logic ---

    function extractRoutes(config, parentPath = "") {
        let extracted = [];
        for (const route of config) {
            if (route.redirectTo) continue;

            let currentPath = parentPath;
            if (route.path && route.path !== "") {
                currentPath = parentPath
                    ? `${parentPath}/${route.path}`
                    : route.path;
            }

            if (route.path !== "**" && !currentPath.includes("**")) {
                if (
                    route.component ||
                    route.loadComponent ||
                    (route.children && route.children.length > 0)
                ) {
                    let title = route.title;
                    if (!title && route.data && route.data.title) {
                        title = route.data.title;
                    }

                    if (typeof title === "function") {
                        title = TEXT.dynamicTitle;
                    }

                    extracted.push({
                        path: currentPath,
                        title: title || "",
                    });
                }
            }

            if (route.children) {
                extracted = extracted.concat(
                    extractRoutes(route.children, currentPath)
                );
            }
        }

        const unique = new Map();
        extracted.forEach((item) => {
            if (!unique.has(item.path)) {
                unique.set(item.path, item);
            }
        });

        return Array.from(unique.values());
    }

    // Helper: Check if a route path definition matches the current URL
    function isRouteMatch(routeDefinition, currentUrl) {
        // Remove leading slash for consistency
        const def = routeDefinition.startsWith("/")
            ? routeDefinition.slice(1)
            : routeDefinition;
        const url = currentUrl.split("?")[0].startsWith("/")
            ? currentUrl.split("?")[0].slice(1)
            : currentUrl.split("?")[0];

        if (def === url) return true;

        const defSegments = def.split("/");
        const urlSegments = url.split("/");

        if (defSegments.length !== urlSegments.length) return false;

        for (let i = 0; i < defSegments.length; i++) {
            const defSeg = defSegments[i];
            const urlSeg = urlSegments[i];

            // If segment starts with ':', it's a parameter, so it matches anything non-empty
            if (defSeg.startsWith(":")) {
                if (!urlSeg) return false; // Parameter cannot be empty? actually url split won't give empty unless //
                continue;
            }

            if (defSeg !== urlSeg) return false;
        }

        return true;
    }

    function initAngularContext() {
        const rootElement = document.querySelector("[ng-version]");
        if (!rootElement) return false;

        const ng = window.ng;
        if (!ng || !ng.getComponent) return false;

        try {
            let rootComp = ng.getComponent(rootElement);

            if (!rootComp) {
                const allElements = document.querySelectorAll("*");
                for (let i = 0; i < Math.min(allElements.length, 100); i++) {
                    const comp = ng.getComponent(allElements[i]);
                    if (comp) {
                        rootComp = comp;
                        break;
                    }
                }
            }

            if (rootComp) {
                for (const key in rootComp) {
                    if (
                        rootComp[key] &&
                        rootComp[key].config &&
                        typeof rootComp[key].navigateByUrl === "function"
                    ) {
                        router = rootComp[key];
                        break;
                    }
                }
            }

            if (!router) {
                const allElements = document.querySelectorAll("*");
                for (let i = 0; i < Math.min(allElements.length, 500); i++) {
                    const el = allElements[i];
                    const comp = ng.getComponent(el);
                    if (comp) {
                        for (const key in comp) {
                            if (
                                comp[key] &&
                                comp[key].config &&
                                typeof comp[key].navigateByUrl === "function"
                            ) {
                                router = comp[key];
                                break;
                            }
                        }
                    }
                    if (router) break;
                }
            }

            if (router) {
                routes = extractRoutes(router.config);
                createUI();
                return true;
            } else {
                return false;
            }
        } catch (e) {
            return false;
        }
    }

    // --- UI Implementation ---

    function createUI() {
        if (document.getElementById("ng-route-switcher-root")) return;

        const link = document.createElement("link");
        link.rel = "stylesheet";
        link.href =
            "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0";
        document.head.appendChild(link);

        const container = document.createElement("div");
        container.id = "ng-route-switcher-root";

        const shadow = container.attachShadow({ mode: "open" });

        const style = document.createElement("style");
        style.textContent = `
            @import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0');
            
            .backdrop {
                position: fixed;
                top: 0;
                left: 0;
                width: 100vw;
                height: 100vh;
                z-index: ${CONFIG.uiZIndex - 2};
                display: none;
            }

            .fab {
                position: fixed;
                width: 50px;
                height: 50px;
                background-color: ${CONFIG.colors.primary};
                border-radius: 50%;
                box-shadow: 0 4px 12px rgba(0,0,0,0.3);
                cursor: grab;
                display: flex;
                align-items: center;
                justify-content: center;
                z-index: ${CONFIG.uiZIndex};
                transition: background-color 0.2s, transform 0.1s, width 0.3s, height 0.3s, border-radius 0.3s;
                color: white;
                user-select: none;
                touch-action: none;
            }
            .fab.snapping {
                transition: transform 0.1s, top 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28), left 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28), background-color 0.2s;
            }
            .fab:active {
                cursor: grabbing;
                transform: scale(0.95);
            }
            .fab:hover {
                background-color: ${CONFIG.colors.primaryHover};
            }
            .fab:hover .minimize-btn {
                opacity: 1;
            }

            /* Minimized (Pill) State */
            .fab.minimized {
                width: 12px;
                height: 48px;
                border-radius: 6px;
                cursor: pointer;
                background-color: ${
                    CONFIG.colors.primary
                }CC; /* Slight transparency */
                overflow: hidden; /* Hide internal elements when small */
            }
            .fab.minimized:hover {
                width: 16px;
                background-color: ${CONFIG.colors.primary};
            }
            .fab.minimized .material-symbols-outlined, 
            .fab.minimized .minimize-btn {
                display: none;
            }

            .minimize-btn {
                position: absolute;
                top: -8px;
                right: -8px;
                width: 24px;
                height: 24px;
                background: rgba(0, 0, 0, 0.4);
                color: white;
                border-radius: 50%;
                display: flex;
                align-items: center;
                justify-content: center;
                cursor: pointer;
                opacity: 0;
                transition: opacity 0.2s, transform 0.2s, background-color 0.2s;
                z-index: 1;
                font-size: 14px;
                box-shadow: 0 2px 4px rgba(0,0,0,0.2);
            }
            .minimize-btn:hover {
                transform: scale(1.1);
                background: rgba(0, 0, 0, 0.7);
            }
            .minimize-btn .material-symbols-outlined {
                font-size: 16px;
                font-weight: bold;
            }

            .material-symbols-outlined {
                font-family: 'Material Symbols Outlined';
                font-weight: normal;
                font-style: normal;
                font-size: 24px;
                line-height: 1;
                letter-spacing: normal;
                text-transform: none;
                display: inline-block;
                white-space: nowrap;
                word-wrap: normal;
                direction: ltr;
            }
            
            /* ... (Rest of existing styles) ... */
            .menu {
                position: fixed;
                background: ${CONFIG.colors.background};
                border-radius: 8px;
                box-shadow: 0 4px 20px rgba(0,0,0,0.2);
                width: 300px;
                max-height: 400px; 
                z-index: ${CONFIG.uiZIndex - 1};
                display: none;
                flex-direction: column;
                font-family: sans-serif;
                font-size: 14px;
                border: 1px solid ${CONFIG.colors.border};
                overflow: hidden; 
            }
            .menu.open {
                display: flex;
            }
            .menu-header {
                padding: 12px;
                background: #f5f5f5;
                font-weight: bold;
                border-bottom: 1px solid ${CONFIG.colors.border};
                display: flex;
                justify-content: space-between;
                align-items: center;
                color: ${CONFIG.colors.text};
                flex-shrink: 0;
            }
            .search-box {
                padding: 8px;
                border-bottom: 1px solid ${CONFIG.colors.border};
                flex-shrink: 0;
            }
            .search-box input {
                width: 100%;
                padding: 6px;
                box-sizing: border-box;
                border: 1px solid #ddd;
                border-radius: 4px;
                outline: none;
            }
            .search-box input:focus {
                border-color: ${CONFIG.colors.primary};
                box-shadow: 0 0 0 1px ${CONFIG.colors.primary};
            }
            .route-list {
                list-style: none;
                padding: 0;
                margin: 0;
                overflow-y: auto; 
                flex: 1; 
            }
            .route-list::-webkit-scrollbar {
                width: 6px;
                height: 6px;
            }
            .route-list::-webkit-scrollbar-track {
                background: ${CONFIG.colors.scrollTrack};
            }
            .route-list::-webkit-scrollbar-thumb {
                background: ${CONFIG.colors.scrollThumb};
                border-radius: 3px;
            }
            .route-list::-webkit-scrollbar-thumb:hover {
                background: ${CONFIG.colors.scrollThumbHover};
            }

            .route-item {
                padding: 10px 12px;
                cursor: pointer;
                color: ${CONFIG.colors.text};
                border-bottom: 1px solid #f0f0f0;
                transition: background 0.1s;
            }
            .route-item:hover {
                background-color: ${CONFIG.colors.primaryLight};
                color: ${CONFIG.colors.primary};
            }
            .route-item:last-child {
                border-bottom: none;
            }
            .route-path {
                font-weight: 500;
            }
            .route-title {
                font-size: 0.85em;
                color: #666;
                margin-top: 2px;
            }
            .refresh-btn {
                font-size: 12px;
                color: ${CONFIG.colors.primary};
                cursor: pointer;
                background: none;
                border: none;
                padding: 0;
            }
            .route-item {
                position: relative; /* For copy button positioning */
            }
            .route-item.active {
                background-color: ${CONFIG.colors.primaryLight};
                color: ${CONFIG.colors.primary};
                font-weight: bold;
                border-left: 4px solid ${CONFIG.colors.primary};
            }
            .route-item:focus {
                outline: none;
                background-color: ${CONFIG.colors.primaryLight};
                border-left: 4px solid ${CONFIG.colors.primary};
            }
            .copy-btn {
                position: absolute;
                right: 10px;
                top: 50%;
                transform: translateY(-50%);
                background: none;
                border: none;
                cursor: pointer;
                color: #aaa;
                display: none; /* Show on hover */
                padding: 4px;
                border-radius: 4px;
                transition: color 0.2s, background 0.2s;
            }
            .route-item:hover .copy-btn {
                display: flex;
            }
            .copy-btn:hover {
                color: ${CONFIG.colors.primary};
                background-color: rgba(0,0,0,0.05);
            }
        `;

        const backdrop = document.createElement("div");
        backdrop.className = "backdrop";
        backdrop.onclick = () => {
            if (isOpen) toggleMenu();
        };

        const fab = document.createElement("div");
        fab.className = "fab";
        fab.innerHTML =
            '<span class="material-symbols-outlined">explore</span>';
        fab.title = TEXT.tooltip;

        // Minimize Button
        const minimizeBtn = document.createElement("div");
        minimizeBtn.className = "minimize-btn";
        minimizeBtn.innerHTML =
            '<span class="material-symbols-outlined">close</span>';
        minimizeBtn.title = "Minimize";
        fab.appendChild(minimizeBtn);

        let storedState = null;
        try {
            storedState = JSON.parse(localStorage.getItem("ARS_STATE"));
        } catch (e) {}

        let initialTop = window.innerHeight - 80;
        let initialLeft = window.innerWidth - 80;
        let isMinimized = false;
        let dockedSide = "right";

        if (storedState) {
            initialTop =
                storedState.top !== undefined ? storedState.top : initialTop;
            initialLeft =
                storedState.left !== undefined ? storedState.left : initialLeft;
            isMinimized = !!storedState.isMinimized;
            dockedSide = storedState.dockedSide || "right";
        }

        // Validate bounds
        if (initialTop < 20) initialTop = 20;
        if (initialTop > window.innerHeight - 70)
            initialTop = window.innerHeight - 70;
        if (initialLeft < 0) initialLeft = 0;
        if (initialLeft > window.innerWidth - 12)
            initialLeft = window.innerWidth - 50;

        fab.style.top = `${initialTop}px`;
        fab.style.left = `${initialLeft}px`;

        if (isMinimized) {
            fab.classList.add("minimized");
            // small offset check for minimized
            if (dockedSide === "right") {
                fab.style.left = `${window.innerWidth - 12}px`;
            } else {
                fab.style.left = "0px";
            }
        }

        const menu = document.createElement("div");
        menu.className = "menu";

        const header = document.createElement("div");
        header.className = "menu-header";

        const titleSpan = document.createElement("span");
        titleSpan.textContent = TEXT.title;

        const refreshBtn = document.createElement("button");
        refreshBtn.className = "refresh-btn";
        refreshBtn.textContent = TEXT.refresh;
        refreshBtn.onclick = (e) => {
            e.stopPropagation();
            refreshRoutes();
        };

        header.appendChild(titleSpan);
        header.appendChild(refreshBtn);

        const searchBox = document.createElement("div");
        searchBox.className = "search-box";
        const searchInput = document.createElement("input");
        searchInput.placeholder = TEXT.searchPlaceholder;
        searchBox.appendChild(searchInput);

        const ul = document.createElement("ul");
        ul.className = "route-list";

        menu.appendChild(header);
        menu.appendChild(searchBox);
        menu.appendChild(ul);

        shadow.appendChild(style);
        shadow.appendChild(backdrop);
        shadow.appendChild(fab);
        shadow.appendChild(menu);
        document.body.appendChild(container);

        let isOpen = false;

        // --- Persistence Helper ---
        function saveState() {
            const state = {
                top: parseFloat(fab.style.top),
                left: parseFloat(fab.style.left),
                isMinimized: isMinimized,
                dockedSide: dockedSide,
            };
            localStorage.setItem("ARS_STATE", JSON.stringify(state));
        }

        // --- Drag & Drop Logic ---
        let isDragging = false;
        let startX, startY, initialFabLeft, initialFabTop;
        let dragThreshold = 5;
        let hasMoved = false;

        // Track which side the FAB is docked to ('left' or 'right')
        fab.addEventListener("mousedown", dragStart);
        document.addEventListener("mousemove", drag);
        document.addEventListener("mouseup", dragEnd);

        fab.addEventListener("touchstart", dragStart, { passive: false });
        document.addEventListener("touchmove", drag, { passive: false });
        document.addEventListener("touchend", dragEnd);

        // --- Minimize Logic ---
        // Prevent drag from starting on the minimize button
        minimizeBtn.addEventListener("mousedown", (e) => e.stopPropagation());
        minimizeBtn.addEventListener("touchstart", (e) => e.stopPropagation(), {
            passive: true,
        });

        // Handle click
        minimizeBtn.onclick = (e) => {
            e.stopPropagation();
            toggleMinimize();
        };

        function toggleMinimize() {
            if (isOpen) toggleMenu(); // Close menu if open

            isMinimized = !isMinimized;

            if (isMinimized) {
                fab.classList.add("minimized");
                // Snap to very edge
                snapToEdge(true);
            } else {
                fab.classList.remove("minimized");
                snapToEdge();
            }
            saveState(); // Save state AFTER snapping to new position
        }

        // --- Resize Logic ---
        let resizeTimeout;
        window.addEventListener("resize", () => {
            clearTimeout(resizeTimeout);
            resizeTimeout = setTimeout(() => {
                repositionOnResize();
            }, 100);
        });

        function repositionOnResize() {
            if (isDragging) return;

            const winWidth = window.innerWidth;
            const winHeight = window.innerHeight;
            const rect = fab.getBoundingClientRect();

            let newLeft;
            // Sticky logic
            if (dockedSide === "left") {
                newLeft = isMinimized ? 0 : 20;
            } else {
                newLeft =
                    winWidth - (isMinimized ? 12 : 50) - (isMinimized ? 0 : 20);
            }

            let newTop = rect.top;
            if (newTop < 20) newTop = 20;
            if (newTop > winHeight - 50 - 20) newTop = winHeight - 50 - 20;

            fab.style.left = `${newLeft}px`;
            fab.style.top = `${newTop}px`;

            if (isOpen) {
                adjustMenuPosition(newLeft, newTop);
            }
        }

        function dragStart(e) {
            if (e.type === "touchstart") e.preventDefault();

            fab.classList.remove("snapping");

            isDragging = true;
            hasMoved = false;

            const clientX = e.type.includes("touch")
                ? e.touches[0].clientX
                : e.clientX;
            const clientY = e.type.includes("touch")
                ? e.touches[0].clientY
                : e.clientY;

            startX = clientX;
            startY = clientY;

            const rect = fab.getBoundingClientRect();
            initialFabLeft = rect.left;
            initialFabTop = rect.top;
        }

        function drag(e) {
            if (!isDragging) return;

            // Use requestAnimationFrame for smoother performance
            requestAnimationFrame(() => {
                const clientX = e.type.includes("touch")
                    ? e.touches[0].clientX
                    : e.clientX;
                const clientY = e.type.includes("touch")
                    ? e.touches[0].clientY
                    : e.clientY;

                const dx = clientX - startX;
                const dy = clientY - startY;

                if (
                    Math.abs(dx) > dragThreshold ||
                    Math.abs(dy) > dragThreshold
                ) {
                    hasMoved = true;
                }

                let newLeft = initialFabLeft + dx;
                let newTop = initialFabTop + dy;

                fab.style.left = `${newLeft}px`;
                fab.style.top = `${newTop}px`;
            });
        }

        function dragEnd(e) {
            if (!isDragging) return;
            isDragging = false;

            fab.classList.add("snapping");

            if (hasMoved) {
                snapToEdge(isMinimized);
            } else {
                if (isMinimized) {
                    toggleMinimize(); // Restore on click
                } else {
                    toggleMenu();
                }
            }
            saveState(); // Save position after drag/snap
        }

        function snapToEdge(isMin = false) {
            const rect = fab.getBoundingClientRect();
            const winWidth = window.innerWidth;
            const winHeight = window.innerHeight;

            const distToLeft = rect.left;
            const distToRight = winWidth - rect.right;

            let finalLeft;
            if (distToLeft < distToRight) {
                finalLeft = isMin ? 0 : 20;
                dockedSide = "left";
            } else {
                finalLeft = winWidth - (isMin ? 12 : 50) - (isMin ? 0 : 20);
                dockedSide = "right";
            }

            let finalTop = rect.top;
            if (finalTop < 20) finalTop = 20;
            if (finalTop > winHeight - 50 - 20) finalTop = winHeight - 50 - 20;

            fab.style.left = `${finalLeft}px`;
            fab.style.top = `${finalTop}px`;

            if (isOpen) {
                adjustMenuPosition(finalLeft, finalTop);
            }
        }

        // --- Menu Logic ---

        function toggleMenu(focusSearch = true) {
            if (isMinimized) return; // Should not happen but safety check

            isOpen = !isOpen;
            menu.className = isOpen ? "menu open" : "menu";
            backdrop.style.display = isOpen ? "block" : "none";

            if (isOpen) {
                renderList(routes); // Ensure list is up to date first (for height calc)

                // Use style properties for target position to avoid animation artifacts
                const fabLeft = parseFloat(fab.style.left);
                const fabTop = parseFloat(fab.style.top);
                adjustMenuPosition(fabLeft, fabTop);

                if (focusSearch) {
                    searchInput.focus();
                } else {
                    // Focus on active item or first item
                    setTimeout(() => {
                        focusActiveItem();
                    }, 50); // Small delay to ensure rendering
                }
            }
        }

        function focusActiveItem() {
            const activeItem = ul.querySelector(".route-item.active");
            if (activeItem) {
                activeItem.focus();
            } else {
                const firstItem = ul.querySelector(".route-item");
                if (firstItem) firstItem.focus();
            }
        }

        function adjustMenuPosition(fabLeft, fabTop) {
            const menuWidth = 300;
            const winHeight = window.innerHeight;
            const winWidth = window.innerWidth;

            // Use actual height (with fallback)
            const menuHeight = menu.offsetHeight || 100;

            let menuLeft = fabLeft - menuWidth - 10;
            let menuTop = fabTop + 50 - menuHeight;

            if (fabLeft < winWidth / 2) {
                menuLeft = fabLeft + 60;
            }

            if (menuTop + menuHeight > winHeight - 20) {
                menuTop = winHeight - menuHeight - 20;
            }
            if (menuTop < 20) {
                menuTop = 20;
            }

            menu.style.left = `${menuLeft}px`;
            menu.style.top = `${menuTop}px`;
        }

        searchInput.addEventListener("input", (e) => {
            const term = e.target.value.toLowerCase();
            const filtered = routes.filter(
                (r) =>
                    r.path.toLowerCase().includes(term) ||
                    (r.title && String(r.title).toLowerCase().includes(term))
            );
            renderList(filtered);
        });

        document.addEventListener("keydown", (e) => {
            if (e.key === "Escape" && isOpen) {
                toggleMenu();
            }
            // Cmd+K or Ctrl+K to toggle
            if ((e.metaKey || e.ctrlKey) && e.key === "k") {
                e.preventDefault();

                // If minimized, expand first, then open menu
                if (isMinimized) {
                    toggleMinimize();
                    setTimeout(() => toggleMenu(false), 50);
                    return;
                }

                // If open, close. If closed, open and focus LIST (false).
                if (isOpen) {
                    toggleMenu();
                } else {
                    toggleMenu(false);
                }
            }
        });

        // List Navigation
        ul.addEventListener("keydown", (e) => {
            // Use shadow.activeElement because we are in Shadow DOM
            const active = shadow.activeElement;
            if (!active || !active.classList.contains("route-item")) return;

            if (e.key === "ArrowDown") {
                e.preventDefault();
                const next = active.nextElementSibling;
                if (next) next.focus();
            } else if (e.key === "ArrowUp") {
                e.preventDefault();
                const prev = active.previousElementSibling;
                if (prev) prev.focus();
            } else if (e.key === "Enter") {
                e.preventDefault();
                active.click();
            }
        });

        function renderList(items) {
            ul.innerHTML = "";
            if (items.length === 0) {
                const li = document.createElement("li");
                li.className = "route-item";
                li.style.color = "#999";
                li.style.cursor = "default";
                li.textContent = TEXT.noRoutes;
                ul.appendChild(li);
                return;
            }

            const currentUrl = router ? router.url : "";

            items.forEach((item) => {
                const li = document.createElement("li");
                li.className = "route-item";
                li.tabIndex = 0; // Make focusable

                // Highlight active route (parameter aware matching)
                if (isRouteMatch(item.path, currentUrl)) {
                    li.classList.add("active");
                }

                let content = `<div class="route-path">/${item.path}</div>`;
                if (item.title) {
                    content += `<div class="route-title">${item.title}</div>`;
                }

                const copyBtn = document.createElement("button");
                copyBtn.className = "copy-btn";
                copyBtn.title = "Copy Path";
                copyBtn.innerHTML =
                    '<span class="material-symbols-outlined" style="font-size: 18px;">content_copy</span>';
                copyBtn.onclick = (e) => {
                    e.stopPropagation();
                    const textToCopy = "/" + item.path;
                    navigator.clipboard.writeText(textToCopy).then(() => {
                        // Quick toast or feedback
                        const originalIcon = copyBtn.innerHTML;
                        copyBtn.innerHTML =
                            '<span class="material-symbols-outlined" style="font-size: 18px; color: green;">check</span>';
                        setTimeout(() => {
                            copyBtn.innerHTML = originalIcon;
                        }, 1000);
                    });
                };

                li.innerHTML = content;
                li.appendChild(copyBtn);

                li.onclick = () => {
                    const routePath = item.path;
                    if (routePath.includes(":")) {
                        const newPath = prompt(TEXT.paramPrompt, routePath);
                        if (newPath !== null) {
                            router.navigateByUrl(newPath);
                            // Re-render to update highlight and focus active item
                            setTimeout(() => {
                                renderList(items);
                                focusActiveItem();
                            }, 100);
                        }
                    } else {
                        router.navigateByUrl(routePath);
                        setTimeout(() => {
                            renderList(items);
                            focusActiveItem();
                        }, 100);
                    }
                };
                ul.appendChild(li);
            });
        }

        renderList(routes);

        function refreshRoutes() {
            if (router) {
                routes = extractRoutes(router.config);
                renderList(routes);
            }
        }
    }

    const poller = setInterval(() => {
        pollAttempts++;
        if (initAngularContext()) {
            clearInterval(poller);
        } else if (pollAttempts >= CONFIG.maxPollAttempts) {
            clearInterval(poller);
        }
    }, CONFIG.pollInterval);
})();