Skip to content

Commit 316565d

Browse files
pladariaPedro Ladaria
andauthored
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 <Form> <Stack space={16}> <Select name="field" label="field" options={[{ value: "x", text: "x" }]} /> <ButtonPrimary submit>Submit</ButtonPrimary> </Stack> </Form> ``` and opening the preview in an iphone --------- Co-authored-by: Pedro Ladaria <pedro.jose.ladaria.linden@telefonica.com>
1 parent 34bcc78 commit 316565d

File tree

3 files changed

+102
-8
lines changed

3 files changed

+102
-8
lines changed

src/__tests__/form-test.tsx

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
import * as React from 'react';
22
import {render, screen, waitFor} from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
4-
import {ButtonPrimary, Form, TextField, EmailField, PasswordField, Switch, PhoneNumberField} from '..';
4+
import {
5+
ButtonPrimary,
6+
Form,
7+
TextField,
8+
EmailField,
9+
PasswordField,
10+
Switch,
11+
PhoneNumberField,
12+
DateField,
13+
DateTimeField,
14+
Select,
15+
MonthField,
16+
} from '..';
517
import ThemeContextProvider from '../theme-context-provider';
618
import {makeTheme} from './test-utils';
719

@@ -296,3 +308,43 @@ test('Disabling a field removes the error state and disabled fields are not subm
296308
{phone: '654 83 44 55', switch: false}
297309
);
298310
});
311+
312+
test.each`
313+
platform | type | expectedFocus
314+
${'ios'} | ${'date'} | ${false}
315+
${'ios'} | ${'datetime-local'} | ${false}
316+
${'ios'} | ${'month'} | ${false}
317+
${'ios'} | ${'select'} | ${false}
318+
${'ios'} | ${'text'} | ${true}
319+
${'android'} | ${'date'} | ${true}
320+
${'android'} | ${'datetime-local'} | ${true}
321+
${'android'} | ${'month'} | ${true}
322+
${'android'} | ${'select'} | ${true}
323+
${'android'} | ${'text'} | ${true}
324+
`('autofocus on error - $platform $type $expectedFocus', async ({platform, type, expectedFocus}) => {
325+
const FormComponent = () => {
326+
return (
327+
<ThemeContextProvider theme={makeTheme({platformOverrides: {platform}})}>
328+
<Form onSubmit={() => {}}>
329+
{type === 'date' && <DateField label="Field" name="field" />}
330+
{type === 'datetime-local' && <DateTimeField label="Field" name="field" />}
331+
{type === 'month' && <MonthField label="Field" name="field" />}
332+
{type === 'select' && (
333+
<Select name="field" label="Field" options={[{value: '1', text: '1'}]} />
334+
)}
335+
{type === 'text' && <TextField label="Field" name="field" />}
336+
<ButtonPrimary submit>Submit</ButtonPrimary>
337+
</Form>
338+
</ThemeContextProvider>
339+
);
340+
};
341+
342+
render(<FormComponent />);
343+
344+
const submitButton = await screen.findByRole('button', {name: 'Submit'});
345+
await userEvent.click(submitButton);
346+
347+
const input = await screen.findByLabelText('Field');
348+
// eslint-disable-next-line testing-library/no-node-access
349+
expect(document.activeElement === input).toBe(expectedFocus);
350+
});

src/form.tsx

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import classnames from 'classnames';
66
import * as styles from './form.css';
77
import * as tokens from './text-tokens';
88
import ScreenReaderOnly from './screen-reader-only';
9+
import {isIos} from './utils/platform';
910

1011
import type {FormStatus, FormErrors, FieldRegistration} from './form-context';
1112

@@ -21,6 +22,8 @@ if (
2122

2223
export type FormValues = {[name: string]: any};
2324

25+
type HTMLFieldElement = HTMLSelectElement | HTMLInputElement;
26+
2427
type FormProps = {
2528
id?: string;
2629
onSubmit: (values: FormValues, rawValues: FormValues) => Promise<void> | void;
@@ -49,7 +52,7 @@ const Form = ({
4952
const [formErrors, setFormErrors] = React.useState<FormErrors>({});
5053
const fieldRegistrations = React.useRef(new Map<string, FieldRegistration>());
5154
const formRef = React.useRef<HTMLFormElement | null>(null);
52-
const {texts, t} = useTheme();
55+
const {texts, t, platformOverrides} = useTheme();
5356
const reactId = React.useId();
5457
const id = idProp || reactId;
5558

@@ -88,6 +91,26 @@ const Form = ({
8891
[]
8992
);
9093

94+
/**
95+
* In iOS the pickers/selects are automatically opened when the input is focused
96+
* This is not what we want so, for some specific elements, we disable the autofocus on error
97+
*/
98+
const shouldAutofocusFieldOnError = React.useCallback(
99+
(element: HTMLFieldElement): boolean => {
100+
if (!isIos(platformOverrides)) {
101+
return true;
102+
}
103+
if (element.tagName === 'SELECT') {
104+
return false;
105+
}
106+
if (['date', 'datetime-local', 'month'].includes(element.type)) {
107+
return false;
108+
}
109+
return true;
110+
},
111+
[platformOverrides]
112+
);
113+
91114
/**
92115
* returns true if all fields are ok and focuses the first field with an error
93116
*/
@@ -114,16 +137,19 @@ const Form = ({
114137
const reg = fieldRegistrations.current.get(name);
115138
return reg?.focusableElement || reg?.input;
116139
})
117-
.filter(Boolean) as Array<HTMLSelectElement | HTMLDivElement>; // casted to remove inferred nulls/undefines
140+
.filter(Boolean) as Array<HTMLFieldElement>; // casted to remove inferred nulls/undefines
118141

119142
if (elementsWithErrors.length) {
120143
elementsWithErrors.sort((a, b) =>
121144
a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
122145
);
123-
elementsWithErrors[0].focus();
146+
const firstElementWithError = elementsWithErrors[0];
147+
if (shouldAutofocusFieldOnError(firstElementWithError)) {
148+
firstElementWithError.focus();
149+
}
124150
try {
125151
// polyfilled, see import at the top of this file
126-
elementsWithErrors[0].scrollIntoView({behavior: 'smooth', block: 'center', inline: 'center'});
152+
firstElementWithError.scrollIntoView({behavior: 'smooth', block: 'center', inline: 'center'});
127153
} catch (e) {
128154
// ignore errors
129155
// element.scrollIntoView not available in unit test environment
@@ -135,7 +161,14 @@ const Form = ({
135161
onValidationErrors(errors);
136162
}
137163
return errors;
138-
}, [onValidationErrors, rawValues, texts, values, t]);
164+
}, [
165+
onValidationErrors,
166+
rawValues,
167+
texts.formFieldErrorIsMandatory,
168+
t,
169+
values,
170+
shouldAutofocusFieldOnError,
171+
]);
139172

140173
const jumpToNext = React.useCallback(
141174
(currentName: string) => {

src/utils/platform.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,19 @@ export const isRunningAcceptanceTest = (platformOverrides: Theme['platformOverri
2121

2222
const isEdgeOrIE = Boolean(typeof self !== 'undefined' && (self as any).MSStream);
2323

24-
export const isAndroid = (platformOverrides: Theme['platformOverrides']): boolean =>
25-
getUserAgent(platformOverrides).toLowerCase().includes('android') && !isEdgeOrIE;
24+
export const isAndroid = (platformOverrides: Theme['platformOverrides']): boolean => {
25+
if (platformOverrides.platform === 'android') {
26+
return true;
27+
}
28+
29+
return getUserAgent(platformOverrides).toLowerCase().includes('android') && !isEdgeOrIE;
30+
};
2631

2732
export const isIos = (platformOverrides: Theme['platformOverrides']): boolean => {
33+
if (platformOverrides.platform === 'ios') {
34+
return true;
35+
}
36+
2837
// IE and Edge mobile browsers includes Android and iPhone in the user agent
2938
if (/iPad|iPhone|iPod/.test(getUserAgent(platformOverrides)) && !isEdgeOrIE) {
3039
return true;

0 commit comments

Comments
 (0)