Skip to content

Leaderboard/History tables have no accessible live-refresh announcements or sortable-column semantics for screen readers #86

Description

@prodbycorne

Problem

LeaderboardPage (src/app/leaderboard/page.tsx) renders a plain Chakra Table whose contents silently replace themselves every 30 seconds (REFRESH_MS in useLeaderboard.ts) and whenever the user changes sortKey/page/search — with no aria-live region announcing that the table refreshed, no aria-sort attribute on the sortable column headers (Th elements), and the sort control is a plain Select disconnected from the table headers it affects, so there's no programmatic association a screen reader can use to know which column is currently driving the sort order. HistoryPage (src/app/history/page.tsx) has the same underlying Table/Th/Td structure with sortable-looking columns (Date, Action, Amount, Credits Earned) but no sort functionality or semantics at all, and its own periodic-refresh-free but pagination-driven content swap likewise has no live-region announcement when the page changes.

This is functionally distinct from the color-contrast and theme-toggle work already completed under closed issue #44 — it's about interaction semantics and dynamic-content announcements, not color. Under WCAG 2.1 SC 4.1.3 (Status Messages) and the ARIA table sorting pattern, both tables should announce content changes and expose sort state programmatically; neither does.

Acceptance Criteria

  • Add aria-sort (ascending/descending/none) to the relevant Th elements in LeaderboardPage, kept in sync with sortKey.
  • Add an aria-live="polite" region (visually hidden if needed) that announces when the leaderboard/history table content updates due to auto-refresh, sort change, or pagination, including a summary of what changed (e.g. 'Leaderboard updated, showing rank 1-10 of 4,213').
  • Ensure the leaderboard's sort Select and the table headers are programmatically associated (e.g. via aria-controls or by making the headers themselves the sort control, matching the ARIA table-sort design pattern) rather than being two independent, unlinked controls.
  • Add automated accessibility tests (axe-core, extending the pattern in tests/contrast-audit.spec.ts) asserting the aria-sort/live-region behavior on both pages.

Relevant Files

  • src/app/leaderboard/page.tsx — Table/Th elements (~L100-160) with no aria-sort, and periodic refresh with no live-region announcement
  • src/app/history/page.tsx — Table/Th elements (~L144-201) with the same lack of sort/live-region semantics
  • src/hooks/useLeaderboard.ts — drives the 30s auto-refresh that currently has no accessible announcement

Additional Notes

Re-read both src/app/leaderboard/page.tsx and src/app/history/page.tsx directly to verify. Confirmed:

  • LeaderboardPage's sort control is a plain Chakra Select (value={sortKey}, onChange={(e) => setSortKey(...)}) fully independent from the Th header cells rendered further down (#, Address, Credits, Stake) — there is no onClick on any Th, no aria-sort, and no aria-controls/id pairing between the Select and the table, exactly as described.
  • LeaderboardPage additionally has a manual Button onClick={refresh} alongside the automatic 30s refresh from useLeaderboard — meaning there are actually two independent triggers for content replacement (auto-refresh timer and manual button), both needing the same live-region treatment; the manual refresh path is a detail not called out in the original body and worth an explicit acceptance-criteria bullet since a live-region fix that only hooks into the timer-driven refresh would miss the manual button's clicks.
  • HistoryPage's Th cells (Date, Action, Amount, Credits Earned, Transaction) confirmed to have zero sort affordance at all — not even a visual sort indicator — so "sortable-looking" in the original body is accurate only in that they resemble a data table header, not that any sort UI exists; the fix here is closer to "add sorting" than "add semantics to existing sorting," which is a slightly larger scope than the Leaderboard side and worth flagging as an open question: does this issue want sort added to History, or just live-region announcements for its existing pagination-driven changes? The acceptance criteria as written only asks for aria-sort on Leaderboard and live-region on both — worth being explicit that History doesn't get new sort functionality here, only the live-region.
  • Table variant="unstyled" is used on both pages — Chakra's unstyled variant strips the default ARIA-friendly styling hooks but doesn't strip semantics, so role="table"/row/columnheader are still emitted correctly by the underlying HTML table elements; this rules out a base-semantics issue and confirms the gap really is only sort-state and live-region announcements, not fundamentally broken table markup.

Edge cases to cover

  • Empty search results / zero rows on Leaderboard (filteredCount === 0 branch, confirmed present in the file) — the live region should still announce "No results found" rather than going silent, since a screen-reader user who just typed a search term needs confirmation the search executed.
  • Pagination edge case: last page with fewer than PAGE_SIZE rows — the announcement text (e.g. "showing rank 1-10 of 4,213") needs to correctly compute the upper bound on a partial final page, not just page * PAGE_SIZE.
  • Debounced search input: if searchQuery changes trigger a live-region update on every keystroke rather than after debounce settles, screen readers will be spammed; the announcement should be tied to when paged/filteredCount actually changes, not to keystroke-level state.
  • Simultaneous auto-refresh + user-initiated sort change landing in close succession — ensure the live region doesn't emit two overlapping/garbled announcements (a queued aria-live="polite" region naturally handles this, but worth an explicit test).

Implementation sketch

  1. Add a shared, visually-hidden (Box position="absolute" w="1px" h="1px" overflow="hidden", the standard "visually-hidden" pattern) aria-live="polite" aria-atomic="true" region per page, driven by a useEffect that watches paged/filteredCount/sortKey/currentPage and sets an announcement string.
  2. On Leaderboard's Th cells: add aria-sort={sortKey === 'credits' && column === 'credits' ? 'descending' : 'none'} (mapping each sortable column), and make the Th itself clickable (onClick={() => setSortKey(column)}) in addition to (or instead of) the Select, with aria-controls linking the Select to the table's id if both controls are kept.
  3. For History: add the same visually-hidden live region tied to currentPage/paged, with no aria-sort changes since no sort exists to announce.
  4. Reuse whatever visually-hidden live-region component/hook is built for UnlockModal's autofocus uses a fragile document-wide querySelector instead of a ref, with no ARIA-live status region #85 (UnlockModal/DepositModal step announcements) for consistency across the app rather than writing three separate ad hoc implementations.

Test plan

  • axe-core (extending tests/contrast-audit.spec.ts): assert aria-sort values on Leaderboard Th elements match sortKey state across all sort options.
  • RTL: simulate the 30s auto-refresh (mock timers), assert the live region's text content updates to reflect new data.
  • RTL: click the manual "Refresh" button on Leaderboard, assert the live region updates (covers the dual-trigger gap noted above).
  • RTL: type a search query that yields zero results, assert the live region announces "No results" rather than remaining stale from the prior state.
  • RTL: paginate History to its last (partial) page, assert the announced range's upper bound matches the actual row count, not a full PAGE_SIZE multiple.

Cross-references

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignOfficial Campaign | FWC26Campaign: Official Campaign | FWC26accessibilityWCAG / keyboard / screen reader complianceleaderboardLeaderboard feature — data, sorting, paginationvery hardExtremely hard — deep expertise, careful design, and significant time required

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions