Skip to content

Commit 7e98542

Browse files
feat(a11y): live-region announcements for errors and status
Global announcer (polite role=status + assertive role=alert, auto-clear); aria-busy on pay button; announce processing/failure (localized: processingPaymentText, paymentFailedText); announced validation errors via shared LiveError; accessible name for the VGS field. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019vLeCgNLD7yJrWuKwFh5Vu
1 parent f13e8f7 commit 7e98542

29 files changed

Lines changed: 145 additions & 24 deletions

src/Components/Announcer.res

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Global ARIA live-region announcer. Renders two visually-hidden regions that
2+
// persist in the DOM for the page lifetime so screen readers pick up dynamic
3+
// status/error messages. Messages auto-clear after 5s so stale text is not
4+
// re-read on subsequent focus.
5+
@react.component
6+
let make = () => {
7+
let (announcement, setAnnouncement) = Recoil.useRecoilState(
8+
AccessibilityAnnouncer.announcementAtom,
9+
)
10+
let {localeString} = Recoil.useRecoilValueFromAtom(RecoilAtoms.configAtom)
11+
12+
React.useEffect(() => {
13+
let handle = (ev: Window.event) => {
14+
try {
15+
let dict = ev.data->Utils.safeParse->Utils.getDictFromJson
16+
switch dict->Dict.get("submitSuccessful")->Option.flatMap(JSON.Decode.bool) {
17+
| Some(false) =>
18+
let message =
19+
dict
20+
->Utils.getDictFromDict("error")
21+
->Utils.getString("message", localeString.enterValidDetailsText)
22+
setAnnouncement(_ => {
23+
message,
24+
assertive: true,
25+
})
26+
| _ => ()
27+
}
28+
} catch {
29+
| _ => ()
30+
}
31+
}
32+
Window.addEventListener("message", handle)
33+
Some(() => Window.removeEventListener("message", handle))
34+
}, [localeString.enterValidDetailsText])
35+
36+
React.useEffect(() => {
37+
if announcement.message !== "" {
38+
let timeoutId = setTimeout(() => {
39+
setAnnouncement(_ => AccessibilityAnnouncer.defaultAnnouncement)
40+
}, 5000)
41+
Some(() => clearTimeout(timeoutId))
42+
} else {
43+
None
44+
}
45+
}, [announcement.message])
46+
47+
<div className={AccessibilityUtils.visuallyHiddenClass}>
48+
<div id="hyperswitch-sdk-live-status" role="status" ariaLive={#polite} ariaAtomic=true>
49+
{(announcement.assertive ? "" : announcement.message)->React.string}
50+
</div>
51+
<div id="hyperswitch-sdk-live-alert" role="alert" ariaLive={#assertive} ariaAtomic=true>
52+
{(announcement.assertive ? announcement.message : "")->React.string}
53+
</div>
54+
</div>
55+
}

src/Components/ErrorComponent.res

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@ let make = (~errorStr=None, ~cardError="", ~expiryError="", ~cvcError="") => {
2323
switch innerLayout {
2424
| Spaced =>
2525
<RenderIf condition=isSpacedErrorShown>
26-
<div className="Error pt-1" style=errorTextStyle>
27-
{React.string(errorStr->Belt.Option.getWithDefault(""))}
28-
</div>
26+
<LiveError
27+
text={errorStr->Belt.Option.getWithDefault("")}
28+
className="Error pt-1"
29+
style={errorTextStyle}
30+
/>
2931
</RenderIf>
3032
| Compressed =>
3133
<RenderIf condition=isCompressedErrorShown>
32-
<div className="Error pt-1" style=errorTextStyle> {React.string("Invalid input")} </div>
34+
<LiveError text={"Invalid input"} className="Error pt-1" style={errorTextStyle} />
3335
</RenderIf>
3436
}
3537
}

src/Components/Loader.res

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@ let make = (~branding="auto", ~showText=true) => {
2323
<div
2424
className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-[#0069FD] border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"
2525
role="status">
26-
<span
27-
className="!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]">
26+
<span className={AccessibilityUtils.visuallyHiddenClass}>
2827
{"Loading..."->React.string}
2928
</span>
3029
</div>

src/Components/PayNowButton.res

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ let make = (~onClickHandler=?, ~label=?) => {
1616
open PaymentTypeContext
1717
let (showLoader, setShowLoader) = React.useState(() => false)
1818
let (isPayNowButtonDisable, setIsPayNowButtonDisable) = React.useState(() => false)
19+
let announce = AccessibilityAnnouncer.useAnnounce()
1920
let {themeObj, localeString} = configAtom->Recoil.useRecoilValueFromAtom
2021
let {sdkHandleConfirmPayment} = optionAtom->Recoil.useRecoilValueFromAtom
2122

@@ -40,6 +41,7 @@ let make = (~onClickHandler=?, ~label=?) => {
4041
if !(submitSuccessfulVal->JSON.Decode.bool->Option.getOr(false)) {
4142
setIsPayNowButtonDisable(_ => false)
4243
setShowLoader(_ => false)
44+
announce(~assertive=true, localeString.paymentFailedText)
4345
}
4446
| None => ()
4547
}
@@ -55,13 +57,15 @@ let make = (~onClickHandler=?, ~label=?) => {
5557
let handleOnClick = _ => {
5658
setIsPayNowButtonDisable(_ => true)
5759
setShowLoader(_ => true)
60+
announce(localeString.processingPaymentText)
5861
EventListenerManager.addSmartEventListener("message", handleMessage, "onSubmitSuccessful")
5962
messageParentWindow([("handleSdkConfirm", confirmPayload)])
6063
}
6164

6265
<div className="flex flex-col gap-1 h-auto w-full items-center">
6366
<button
6467
disabled=isPayNowButtonDisable
68+
ariaBusy={showLoader}
6569
onClick={onClickHandler->Option.isNone ? handleOnClick : onClickHandlerFunc}
6670
className={`w-full flex flex-row justify-center items-center`}
6771
style={

src/Components/SavedCardItem.res

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -341,14 +341,14 @@ let make = (
341341
</RenderIf>
342342
<RenderIf
343343
condition={hideCardExpiry && isActive && innerLayout === Spaced && cvcError != ""}>
344-
<div
344+
<LiveError
345+
text={cvcError}
345346
className="Error pt-1 mt-1 ml-3"
346-
style={
347+
style={{
347348
color: themeObj.colorDangerText,
348349
fontSize: themeObj.fontSizeSm,
349-
}>
350-
{React.string(cvcError)}
351-
</div>
350+
}}
351+
/>
352352
</RenderIf>
353353
<RenderIf
354354
condition={isActive && displayBillingDetails && billingDetailsArrayLength > 0}>
@@ -361,14 +361,14 @@ let make = (
361361
</RenderIf>
362362
<RenderIf
363363
condition={!hideCardExpiry && isActive && innerLayout === Spaced && cvcError != ""}>
364-
<div
364+
<LiveError
365+
text={cvcError}
365366
className="Error pt-1 mt-1 ml-1"
366-
style={
367+
style={{
367368
color: themeObj.colorDangerText,
368369
fontSize: themeObj.fontSizeSm,
369-
}>
370-
{React.string(cvcError)}
371-
</div>
370+
}}
371+
/>
372372
</RenderIf>
373373
<RenderIf condition={isCardExpired}>
374374
<div className="italic mt-3 ml-1" style={fontSize: "14px", opacity: "0.7"}>

src/Components/UpdateIntentOverlay.res

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ let make = () => {
55

66
<RenderIf condition=isUpdateIntentLoading>
77
<div
8-
className="absolute inset-0 flex flex-col items-center justify-center gap-2 backdrop-blur-sm bg-white/40 z-[999] rounded-[inherit]">
8+
className="absolute inset-0 flex flex-col items-center justify-center gap-2 backdrop-blur-sm bg-white/40 z-[999] rounded-[inherit]"
9+
role="status"
10+
ariaLive={#polite}>
911
<div
1012
className="w-6 h-6 animate-spin rounded-full border-3 border-black/10 border-t-black/60"
1113
/>

src/Components/VGSInputComponent.res

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,7 @@ let make = (~fieldName="", ~id="", ~isFocused=false, ~errorStr=?, ~compact=false
3333
fontSize: themeObj.fontSizeLg,
3434
marginBottom: "5px",
3535
opacity: "0.6",
36-
}
37-
ariaHidden=true>
36+
}>
3837
{React.string(fieldName)}
3938
</div>
4039
</RenderIf>
@@ -44,6 +43,7 @@ let make = (~fieldName="", ~id="", ~isFocused=false, ~errorStr=?, ~compact=false
4443
<div className="flex flex-row">
4544
<div
4645
id
46+
title=fieldName
4747
style={
4848
background: themeObj.colorBackground,
4949
// Compact (saved-card cvc): horizontal padding only + fixed height

src/LocaleStrings/ArabicLocale.res

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,4 +277,6 @@ let localeStrings: LocaleStringTypes.localeStrings = {
277277
closeLabel: "إغلاق",
278278
dialogLabel: "مربع حوار",
279279
paymentMethodsGroupLabel: "طرق الدفع",
280+
processingPaymentText: "جارٍ معالجة الدفع",
281+
paymentFailedText: "فشل الدفع. يرجى مراجعة التفاصيل والمحاولة مجددًا.",
280282
}

src/LocaleStrings/CatalanLocale.res

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,4 +276,6 @@ let localeStrings: LocaleStringTypes.localeStrings = {
276276
closeLabel: "Tancar",
277277
dialogLabel: "Diàleg",
278278
paymentMethodsGroupLabel: "Mètodes de pagament",
279+
processingPaymentText: "Processant el pagament",
280+
paymentFailedText: "El pagament ha fallat. Si us plau, reviseu els vostres detalls i torneu-ho a provar.",
279281
}

src/LocaleStrings/ChineseLocale.res

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,4 +274,6 @@ let localeStrings: LocaleStringTypes.localeStrings = {
274274
closeLabel: "关闭",
275275
dialogLabel: "对话框",
276276
paymentMethodsGroupLabel: "支付方式",
277+
processingPaymentText: "正在处理付款",
278+
paymentFailedText: "付款失败,请检查您的信息后重试。",
277279
}

0 commit comments

Comments
 (0)