diff --git a/src/__tests__/form-test.tsx b/src/__tests__/form-test.tsx
index bf5a72ba4..8500fbf37 100644
--- a/src/__tests__/form-test.tsx
+++ b/src/__tests__/form-test.tsx
@@ -1,7 +1,19 @@
import * as React from 'react';
import {render, screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import {ButtonPrimary, Form, TextField, EmailField, PasswordField, Switch, PhoneNumberField} from '..';
+import {
+ ButtonPrimary,
+ Form,
+ TextField,
+ EmailField,
+ PasswordField,
+ Switch,
+ PhoneNumberField,
+ DateField,
+ DateTimeField,
+ Select,
+ MonthField,
+} from '..';
import ThemeContextProvider from '../theme-context-provider';
import {makeTheme} from './test-utils';
@@ -296,3 +308,43 @@ test('Disabling a field removes the error state and disabled fields are not subm
{phone: '654 83 44 55', switch: false}
);
});
+
+test.each`
+ platform | type | expectedFocus
+ ${'ios'} | ${'date'} | ${false}
+ ${'ios'} | ${'datetime-local'} | ${false}
+ ${'ios'} | ${'month'} | ${false}
+ ${'ios'} | ${'select'} | ${false}
+ ${'ios'} | ${'text'} | ${true}
+ ${'android'} | ${'date'} | ${true}
+ ${'android'} | ${'datetime-local'} | ${true}
+ ${'android'} | ${'month'} | ${true}
+ ${'android'} | ${'select'} | ${true}
+ ${'android'} | ${'text'} | ${true}
+`('autofocus on error - $platform $type $expectedFocus', async ({platform, type, expectedFocus}) => {
+ const FormComponent = () => {
+ return (
+
+
+
+ );
+ };
+
+ 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 0fdfe9828..92c6aec09 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 68d1f94bf..564c997e8 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;