diff --git a/cspell.json b/cspell.json index 5512d8a13f1e..e4eb527af2d9 100644 --- a/cspell.json +++ b/cspell.json @@ -598,6 +598,7 @@ "reactnativebackgroundtask", "reactnativehybridapp", "reactnativekeycommand", + "reannounce", "reauthentication", "Reauthenticator", "Rebooked", diff --git a/src/components/Form/FormContext.tsx b/src/components/Form/FormContext.tsx index 8e3c30303e1f..de0becf4b409 100644 --- a/src/components/Form/FormContext.tsx +++ b/src/components/Form/FormContext.tsx @@ -7,12 +7,16 @@ type InputProps = Omit; type RegisterInput = (inputID: keyof Form, shouldSubmitForm: boolean, inputProps: InputProps) => InputProps; type FormContext = { registerInput: RegisterInput; + getErrorAnnouncementKey: () => number; + getFallbackAnnouncementMessage: () => string; }; export default createContext({ registerInput: () => { throw new Error('Registered input should be wrapped with FormWrapper'); }, + getErrorAnnouncementKey: () => 0, + getFallbackAnnouncementMessage: () => '', }); export type {RegisterInput}; diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index 14c3b2de117c..86bbb88ba251 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -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'; @@ -128,6 +130,7 @@ function FormProvider({ shouldRenderFooterAboveSubmit = false, shouldUseStrictHtmlTagValidation = false, shouldPreventDefaultFocusOnPressSubmit = false, + shouldHideFixErrorsAlert = false, keyboardSubmitBehavior = CONST.KEYBOARD_SUBMIT_BEHAVIOR.DISMISS_THEN_SUBMIT, ref, ...rest @@ -149,9 +152,26 @@ function FormProvider({ } const [errors, setErrors] = useState({}); + 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; @@ -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; } @@ -481,7 +502,17 @@ 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); + } + submit(); + }, [hasServerError, submit]); return ( @@ -489,11 +520,12 @@ function FormProvider({ (null); const formContentRef = useRef(null); + const {getErrorAnnouncementKey, getFallbackAnnouncementMessage} = useContext(FormContext); + const errorAnnouncementKey = getErrorAnnouncementKey(); + const fallbackAnnouncementMessage = getFallbackAnnouncementMessage(); + const isWeb = getPlatform() === CONST.PLATFORM.WEB; const [formState] = useOnyx(`${formID}`); @@ -224,6 +231,15 @@ function FormWrapper({ }} > {children} + {isWeb && !!fallbackAnnouncementMessage && errorAnnouncementKey > 1 && ( + + {fallbackAnnouncementMessage} + + )} {!shouldSubmitButtonStickToBottom && SubmitButton} ); diff --git a/src/components/FormAlertWrapper.tsx b/src/components/FormAlertWrapper.tsx index 9c8499a841c5..5ea9e47210df 100644 --- a/src/components/FormAlertWrapper.tsx +++ b/src/components/FormAlertWrapper.tsx @@ -50,6 +50,9 @@ function FormAlertWrapper({ const {translate} = useLocalize(); const {isOffline} = useNetwork(); + const defaultFixErrorsMessage = `${translate('common.please')} ${translate('common.fixTheErrors')} ${translate('common.inTheFormBeforeContinuing')}.`; + const announcementMessage = message?.length ? message : defaultFixErrorsMessage; + let content; if (!message?.length) { content = ( @@ -72,8 +75,10 @@ function FormAlertWrapper({ {isAlertVisible && ( {content} diff --git a/src/components/FormHelpMessage.tsx b/src/components/FormHelpMessage.tsx index f23efdd5007a..cbe9bbc56f6f 100644 --- a/src/components/FormHelpMessage.tsx +++ b/src/components/FormHelpMessage.tsx @@ -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'; @@ -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'; @@ -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({ @@ -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 ''; @@ -72,8 +103,6 @@ function FormHelpMessage({ return `${replacedText}`; }, [isError, message, shouldRenderMessageAsHTML]); - useAccessibilityAnnouncement(message, shouldAnnounceError); - const errorIconLabel = isError && shouldShowRedDotIndicator ? CONST.ACCESSIBILITY_LABELS.ERROR : undefined; if (isEmpty(message) && isEmpty(children)) { @@ -111,8 +140,6 @@ 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} > @@ -120,8 +147,6 @@ function FormHelpMessage({ ))} {shouldUseSeparateWebLiveAnnouncement && ( - // Keep a separate live region for immediate web announcements without - // changing the visible described text Safari relies on when refocusing inputs. )} + {isWeb && shouldReannounceOnSubmit && hasError && errorAnnouncementKey > 0 && ( + + {errorAnnouncementText} + + )} ); diff --git a/src/hooks/useAccessibilityAnnouncement/index.ios.ts b/src/hooks/useAccessibilityAnnouncement/index.ios.ts index ee479d2d37ca..5fd73f926d06 100644 --- a/src/hooks/useAccessibilityAnnouncement/index.ios.ts +++ b/src/hooks/useAccessibilityAnnouncement/index.ios.ts @@ -8,6 +8,7 @@ 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()) { @@ -15,7 +16,10 @@ 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; } @@ -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; diff --git a/src/hooks/useAccessibilityAnnouncement/index.native.ts b/src/hooks/useAccessibilityAnnouncement/index.native.ts index 80f307820bb9..a8d638bd4ef0 100644 --- a/src/hooks/useAccessibilityAnnouncement/index.native.ts +++ b/src/hooks/useAccessibilityAnnouncement/index.native.ts @@ -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(() => { @@ -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; diff --git a/src/hooks/useAccessibilityAnnouncement/types.ts b/src/hooks/useAccessibilityAnnouncement/types.ts index fa7a8e4c1c53..8b3b447ebc60 100644 --- a/src/hooks/useAccessibilityAnnouncement/types.ts +++ b/src/hooks/useAccessibilityAnnouncement/types.ts @@ -1,6 +1,7 @@ type UseAccessibilityAnnouncementOptions = { shouldAnnounceOnNative?: boolean; shouldAnnounceOnWeb?: boolean; + announcementKey?: number; }; export default UseAccessibilityAnnouncementOptions;