openvibers/openvibe.trade Commits

Trade: data tables that scroll on a phone are keyboard-focusable (#59)

OP OpenVibers committed 29 days ago · 1abbca9f82 · 3 files, +8 −5

On a phone, Trade's data tables scroll sideways (`trade.css`: `table.data { display: block; overflow-x: auto }` under 700 px) but could not be reached by keyboard. An axe scan of inner pages at 390 px flagged `/i/<symbol>` (observations) and `/sources` (`scrollable-region-focusable`, serious).

Every `table.data` now has `tabindex="0"`: instruments, observations, sources, watchlists and alert rules. `test/freshness.test.js` checks `/sources`.

Changes

ddiff --git a/public/css/trade.css b/public/css/trade.css
iindex cd23b8a..fde5ad6 100644
---- a/public/css/trade.css
++++ b/public/css/trade.css
@@@ -56,5 +56,6 @@ form.inline button { margin: 0; padding: 2px 10px; font-size: .85rem; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
@media (max-width: 700px) {
h1 { font-size: 1.5rem; }
++ /* A table scrolls sideways here, so each has tabindex="0" (axe scrollable-region-focusable). */
table.data { display: block; overflow-x: auto; }
}
ddiff --git a/server/render/views.js b/server/render/views.js
iindex 51f03d0..07c064c 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -83,7 +83,7 @@ ${first ? raw(tradeShowcase()) : html`<h1>Sourced market information</h1>`}
<button type="submit">Find</button>
</form>
<h2>Instruments</h2>
--${page.instruments.length ? html`<table class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Kind</th>${exchanges ? html`<th scope="col">Exchange</th>` : ''}<th scope="col">Latest</th></tr></thead><tbody>${rows}</tbody></table>
++${page.instruments.length ? html`<table tabindex="0" class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Kind</th>${exchanges ? html`<th scope="col">Exchange</th>` : ''}<th scope="col">Latest</th></tr></thead><tbody>${rows}</tbody></table>
${raw(paginationHtml(pager))}`
: html`<p class="empty">No instruments have been added yet.</p>`}
<h2>Recent documents</h2>
@@@ -120,7 +120,7 @@ function observationsTable(instrument, observations) {
<td>${when(o.retrieved_at)}</td>
<td>${staleBadge(o.freshness)}</td>
</tr>`);
-- return html`<table class="data observations">
++ return html`<table tabindex="0" class="data observations">
<caption>Latest observation per metric. Values are exactly as the source stated them.</caption>
<thead><tr><th scope="col">Metric</th><th scope="col">Value</th><th scope="col">Observed</th><th scope="col">Source</th><th scope="col">Retrieved</th><th scope="col">Freshness</th></tr></thead>
<tbody>${rows}</tbody></table>`;
@@@ -135,7 +135,7 @@ function sourcesTable(list, { caption }) {
<td>${s.stale_after_sec ? `${Math.round(s.stale_after_sec / 60)} min` : ''}</td>
<td>${s.upstream_status || html`<span class="muted">not reported</span>`}</td>
</tr>`);
-- return html`<table class="data sources"><caption>${caption}</caption>
++ return html`<table tabindex="0" class="data sources"><caption>${caption}</caption>
<thead><tr><th scope="col">Source</th><th scope="col">Freshness</th><th scope="col">Last successful fetch</th><th scope="col">Stale after</th><th scope="col">Sources reports</th></tr></thead>
<tbody>${rows}</tbody></table>`;
}
@@@ -249,7 +249,7 @@ function watchlistsPage({ viewer, lists, rules, deliveries, csrf, loginHref, not
}
const listHtml = lists.map((w) => html`<section class="card" aria-label="${w.name}">
<h2>${w.name}</h2>
--${w.items.length ? html`<table class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Note</th><th scope="col"></th></tr></thead><tbody>
++${w.items.length ? html`<table tabindex="0" class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Note</th><th scope="col"></th></tr></thead><tbody>
${w.items.map((it) => html`<tr><td><a href="${new URL(it.instrument.url).pathname}">${it.instrument.symbol}</a></td><td>${it.instrument.name}</td><td>${it.note || ''}</td>
<td><form method="post" action="/watchlists/item/remove" class="inline">${csrfField(csrf)}<input type="hidden" name="watchlist_id" value="${w.id}"><input type="hidden" name="symbol" value="${it.instrument.symbol}"><button type="submit">Remove</button></form></td></tr>`)}
</tbody></table>` : html`<p class="empty">Empty. Add instruments from their pages or below.</p>`}
@@@ -274,7 +274,7 @@ ${listHtml}
<label for="wl-name">New watchlist</label><input id="wl-name" name="name" maxlength="80" required>
<button type="submit">Create</button></form>
<h2>Your alerts</h2>
--${rules.length ? html`<table class="data"><thead><tr><th scope="col">Instrument</th><th scope="col">Condition</th><th scope="col">Created</th><th scope="col"></th></tr></thead><tbody>${ruleRows}</tbody></table>`
++${rules.length ? html`<table tabindex="0" class="data"><thead><tr><th scope="col">Instrument</th><th scope="col">Condition</th><th scope="col">Created</th><th scope="col"></th></tr></thead><tbody>${ruleRows}</tbody></table>`
: html`<p class="empty">No alerts. Create one from an instrument’s page.</p>`}
<h2>Recent alert deliveries</h2>
${deliveries.length ? html`<ul>${delRows}</ul>` : html`<p class="empty">None yet.</p>`}`;
ddiff --git a/test/freshness.test.js b/test/freshness.test.js
iindex 6108a26..930d2c3 100644
---- a/test/freshness.test.js
++++ b/test/freshness.test.js
@@@ -83,6 +83,8 @@ const HOUR = 3600e3;
assert.ok(json.observations.find((o) => o.metric === 'price.close').value === '10.60', 'last real value kept, labelled stale');
const sourcesPage = await t.get('/sources');
assert.ok(/failing since/.test(sourcesPage.text), 'the sources page says the sync is failing');
++ // On a phone the table scrolls sideways (trade.css), so it is a keyboard stop (axe scrollable-region-focusable).
++ assert.ok(!/<table(?![^>]*tabindex)[^>]*class="data/.test(sourcesPage.text), 'every data table is focusable');
const ready = (await t.get('/api/ready')).json();
assert.ok(JSON.stringify(ready).includes('last Sources sync failed'));
t.sources.setDown(false);