Skip to content

UnlockModal's autofocus uses a fragile document-wide querySelector instead of a ref, with no ARIA-live status region #85

Description

@prodbycorne

Problem

UnlockModal's effect that runs when the modal opens (src/components/UnlockModal/UnlockModal.tsx lines 85-101) focuses the amount input like this:

setTimeout(() => {
  const amountInput = document.querySelector('input[type="number"]') as HTMLInputElement;
  if (amountInput) { amountInput.focus(); amountInput.select(); }
}, 100);

document.querySelector matches the first input[type="number"] anywhere in the entire document, not scoped to this modal. Any other numeric input mounted concurrently (e.g. PricesPage's asset lookup form doesn't use type=number, but PoolDetailClient's deposit modal input, or the Farm page's DepositModal input, both use type="number" and can coexist in the DOM tree depending on Chakra's portal/mount behavior) risks stealing focus to the wrong field instead of the unlock modal's own input, and this approach is fundamentally not testable or reliable — it depends on DOM order rather than an explicit reference to this component's own input.

Separately, the multi-step transaction status (signingsubmittingconfirmingsuccess/timeout/error, rendered via the UNLOCK_STEP_LABEL text at lines 394-410 and 464-467) is plain visual text with no aria-live region. A screen-reader user gets no announcement when the step changes from "Waiting for Freighter signature..." to "Submitting transaction to Stellar..." to "Unlock confirmed" — the single most important real-time feedback in the whole unlock flow is entirely inaccessible to assistive technology, which is a materially different and more specific gap than the general contrast-ratio work already covered by closed issue #44.

Acceptance Criteria

  • Replace the document.querySelector autofocus with a useRef scoped to this component's own amount Input.
  • Wrap the step-status text in an aria-live="polite" (or "assertive" for the final success/error state) region so screen readers announce transitions through signing/submitting/confirming/success/error without requiring the user to have focus on that element.
  • Add the same aria-live treatment to DepositModal's equivalent step indicator in src/app/farm/page.tsx and PoolDetailClient's in src/app/farm/[poolId]/PoolDetailClient.tsx for consistency.
  • Add an automated accessibility test (extending the existing tests/contrast-audit.spec.ts style axe-core setup) that asserts the live region exists and updates as the mocked transaction progresses through its steps.

Relevant Files

  • src/components/UnlockModal/UnlockModal.tsx — document.querySelector('input[type="number"]') autofocus (~L93-98) and unannounced step text (~L399-410, 464-467)
  • src/app/farm/page.tsx — DepositModal has the equivalent unannounced step indicator (~L322-337, 398-408)
  • src/app/farm/[poolId]/PoolDetailClient.tsx — deposit modal has the same unannounced step indicator pattern (~L364-371)
  • tests/contrast-audit.spec.ts — existing axe-core-based test infrastructure this work should extend

Additional Notes

Re-read src/components/UnlockModal/UnlockModal.tsx directly: the effect (around lines 85-101 in the current file) confirms the exact document.querySelector('input[type="number"]') pattern inside a setTimeout(..., 100), gated on isUnlock && position. Additional precise findings:

  • The Input component itself doesn't currently receive a type="number" prop passed explicitly in JSX with an identifying id/data-testid, which is exactly why the effect resorts to a document-wide type selector instead of a scoped one — there's no anchor to grab. Adding a ref requires no structural change beyond wiring useRef<HTMLInputElement>(null) into the existing Input's ref prop.
  • Confirmed both isProcessing step banner (rendered conditionally around the Alert status="info" block) and the button's own {UNLOCK_STEP_LABEL[step]} text update from the same step state — both are plain text nodes with no aria-live ancestor, so a screen reader user gets zero announcement through the entire signing → submitting → confirming → success/timeout/error sequence, including the terminal states, which is the most consequential gap since a user who isn't watching the screen would have no way to know the transaction finished or failed.
  • The setTimeout(..., 100) itself is also a code smell independent of the querySelector issue: an arbitrary fixed delay to wait for the modal's mount/animation is fragile relative to using the Modal's onCloseComplete/initialFocusRef prop that Chakra's Modal already supports natively — Chakra Modal accepts an initialFocusRef prop that would eliminate both the setTimeout and the query entirely, worth flagging as the more idiomatic fix path rather than just swapping querySelector for a ref inside a setTimeout.

Edge cases to cover

  • Modal re-opened for a different position while still mounted (Chakra modals are often kept mounted with isOpen toggling) — the effect's dependency array is [isUnlock, position], so a position change while isUnlock stays true should still refocus; confirm the useRef-based fix preserves this behavior (a plain ref persists across re-renders, so this should just work, but it's worth an explicit test).
  • Screen-reader users with prefers-reduced-motion or focus-trap interactions from Chakra's own Modal focus-lock — moving to initialFocusRef needs to coexist with Chakra's internal focus trap rather than fight it.
  • The final success/error/timeout states likely warrant aria-live="assertive" (interrupt) rather than "polite" (queue) since they're terminal and time-sensitive, whereas intermediate signing/submitting/confirming should stay "polite" to avoid overwhelming the user with rapid interruptions — the acceptance criteria's suggestion to use "assertive" only for the final state should be preserved as-is.

Implementation sketch

  1. Add const amountInputRef = useRef<HTMLInputElement>(null); and wire it to the Input's ref prop.
  2. Replace document.querySelector(...) with amountInputRef.current?.focus(); amountInputRef.current?.select();.
  3. Prefer passing amountInputRef to Modal's initialFocusRef prop and dropping the setTimeout entirely if Chakra's focus-lock timing allows it (verify no regression where the modal's built-in animation still results in a scroll-jump or visual glitch).
  4. Wrap the step-status Text/Alert block in a visually-unstyled Box as="span" aria-live={step in (success/error/timeout) ? "assertive" : "polite"} aria-atomic="true">{UNLOCK_STEP_LABEL[step]}</Box> (or a dedicated visually-hidden live region synced to step via useEffect, if visual placement conflicts with a persistent DOM node).
  5. Mirror the same change in DepositModal (src/app/farm/page.tsx) and PoolDetailClient (src/app/farm/[poolId]/PoolDetailClient.tsx) for consistency, per the existing acceptance criteria.

Test plan

  • RTL: render UnlockModal with a mocked second input[type=number] present elsewhere in the DOM (simulating DepositModal/PoolDetailClient coexisting), assert focus lands on UnlockModal's own input, not the other one — this directly reproduces the described focus-stealing bug and would fail against the current implementation.
  • axe-core (extending tests/contrast-audit.spec.ts): assert an aria-live region exists and its accessible-name/text content updates as step is progressed through mocked states.
  • Manual/automated: verify aria-live="assertive" specifically fires for the terminal states by asserting the live region's aria-live attribute value changes, not just its text content.

Cross-references

Metadata

Metadata

Assignees

No one assigned

    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 compliancefarmFarming/staking flow — deposit, lock, unlock, creditsvery 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