Skip to content

Commit ab27f06

Browse files
committed
fix(a11y): improve web error announcements for BaseTextInput
1 parent 9b47fa2 commit ab27f06

2 files changed

Lines changed: 24 additions & 18 deletions

File tree

src/components/FormHelpMessage.tsx

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ type FormHelpMessageProps = {
3535
/** Whether to show information icon */
3636
isInfo?: boolean;
3737

38-
/** Native ID applied to the message text when a direct text association is needed */
39-
messageNativeID?: string;
38+
/** Native ID for accessibility association (aria-describedby) */
39+
nativeID?: string;
4040
};
4141

4242
function FormHelpMessage({
@@ -47,16 +47,16 @@ function FormHelpMessage({
4747
shouldShowRedDotIndicator = true,
4848
shouldRenderMessageAsHTML = false,
4949
isInfo = false,
50-
messageNativeID,
50+
nativeID,
5151
}: FormHelpMessageProps) {
5252
const theme = useTheme();
5353
const styles = useThemeStyles();
5454
const icons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'Exclamation']);
5555
const isWeb = getPlatform() === CONST.PLATFORM.WEB;
5656
const shouldAnnounceError = isError && typeof message === 'string' && !!message && !shouldRenderMessageAsHTML && children == null;
57-
// Keep directly described form messages as plain text on web so Safari/VoiceOver
58-
// can announce them when focus returns to the associated input.
59-
const shouldUseAlertSemanticsOnVisibleMessage = shouldAnnounceError && (!isWeb || !messageNativeID);
57+
const shouldUseSeparateWebLiveAnnouncement = isWeb && !!nativeID && shouldAnnounceError;
58+
const visibleMessageRole = shouldUseSeparateWebLiveAnnouncement || !shouldAnnounceError ? undefined : CONST.ROLE.ALERT;
59+
const visibleMessageLiveRegion = shouldUseSeparateWebLiveAnnouncement || !shouldAnnounceError ? undefined : 'assertive';
6060

6161
const HTMLMessage = useMemo(() => {
6262
if (typeof message !== 'string' || !shouldRenderMessageAsHTML) {
@@ -109,16 +109,27 @@ function FormHelpMessage({
109109
) : (
110110
<Text
111111
style={[isError ? styles.formError : styles.formHelp, styles.mb0]}
112-
nativeID={messageNativeID}
113-
role={shouldUseAlertSemanticsOnVisibleMessage ? CONST.ROLE.ALERT : undefined}
112+
nativeID={nativeID}
113+
role={visibleMessageRole}
114114
// TalkBack on some Android versions skips role-only alert announcements,
115115
// so keep native accessibilityRole/live-region as a platform fallback.
116116
accessibilityRole={!isWeb && shouldAnnounceError ? CONST.ROLE.ALERT : undefined}
117-
accessibilityLiveRegion={shouldUseAlertSemanticsOnVisibleMessage ? 'assertive' : undefined}
117+
accessibilityLiveRegion={visibleMessageLiveRegion}
118118
>
119119
{message}
120120
</Text>
121121
))}
122+
{shouldUseSeparateWebLiveAnnouncement && (
123+
// Keep a separate live region for immediate web announcements without
124+
// changing the visible described text Safari relies on when refocusing inputs.
125+
<Text
126+
style={styles.hiddenElementOutsideOfWindow}
127+
role={CONST.ROLE.ALERT}
128+
accessibilityLiveRegion="assertive"
129+
>
130+
{message}
131+
</Text>
132+
)}
122133
</View>
123134
</View>
124135
);

src/components/TextInput/BaseTextInput/implementation/index.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -327,9 +327,8 @@ function BaseTextInput({
327327
// This is workaround for https://github.com/Expensify/App/issues/47939: in case when user is using Chrome on Android we set inputMode to 'search' to disable autocomplete bar above the keyboard.
328328
// If we need some other inputMode (eg. 'decimal'), then the autocomplete bar will show, but we can do nothing about it as it's a known Chrome bug.
329329
const inputMode = inputProps.inputMode ?? (isMobileChrome() ? 'search' : undefined);
330-
const inputNativeID = inputProps.nativeID ?? inputProps.id ?? inputID ?? `text-input-${helpMessageId}`;
331330
const helpMessageTextID = `${helpMessageId}-text`;
332-
const accessibilityLabel = inputProps.accessibilityLabel ?? [label, hint].filter(Boolean).join(', ');
331+
const accessibilityLabel = [label, hint, errorText].filter(Boolean).join(', ');
333332
const loadingSpinnerReasonAttributes: SkeletonSpanReasonAttributes = {
334333
context: 'BaseTextInput.isLoading',
335334
isLoading: !!inputProps.isLoading,
@@ -389,7 +388,7 @@ function BaseTextInput({
389388
label={label}
390389
labelTranslateY={labelTranslateY}
391390
labelScale={labelScale}
392-
for={inputNativeID}
391+
for={inputProps.nativeID}
393392
isMultiline={isMultiline}
394393
/>
395394
</>
@@ -451,7 +450,6 @@ function BaseTextInput({
451450
}}
452451
// eslint-disable-next-line
453452
{...inputProps}
454-
nativeID={inputNativeID}
455453
// Filter out role="presentation" so it doesn't strip the native
456454
// semantics of the <input>. Other roles (e.g. searchbox) are preserved.
457455
role={role === CONST.ROLE.PRESENTATION ? undefined : role}
@@ -497,12 +495,9 @@ function BaseTextInput({
497495
readOnly={isReadOnly}
498496
defaultValue={defaultValue}
499497
markdownStyle={markdownStyle}
500-
accessibilityLabel={accessibilityLabel}
498+
accessibilityLabel={inputProps.accessibilityLabel ?? accessibilityLabel}
501499
keyboardType={inputProps.keyboardType}
502-
// Keep aria-describedby for baseline compatibility and issue requirements,
503-
// while aria-errormessage enhances announcement on browsers that support it.
504500
aria-describedby={inputHelpText ? helpMessageTextID : undefined}
505-
aria-errormessage={errorText ? helpMessageTextID : undefined}
506501
aria-invalid={errorText ? true : undefined}
507502
/>
508503
{!!suffixCharacter && (
@@ -577,7 +572,7 @@ function BaseTextInput({
577572
</PressableWithoutFeedback>
578573
{!!inputHelpText && (
579574
<FormHelpMessage
580-
messageNativeID={helpMessageTextID}
575+
nativeID={helpMessageTextID}
581576
isError={!!errorText}
582577
message={inputHelpText}
583578
/>

0 commit comments

Comments
 (0)