Skip to content

Commit 2d38c44

Browse files
feat(a11y): input and form-control accessibility
Add ARIA semantics across all text inputs and selects; required state wired from fieldConfig.isRequired; ARIA on bypassing selects/inputs. Localization: accessibility locale dictionary scaffold + the keys used here (cardNetworkLabel, morePaymentMethodsLabel, yearLabel, monthLabel, optionTabLabel). Reuse: shared LiveError component for announced errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019vLeCgNLD7yJrWuKwFh5Vu
1 parent 8afe79b commit 2d38c44

36 files changed

Lines changed: 365 additions & 88 deletions

src/CardCVCElement.res

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,5 +249,6 @@ let make = (
249249
height={isVaultCvcFlow ? "1.8rem" : ""}
250250
name=TestUtils.cardCVVInputTestId
251251
autocomplete="cc-csc"
252+
ariaRequired=true
252253
/>
253254
}

src/CardSchemeComponent.res

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ module CoBadgeCardSchemeDropDown = {
22
@react.component
33
let make = (~eligibleCardSchemes, ~setCardBrand) => {
44
let loggerState = Recoil.useRecoilValueFromAtom(RecoilAtoms.loggerAtom)
5+
let {localeString} = Recoil.useRecoilValueFromAtom(RecoilAtoms.configAtom)
56
<select
67
className="w-4"
8+
ariaLabel={localeString.cardNetworkLabel}
79
onClick={_ =>
810
loggerState.setLogInfo(~value="CardSchemeMenu expanded", ~eventName=CARD_SCHEME_SELECTION)}
911
onChange={ev => {

src/Components/ClickToPayNotYou.res

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ let make = (~setIsShowClickToPayNotYou, ~isCTPAuthenticateNotYouClicked, ~getVis
189189
<div className="w-full flex space-x-2">
190190
<div className="relative w-1/3">
191191
<select
192+
id="ctp-identifier-type"
193+
ariaLabel="Identifier type"
192194
value={identifierType->getIdentityType}
193195
onChange={handleTypeChange}
194196
className="w-full p-3 pr-10 border border-gray-300 rounded-md appearance-none">
@@ -207,6 +209,9 @@ let make = (~setIsShowClickToPayNotYou, ~isCTPAuthenticateNotYouClicked, ~getVis
207209
{identifierType === EMAIL_ADDRESS
208210
? <input
209211
type_="text"
212+
id="ctp-email"
213+
ariaLabel="Email"
214+
ariaRequired=true
210215
value={identifier}
211216
onChange={handleInputChange}
212217
placeholder="Enter email"
@@ -216,6 +221,8 @@ let make = (~setIsShowClickToPayNotYou, ~isCTPAuthenticateNotYouClicked, ~getVis
216221
: <div className="w-2/3 flex border border-gray-300 rounded-md overflow-hidden">
217222
<div className="relative">
218223
<select
224+
id="ctp-country-code"
225+
ariaLabel="Country code"
219226
value={countryCode}
220227
onChange={handleCountryCodeChange}
221228
className="h-full p-3 appearance-none focus:outline-none">
@@ -239,6 +246,9 @@ let make = (~setIsShowClickToPayNotYou, ~isCTPAuthenticateNotYouClicked, ~getVis
239246
</div>
240247
<input
241248
type_="tel"
249+
id="ctp-phone"
250+
ariaLabel="Mobile number"
251+
ariaRequired=true
242252
value={identifier}
243253
onChange={handlePhoneInputChange}
244254
placeholder="Mobile number"

src/Components/DynamicFields/CardHolderNameField.res

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ module FullNameFieldInput = {
7878
placeholder
7979
inputRef
8080
?autocomplete
81+
ariaRequired={firstNameFieldConfig.isRequired}
8182
/>
8283
}
8384
}

src/Components/DynamicFields/EmailField.res

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ module EmailInput = {
4242
placeholder
4343
inputRef={fieldRef}
4444
autocomplete
45+
ariaRequired={primaryFieldConfig.isRequired}
4546
/>
4647
}
4748
}

src/Components/DynamicFields/GenericInputField.res

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,6 @@ let make = (~fieldConfig: fieldConfig) => {
3232
inputRef={fieldRef}
3333
?autocomplete
3434
maxLength=?{fieldConfig.maxInputLength}
35+
ariaRequired={fieldConfig.isRequired}
3536
/>
3637
}

src/Components/DynamicFields/PhoneField.res

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,6 @@ let make = (~fieldConfig: fieldConfig) => {
4141
inputRef={fieldRef}
4242
autocomplete
4343
?maxLength
44+
ariaRequired={fieldConfig.isRequired}
4445
/>
4546
}

src/Components/Input.res

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ let make = (
1818
~placeholder="",
1919
~className="",
2020
~inputRef,
21+
~ariaRequired=false,
22+
~fieldId=?,
2123
) => {
2224
let options = Recoil.useRecoilValueFromAtom(elementOptions)
2325
let {themeObj} = Recoil.useRecoilValueFromAtom(configAtom)
@@ -59,13 +61,23 @@ let make = (
5961
""
6062
}
6163

64+
let inputId = fieldId->Option.getOr(id->String.length > 0 ? id : fieldName)
65+
let accessibleLabel = AccessibilityUtils.getAccessibleLabel(
66+
~fieldName,
67+
~placeholder,
68+
~fallback=inputId,
69+
)
70+
let hasError = errorString->AccessibilityUtils.hasOptionalText
71+
let describedById = hasError ? Some(inputId ++ "-error") : None
72+
let ariaInvalid = AccessibilityUtils.ariaInvalid(~hasError, ~isValid)
73+
6274
<div className={` flex flex-col w-full`} style={color: themeObj.colorText}>
6375
<RenderIf condition={fieldName->String.length > 0}>
64-
<div> {React.string(fieldName)} </div>
76+
<label htmlFor={inputId}> {React.string(fieldName)} </label>
6577
</RenderIf>
6678
<div className="flex flex-row " style={direction: direction}>
6779
<input
68-
id
80+
id={inputId}
6981
style={
7082
background: themeObj.colorBackground,
7183
padding: themeObj.spacingUnit,
@@ -83,17 +95,21 @@ let make = (
8395
onChange
8496
onBlur=handleBlur
8597
onFocus=handleFocus
86-
ariaLabel={`Type to fill ${fieldName} input`}
98+
ariaLabel={accessibleLabel}
99+
ariaInvalid
100+
ariaRequired
101+
ariaDescribedby=?describedById
87102
/>
88103
<div className={`flex -ml-10 items-center`}> {rightIcon} </div>
89104
</div>
90105
{switch errorString {
91106
| Some(val) =>
92107
<RenderIf condition={val->String.length > 0}>
93-
<div
94-
className="py-1 text-xs text-red-600 transition-colors transition-border ease-out duration-200">
95-
{React.string(val)}
96-
</div>
108+
<LiveError
109+
text={val}
110+
className="py-1 text-xs text-red-600 transition-colors transition-border ease-out duration-200"
111+
id={inputId ++ "-error"}
112+
/>
97113
</RenderIf>
98114
| None => React.null
99115
}}

src/Components/InputField.res

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ let make = (
2525
~labelClassName="",
2626
~paymentType: option<CardThemeType.mode>=?,
2727
~autocomplete="on",
28+
~ariaRequired=false,
29+
~fieldId=?,
2830
) => {
2931
open ElementType
3032
let (eleClassName, setEleClassName) = React.useState(_ => "input-base")
@@ -96,6 +98,16 @@ let make = (
9698
""
9799
}
98100

101+
let inputId = fieldId->Option.getOr(id->String.length > 0 ? id : fieldName)
102+
let accessibleLabel = AccessibilityUtils.getAccessibleLabel(
103+
~fieldName,
104+
~placeholder,
105+
~fallback=inputId,
106+
)
107+
let hasError = errorString->AccessibilityUtils.hasOptionalText
108+
let describedById = hasError ? Some(inputId ++ "-error") : None
109+
let ariaInvalid = AccessibilityUtils.ariaInvalid(~hasError, ~isValid)
110+
99111
let isValidValue = CardUtils.getBoolOptionVal(isValid)
100112

101113
let (cardEmpty, cardComplete, cardInvalid, cardFocused) = React.useMemo(() => {
@@ -125,11 +137,11 @@ let make = (
125137

126138
<div className={` flex flex-col w-full`}>
127139
<RenderIf condition={fieldName->String.length > 0}>
128-
<div className={`${labelClassName}`}> {React.string(fieldName)} </div>
140+
<label htmlFor={inputId} className={`${labelClassName}`}> {React.string(fieldName)} </label>
129141
</RenderIf>
130142
<div className="flex flex-row " style={direction: direction}>
131143
<input
132-
id
144+
id={inputId}
133145
style={
134146
background: "transparent",
135147
width: "-webkit-fill-available",
@@ -148,7 +160,10 @@ let make = (
148160
onBlur=handleBlur
149161
onFocus=handleFocus
150162
autoComplete={autocomplete}
151-
ariaLabel={`Type to fill ${fieldName} input`}
163+
ariaLabel={accessibleLabel}
164+
ariaInvalid
165+
ariaRequired
166+
ariaDescribedby=?describedById
152167
/>
153168
<div className={`flex -ml-10 items-center`}> {rightIcon} </div>
154169
</div>
@@ -157,7 +172,7 @@ let make = (
157172
switch errorString {
158173
| Some(val) =>
159174
<RenderIf condition={val->String.length > 0}>
160-
<div className={`py-1 ${errorClases}`}> {React.string(val)} </div>
175+
<LiveError text={val} className={`py-1 ${errorClases}`} id={inputId ++ "-error"} />
161176
</RenderIf>
162177
| None => React.null
163178
}

src/Components/LiveError.res

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// A field/validation error that is both visually rendered and announced to
2+
// screen readers (role="alert" + assertive, atomic live region).
3+
//
4+
// Renders only the inner alert <div>: callers keep their own conditional-render
5+
// wrapper (the render condition is not always text-based), and pass the matching
6+
// `id` (for aria-describedby linkage from the input), `className`, and `style`.
7+
// `id` defaults to "" → the id attribute is omitted (byte-identical to a div with
8+
// no id); `style` is optional and omitted when not provided.
9+
@react.component
10+
let make = (~text: string, ~className: string, ~style: option<JsxDOM.style>=?, ~id: string="") => {
11+
let elementId = id == "" ? None : Some(id)
12+
<div id=?elementId role="alert" ariaLive={#assertive} ariaAtomic=true className ?style>
13+
{React.string(text)}
14+
</div>
15+
}

0 commit comments

Comments
 (0)