diff --git a/public/analytics.js b/public/analytics.js
index cd51714f..366b6937 100644
--- a/public/analytics.js
+++ b/public/analytics.js
@@ -977,7 +977,7 @@
🛤️ Route Pattern Analysis
Click a route to see details. Most common subpaths — reveals backbone routes, bottlenecks, and preferred relay chains.
Jump to:
diff --git a/public/app.js b/public/app.js
index 728c2485..dada6178 100644
--- a/public/app.js
+++ b/public/app.js
@@ -394,6 +394,54 @@ window.addEventListener('DOMContentLoaded', () => {
else navigate();
});
+/**
+ * Reusable ARIA tab-bar initialiser.
+ * Adds role="tablist" to container, role="tab" + aria-selected to each button,
+ * and arrow-key navigation between tabs.
+ * @param {HTMLElement} container - the tab bar element
+ * @param {Function} [onChange] - optional callback(activeBtn) on tab change
+ */
+function initTabBar(container, onChange) {
+ if (!container || container.getAttribute('role') === 'tablist') return;
+ container.setAttribute('role', 'tablist');
+ const tabs = Array.from(container.querySelectorAll('button, [data-tab], [data-obs]'));
+ tabs.forEach(btn => {
+ btn.setAttribute('role', 'tab');
+ const isActive = btn.classList.contains('active');
+ btn.setAttribute('aria-selected', String(isActive));
+ btn.setAttribute('tabindex', isActive ? '0' : '-1');
+ // Link to panel if aria-controls target exists
+ const panelId = btn.dataset.tab || btn.dataset.obs;
+ if (panelId && document.getElementById(panelId)) {
+ btn.setAttribute('aria-controls', panelId);
+ }
+ });
+ container.addEventListener('click', (e) => {
+ const btn = e.target.closest('[role="tab"]');
+ if (!btn || !container.contains(btn)) return;
+ tabs.forEach(b => { b.setAttribute('aria-selected', 'false'); b.setAttribute('tabindex', '-1'); });
+ btn.setAttribute('aria-selected', 'true');
+ btn.setAttribute('tabindex', '0');
+ if (onChange) onChange(btn);
+ });
+ container.addEventListener('keydown', (e) => {
+ const btn = e.target.closest('[role="tab"]');
+ if (!btn) return;
+ let idx = tabs.indexOf(btn), next = -1;
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (idx + 1) % tabs.length;
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (idx - 1 + tabs.length) % tabs.length;
+ else if (e.key === 'Home') next = 0;
+ else if (e.key === 'End') next = tabs.length - 1;
+ if (next < 0) return;
+ e.preventDefault();
+ tabs.forEach(b => { b.setAttribute('aria-selected', 'false'); b.setAttribute('tabindex', '-1'); });
+ tabs[next].setAttribute('aria-selected', 'true');
+ tabs[next].setAttribute('tabindex', '0');
+ tabs[next].focus();
+ tabs[next].click();
+ });
+}
+
/**
* Make table columns resizable with drag handles. Widths saved to localStorage.
* Call after table is in DOM. Re-call safe (idempotent per table).
diff --git a/public/nodes.js b/public/nodes.js
index f20d6c6b..ed190798 100644
--- a/public/nodes.js
+++ b/public/nodes.js
@@ -43,12 +43,19 @@
`;
loadFullNode(directNode);
+ // Escape to go back to nodes list
+ document.addEventListener('keydown', function nodesEsc(e) {
+ if (e.key === 'Escape') {
+ document.removeEventListener('keydown', nodesEsc);
+ location.hash = '#/nodes';
+ }
+ });
return;
}
app.innerHTML = `
@@ -215,7 +222,7 @@
${TABS.map(t => ``).join('')}
-
-
+
-
-
-
+
+
+