Skip to content

Accessible live-refresh announcements and sortable-column semantics for Leaderboard/History - #121

Merged
prodbycorne merged 10 commits into
SmartDropLabs:mainfrom
miraclesonly:a11y/leaderboard-history-live-regions
Jul 20, 2026
Merged

Accessible live-refresh announcements and sortable-column semantics for Leaderboard/History#121
prodbycorne merged 10 commits into
SmartDropLabs:mainfrom
miraclesonly:a11y/leaderboard-history-live-regions

Conversation

@miraclesonly

Copy link
Copy Markdown
Contributor

Summary

Closes #86.

Adds aria-sort semantics to Leaderboard's sortable columns and
aria-live announcements for dynamic content changes on both
Leaderboard and History, plus two reusable a11y helpers.

Leaderboard

  • aria-sort (descending/none) on the Credits/Stake Th elements,
    kept in sync with sortKey. Following the WAI-ARIA APG sortable-table
    pattern, the actual clickable control is a <Button> inside each
    Th — keyboard-operable for free, visible focus ring, plus a ▼
    indicator for sighted users. aria-sort itself stays on the Th.
  • The existing sort Select is now programmatically associated with
    the table via aria-controls, and given an aria-label — axe-core's
    select-name rule failed on it the moment a real scan could run (it
    had never had an accessible name), fixed here since it's the exact
    element the issue asks me to touch.
  • A live region announces on auto-refresh, manual refresh, sort
    change, and pagination — including the two edge cases the issue
    calls out: zero search results ("No results found.", not silence)
    and the correct upper bound on a partial final page.

History

Same live-region treatment for its pagination-driven changes, no
new sort functionality — the issue is explicit that History only gets
announcements here, not sort semantics.

Reusable helpers (per the issue's ask)

Two unrelated pre-existing bugs fixed

While setting up to actually run the Playwright suite for verification,
I found the entire tests/ config's webServer couldn't start at all,
and next build was a hard TypeScript error — both pre-existing on
main
, both confirmed independent of anything in this PR (reproduced
identically with all my changes reverted), and both are why "E2E Tests"
and "Deploy to GitHub Pages" are currently red on main:

  1. src/app/page.tsx: commit 3e131ad (a merge) kept both sides
    of a conflict instead of resolving it — two competing
    implementations of Home() ended up concatenated, and the result
    doesn't parse. Since every Playwright config here health-checks
    webServer.url against /, that one broken route was silently
    taking down the entire E2E suite for every route, not just /.
    Kept the complete, more recent implementation (the motion/StatCard
    dashboard); dropped the truncated duplicate (useStats/Sparkline
    have no other consumers, so nothing is orphaned in active use).
  2. next.config.ts: duplicate output/images keys, where an
    unconditional output: "export" silently overrode the intended
    isStaticExport-conditional one — contradicts the file's own doc
    comment and is a hard TS error in this Next.js version.

Fixed both since they made it impossible to verify this PR (or
anything else) via Playwright/build at all — flagged as their own
commits, clearly separated from the accessibility work.

Test plan

  • RTL (src/app/leaderboard/page.test.tsx, 6 tests): initial-load
    announcement, 30s auto-refresh re-announcement, manual Refresh
    click announcement (the dual-trigger gap the issue calls out),
    debounced zero-result search announcement, partial-page upper
    bound, aria-sort sync across Select + Th.
  • RTL (src/app/history/page.test.tsx, 4 tests): silent while
    disconnected, loaded-range announcement, zero-entries
    announcement, partial-final-page upper bound.
  • Unit (useLiveAnnouncer.test.ts, 4 tests;
    LiveRegion.test.tsx, 3 tests).
  • Playwright + axe-core (tests/table-accessibility.spec.ts, 6
    tests, extending contrast-audit.spec.ts's pattern): aria-sort
    matches active column for both sort options, aria-controls
    correctly targets the table, and a WCAG-2.1-A/AA-scoped axe scan
    reports zero violations on a populated, sorted table. Uses a new
    page.route() mock for NEXT_PUBLIC_LEADERBOARD_API_URL
    (added to playwright.config.ts's webServer.env) rather than
    mocking the Soroban RPC/event-scan path, which would need
    XDR-level mocking well beyond this issue's scope — History's
    connected-wallet + on-chain history path has no equivalent
    simple-JSON escape hatch, so its Playwright coverage here only
    confirms the live region exists while disconnected; the fuller
    interaction paths are covered by the RTL tests instead.
  • npx vitest run128/128 pass across the whole workspace.
  • npm run lint — clean (only pre-existing, unrelated warnings in
    src/lib/soroban.ts).
  • npm run build and the static-export build
    (NEXT_EXPORT=true) — both succeed.
  • The actual CI-gating e2e suite (e2e/playwright.config.ts,
    pnpm run playwright) — 7/7 pass.

3e131ad ("Merge branch 'main' into feat/live-tvl-user-metrics") kept
both sides of a conflict instead of resolving it: two full, competing
implementations of Home() ended up concatenated in the same file (one
using useStats()/a plain layout, cut off mid-JSX; one using
usePlatformStats()/useTotalUserCredits() with the current
motion-animated dashboard). The result doesn't parse
("Expected '</', got '}'"), which breaks next dev's compilation of
the "/" route specifically — and since every Playwright config here
health-checks webServer.url against "/", that one broken route was
silently taking down the entire E2E suite (confirmed reproducing
identically on a clean upstream/main checkout, and matches the actual
failing "E2E Tests" runs on main: "Timed out waiting ... from
config.webServer").

Kept the complete, more recent implementation (the motion/StatCard
dashboard, which superseded the useStats() version per the commit
history) and dropped the truncated duplicate. useStats()/Sparkline
have no other consumers, so this doesn't orphan anything still in use.

Pre-existing, unrelated to the Leaderboard/History accessibility work
in this PR — fixed because it blocked verifying any of it via
Playwright at all.
The config object defined images and output twice — the second
images: { unoptimized: true } was a harmless no-op duplicate, but the
unconditional output: "export" on line 45 always overrode the
conditional ...(isStaticExport ? { output: "export" } : {}) above it,
contradicting the file's own doc comment ("Unset (default) → server
mode for Vercel / next start, API routes active") and making
`next build` a TypeScript error in newer Next.js versions ("An object
literal cannot have multiple properties with the same name"), which
is what's actually failing in the current "Deploy to GitHub Pages"
CI runs on main.

Pre-existing, unrelated to this PR's Leaderboard/History work — fixed
because it blocked running a production build to verify nothing else
broke.
useLiveAnnouncer's test (next commit) needs to change a hook's input
across renders and assert the returned value updates — the existing
harness only exposed { result, unmount }, with no way to trigger a
second render. Adds rerender(), which just re-invokes the same
render pass; a caller that captures an outer `let` and mutates it
before calling rerender() sees the hook re-run with the new value,
matching how @testing-library/react-hooks' rerender works. Purely
additive — useCountdown.test.ts and useSorobanEvents.test.ts (the
harness's other consumers) still pass unchanged.
For SmartDropLabs#86: a shared helper for driving aria-live regions, so
Leaderboard and History (and any future page) don't each hand-roll
their own announcement-state logic. Only updates its returned value
when the input message's string value actually changes — since
useEffect's dependency comparison is a plain value check for
primitives, a parent that recomputes the same announcement text on
every render (a keystroke before search-debounce settles, an
unrelated poll tick) doesn't cause redundant state churn. Directly
addresses the issue's callout that a naively-implemented live-region
hook could add to the same class of unnecessary-render problems SmartDropLabs#88
documents for the Farm page.
For SmartDropLabs#86: wraps Chakra's VisuallyHidden with aria-live="polite",
aria-atomic="true", and role="status" (a fallback announcement path
for older screen readers/browsers that don't watch aria-live
directly). Visually hidden via clipping rather than display:none/
visibility:hidden, which would otherwise remove it from the
accessibility tree entirely and defeat the point of the region.
Pairs with useLiveAnnouncer; used by both Leaderboard and History in
the following commits.
For SmartDropLabs#86, acceptance criteria on the Leaderboard page:

- aria-sort ("descending"/"none") on the Credits/Stake Th elements,
  kept in sync with sortKey. Following the WAI-ARIA APG sortable-table
  pattern, the actual clickable/focusable control is a <Button> inside
  each Th (keyboard-operable for free, with visible focus styling and
  a ▼ indicator for sighted users too) — aria-sort itself stays on the
  Th, which is where assistive tech expects it.
- The pre-existing sort Select is now programmatically associated
  with the table via aria-controls (pointing at a new id on the
  Table), and given an aria-label — axe-core's select-name check
  failed on it as soon as a real scan could run (it had never had an
  accessible name at all), fixed here since it's the exact element
  this issue is already touching.
- A LiveRegion announces on auto-refresh, manual refresh, sort
  change, and pagination, including the two edge cases called out in
  the issue: zero search results ("No results found.", not silence)
  and the correct upper bound on a partial final page. Deriving the
  message from filteredCount/currentPage/sortKey (not from the raw,
  undebounced search input) means it naturally only updates once
  useLeaderboard's internal search-debounce settles.
…pLabs#86)

RTL coverage per the issue's test plan: initial-load announcement
with the correct rank range, re-announcement on the mocked 30s
auto-refresh, announcement on a manual Refresh click (the dual-trigger
gap the issue explicitly calls out — a fix that only hooked into the
timer would miss this), "No results found." on a debounced
zero-match search rather than staying silent or firing mid-keystroke,
the partial-final-page upper-bound calculation, and aria-sort staying
in sync with sortKey across both the Select and the Th elements.
For SmartDropLabs#86. History gets the same live-region treatment as Leaderboard
for its pagination-driven content changes, but deliberately no
aria-sort work — the issue is explicit that History doesn't get new
sort functionality here, only announcements for its existing table.
Announces nothing while disconnected or still loading (nothing
dynamic is happening to the table yet), "No farming history found."
on zero entries rather than silence, and the loaded page range
otherwise, with the same partial-final-page upper-bound handling as
Leaderboard.
)

RTL coverage: silent while disconnected, announces the loaded range
once history resolves, "No farming history found." on zero entries
rather than staying silent, and the correct upper bound on a partial
final page (PAGE_SIZE 20, 45 entries → page 3 is 41-45, not a full
21-40/41-60 multiple).
…martDropLabs#86)

Extends the pattern in tests/contrast-audit.spec.ts. Adds
NEXT_PUBLIC_LEADERBOARD_API_URL to this config's webServer so the new
spec can serve deterministic leaderboard data via page.route() instead
of mocking the Soroban RPC/event-scan path the app falls back to when
that's unset (which would need XDR-level mocking well beyond this
issue's scope — History's connected-wallet + on-chain tx-history path
has no equivalent simple-JSON escape hatch, so its Playwright coverage
here is limited to confirming the live region exists and is wired up
correctly while disconnected; the fuller History interaction paths are
covered by the RTL tests instead).

Covers: aria-sort matches the active sort column for both options (via
both the Select and the Th buttons), the Select's aria-controls
correctly targets the table's id, and a scoped axe-core scan (WCAG
2.1 A/AA tags only, matching contrast-audit.spec.ts's own
targeted-rather-than-unscoped approach — a full analyze() surfaces
pre-existing, unrelated best-practice gaps like a missing page-level
h1, which are out of scope here) reports zero violations on a
populated, sorted table.
@netlify

netlify Bot commented Jul 20, 2026

Copy link
Copy Markdown

Deploy Preview for smart-drop ready!

Name Link
🔨 Latest commit 650c90e
🔍 Latest deploy log https://app.netlify.com/projects/smart-drop/deploys/6a5e823e0bfa3f00088a34d8
😎 Deploy Preview https://deploy-preview-121--smart-drop.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Jul 20, 2026

Copy link
Copy Markdown

Deploy Preview for spiffy-melomakarona-eb1e8a ready!

Name Link
🔨 Latest commit 650c90e
🔍 Latest deploy log https://app.netlify.com/projects/spiffy-melomakarona-eb1e8a/deploys/6a5e823e576edc00089f8ea9
😎 Deploy Preview https://deploy-preview-121--spiffy-melomakarona-eb1e8a.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@prodbycorne

Copy link
Copy Markdown
Contributor

All ci passed. Good job

@prodbycorne
prodbycorne merged commit b53be3d into SmartDropLabs:main Jul 20, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants