From 316565dc4eec2bc664c45fb3303afbce6112c595 Mon Sep 17 00:00:00 2001 From: Pedro Ladaria Date: Tue, 14 Jan 2025 12:24:08 +0100 Subject: [PATCH] feat(Form): Disable autofocus on error for some fields on iOS (#1307) Fields that are excluded from autofocus on error in iOS because the focus action opens the picker/selector * date * datetime-local * month * select Note: scroll to element not affected A simple unit test that covers these scenarios has been added but you can be manually test this using playroom with a snippet like: ```jsx
+ )} + {type === 'text' && } + Submit + + + ); + }; + + render(); + + const submitButton = await screen.findByRole('button', {name: 'Submit'}); + await userEvent.click(submitButton); + + const input = await screen.findByLabelText('Field'); + // eslint-disable-next-line testing-library/no-node-access + expect(document.activeElement === input).toBe(expectedFocus); +}); diff --git a/src/form.tsx b/src/form.tsx index 0fdfe98281..92c6aec097 100644 --- a/src/form.tsx +++ b/src/form.tsx @@ -6,6 +6,7 @@ import classnames from 'classnames'; import * as styles from './form.css'; import * as tokens from './text-tokens'; import ScreenReaderOnly from './screen-reader-only'; +import {isIos} from './utils/platform'; import type {FormStatus, FormErrors, FieldRegistration} from './form-context'; @@ -21,6 +22,8 @@ if ( export type FormValues = {[name: string]: any}; +type HTMLFieldElement = HTMLSelectElement | HTMLInputElement; + type FormProps = { id?: string; onSubmit: (values: FormValues, rawValues: FormValues) => Promise | void; @@ -49,7 +52,7 @@ const Form = ({ const [formErrors, setFormErrors] = React.useState({}); const fieldRegistrations = React.useRef(new Map()); const formRef = React.useRef(null); - const {texts, t} = useTheme(); + const {texts, t, platformOverrides} = useTheme(); const reactId = React.useId(); const id = idProp || reactId; @@ -88,6 +91,26 @@ const Form = ({ [] ); + /** + * In iOS the pickers/selects are automatically opened when the input is focused + * This is not what we want so, for some specific elements, we disable the autofocus on error + */ + const shouldAutofocusFieldOnError = React.useCallback( + (element: HTMLFieldElement): boolean => { + if (!isIos(platformOverrides)) { + return true; + } + if (element.tagName === 'SELECT') { + return false; + } + if (['date', 'datetime-local', 'month'].includes(element.type)) { + return false; + } + return true; + }, + [platformOverrides] + ); + /** * returns true if all fields are ok and focuses the first field with an error */ @@ -114,16 +137,19 @@ const Form = ({ const reg = fieldRegistrations.current.get(name); return reg?.focusableElement || reg?.input; }) - .filter(Boolean) as Array; // casted to remove inferred nulls/undefines + .filter(Boolean) as Array; // casted to remove inferred nulls/undefines if (elementsWithErrors.length) { elementsWithErrors.sort((a, b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1 ); - elementsWithErrors[0].focus(); + const firstElementWithError = elementsWithErrors[0]; + if (shouldAutofocusFieldOnError(firstElementWithError)) { + firstElementWithError.focus(); + } try { // polyfilled, see import at the top of this file - elementsWithErrors[0].scrollIntoView({behavior: 'smooth', block: 'center', inline: 'center'}); + firstElementWithError.scrollIntoView({behavior: 'smooth', block: 'center', inline: 'center'}); } catch (e) { // ignore errors // element.scrollIntoView not available in unit test environment @@ -135,7 +161,14 @@ const Form = ({ onValidationErrors(errors); } return errors; - }, [onValidationErrors, rawValues, texts, values, t]); + }, [ + onValidationErrors, + rawValues, + texts.formFieldErrorIsMandatory, + t, + values, + shouldAutofocusFieldOnError, + ]); const jumpToNext = React.useCallback( (currentName: string) => { diff --git a/src/utils/platform.tsx b/src/utils/platform.tsx index 68d1f94bf2..564c997e88 100644 --- a/src/utils/platform.tsx +++ b/src/utils/platform.tsx @@ -21,10 +21,19 @@ export const isRunningAcceptanceTest = (platformOverrides: Theme['platformOverri const isEdgeOrIE = Boolean(typeof self !== 'undefined' && (self as any).MSStream); -export const isAndroid = (platformOverrides: Theme['platformOverrides']): boolean => - getUserAgent(platformOverrides).toLowerCase().includes('android') && !isEdgeOrIE; +export const isAndroid = (platformOverrides: Theme['platformOverrides']): boolean => { + if (platformOverrides.platform === 'android') { + return true; + } + + return getUserAgent(platformOverrides).toLowerCase().includes('android') && !isEdgeOrIE; +}; export const isIos = (platformOverrides: Theme['platformOverrides']): boolean => { + if (platformOverrides.platform === 'ios') { + return true; + } + // IE and Edge mobile browsers includes Android and iPhone in the user agent if (/iPad|iPhone|iPod/.test(getUserAgent(platformOverrides)) && !isEdgeOrIE) { return true;