fix: make table rows keyboard-accessible with delegated event listeners (fixes #9)

- Replace inline onclick on <tr> elements with data-action/data-value attributes
- Add tabindex="0" and role="row" to all clickable rows
- Add delegated click and keydown (Enter/Space) listeners on containers
- Remove window._pktSelect, _pktToggleGroup, _pktSelectHash, _nodeSelect globals
- Convert to local functions referenced by delegated handlers

Affected files: packets.js, nodes.js, analytics.js
(channels.js and observers.js had no interactive <tr> elements)
This commit is contained in:
you
2026-03-19 15:50:18 +00:00
parent c86af95d44
commit f7d4d2a6b7
3 changed files with 57 additions and 17 deletions

View File

@@ -88,6 +88,20 @@
renderTab(btn.dataset.tab);
});
// Delegated click/keyboard handler for clickable table rows
const analyticsContent = document.getElementById('analyticsContent');
if (analyticsContent) {
const handler = (e) => {
const row = e.target.closest('tr[data-action="navigate"]');
if (!row) return;
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
if (e.type === 'keydown') e.preventDefault();
location.hash = row.dataset.value;
};
analyticsContent.addEventListener('click', handler);
analyticsContent.addEventListener('keydown', handler);
}
try {
window._analyticsData = {};
const [hashData, rfData, topoData, chanData] = await Promise.all([
@@ -568,7 +582,7 @@
<table class="analytics-table">
<thead><tr><th>Channel</th><th>Hash</th><th>Messages</th><th>Unique Senders</th><th>Last Activity</th><th>Decrypted</th></tr></thead>
<tbody>
${ch.channels.map(c => `<tr class="clickable-row" onclick="location.hash='#/channels?ch=${c.hash}'">
${ch.channels.map(c => `<tr class="clickable-row" data-action="navigate" data-value="#/channels?ch=${c.hash}" tabindex="0" role="row">
<td><strong>${esc(c.name || 'Unknown')}</strong></td>
<td class="mono">${c.hash}</td>
<td>${c.messages}</td>
@@ -679,7 +693,7 @@
<table class="analytics-table">
<thead><tr><th>Node</th><th>Hash Size</th><th>Adverts</th><th>Last Seen</th></tr></thead>
<tbody>
${data.multiByteNodes.map(n => `<tr class="clickable-row" onclick="location.hash='#/nodes/${n.pubkey ? encodeURIComponent(n.pubkey) : ''}'">
${data.multiByteNodes.map(n => `<tr class="clickable-row" data-action="navigate" data-value="#/nodes/${n.pubkey ? encodeURIComponent(n.pubkey) : ''}" tabindex="0" role="row">
<td><strong>${esc(n.name)}</strong></td>
<td><span class="badge badge-hash-${n.hashSize}">${n.hashSize}-byte</span></td>
<td>${n.packets}</td>
@@ -697,7 +711,7 @@
<tbody>
${data.topHops.map(h => {
const link = h.pubkey ? `#/nodes/${encodeURIComponent(h.pubkey)}` : `#/packets?search=${h.hex}`;
return `<tr class="clickable-row" onclick="location.hash='${link}'">
return `<tr class="clickable-row" data-action="navigate" data-value="${link}" tabindex="0" role="row">
<td class="mono">${h.hex}</td>
<td>${h.name ? `<strong>${esc(h.name)}</strong>` : '<span class="text-muted">unknown</span>'}</td>
<td><span class="badge badge-hash-${h.size}">${h.size}-byte</span></td>

View File

@@ -257,6 +257,20 @@
th.addEventListener('click', () => { sortBy = th.dataset.sort; loadNodes(); });
});
// Delegated click/keyboard handler for table rows
const tbody = document.getElementById('nodesBody');
if (tbody) {
const handler = (e) => {
const row = e.target.closest('tr[data-action="select"]');
if (!row) return;
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
if (e.type === 'keydown') e.preventDefault();
selectNode(row.dataset.value);
};
tbody.addEventListener('click', handler);
tbody.addEventListener('keydown', handler);
}
renderRows();
}
@@ -271,7 +285,7 @@
tbody.innerHTML = nodes.map(n => {
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
return `<tr data-key="${n.public_key}" onclick="window._nodeSelect('${n.public_key}')" class="${selectedKey === n.public_key ? 'selected' : ''}">
return `<tr data-key="${n.public_key}" data-action="select" data-value="${n.public_key}" tabindex="0" role="row" class="${selectedKey === n.public_key ? 'selected' : ''}">
<td>${favStar(n.public_key, 'node-fav')}<strong>${n.name || '(unnamed)'}</strong></td>
<td class="mono">${truncate(n.public_key, 16)}</td>
<td><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span></td>
@@ -407,7 +421,5 @@
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
window._nodeSelect = selectNode;
registerPage('nodes', { init, destroy });
})();

View File

@@ -290,6 +290,24 @@
}, 250));
fNode.addEventListener('blur', () => { setTimeout(() => fNodeDrop.classList.add('hidden'), 200); });
// Delegated click/keyboard handler for table rows
const pktBody = document.getElementById('pktBody');
if (pktBody) {
const handler = (e) => {
const row = e.target.closest('tr[data-action]');
if (!row) return;
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
if (e.type === 'keydown') e.preventDefault();
const action = row.dataset.action;
const value = row.dataset.value;
if (action === 'select') selectPacket(Number(value));
else if (action === 'select-hash') pktSelectHash(value);
else if (action === 'toggle-select') { pktToggleGroup(value); pktSelectHash(value); }
};
pktBody.addEventListener('click', handler);
pktBody.addEventListener('keydown', handler);
}
renderTableRows();
makeColumnsResizable('#pktTable', 'meshcore-pkt-col-widths');
}
@@ -310,10 +328,7 @@
const groupTypeClass = payloadTypeColor(p.payload_type);
const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
const isSingle = p.count <= 1;
const rowClick = isSingle
? `window._pktSelectHash('${p.hash}')`
: `window._pktToggleGroup('${p.hash}'); window._pktSelectHash('${p.hash}')`;
html += `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" onclick="${rowClick}">
html += `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" tabindex="0" role="row">
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
<td>${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
<td>${timeAgo(p.latest)}</td>
@@ -335,7 +350,7 @@
let childPath = [];
try { childPath = JSON.parse(c.path_json || '[]'); } catch {}
const childPathStr = renderPath(childPath);
html += `<tr class="group-child" data-id="${c.id}" onclick="window._pktSelect(${c.id})">
html += `<tr class="group-child" data-id="${c.id}" data-action="select" data-value="${c.id}" tabindex="0" role="row">
<td></td><td>${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
<td>${timeAgo(c.timestamp)}</td>
<td class="mono">${truncate(c.hash || '', 8)}</td>
@@ -365,7 +380,7 @@
const pathStr = renderPath(pathHops);
const detail = getDetailPreview(decoded);
return `<tr data-id="${p.id}" onclick="window._pktSelect(${p.id})" class="${selectedId === p.id ? 'selected' : ''}">
return `<tr data-id="${p.id}" data-action="select" data-value="${p.id}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
<td></td><td>${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
<td>${timeAgo(p.timestamp)}</td>
<td class="mono">${truncate(p.hash || String(p.id), 8)}</td>
@@ -741,8 +756,7 @@
})();
// Global handlers
window._pktSelect = selectPacket;
window._pktToggleGroup = async (hash) => {
async function pktToggleGroup(hash) {
if (expandedHashes.has(hash)) {
expandedHashes.delete(hash);
renderTableRows();
@@ -763,14 +777,14 @@
expandedHashes.add(hash);
renderTableRows();
} catch {}
};
window._pktSelectHash = async (hash) => {
}
async function pktSelectHash(hash) {
// When grouped, find first packet with this hash
try {
const data = await api(`/packets?hash=${hash}&limit=1`);
if (data.packets?.[0]) selectPacket(data.packets[0].id);
} catch {}
};
}
window._pktRefresh = loadPackets;
window._pktBYOP = showBYOP;