Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,7 @@
"reactnativebackgroundtask",
"reactnativehybridapp",
"reactnativekeycommand",
"reannounce",
"reauthentication",
"Reauthenticator",
"Rebooked",
Expand Down
4 changes: 4 additions & 0 deletions src/components/Form/FormContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ type InputProps = Omit<InputComponentBaseProps, 'InputComponent' | 'inputID'>;
type RegisterInput = (inputID: keyof Form, shouldSubmitForm: boolean, inputProps: InputProps) => InputProps;
type FormContext = {
registerInput: RegisterInput;
getErrorAnnouncementKey: () => number;
getFallbackAnnouncementMessage: () => string;
};

export default createContext<FormContext>({
registerInput: () => {
throw new Error('Registered input should be wrapped with FormWrapper');
},
getErrorAnnouncementKey: () => 0,
getFallbackAnnouncementMessage: () => '',
});

export type {RegisterInput};
36 changes: 34 additions & 2 deletions src/components/Form/FormProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import type {ValueOf} from 'type-fest';
import {useInputBlurActions} from '@components/InputBlurContext';
import type {LocalizedTranslate} from '@components/LocaleContextProvider';
import {getIsRestoringKeyboardFocus} from '@components/TextInput';
import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement';
import useDebounceNonReactive from '@hooks/useDebounceNonReactive';
import useIsFocusedRef from '@hooks/useIsFocusedRef';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import {isSafari} from '@libs/Browser';
import {getLatestErrorMessage} from '@libs/ErrorUtils';
import {prepareValues} from '@libs/ValidationUtils';
import Visibility from '@libs/Visibility';
import {clearErrorFields, clearErrors, setDraftValues, setErrors as setFormErrors} from '@userActions/FormActions';
Expand Down Expand Up @@ -128,6 +130,7 @@ function FormProvider({
shouldRenderFooterAboveSubmit = false,
shouldUseStrictHtmlTagValidation = false,
shouldPreventDefaultFocusOnPressSubmit = false,
shouldHideFixErrorsAlert = false,
keyboardSubmitBehavior = CONST.KEYBOARD_SUBMIT_BEHAVIOR.DISMISS_THEN_SUBMIT,
ref,
...rest
Expand All @@ -149,9 +152,26 @@ function FormProvider({
}

const [errors, setErrors] = useState<GenericFormInputErrors>({});
const [errorAnnouncementKey, setErrorAnnouncementKey] = useState(0);
const hasServerError = useMemo(() => !!formState && !isEmptyObject(formState?.errors), [formState]);
const {setIsBlurred} = useInputBlurActions();

const errorMessage = formState ? getLatestErrorMessage(formState) : undefined;
const isGeneralAlertVisible = ((!isEmptyObject(errors) || !isEmptyObject(formState?.errorFields)) && !shouldHideFixErrorsAlert) || !!errorMessage;
const firstFieldErrorMessage = useMemo(() => {
for (const errorMsg of Object.values(errors)) {
if (errorMsg) {
return errorMsg;
}
}
return '';
}, [errors]);

useAccessibilityAnnouncement(firstFieldErrorMessage, !isGeneralAlertVisible && !!firstFieldErrorMessage && errorAnnouncementKey > 1, {
shouldAnnounceOnNative: true,
announcementKey: errorAnnouncementKey,
});

const onValidate = useCallback(
(values: FormOnyxValues, shouldClearServerError = true) => {
const trimmedStringValues = shouldTrimValues ? prepareValues(values) : values;
Expand Down Expand Up @@ -264,6 +284,7 @@ function FormProvider({

// Validate form and return early if any errors are found
if (!isEmptyObject(onValidate(trimmedStringValues))) {
setErrorAnnouncementKey((prev) => prev + 1);
return;
}

Expand Down Expand Up @@ -481,19 +502,30 @@ function FormProvider({
isFocusedRef,
],
);
const value = useMemo(() => ({registerInput}), [registerInput]);
const fallbackAnnouncementMessage = !isGeneralAlertVisible ? firstFieldErrorMessage : '';
const getErrorAnnouncementKey = useCallback(() => errorAnnouncementKey, [errorAnnouncementKey]);
const getFallbackAnnouncementMessage = useCallback(() => fallbackAnnouncementMessage, [fallbackAnnouncementMessage]);
const value = useMemo(() => ({registerInput, getErrorAnnouncementKey, getFallbackAnnouncementMessage}), [registerInput, getErrorAnnouncementKey, getFallbackAnnouncementMessage]);

const submitAndAnnounce = useCallback(() => {
if (hasServerError) {
setErrorAnnouncementKey((prev) => prev + 1);
}
Comment thread
Krishna2323 marked this conversation as resolved.
submit();
}, [hasServerError, submit]);

return (
<FormContext.Provider value={value}>
{/* eslint-disable react/jsx-props-no-spreading */}
<FormWrapper
{...rest}
formID={formID}
onSubmit={submit}
onSubmit={submitAndAnnounce}
inputRefs={inputRefs}
errors={errors}
isLoading={isLoading}
enabledWhenOffline={enabledWhenOffline}
shouldHideFixErrorsAlert={shouldHideFixErrorsAlert}
shouldRenderFooterAboveSubmit={shouldRenderFooterAboveSubmit}
shouldPreventDefaultFocusOnPressSubmit={shouldPreventDefaultFocusOnPressSubmit}
ref={formWrapperRef}
Expand Down
18 changes: 17 additions & 1 deletion src/components/Form/FormWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useImperativeHandle, useRef} from 'react';
import React, {useContext, useImperativeHandle, useRef} from 'react';
import type {ForwardedRef, RefObject} from 'react';
// eslint-disable-next-line no-restricted-imports
import type {ScrollView as RNScrollView, StyleProp, ViewStyle} from 'react-native';
Expand All @@ -7,17 +7,20 @@ import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import FormElement from '@components/FormElement';
import ScrollView from '@components/ScrollView';
import ScrollViewWithContext from '@components/ScrollViewWithContext';
import Text from '@components/Text';
import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle';
import useOnyx from '@hooks/useOnyx';
import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
import useThemeStyles from '@hooks/useThemeStyles';
import Accessibility from '@libs/Accessibility';
import {getLatestErrorMessage} from '@libs/ErrorUtils';
import getPlatform from '@libs/getPlatform';
import CONST from '@src/CONST';
import type {OnyxFormKey} from '@src/ONYXKEYS';
import type {Form} from '@src/types/form';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import FormContext from './FormContext';
import type {FormInputErrors, FormProps, FormWrapperRef, InputRefs} from './types';

type FormWrapperProps = ChildrenProps &
Expand Down Expand Up @@ -106,6 +109,10 @@ function FormWrapper({
const styles = useThemeStyles();
const formRef = useRef<RNScrollView>(null);
const formContentRef = useRef<View>(null);
const {getErrorAnnouncementKey, getFallbackAnnouncementMessage} = useContext(FormContext);
const errorAnnouncementKey = getErrorAnnouncementKey();
const fallbackAnnouncementMessage = getFallbackAnnouncementMessage();
const isWeb = getPlatform() === CONST.PLATFORM.WEB;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is against our cross platform philosophy but we'll fix as followup by deprecating hidden Text in favor of web hook implementation.


const [formState] = useOnyx<OnyxFormKey, Form>(`${formID}`);

Expand Down Expand Up @@ -224,6 +231,15 @@ function FormWrapper({
}}
>
{children}
{isWeb && !!fallbackAnnouncementMessage && errorAnnouncementKey > 1 && (
<Text
key={`fallback-announce-${errorAnnouncementKey}`}
style={styles.hiddenElementOutsideOfWindow}
role={CONST.ROLE.ALERT}
>
{fallbackAnnouncementMessage}
</Text>
)}
{!shouldSubmitButtonStickToBottom && SubmitButton}
</FormElement>
);
Expand Down
7 changes: 6 additions & 1 deletion src/components/FormAlertWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ function FormAlertWrapper({
const {translate} = useLocalize();
const {isOffline} = useNetwork();

const defaultFixErrorsMessage = `${translate('common.please')} ${translate('common.fixTheErrors')} ${translate('common.inTheFormBeforeContinuing')}.`;

@aimane-chnaif aimane-chnaif Mar 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is against our translation guideline
https://github.com/Expensify/App/blob/main/contributingGuides/philosophies/INTERNATIONALIZATION.md#--string-concatenation-should-not-be-used-for-translations

Let's not block on this since it's existing on main but should be migrated as followup.

<Text style={[styles.formError, styles.mb0]}>
{`${translate('common.please')} `}
<TextLink
style={styles.label}
onPress={onFixTheErrorsLinkPressed}
>
{translate('common.fixTheErrors')}
</TextLink>
{` ${translate('common.inTheFormBeforeContinuing')}.`}
</Text>

const announcementMessage = message?.length ? message : defaultFixErrorsMessage;

let content;
if (!message?.length) {
content = (
Expand All @@ -72,8 +75,10 @@ function FormAlertWrapper({
<View style={containerStyles}>
{isAlertVisible && (
<FormHelpMessage
message={message}
message={announcementMessage}
shouldRenderMessageAsHTML={isMessageHtml}
style={[styles.mb3, errorMessageStyle]}
shouldReannounceOnSubmit
>
{content}
</FormHelpMessage>
Expand Down
48 changes: 41 additions & 7 deletions src/components/FormHelpMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import isEmpty from 'lodash/isEmpty';
import React, {useMemo} from 'react';
import React, {useContext, useMemo} from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement';
Expand All @@ -9,6 +9,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import getPlatform from '@libs/getPlatform';
import Parser from '@libs/Parser';
import CONST from '@src/CONST';
import FormContext from './Form/FormContext';
import Icon from './Icon';
import RenderHTML from './RenderHTML';
import Text from './Text';
Expand Down Expand Up @@ -37,6 +38,11 @@ type FormHelpMessageProps = {

/** Native ID for accessibility association (aria-describedby) */
nativeID?: string;

/** Whether this message should be re-announced on repeated form submissions.
* All error messages announce on first appearance. This prop controls whether
* the message also re-announces when the form is re-submitted with the same errors. */
shouldReannounceOnSubmit?: boolean;
};

function FormHelpMessage({
Expand All @@ -48,16 +54,41 @@ function FormHelpMessage({
shouldRenderMessageAsHTML = false,
isInfo = false,
nativeID,
shouldReannounceOnSubmit = false,
}: FormHelpMessageProps) {
const theme = useTheme();
const styles = useThemeStyles();
const icons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'Exclamation']);
const {getErrorAnnouncementKey} = useContext(FormContext);
const errorAnnouncementKey = getErrorAnnouncementKey();

const isWeb = getPlatform() === CONST.PLATFORM.WEB;
const shouldAnnounceError = isError && typeof message === 'string' && !!message && !shouldRenderMessageAsHTML && children == null;
const shouldUseSeparateWebLiveAnnouncement = isWeb && !!nativeID && shouldAnnounceError;
const visibleMessageRole = shouldUseSeparateWebLiveAnnouncement || !shouldAnnounceError ? undefined : CONST.ROLE.ALERT;
const visibleMessageLiveRegion = shouldUseSeparateWebLiveAnnouncement || !shouldAnnounceError ? undefined : 'assertive';

const errorAnnouncementText = useMemo(() => {
if (!isError || typeof message !== 'string') {
return '';
}
const trimmedMessage = message.trim();
if (!trimmedMessage) {
return '';
}
return shouldRenderMessageAsHTML ? Parser.htmlToText(trimmedMessage) : trimmedMessage;
}, [isError, message, shouldRenderMessageAsHTML]);

const hasError = errorAnnouncementText.length > 0;

useAccessibilityAnnouncement(message, shouldAnnounceError);

// Re-announce on native via AccessibilityInfo when form is re-submitted
useAccessibilityAnnouncement(errorAnnouncementText, hasError && shouldReannounceOnSubmit && errorAnnouncementKey > 0, {
shouldAnnounceOnNative: true,
announcementKey: errorAnnouncementKey,
});

const HTMLMessage = useMemo(() => {
if (typeof message !== 'string' || !shouldRenderMessageAsHTML) {
return '';
Expand All @@ -72,8 +103,6 @@ function FormHelpMessage({
return `<muted-text-label>${replacedText}</muted-text-label>`;
}, [isError, message, shouldRenderMessageAsHTML]);

useAccessibilityAnnouncement(message, shouldAnnounceError);

const errorIconLabel = isError && shouldShowRedDotIndicator ? CONST.ACCESSIBILITY_LABELS.ERROR : undefined;

if (isEmpty(message) && isEmpty(children)) {
Expand Down Expand Up @@ -111,17 +140,13 @@ function FormHelpMessage({
style={[isError ? styles.formError : styles.formHelp, styles.mb0]}
nativeID={nativeID}
role={visibleMessageRole}
// TalkBack on some Android versions skips role-only alert announcements,
// so keep native accessibilityRole/live-region as a platform fallback.
accessibilityRole={!isWeb && shouldAnnounceError ? CONST.ROLE.ALERT : undefined}
accessibilityLiveRegion={visibleMessageLiveRegion}
>
{message}
</Text>
))}
{shouldUseSeparateWebLiveAnnouncement && (
// Keep a separate live region for immediate web announcements without
// changing the visible described text Safari relies on when refocusing inputs.
<Text
style={styles.hiddenElementOutsideOfWindow}
role={CONST.ROLE.ALERT}
Expand All @@ -130,6 +155,15 @@ function FormHelpMessage({
{message}
</Text>
)}
{isWeb && shouldReannounceOnSubmit && hasError && errorAnnouncementKey > 0 && (
<Text
key={`reannounce-${errorAnnouncementKey}`}
style={styles.hiddenElementOutsideOfWindow}

@aimane-chnaif aimane-chnaif Mar 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same deprecation concern here.

Btw why did we need hidden Text in both places? here in FormHelpMessage and in FormWrapper.

FormWrapper contains FormHelpMessage as child.

FormWrapper > FormAlertWithSubmitButton > FormAlertWrapper > FormHelpMessage

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The web hook can't replace these because it doesn't support announcementKey for re-announcements.

  • FormHelpMessage's is for re-announcing the general form alert ('Please fix the errors') on re-submit.
  • FormWrapper's is a fallback for forms with only field-level errors (no general alert) -- it announces the first field error on submit.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The web hook can't replace these because it doesn't support announcementKey for re-announcements.

Why can't introduce announcementKey like you did for native?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expected Result

When validation errors occur, the error message should be automatically announced by screen readers and user should receive immediate auditory feedback about the error and its location

  • Focus or programmatic notification should help users understand what needs to be corrected

I’ve already spent a significant amount of time on this PR and gone beyond the original scope. I implemented a re-announcement feature, which turned out to be much more complex than the expected behavior.

Introducing announcementKey would require reworking and re-testing everything again, especially since getting this to work consistently across JAWS, NVDA, and VoiceOver was already quite challenging.

@rushatgabhane Could you please check if it’s possible to increase the bounty for this, given that the work has gone beyond the original scope? I can take care of updating the hook if that works.

@rushatgabhane rushatgabhane Mar 18, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please check if it’s possible to increase the bounty for this, given that the work has gone beyond the original scope

an expensify employee would decide that. but yeah if the difficulty is more than expected, you can ask for more and give an explanation when payment is due

role={CONST.ROLE.ALERT}
>
{errorAnnouncementText}
</Text>
)}
</View>
</View>
);
Expand Down
9 changes: 7 additions & 2 deletions src/hooks/useAccessibilityAnnouncement/index.ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@ const DELAY_FOR_ACCESSIBILITY_TREE_SYNC = 100;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounceMessage: boolean, _options?: UseAccessibilityAnnouncementOptions) {
const previousAnnouncedMessageRef = useRef('');
const previousKeyRef = useRef(_options?.announcementKey);

useEffect(() => {
if (!shouldAnnounceMessage || typeof message !== 'string' || !message.trim()) {
previousAnnouncedMessageRef.current = '';
return;
}

if (previousAnnouncedMessageRef.current === message) {
const keyChanged = _options?.announcementKey !== undefined && _options.announcementKey !== previousKeyRef.current;
previousKeyRef.current = _options?.announcementKey;

if (!keyChanged && previousAnnouncedMessageRef.current === message) {
return;
}

Expand All @@ -26,8 +30,9 @@ function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounc
AccessibilityInfo.announceForAccessibility(message);
}, DELAY_FOR_ACCESSIBILITY_TREE_SYNC);

// eslint-disable-next-line consistent-return
return () => clearTimeout(timeout);
}, [message, shouldAnnounceMessage]);
}, [message, shouldAnnounceMessage, _options?.announcementKey]);
}

export default useAccessibilityAnnouncement;
8 changes: 6 additions & 2 deletions src/hooks/useAccessibilityAnnouncement/index.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type UseAccessibilityAnnouncementOptions from './types';

function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounceMessage: boolean, options?: UseAccessibilityAnnouncementOptions) {
const previousAnnouncedMessageRef = useRef('');
const previousKeyRef = useRef(options?.announcementKey);
const shouldAnnounceOnNative = options?.shouldAnnounceOnNative ?? false;

useEffect(() => {
Expand All @@ -13,13 +14,16 @@ function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounc
return;
}

if (previousAnnouncedMessageRef.current === message) {
const keyChanged = options?.announcementKey !== undefined && options.announcementKey !== previousKeyRef.current;
previousKeyRef.current = options?.announcementKey;

if (!keyChanged && previousAnnouncedMessageRef.current === message) {
return;
}

previousAnnouncedMessageRef.current = message;
AccessibilityInfo.announceForAccessibility(message);
}, [message, shouldAnnounceMessage, shouldAnnounceOnNative]);
}, [message, shouldAnnounceMessage, shouldAnnounceOnNative, options?.announcementKey]);
}

export default useAccessibilityAnnouncement;
1 change: 1 addition & 0 deletions src/hooks/useAccessibilityAnnouncement/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
type UseAccessibilityAnnouncementOptions = {
shouldAnnounceOnNative?: boolean;
shouldAnnounceOnWeb?: boolean;
announcementKey?: number;
};

export default UseAccessibilityAnnouncementOptions;
Loading