Skip to content

Commit

Permalink
feat(Form): Disable autofocus on error for some fields on iOS (#1307)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
pladaria and Pedro Ladaria authored Jan 14, 2025
1 parent 34bcc78 commit 316565d
Show file tree
Hide file tree
Showing 3 changed files with 102 additions and 8 deletions.
54 changes: 53 additions & 1 deletion src/__tests__/form-test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 (
<ThemeContextProvider theme={makeTheme({platformOverrides: {platform}})}>
<Form onSubmit={() => {}}>
{type === 'date' && <DateField label="Field" name="field" />}
{type === 'datetime-local' && <DateTimeField label="Field" name="field" />}
{type === 'month' && <MonthField label="Field" name="field" />}
{type === 'select' && (
<Select name="field" label="Field" options={[{value: '1', text: '1'}]} />
)}
{type === 'text' && <TextField label="Field" name="field" />}
<ButtonPrimary submit>Submit</ButtonPrimary>
</Form>
</ThemeContextProvider>
);
};

render(<FormComponent />);

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);
});
43 changes: 38 additions & 5 deletions src/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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> | void;
Expand Down Expand Up @@ -49,7 +52,7 @@ const Form = ({
const [formErrors, setFormErrors] = React.useState<FormErrors>({});
const fieldRegistrations = React.useRef(new Map<string, FieldRegistration>());
const formRef = React.useRef<HTMLFormElement | null>(null);
const {texts, t} = useTheme();
const {texts, t, platformOverrides} = useTheme();
const reactId = React.useId();
const id = idProp || reactId;

Expand Down Expand Up @@ -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
*/
Expand All @@ -114,16 +137,19 @@ const Form = ({
const reg = fieldRegistrations.current.get(name);
return reg?.focusableElement || reg?.input;
})
.filter(Boolean) as Array<HTMLSelectElement | HTMLDivElement>; // casted to remove inferred nulls/undefines
.filter(Boolean) as Array<HTMLFieldElement>; // 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
Expand All @@ -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) => {
Expand Down
13 changes: 11 additions & 2 deletions src/utils/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down

1 comment on commit 316565d

@github-actions
Copy link

Choose a reason for hiding this comment

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

Deploy preview for mistica-web ready!

✅ Preview
https://mistica-89g4ymmkd-flows-projects-65bb050e.vercel.app

Built with commit 316565d.
This pull request is being automatically deployed with vercel-action

Please sign in to comment.