Accessible live-refresh announcements and sortable-column semantics for Leaderboard/History - #121
Merged
prodbycorne merged 10 commits intoJul 20, 2026
Conversation
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.
…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.
✅ Deploy Preview for smart-drop ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for spiffy-melomakarona-eb1e8a ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Contributor
|
All ci passed. Good job |
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #86.
Adds
aria-sortsemantics to Leaderboard's sortable columns andaria-liveannouncements for dynamic content changes on bothLeaderboard and History, plus two reusable a11y helpers.
Leaderboard
aria-sort(descending/none) on the Credits/StakeThelements,kept in sync with
sortKey. Following the WAI-ARIA APG sortable-tablepattern, the actual clickable control is a
<Button>inside eachTh— keyboard-operable for free, visible focus ring, plus a ▼indicator for sighted users.
aria-sortitself stays on theTh.Selectis now programmatically associated withthe table via
aria-controls, and given anaria-label— axe-core'sselect-namerule failed on it the moment a real scan could run (ithad never had an accessible name), fixed here since it's the exact
element the issue asks me to touch.
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)
useLiveAnnouncer(src/hooks/): only updates its returned valuewhen the message's string value actually changes, so a parent
recomputing the same announcement 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 about avoiding the render-storm pattern documented for the
Farm page in Farm page re-renders its entire pool list and deposit modal on every 5s event-poll tick with no row-level memoization for pools #88.
LiveRegion(src/components/): wraps Chakra'sVisuallyHiddenwith
aria-live="polite",aria-atomic="true",role="status".Two unrelated pre-existing bugs fixed
While setting up to actually run the Playwright suite for verification,
I found the entire
tests/config'swebServercouldn't start at all,and
next buildwas a hard TypeScript error — both pre-existing onmain, both confirmed independent of anything in this PR (reproducedidentically with all my changes reverted), and both are why "E2E Tests"
and "Deploy to GitHub Pages" are currently red on
main:src/app/page.tsx: commit3e131ad(a merge) kept both sidesof a conflict instead of resolving it — two competing
implementations of
Home()ended up concatenated, and the resultdoesn't parse. Since every Playwright config here health-checks
webServer.urlagainst/, that one broken route was silentlytaking 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/Sparklinehave no other consumers, so nothing is orphaned in active use).
next.config.ts: duplicateoutput/imageskeys, where anunconditional
output: "export"silently overrode the intendedisStaticExport-conditional one — contradicts the file's own doccomment 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
src/app/leaderboard/page.test.tsx, 6 tests): initial-loadannouncement, 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-sortsync across Select + Th.src/app/history/page.test.tsx, 4 tests): silent whiledisconnected, loaded-range announcement, zero-entries
announcement, partial-final-page upper bound.
useLiveAnnouncer.test.ts, 4 tests;LiveRegion.test.tsx, 3 tests).tests/table-accessibility.spec.ts, 6tests, extending
contrast-audit.spec.ts's pattern):aria-sortmatches active column for both sort options,
aria-controlscorrectly 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 forNEXT_PUBLIC_LEADERBOARD_API_URL(added to
playwright.config.ts'swebServer.env) rather thanmocking 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 run— 128/128 pass across the whole workspace.npm run lint— clean (only pre-existing, unrelated warnings insrc/lib/soroban.ts).npm run buildand the static-export build(
NEXT_EXPORT=true) — both succeed.e2e/playwright.config.ts,pnpm run playwright) — 7/7 pass.