-
Notifications
You must be signed in to change notification settings - Fork 134
feat(a11y): live-region announcements for errors and status #1650
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/a11y-keyboard-focus
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| // Global ARIA live-region announcer. Renders two visually-hidden regions that | ||
| // persist in the DOM for the page lifetime so screen readers pick up dynamic | ||
| // status/error messages. Messages auto-clear after 5s so stale text is not | ||
| // re-read on subsequent focus. | ||
| @react.component | ||
| let make = () => { | ||
| let (announcement, setAnnouncement) = Recoil.useRecoilState( | ||
| AccessibilityAnnouncer.announcementAtom, | ||
| ) | ||
| let {localeString} = Recoil.useRecoilValueFromAtom(RecoilAtoms.configAtom) | ||
|
|
||
| React.useEffect(() => { | ||
| let handle = (ev: Window.event) => { | ||
| try { | ||
| let dict = ev.data->Utils.safeParse->Utils.getDictFromJson | ||
| switch dict->Dict.get("submitSuccessful")->Option.flatMap(JSON.Decode.bool) { | ||
| | Some(false) => | ||
| let message = | ||
| dict | ||
| ->Utils.getDictFromDict("error") | ||
| ->Utils.getString("message", localeString.enterValidDetailsText) | ||
| setAnnouncement(_ => { | ||
| message, | ||
| assertive: true, | ||
| }) | ||
| | _ => () | ||
| } | ||
| } catch { | ||
| | _ => () | ||
| } | ||
| } | ||
| Window.addEventListener("message", handle) | ||
| Some(() => Window.removeEventListener("message", handle)) | ||
| }, [localeString.enterValidDetailsText]) | ||
|
|
||
| React.useEffect(() => { | ||
| if announcement.message !== "" { | ||
| let timeoutId = setTimeout(() => { | ||
| setAnnouncement(_ => AccessibilityAnnouncer.defaultAnnouncement) | ||
| }, 5000) | ||
| Some(() => clearTimeout(timeoutId)) | ||
| } else { | ||
| None | ||
| } | ||
| }, [announcement.message]) | ||
|
|
||
| <div className={AccessibilityUtils.visuallyHiddenClass}> | ||
| <div id="hyperswitch-sdk-live-status" role="status" ariaLive={#polite} ariaAtomic=true> | ||
| {(announcement.assertive ? "" : announcement.message)->React.string} | ||
| </div> | ||
| <div id="hyperswitch-sdk-live-alert" role="alert" ariaLive={#assertive} ariaAtomic=true> | ||
| {(announcement.assertive ? announcement.message : "")->React.string} | ||
| </div> | ||
| </div> | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ let make = (~onClickHandler=?, ~label=?) => { | |
| open PaymentTypeContext | ||
| let (showLoader, setShowLoader) = React.useState(() => false) | ||
| let (isPayNowButtonDisable, setIsPayNowButtonDisable) = React.useState(() => false) | ||
| let announce = AccessibilityAnnouncer.useAnnounce() | ||
| let {themeObj, localeString} = configAtom->Recoil.useRecoilValueFromAtom | ||
| let {sdkHandleConfirmPayment} = optionAtom->Recoil.useRecoilValueFromAtom | ||
|
|
||
|
|
@@ -40,6 +41,7 @@ let make = (~onClickHandler=?, ~label=?) => { | |
| if !(submitSuccessfulVal->JSON.Decode.bool->Option.getOr(false)) { | ||
| setIsPayNowButtonDisable(_ => false) | ||
| setShowLoader(_ => false) | ||
| announce(~assertive=true, localeString.paymentFailedText) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. PayNowButton.res — calls announce(~assertive=true, localeString.paymentFailedText) (generic message) Then Announcer.res— message listener extracts the specific error message from the response payload
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
| | None => () | ||
| } | ||
|
|
@@ -55,13 +57,15 @@ let make = (~onClickHandler=?, ~label=?) => { | |
| let handleOnClick = _ => { | ||
| setIsPayNowButtonDisable(_ => true) | ||
| setShowLoader(_ => true) | ||
| announce(localeString.processingPaymentText) | ||
| EventListenerManager.addSmartEventListener("message", handleMessage, "onSubmitSuccessful") | ||
| messageParentWindow([("handleSdkConfirm", confirmPayload)]) | ||
| } | ||
|
|
||
| <div className="flex flex-col gap-1 h-auto w-full items-center"> | ||
| <button | ||
| disabled=isPayNowButtonDisable | ||
| ariaBusy={showLoader} | ||
| onClick={onClickHandler->Option.isNone ? handleOnClick : onClickHandlerFunc} | ||
| className={`w-full flex flex-row justify-center items-center`} | ||
| style={ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| // A single announcement consumed by the global <Announcer /> live regions. | ||
| // `assertive=true` routes the message to the `role="alert"` region (errors); | ||
| // `assertive=false` routes it to the `role="status"` region (status updates). | ||
| type announcement = { | ||
| message: string, | ||
| assertive: bool, | ||
| } | ||
|
|
||
| let defaultAnnouncement = { | ||
| message: "", | ||
| assertive: false, | ||
| } | ||
|
|
||
| let announcementAtom = Recoil.atom("accessibilityAnnouncement", defaultAnnouncement) | ||
|
|
||
| // Returns a function components can call to announce a message to screen readers. | ||
| let useAnnounce = () => { | ||
| let setAnnouncement = Recoil.useSetRecoilState(announcementAtom) | ||
| (~assertive=false, message) => setAnnouncement(_ => {message, assertive}) | ||
| } |

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.