From 61b46df34dcc204bc0ac6b2c5e90f0e3aee76edc Mon Sep 17 00:00:00 2001 From: you Date: Thu, 19 Mar 2026 19:00:43 +0000 Subject: [PATCH] fix: ARIA tab pattern, form labels, focus management (closes #10, #13, #14) --- public/analytics.js | 2 +- public/app.js | 48 +++++++++++++++++++++++++++++++++++++++++++++ public/nodes.js | 28 +++++++++++++++++++++++--- public/packets.js | 21 ++++++++++++++++---- public/traces.js | 2 +- 5 files changed, 92 insertions(+), 9 deletions(-) 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('')}
- @@ -223,7 +230,7 @@ - @@ -243,6 +250,8 @@ `; // Tab clicks + const nodeTabs = document.getElementById('nodeTabs'); + initTabBar(nodeTabs); el.querySelectorAll('.node-tab').forEach(btn => { btn.addEventListener('click', () => { activeTab = btn.dataset.tab; loadNodes(); }); }); @@ -270,6 +279,19 @@ tbody.addEventListener('keydown', handler); } + // Escape to close node detail panel + document.addEventListener('keydown', function nodesPanelEsc(e) { + if (e.key === 'Escape') { + const panel = document.getElementById('nodesRight'); + if (panel && !panel.classList.contains('empty')) { + panel.classList.add('empty'); + panel.innerHTML = 'Select a node to view details'; + selectedKey = null; + renderRows(); + } + } + }); + renderRows(); } diff --git a/public/packets.js b/public/packets.js index 512ae404..a6bf540a 100644 --- a/public/packets.js +++ b/public/packets.js @@ -231,14 +231,14 @@
- +
- - - + + +
@@ -370,6 +370,19 @@ pktBody.addEventListener('keydown', handler); } + // Escape to close packet detail panel + document.addEventListener('keydown', function pktEsc(e) { + if (e.key === 'Escape') { + const panel = document.getElementById('pktRight'); + if (panel && !panel.classList.contains('empty')) { + panel.classList.add('empty'); + panel.innerHTML = '
Select a packet to view details'; + selectedId = null; + renderTableRows(); + } + } + }); + renderTableRows(); makeColumnsResizable('#pktTable', 'meshcore-pkt-col-widths'); } diff --git a/public/traces.js b/public/traces.js index f9e63df1..2781db74 100644 --- a/public/traces.js +++ b/public/traces.js @@ -17,7 +17,7 @@

🔍 Packet Trace