Skip to content

Commit 3977964

Browse files
authored
Fix TextInput feedback message accessibility issue
2 parents 8059216 + 123a378 commit 3977964

File tree

1 file changed

+61
-30
lines changed

1 file changed

+61
-30
lines changed

src/lib/components/TextInput/TextInput.js

Lines changed: 61 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,17 @@ import classnames from "classnames";
5353
required [Boolean] - Mark the input as required in a form.
5454
step [Number] - The step attribute of the input element.
5555
type [String] - The type of input element. Defaults to "text". One of "date", "datetime-local", "email", "month", "number", "password", "range", "search", "tel", "text", "time", "url", or "week".
56-
value [Boolean] - Whether or not the checkbox is checked. Defaults to false.
56+
value [String] - Value of the text input
57+
isFocused [Boolean] - prop to control aria-live behavior
58+
59+
60+
* Accessibility features:
61+
* - Uses `aria-describedby` to link input with its feedback and helper text.
62+
* - Uses `aria-live="assertive"` or `polite` on feedback to announce changes via screen readers.
63+
* - Uses `role="alert"` for screen reader announcement of dynamic feedback.
64+
* - Dynamically sets a `key` on feedback container to force DOM update and screen reader re-announcement.
65+
* - Limits announcements to only the input currently in focus using the `isFocused` prop.
66+
*
5767
*/
5868

5969
const TextInput = React.forwardRef((props, ref) => {
@@ -83,66 +93,86 @@ const TextInput = React.forwardRef((props, ref) => {
8393
step,
8494
type,
8595
value,
96+
isFocused, // Controls whether screen readers announce feedback for this input
8697
} = props;
8798

8899
const controlClasses = classnames("text", "control", classes, {
89-
disabled: disabled,
90-
inline: inline,
100+
disabled,
101+
inline,
91102
invalid: isValid !== undefined && !isValid,
92103
});
104+
93105
const feedbackClasses = classnames(
94106
"control-feedback",
95107
`${feedbackContext || "error"}`,
96108
{
97-
"visually-hidden": !feedbackText,
109+
"visually-hidden": !feedbackText, // visually hide only when there's no error
98110
}
99111
);
112+
100113
const feedbackId = `${id}-feedback`;
101114
const helperId = `${id}-helper`;
102115
const inputClasses = isValid === false ? "invalid" : null;
103116

117+
/**
118+
* Compose value for `aria-describedby` on input.
119+
* Links to feedback text and/or helper text for screen readers.
120+
*/
104121
const getDescribedByIds = () => {
105-
if (!helperText && !feedbackText) {
106-
return null;
107-
} else {
108-
return `${feedbackText ? `${feedbackId} ` : ""}${
109-
helperText ? helperId : ""
110-
}`;
111-
}
122+
if (!helperText && !feedbackText) return null;
123+
return `${feedbackText ? `${feedbackId} ` : ""}${
124+
helperText ? helperId : ""
125+
}`.trim();
112126
};
113127

114128
return (
115129
<div className={controlClasses}>
116130
<label htmlFor={id}>{label}</label>
131+
117132
<input
118-
aria-describedby={getDescribedByIds()}
119-
autoComplete={autoComplete || "off"}
120-
autoFocus={autoFocus}
121-
className={inputClasses}
122-
disabled={disabled}
123133
id={id}
124134
name={name}
125-
max={max || null}
126-
maxLength={maxLength || null}
127-
min={min || null}
128-
onBlur={onBlur}
135+
type={type || "text"}
136+
className={inputClasses}
137+
ref={ref}
138+
value={value}
129139
onChange={onChange}
140+
onBlur={onBlur}
130141
onFocus={onFocus}
131-
placeholder={placeholder || null}
142+
autoComplete={autoComplete || "off"}
143+
autoFocus={autoFocus}
144+
disabled={disabled}
132145
readOnly={readOnly || null}
133-
ref={ref}
134-
required={required}
146+
placeholder={placeholder || null}
147+
max={max || null}
148+
min={min || null}
149+
maxLength={maxLength || null}
135150
step={step || null}
136-
type={type || "text"}
137-
value={value}
151+
required={required}
152+
aria-invalid={isValid === false}
153+
aria-describedby={getDescribedByIds()}
138154
/>
155+
156+
{/*
157+
Feedback region with dynamic key to force DOM updates.
158+
Only sets `aria-live` and `role=alert` when the field is focused
159+
to prevent multiple announcements from unrelated fields.
160+
*/}
139161
<div
140-
aria-live={polite ? "polite" : "assertive"}
141-
className={feedbackClasses}
142-
dangerouslySetInnerHTML={{ __html: feedbackText || null }}
162+
key={feedbackText ? `${feedbackText}-${Date.now()}` : "empty-feedback"}
143163
id={feedbackId}
144-
role="alert"
145-
/>
164+
className={feedbackClasses}
165+
role={isFocused ? "alert" : undefined}
166+
aria-live={isFocused ? (polite ? "polite" : "assertive") : undefined}
167+
aria-atomic="true" // Ensures full feedback text is read even on partial updates
168+
>
169+
{feedbackText ? (
170+
<span>{feedbackText}</span>
171+
) : (
172+
<span aria-hidden="true">&nbsp;</span>
173+
)}
174+
</div>
175+
146176
{helperText && (
147177
<div
148178
className="helper-text"
@@ -194,6 +224,7 @@ TextInput.propTypes = {
194224
"week",
195225
]),
196226
value: PropTypes.string,
227+
isFocused: PropTypes.bool, // Important for screen reader conditional announcement
197228
};
198229

199230
export default TextInput;

0 commit comments

Comments
 (0)