Skip to content

Commit 7dfa546

Browse files
committed
feat: routes tab in devtools
1 parent a1fdd48 commit 7dfa546

File tree

7 files changed

+465
-0
lines changed

7 files changed

+465
-0
lines changed

cmd/rfw/plugins/devtools/devtools.js

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,26 @@ const markup = `
5050
.tree-scroll{overflow:auto; padding:8px}
5151
.node{display:flex; align-items:center; gap:8px; padding:6px 8px; border-radius:8px; cursor:pointer; color:var(--rose-100)}
5252
.node:hover{background:var(--tile-hover)}
53+
.node.active{background:var(--tile-hover)}
5354
.node .kind{font-size:11px; color:var(--accent-2); padding:2px 6px; border:1px solid var(--border-2); border-radius:999px; background:var(--tile-bg)}
5455
.node .name{font-weight:600}
5556
.node .meta{margin-left:auto; display:flex; align-items:center; gap:6px; font-variant-numeric:tabular-nums; color:var(--rose-300)}
5657
.node .meta span{display:inline-flex; align-items:center; gap:4px; padding:2px 8px; border-radius:999px; border:1px solid var(--tile-border); background:var(--tile-bg)}
58+
.route-action{padding:4px 10px; border-radius:8px; border:1px solid var(--tile-border); background:var(--tile-bg); color:var(--rose-50); font-size:12px; cursor:pointer; transition:background .12s ease, border-color .12s ease}
59+
.route-action:hover{background:var(--tile-hover); border-color:var(--border-2)}
60+
.rfw-route-popup{position:absolute; right:18px; bottom:70px; width:280px; background:var(--panel); border:1px solid var(--border); border-radius:12px; box-shadow:var(--shadow); display:flex; flex-direction:column; padding:12px; gap:12px; z-index:2147483650}
61+
.rfw-route-popup.hidden{display:none !important}
62+
.rfw-route-popup h3{margin:0; font-size:16px; color:var(--rose-50)}
63+
.rfw-route-popup p{margin:0; font-size:12px; color:var(--rose-200)}
64+
.route-fields{display:flex; flex-direction:column; gap:8px}
65+
.route-field{display:flex; flex-direction:column; gap:4px; font-size:12px; color:var(--rose-200)}
66+
.route-field input{padding:6px 8px; border-radius:8px; border:1px solid var(--tile-border); background:var(--bg-2); color:var(--rose-50); font:inherit; outline:none}
67+
.route-actions{display:flex; gap:8px; justify-content:flex-end}
68+
.route-actions button{padding:6px 12px; border-radius:8px; border:1px solid var(--tile-border); background:var(--tile-bg); color:var(--rose-50); cursor:pointer; font:inherit}
69+
.route-actions button.primary{background:var(--accent); border-color:var(--accent); color:#fff}
70+
.route-actions button.primary:hover{background:var(--accent-2); border-color:var(--accent-2)}
71+
.route-actions button:hover{background:var(--tile-hover)}
72+
.route-error{color:var(--bad); font-size:12px; min-height:16px}
5773
5874
.rfw-detail{overflow-y:auto; flex:1;background:var(--chip-bg);display:flex;flex-direction:column}
5975
.rfw-detail .rfw-subheader{display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--border)}
@@ -129,6 +145,7 @@ const markup = `
129145
130146
<nav class="rfw-tabs" role="tablist" aria-label="Tabs">
131147
<button class="rfw-button rfw-tab" role="tab" aria-selected="true" aria-controls="tab-components" id="tabbtn-components">Components</button>
148+
<button class="rfw-button rfw-tab" role="tab" aria-selected="false" aria-controls="tab-routes" id="tabbtn-routes">Routes</button>
132149
<button class="rfw-button rfw-tab" role="tab" aria-selected="false" aria-controls="tab-store" id="tabbtn-store">Store</button>
133150
<button class="rfw-button rfw-tab" role="tab" aria-selected="false" aria-controls="tab-signals" id="tabbtn-signals">Signals</button>
134151
<button class="rfw-button rfw-tab" role="tab" aria-selected="false" aria-controls="tab-plugins" id="tabbtn-plugins">Plugins</button>
@@ -164,6 +181,29 @@ const markup = `
164181
</div>
165182
</section>
166183
184+
<!-- Routes -->
185+
<section id="tab-routes" role="tabpanel" aria-labelledby="tabbtn-routes" class="hidden" style="display:flex;flex:1">
186+
<div class="rfw-split">
187+
<aside class="rfw-tree">
188+
<div class="rfw-search">
189+
<input id="routeFilter" class="rfw-input" type="search" placeholder="Filter routes…" />
190+
<button class="rfw-button rfw-iconbtn" id="refreshRoutes" title="Refresh routes">
191+
<svg viewBox="0 0 24 24" fill="none"><path d="M4 4v6h6M20 20v-6h-6M5 19a9 9 0 0 1 14-7M19 5a9 9 0 0 0-14 7" stroke="var(--rose-400)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
192+
</button>
193+
</div>
194+
<div class="tree-scroll" id="routeTree"></div>
195+
</aside>
196+
<article class="rfw-detail">
197+
<div class="rfw-subheader">
198+
<span style="font-weight:600" id="routeTitle">Select a route</span>
199+
<span class="rfw-spacer"></span>
200+
<button class="rfw-button route-action" id="routeDetailGo" title="Navigate to route">Open</button>
201+
</div>
202+
<div class="kv" id="routeDetail"></div>
203+
</article>
204+
</div>
205+
</section>
206+
167207
<!-- Store -->
168208
<section id="tab-store" role="tabpanel" aria-labelledby="tabbtn-store" class="hidden" style="display:flex;flex:1">
169209
<div class="rfw-split">
@@ -295,6 +335,16 @@ const markup = `
295335
</section>
296336
297337
</div>
338+
<div id="routePopup" class="rfw-route-popup hidden" data-rfw-ignore role="dialog" aria-modal="false" aria-labelledby="routePopupTitle">
339+
<h3 id="routePopupTitle">Configure route</h3>
340+
<p id="routePopupInfo">Provide values for dynamic parameters.</p>
341+
<div class="route-fields" id="routePopupFields"></div>
342+
<div class="route-error" id="routePopupError"></div>
343+
<div class="route-actions">
344+
<button class="rfw-button" id="routePopupCancel" type="button">Cancel</button>
345+
<button class="rfw-button primary" id="routePopupConfirm" type="button">Navigate</button>
346+
</div>
347+
</div>
298348
</section>
299349
`;
300350

@@ -309,9 +359,31 @@ const fab = $("#rfwDevtoolsToggle");
309359
const minBtn = $("#minBtn");
310360
const closeBtn = $("#closeBtn");
311361
const hHandle = $('[data-resize="h"]');
362+
const routeTree = $("#routeTree");
363+
const routeTitle = $("#routeTitle");
364+
const routeDetail = $("#routeDetail");
365+
const routeDetailGo = $("#routeDetailGo");
366+
const routeFilter = $("#routeFilter");
367+
const routePopup = $("#routePopup");
368+
const routePopupTitle = $("#routePopupTitle");
369+
const routePopupInfo = $("#routePopupInfo");
370+
const routePopupFields = $("#routePopupFields");
371+
const routePopupError = $("#routePopupError");
372+
const routePopupConfirm = $("#routePopupConfirm");
373+
const routePopupCancel = $("#routePopupCancel");
374+
let routeSnapshot = [];
375+
let selectedRoutePath = "";
376+
let selectedRoute = null;
377+
let popupRoute = null;
378+
routeDetailGo?.setAttribute("disabled", "true");
312379

313380
const tabs = [
314381
{ btn: $("#tabbtn-components"), panel: $("#tab-components") },
382+
{
383+
btn: $("#tabbtn-routes"),
384+
panel: $("#tab-routes"),
385+
onShow: refreshRoutes,
386+
},
315387
{ btn: $("#tabbtn-store"), panel: $("#tab-store"), onShow: refreshStore },
316388
{
317389
btn: $("#tabbtn-signals"),
@@ -354,12 +426,14 @@ function openDevtools() {
354426
setTimeout(() => (fab.style.transform = ""), 120);
355427
}
356428
refreshTree();
429+
refreshRoutes();
357430
refreshStore();
358431
refreshSignals();
359432
refreshPlugins();
360433
}
361434
function closeDevtools() {
362435
overlay?.classList.add("hidden");
436+
closeRoutePopup();
363437
}
364438
function toggleDevtools() {
365439
overlay?.classList.contains("hidden") ? openDevtools() : closeDevtools();
@@ -376,6 +450,11 @@ document.addEventListener("keydown", (e) => {
376450
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "d") {
377451
e.preventDefault();
378452
toggleDevtools();
453+
return;
454+
}
455+
if (e.key === "Escape" && !routePopup?.classList.contains("hidden")) {
456+
e.preventDefault();
457+
closeRoutePopup();
379458
}
380459
});
381460

@@ -560,6 +639,232 @@ function renderJSONRow(label, value) {
560639
return renderRow(label, formatJSON(value), true);
561640
}
562641

642+
function renderRoutes(list) {
643+
if (!routeTree) return;
644+
routeTree.innerHTML = "";
645+
const frag = document.createDocumentFragment();
646+
const walk = (nodes, depth) => {
647+
nodes.forEach((route) => {
648+
const el = document.createElement("div");
649+
el.className = "node";
650+
if (depth) el.style.paddingLeft = `${depth * 12}px`;
651+
const kind = document.createElement("span");
652+
kind.className = "kind";
653+
const dynamic = Array.isArray(route.params) && route.params.length > 0;
654+
kind.textContent = dynamic ? "dynamic" : "static";
655+
el.appendChild(kind);
656+
657+
const name = document.createElement("span");
658+
name.className = "name";
659+
name.textContent = route.path || route.template || "/";
660+
el.appendChild(name);
661+
662+
const meta = document.createElement("span");
663+
meta.className = "meta";
664+
if (dynamic) {
665+
const badge = document.createElement("span");
666+
const count = route.params.length;
667+
badge.textContent = `${count} param${count > 1 ? "s" : ""}`;
668+
meta.appendChild(badge);
669+
}
670+
const btn = document.createElement("button");
671+
btn.className = "route-action";
672+
btn.type = "button";
673+
btn.textContent = "Open";
674+
btn.title = `Navigate to ${route.path || route.template || "/"}`;
675+
btn.addEventListener("click", (evt) => {
676+
evt.stopPropagation();
677+
navigateRoute(route);
678+
});
679+
meta.appendChild(btn);
680+
el.appendChild(meta);
681+
682+
el.dataset.path = (route.path || route.template || "/").toLowerCase();
683+
el.addEventListener("click", () => selectRoute(route));
684+
frag.appendChild(el);
685+
if (Array.isArray(route.children) && route.children.length) {
686+
walk(route.children, depth + 1);
687+
}
688+
});
689+
};
690+
walk(list, 0);
691+
routeTree.replaceChildren(frag);
692+
highlightSelectedRoute();
693+
}
694+
695+
function highlightSelectedRoute() {
696+
if (!routeTree) return;
697+
const nodes = $$(".node", routeTree);
698+
nodes.forEach((n) => {
699+
if (selectedRoutePath) {
700+
n.classList.toggle(
701+
"active",
702+
n.dataset.path === selectedRoutePath.toLowerCase(),
703+
);
704+
} else {
705+
n.classList.remove("active");
706+
}
707+
});
708+
}
709+
710+
function selectRoute(route) {
711+
selectedRoute = route;
712+
selectedRoutePath = route?.path || route?.template || "";
713+
highlightSelectedRoute();
714+
if (!routeTitle || !routeDetail || !routeDetailGo) return;
715+
if (!route) {
716+
routeTitle.textContent = "Select a route";
717+
routeDetail.innerHTML = "";
718+
routeDetailGo.setAttribute("disabled", "true");
719+
return;
720+
}
721+
routeTitle.textContent = route.path || route.template || "/";
722+
const rows = [];
723+
rows.push(renderRow("Path", route.path || "/"));
724+
if (route.template && route.template !== route.path)
725+
rows.push(renderRow("Template", route.template));
726+
if (Array.isArray(route.params) && route.params.length) {
727+
rows.push(
728+
renderRow(
729+
"Parameters",
730+
route.params.join(", ") || "-",
731+
),
732+
);
733+
}
734+
rows.push(
735+
renderRow(
736+
"Children",
737+
Array.isArray(route.children) ? String(route.children.length) : "0",
738+
),
739+
);
740+
routeDetail.innerHTML = rows.join("");
741+
routeDetailGo.removeAttribute("disabled");
742+
}
743+
744+
function findRouteByPath(path, nodes) {
745+
for (const route of nodes) {
746+
const value = route.path || route.template || "";
747+
if (value === path) return route;
748+
if (Array.isArray(route.children)) {
749+
const found = findRouteByPath(path, route.children);
750+
if (found) return found;
751+
}
752+
}
753+
return null;
754+
}
755+
756+
function refreshRoutes() {
757+
if (!routeTree) return;
758+
try {
759+
if (typeof globalThis.RFW_DEVTOOLS_ROUTES === "function") {
760+
const data = JSON.parse(globalThis.RFW_DEVTOOLS_ROUTES());
761+
routeSnapshot = Array.isArray(data) ? data : [];
762+
renderRoutes(routeSnapshot);
763+
if (selectedRoutePath) {
764+
const current = findRouteByPath(selectedRoutePath, routeSnapshot);
765+
selectRoute(current);
766+
}
767+
return;
768+
}
769+
} catch (err) {
770+
console.warn("DevTools routes refresh error", err);
771+
}
772+
routeSnapshot = [];
773+
if (routeTree) routeTree.textContent = "";
774+
selectRoute(null);
775+
}
776+
777+
function navigateRoute(route) {
778+
if (!route) return;
779+
const dynamic = Array.isArray(route.params) && route.params.length > 0;
780+
if (dynamic) {
781+
openRoutePopup(route);
782+
return;
783+
}
784+
goToPath(route.path || route.template || "/");
785+
}
786+
787+
function goToPath(path) {
788+
if (!path) return;
789+
if (typeof globalThis.goNavigate === "function") {
790+
globalThis.goNavigate(path);
791+
} else {
792+
window.location.assign(path);
793+
}
794+
}
795+
796+
function openRoutePopup(route) {
797+
if (!routePopup || !routePopupFields) return;
798+
popupRoute = route;
799+
routePopup.classList.remove("hidden");
800+
if (routePopupTitle) routePopupTitle.textContent = route.path || route.template || "/";
801+
if (routePopupInfo) {
802+
const template = route.template && route.template !== route.path ? route.template : "Provide values for dynamic parameters.";
803+
routePopupInfo.textContent = template;
804+
}
805+
routePopupFields.innerHTML = "";
806+
(route.params || []).forEach((name) => {
807+
const field = document.createElement("label");
808+
field.className = "route-field";
809+
field.innerHTML = `<span>${escapeHTML(name)}</span>`;
810+
const input = document.createElement("input");
811+
input.type = "text";
812+
input.dataset.key = name;
813+
input.placeholder = name;
814+
field.appendChild(input);
815+
routePopupFields.appendChild(field);
816+
});
817+
if (routePopupError) routePopupError.textContent = "";
818+
const first = routePopupFields.querySelector("input");
819+
if (first) first.focus();
820+
}
821+
822+
function closeRoutePopup() {
823+
if (!routePopup) return;
824+
routePopup.classList.add("hidden");
825+
popupRoute = null;
826+
routePopupFields?.replaceChildren();
827+
if (routePopupError) routePopupError.textContent = "";
828+
}
829+
830+
function confirmRoutePopup() {
831+
if (!popupRoute || !routePopupFields) return;
832+
let target = popupRoute.path || popupRoute.template || "/";
833+
const inputs = Array.from(routePopupFields.querySelectorAll("input[data-key]"));
834+
for (const input of inputs) {
835+
const key = input.dataset.key;
836+
const value = input.value.trim();
837+
if (!value) {
838+
if (routePopupError) {
839+
routePopupError.textContent = `Missing value for ${key}`;
840+
}
841+
input.focus();
842+
return;
843+
}
844+
const encoded = encodeURIComponent(value);
845+
const pattern = new RegExp(`:${key}(?=/|$)`, "g");
846+
target = target.replace(pattern, encoded);
847+
}
848+
closeRoutePopup();
849+
goToPath(target);
850+
}
851+
852+
$("#refreshRoutes")?.addEventListener("click", refreshRoutes);
853+
routeFilter?.addEventListener("input", (e) => {
854+
const q = e.target.value.trim().toLowerCase();
855+
const nodes = $$(".node", routeTree);
856+
nodes.forEach((n) => {
857+
const text = n.textContent.toLowerCase();
858+
n.style.display = text.includes(q) ? "" : "none";
859+
});
860+
});
861+
routeDetailGo?.addEventListener("click", () => {
862+
const current = selectedRoute || findRouteByPath(selectedRoutePath, routeSnapshot);
863+
navigateRoute(current);
864+
});
865+
routePopupCancel?.addEventListener("click", closeRoutePopup);
866+
routePopupConfirm?.addEventListener("click", confirmRoutePopup);
867+
563868
$("#treeFilter")?.addEventListener("input", (e) => {
564869
const q = e.target.value.trim().toLowerCase();
565870
const nodes = $$(".node", treeContainer);
@@ -872,6 +1177,7 @@ $("#pluginFilter")?.addEventListener("input", (e) => {
8721177
window.RFW_DEVTOOLS_REFRESH_STORES = refreshStore;
8731178
window.RFW_DEVTOOLS_REFRESH_SIGNALS = refreshSignals;
8741179
window.RFW_DEVTOOLS_REFRESH_PLUGINS = refreshPlugins;
1180+
window.RFW_DEVTOOLS_REFRESH_ROUTES = refreshRoutes;
8751181

8761182
const varsTree = $("#varsTree");
8771183
const varsTitle = $("#varsTitle");

docs/articles/api/router.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ Client-side router with lazy loaded components and guards.
1111
| `ExposeNavigate()` | Exposes navigation to JavaScript as `goNavigate` and auto-routes internal links. |
1212
| `NotFoundComponent` / `NotFoundCallback` | Handle unmatched routes. |
1313
| `Reset()` | Clears registered routes and the current component. |
14+
| `RegisteredRoutes() []RegisteredRoute` | Returns all registered routes with nested children and dynamic parameter metadata. |
1415
| `Route.Children []Route` | Nests routes under a parent. |
1516
| `Guard` | Runs before navigation and can cancel by returning `false`. |
1617

18+
`RegisteredRoute` exposes the registered `Template`, resolved `Path`, dynamic `Params`, and `Children` for inspection tooling.
19+

0 commit comments

Comments
 (0)