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 (signing → submitting → confirming → success/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
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
- Add
const amountInputRef = useRef<HTMLInputElement>(null); and wire it to the Input's ref prop.
- Replace
document.querySelector(...) with amountInputRef.current?.focus(); amountInputRef.current?.select();.
- 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).
- 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).
- 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
Problem
UnlockModal's effect that runs when the modal opens (src/components/UnlockModal/UnlockModal.tsxlines 85-101) focuses the amount input like this:document.querySelectormatches the firstinput[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, butPoolDetailClient's deposit modal input, or theFarmpage'sDepositModalinput, both usetype="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 (
signing→submitting→confirming→success/timeout/error, rendered via theUNLOCK_STEP_LABELtext at lines 394-410 and 464-467) is plain visual text with noaria-liveregion. 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
document.querySelectorautofocus with auseRefscoped to this component's own amountInput.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.aria-livetreatment toDepositModal's equivalent step indicator insrc/app/farm/page.tsxandPoolDetailClient's insrc/app/farm/[poolId]/PoolDetailClient.tsxfor consistency.tests/contrast-audit.spec.tsstyle 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 extendAdditional Notes
Re-read
src/components/UnlockModal/UnlockModal.tsxdirectly: the effect (around lines 85-101 in the current file) confirms the exactdocument.querySelector('input[type="number"]')pattern inside asetTimeout(..., 100), gated onisUnlock && position. Additional precise findings:Inputcomponent itself doesn't currently receive atype="number"prop passed explicitly in JSX with an identifyingid/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 arefrequires no structural change beyond wiringuseRef<HTMLInputElement>(null)into the existingInput'srefprop.isProcessingstep banner (rendered conditionally around theAlert status="info"block) and the button's own{UNLOCK_STEP_LABEL[step]}text update from the samestepstate — both are plain text nodes with noaria-liveancestor, 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.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 theModal'sonCloseComplete/initialFocusRefprop that Chakra'sModalalready supports natively — ChakraModalaccepts aninitialFocusRefprop that would eliminate both thesetTimeoutand 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
isOpentoggling) — the effect's dependency array is[isUnlock, position], so a position change whileisUnlockstays true should still refocus; confirm theuseRef-based fix preserves this behavior (a plain ref persists across re-renders, so this should just work, but it's worth an explicit test).prefers-reduced-motionor focus-trap interactions from Chakra's ownModalfocus-lock — moving toinitialFocusRefneeds to coexist with Chakra's internal focus trap rather than fight it.success/error/timeoutstates likely warrantaria-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
const amountInputRef = useRef<HTMLInputElement>(null);and wire it to theInput'srefprop.document.querySelector(...)withamountInputRef.current?.focus(); amountInputRef.current?.select();.amountInputReftoModal'sinitialFocusRefprop and dropping thesetTimeoutentirely 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).Text/Alertblock in a visually-unstyledBox 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 tostepviauseEffect, if visual placement conflicts with a persistent DOM node).DepositModal(src/app/farm/page.tsx) andPoolDetailClient(src/app/farm/[poolId]/PoolDetailClient.tsx) for consistency, per the existing acceptance criteria.Test plan
UnlockModalwith a mocked secondinput[type=number]present elsewhere in the DOM (simulatingDepositModal/PoolDetailClientcoexisting), assert focus lands onUnlockModal's own input, not the other one — this directly reproduces the described focus-stealing bug and would fail against the current implementation.tests/contrast-audit.spec.ts): assert anaria-liveregion exists and its accessible-name/text content updates asstepis progressed through mocked states.aria-live="assertive"specifically fires for the terminal states by asserting the live region'saria-liveattribute value changes, not just its text content.Cross-references
useLiveAnnouncement-style hook or visually-hidden live-region component built for one could be reused by the other for consistency in how the app announces async state changes.UnlockModal'serrorstate (rendered in thestep === "error"branch) presumably surfaces messages produced by whichever error-handling module is wired intouseErrorHandler()— once Two parallel, divergent error-handling libraries (error-handler.ts vs errorHandler.ts) coexist, risking inconsistent error UX #87's consolidation happens, verify the aria-live wrapper added here still announces the (possibly restructured) error message correctly.