Skip to content

Commit 54c0f0f

Browse files
feat(a11y): keyboard navigation and focus management
Focus hooks (useFocusTrap/useEscapeKey/useReturnFocus); Modal dialog + focus trap + Escape + accessible close; accordion roving tabindex + arrow-key radiogroup; Checkbox Space. ARIA labels localized (closeLabel, dialogLabel, paymentMethodsGroupLabel). Reuse: shared AccessibilityUtils (focus/querySelectorAllWithin/activeElement + focusableSelector) consumed by the focus hooks and accordion, removing duplicated externals.
1 parent 4d6fabc commit 54c0f0f

26 files changed

Lines changed: 361 additions & 8 deletions

src/Components/Accordion.res

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ let make = (
66
~checkoutEle: React.element,
77
~borderBottom: bool,
88
~borderRadiusStyle,
9+
~index: int=0,
10+
~isFocused: bool=true,
11+
~registerItemRef: (int, Nullable.t<Dom.element>) => unit=(_, _) => (),
12+
~onArrowNav: (int, int) => unit=(_, _) => (),
913
) => {
1014
let {themeObj, localeString} = Recoil.useRecoilValueFromAtom(configAtom)
1115
let {layout, customMethodNames} = Recoil.useRecoilValueFromAtom(optionAtom)
@@ -34,21 +38,40 @@ let make = (
3438
paymentOption.displayName,
3539
paymentOption.icon,
3640
)
41+
let selected = selectedOption == paymentOption.paymentMethodName
3742
<div
3843
className={`AccordionItem flex flex-col`}
3944
style={
4045
minHeight: "60px",
4146
width: "-webkit-fill-available",
42-
cursor: "pointer",
4347
marginBottom: layoutClass.spacedAccordionItems ? themeObj.spacingAccordionItem : "",
4448
border: `1px solid ${themeObj.borderColor}`,
4549
borderRadius: {borderRadiusStyle},
4650
borderBottomStyle: borderBottom ? "solid" : "hidden",
47-
}
48-
onClick={_ => setSelectedOption(_ => paymentOption.paymentMethodName)}>
51+
}>
4952
<div
5053
className={`flex flex-row items-center ${accordionClass}`}
51-
style={columnGap: themeObj.spacingUnit}>
54+
role="radio"
55+
ariaChecked={selected ? #"true" : #"false"}
56+
tabIndex={isFocused ? 0 : -1}
57+
ref={ReactDOM.Ref.callbackDomRef(el => registerItemRef(index, el))}
58+
onKeyDown={event => {
59+
let key = JsxEvent.Keyboard.key(event)
60+
switch key {
61+
| "ArrowDown" | "ArrowRight" =>
62+
event->JsxEvent.Keyboard.preventDefault
63+
onArrowNav(index, 1)
64+
| "ArrowUp" | "ArrowLeft" =>
65+
event->JsxEvent.Keyboard.preventDefault
66+
onArrowNav(index, -1)
67+
| "Enter" | " " =>
68+
event->JsxEvent.Keyboard.preventDefault
69+
setSelectedOption(_ => paymentOption.paymentMethodName)
70+
| _ => ()
71+
}
72+
}}
73+
style={columnGap: themeObj.spacingUnit, minHeight: "60px", cursor: "pointer"}
74+
onClick={_ => setSelectedOption(_ => paymentOption.paymentMethodName)}>
5275
<RenderIf condition=layoutClass.radios>
5376
<Radio checked=radioClass />
5477
</RenderIf>

src/Components/AccordionContainer.res

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
open RecoilAtoms
2+
23
module Loader = {
34
@react.component
45
let make = (~cardShimmerCount) => {
@@ -70,6 +71,16 @@ let make = (
7071
let layoutClass = CardUtils.getLayoutClass(layout)
7172
let (showMore, setShowMore) = React.useState(_ => false)
7273
let (selectedOption, setSelectedOption) = Recoil.useRecoilState(selectedOptionAtom)
74+
75+
// Roving-tabindex focus management for the radiogroup. The primary list and the
76+
// "more" dropdown list are each treated as their own roving group: arrow keys move
77+
// DOM focus within the rendered list and wrap at its ends. `-1` means "nothing has
78+
// been focused yet", in which case the roving-tabbable item falls back to the
79+
// selected item (else the first item).
80+
let (cardFocusedIndex, setCardFocusedIndex) = React.useState(_ => -1)
81+
let (dropDownFocusedIndex, setDropDownFocusedIndex) = React.useState(_ => -1)
82+
let cardItemRefs = React.useRef([])
83+
let dropDownItemRefs = React.useRef([])
7384
let paymentMethodListValue = Recoil.useRecoilValueFromAtom(PaymentUtils.paymentMethodListValue)
7485
let {
7586
displayInSeparateScreen,
@@ -140,6 +151,44 @@ let make = (
140151
}
141152
}
142153

154+
// Store/clear an item's DOM element in a list's ref registry at its index.
155+
let registerItemRef = (refs: React.ref<array<Nullable.t<Dom.element>>>, index, el) => {
156+
while refs.current->Array.length <= index {
157+
refs.current->Array.push(Nullable.null)->ignore
158+
}
159+
refs.current[index] = el
160+
}
161+
162+
// The roving-tabbable index for a list: the explicitly-focused item if any,
163+
// otherwise the selected item, otherwise the first item.
164+
let getRovingIndex = (focusedIndex, list: array<PaymentMethodsRecord.paymentFieldsInfo>) =>
165+
if focusedIndex >= 0 {
166+
focusedIndex
167+
} else {
168+
switch list->Array.findIndex(o => o.paymentMethodName == selectedOption) {
169+
| -1 => 0
170+
| selectedIndex => selectedIndex
171+
}
172+
}
173+
174+
// Move DOM focus within a single list (wrapping at the ends) and update its
175+
// focused-index state so the roving tabindex follows.
176+
let moveFocus = (
177+
refs: React.ref<array<Nullable.t<Dom.element>>>,
178+
setFocusedIndex,
179+
length,
180+
index,
181+
delta,
182+
) =>
183+
if length > 0 {
184+
let nextIndex = mod(mod(index + delta, length) + length, length)
185+
setFocusedIndex(_ => nextIndex)
186+
refs.current
187+
->Array.get(nextIndex)
188+
->Option.flatMap(Nullable.toOption)
189+
->Option.forEach(el => el->AccessibilityUtils.focus)
190+
}
191+
143192
React.useEffect0(() => {
144193
let shouldAutoOpenSavedMethods =
145194
!layoutClass.savedMethodCustomization.defaultCollapsed &&
@@ -155,6 +204,8 @@ let make = (
155204
<div className="w-full">
156205
<div
157206
className="AccordionContainer flex flex-col overflow-auto no-scrollbar"
207+
role="radiogroup"
208+
ariaLabel={localeString.paymentMethodsGroupLabel}
158209
style={
159210
marginTop: themeObj.spacingAccordionItem,
160211
width: "-webkit-fill-available",
@@ -173,6 +224,17 @@ let make = (
173224
borderBottom={(!showMore &&
174225
i == cardOptionDetails->Array.length - 1 &&
175226
!layoutClass.spacedAccordionItems) || layoutClass.spacedAccordionItems}
227+
index=i
228+
isFocused={i == getRovingIndex(cardFocusedIndex, cardOptionDetails)}
229+
registerItemRef={registerItemRef(cardItemRefs, ...)}
230+
onArrowNav={(index, delta) =>
231+
moveFocus(
232+
cardItemRefs,
233+
setCardFocusedIndex,
234+
cardOptionDetails->Array.length,
235+
index,
236+
delta,
237+
)}
176238
/>
177239
})
178240
->React.array}
@@ -190,6 +252,17 @@ let make = (
190252
borderRadiusStyle={borderRadiusStyle}
191253
borderBottom={(i == dropDownOptionsDetails->Array.length - 1 &&
192254
!layoutClass.spacedAccordionItems) || layoutClass.spacedAccordionItems}
255+
index=i
256+
isFocused={i == getRovingIndex(dropDownFocusedIndex, dropDownOptionsDetails)}
257+
registerItemRef={registerItemRef(dropDownItemRefs, ...)}
258+
onArrowNav={(index, delta) =>
259+
moveFocus(
260+
dropDownItemRefs,
261+
setDropDownFocusedIndex,
262+
dropDownOptionsDetails->Array.length,
263+
index,
264+
delta,
265+
)}
193266
/>
194267
})
195268
->React.array}
@@ -198,6 +271,9 @@ let make = (
198271
<RenderIf condition={!showMore && dropDownOptionsDetails->Array.length > 0}>
199272
<button
200273
className="AccordionMore flex overflow-auto no-scrollbar"
274+
type_="button"
275+
ariaExpanded=false
276+
ariaLabel={localeString.morePaymentMethodsLabel}
201277
onClick={_ => setShowMore(_ => !showMore)}
202278
style={
203279
borderRadius: themeObj.borderRadius,

src/Components/Checkbox.res

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ let make = (~isChecked, ~onChange, ~label, ~ariaLabelChecked="", ~ariaLabelUnche
6262
let keyCode = JsxEvent.Keyboard.keyCode(event)
6363
if key == "Enter" || keyCode == 13 {
6464
onChange(!isChecked)
65+
} else if key == " " {
66+
// WAI-ARIA checkbox idiom: Space toggles. preventDefault stops page scroll.
67+
event->JsxEvent.Keyboard.preventDefault
68+
onChange(!isChecked)
6569
}
6670
}}
6771
role="checkbox"

src/Components/FullScreenPortal.res

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,27 @@ open Utils
22
@val @scope(("window", "parent", "frames", `"fullscreen"`, "document"))
33
external getElementById: string => Dom.element = "getElementById"
44

5+
/*
6+
* Accessibility note (Task 12 — keyboard-navigation gap closure):
7+
*
8+
* This component is a thin portal: it renders `children` into the REMOTE
9+
* `#fullscreen` iframe document via `ReactDOM.createPortal`. The only consumers
10+
* (ACHBankDebit / BecsBankDebit) portal a `<BankDebitModal>`, whose content is
11+
* the shared `Modal` component. `Modal` already provides the full dialog
12+
* contract: `role="dialog"` + `ariaModal=true` + an accessible name, plus
13+
* `AccessibilityHooks.useReturnFocus` / `useFocusTrap` / `useEscapeKey`
14+
* (Escape -> close path that posts `("fullscreen", false)` to the parent).
15+
*
16+
* Therefore NO dialog semantics, focus-into-dialog, or Escape handling are added
17+
* here: wrapping the portalled `Modal` in a second `role="dialog"`/`ariaModal`
18+
* container would create a nested/duplicate dialog (a screen-reader regression),
19+
* not an additive landmark. The genuine dismissal path lives on `Modal` and must
20+
* stay the single source of truth so the bank-debit flow is not corrupted.
21+
*
22+
* Cross-document note: `AccessibilityHooks` derive their keydown target and
23+
* active element from the rendered modal container's owner document, so the same
24+
* modal contract applies when content is portalled into the fullscreen iframe.
25+
*/
526
@react.component
627
let make = (~children) => {
728
let (fullScreenIframeNode, setFullScreenIframeNode) = React.useState(() => Nullable.null)

src/Components/Modal.res

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ let make = (
1212
~loader=false,
1313
~showClose=true,
1414
~testMode=true,
15+
~ariaLabel=?,
1516
~setOpenModal,
1617
~openModal,
1718
) => {
18-
let {themeObj} = Recoil.useRecoilValueFromAtom(RecoilAtoms.configAtom)
19+
let {themeObj, localeString} = Recoil.useRecoilValueFromAtom(RecoilAtoms.configAtom)
20+
let modalAriaLabel = ariaLabel->Option.getOr(localeString.dialogLabel)
1921
let closeModal = () => {
2022
setOpenModal(_ => false)
2123
switch closeCallback {
@@ -31,6 +33,27 @@ let make = (
3133
None
3234
}, [loader])
3335

36+
// Ref on the modal content container, used for focus management (focus trap +
37+
// moving focus into the dialog on open).
38+
let containerRef = React.useRef(Nullable.null)
39+
40+
// Accessibility: return focus to the trigger on close, trap focus while open,
41+
// and close on Escape — preserving all existing close behaviour.
42+
AccessibilityHooks.useReturnFocus(~active=openModal, ~containerRef)
43+
AccessibilityHooks.useFocusTrap(~active=openModal, ~containerRef)
44+
AccessibilityHooks.useEscapeKey(~enabled=openModal, ~onEscape=closeModal, ~containerRef)
45+
46+
// On open, move focus to the first focusable element inside the dialog.
47+
React.useEffect(() => {
48+
if openModal {
49+
switch AccessibilityHooks.getFocusableElements(containerRef)->Array.get(0) {
50+
| Some(el) => el->AccessibilityUtils.focus
51+
| None => ()
52+
}
53+
}
54+
None
55+
}, [openModal])
56+
3457
let loaderVisibility = loader ? "visible" : "hidden"
3558
let contentVisibility = React.useMemo(() => {
3659
!openModal ? "hidden" : "visible"
@@ -51,6 +74,10 @@ let make = (
5174
}>
5275
{loaderUI}
5376
{<div
77+
ref={ReactDOM.Ref.domRef(containerRef)}
78+
role="dialog"
79+
ariaModal=true
80+
ariaLabel={modalAriaLabel}
5481
className={`w-full h-full sm:h-auto sm:w-[55%] md:w-[45%] lg:w-[35%] xl:w-[32%] 2xl:w-[27%] m-auto bg-white flex flex-col justify-start sm:justify-center px-5 pb-5 md:pb-6 pt-4 md:pt-7 rounded-none sm:rounded-md relative overflow-scroll ${animate}`}
5582
style={
5683
transition: "opacity .35s ease .1s,transform .35s ease .1s,-webkit-transform .35s ease .1s",
@@ -69,11 +96,13 @@ let make = (
6996
</div>
7097
</RenderIf>
7198
<RenderIf condition=showClose>
72-
<div
99+
<button
100+
type_="button"
73101
className="p-4 flex justify-end self-end mb-4 cursor-pointer"
74-
onClick={_ => closeModal()}>
102+
onClick={_ => closeModal()}
103+
ariaLabel={localeString.closeLabel}>
75104
<Icon name="cross" size=23 />
76-
</div>
105+
</button>
77106
</RenderIf>
78107
</div>
79108
<div className={`mt-12 sm:${marginTop}`}> children </div>

0 commit comments

Comments
 (0)