fix: ARIA tab pattern, form labels, focus management (closes #10, #13, #14)

This commit is contained in:
you
2026-03-19 19:00:43 +00:00
parent 1ca92cc156
commit 61b46df34d
5 changed files with 92 additions and 9 deletions
+1 -1
View File
@@ -977,7 +977,7 @@
<h3>🛤️ Route Pattern Analysis</h3>
<p>Click a route to see details. Most common subpaths — reveals backbone routes, bottlenecks, and preferred relay chains.</p>
<label style="display:inline-flex;align-items:center;gap:6px;margin-bottom:12px;cursor:pointer;font-size:0.9em">
<input type="checkbox" id="hideCollisions" ${localStorage.getItem('subpath-hide-collisions') === '1' ? 'checked' : ''}> Hide likely prefix collisions (self-loops)
<input type="checkbox" id="hideCollisions" aria-label="Hide likely prefix collisions" ${localStorage.getItem('subpath-hide-collisions') === '1' ? 'checked' : ''}> Hide likely prefix collisions (self-loops)
</label>
<div class="subpath-jump-nav">
<span>Jump to:</span>
+48
View File
@@ -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).
+25 -3
View File
@@ -43,12 +43,19 @@
</div>
</div>`;
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 = `<div class="nodes-page">
<div class="nodes-topbar">
<input type="text" class="nodes-search" id="nodeSearch" placeholder="Search nodes by name…">
<input type="text" class="nodes-search" id="nodeSearch" placeholder="Search nodes by name…" aria-label="Search nodes by name">
<div class="nodes-counts" id="nodeCounts"></div>
</div>
<div class="split-layout">
@@ -215,7 +222,7 @@
${TABS.map(t => `<button class="node-tab ${activeTab === t.key ? 'active' : ''}" data-tab="${t.key}">${t.label}</button>`).join('')}
</div>
<div class="nodes-filters">
<select id="nodeLastHeard">
<select id="nodeLastHeard" aria-label="Filter by last heard time">
<option value="">Last Heard: Any</option>
<option value="1h" ${lastHeard==='1h'?'selected':''}>1 hour</option>
<option value="6h" ${lastHeard==='6h'?'selected':''}>6 hours</option>
@@ -223,7 +230,7 @@
<option value="7d" ${lastHeard==='7d'?'selected':''}>7 days</option>
<option value="30d" ${lastHeard==='30d'?'selected':''}>30 days</option>
</select>
<select id="nodeSort">
<select id="nodeSort" aria-label="Sort nodes">
<option value="lastSeen" ${sortBy==='lastSeen'?'selected':''}>Sort: Last Seen</option>
<option value="name" ${sortBy==='name'?'selected':''}>Sort: Name</option>
<option value="packetCount" ${sortBy==='packetCount'?'selected':''}>Sort: Adverts</option>
@@ -243,6 +250,8 @@
</table>`;
// 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 = '<span>Select a node to view details</span>';
selectedKey = null;
renderRows();
}
}
});
renderRows();
}
+17 -4
View File
@@ -231,14 +231,14 @@
</div>
</div>
<div class="filter-bar" id="pktFilters">
<input type="text" placeholder="Packet hash…" id="fHash">
<input type="text" placeholder="Packet hash…" id="fHash" aria-label="Filter by packet hash">
<div class="node-filter-wrap" style="position:relative">
<input type="text" placeholder="Node name…" id="fNode" autocomplete="off" role="combobox" aria-expanded="false" aria-owns="fNodeDropdown" aria-activedescendant="" aria-autocomplete="list">
<div class="node-filter-dropdown hidden" id="fNodeDropdown" role="listbox"></div>
</div>
<select id="fObserver"><option value="">All Observers</option></select>
<select id="fRegion"><option value="">All Regions</option></select>
<select id="fType"><option value="">All Types</option></select>
<select id="fObserver" aria-label="Filter by observer"><option value="">All Observers</option></select>
<select id="fRegion" aria-label="Filter by region"><option value="">All Regions</option></select>
<select id="fType" aria-label="Filter by packet type"><option value="">All Types</option></select>
<button class="btn ${groupByHash ? 'active' : ''}" id="fGroup">Group by Hash</button>
</div>
<table class="data-table" id="pktTable">
@@ -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 = '<div class="panel-resize-handle" id="pktResizeHandle"></div><span>Select a packet to view details</span>';
selectedId = null;
renderTableRows();
}
}
});
renderTableRows();
makeColumnsResizable('#pktTable', 'meshcore-pkt-col-widths');
}
+1 -1
View File
@@ -17,7 +17,7 @@
<h2>🔍 Packet Trace</h2>
</div>
<div class="trace-search">
<input type="text" id="traceHashInput" placeholder="Enter packet hash…" value="${urlHash}">
<input type="text" id="traceHashInput" placeholder="Enter packet hash…" value="${urlHash}" aria-label="Packet hash to trace">
<button class="btn-primary" id="traceBtn">Trace</button>
</div>
<div id="traceResults"></div>