Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update Form to reduce re-renders and add focusFieldAt function to ref #221

Merged
merged 8 commits into from
Aug 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions examples/shared/src/screens/Form.screen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Form, TextInput } from '@react-native-ama/forms';
import { Form, FormActions, TextInput } from '@react-native-ama/forms';
import { Text } from '@react-native-ama/react-native';
import * as React from 'react';
import { ScrollView, StyleSheet } from 'react-native';
Expand All @@ -12,6 +12,7 @@ export const FormScreen = () => {
const [firstName, setFirstName] = React.useState('');
const [lastName, setLastName] = React.useState('');
const [testKeyboardTrap, setTestKeyboardTrap] = React.useState(false);
const formRef = React.useRef<FormActions>(null);
const [invalidFields, setInvalidFields] = React.useState<{
lastName: boolean;
firstName: boolean;
Expand All @@ -36,7 +37,7 @@ export const FormScreen = () => {

return (
<ScrollView style={styles.view}>
<Form onSubmit={handleOnSubmit}>
<Form onSubmit={handleOnSubmit} ref={formRef}>
<TextInput
style={styles.input}
placeholder=""
Expand Down
82 changes: 54 additions & 28 deletions packages/forms/docs/Form.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,44 +4,54 @@ import { Required } from '@site/src/components';

The `<Form />` component provides a "local" context for the [`TextInput`](./TextInput.mdx), [`FormField`](./FormField.md) and [`SwitchListItem`](./SwitchListItem.md) components.

The provider hosts the hosts the `ref` values used by the [TextInput](./TextInput.mdx) to know which [`returnKey`](./TextInput.mdx#returnkeytype) and what would be the next field to focus.
The provider hosts the `ref` values used by the [TextInput](./TextInput.mdx) to know which [`returnKey`](./TextInput.mdx#returnkeytype) and what would be the next field to focus.

## Usage

```jsx
<Form onSubmit={handleSubmit} ref={ref}>
{...}
{...}
</Form>
```

## Example

```jsx
<Form onSubmit={handleSubmit}>
<TextInput
onChangeText={newText => setFirstName(newText)}
defaultValue={text}
label={<Text>First name:</Text>}
/>

<TextInput
onChangeText={newText => setLastName(newText)}
defaultValue={text}
label={<Text>Last name:</Text>}
/>

<SwitchListItem
label={<Text>Subscribe me to the newsletter</Text>}
value={isSubscribed}
onValueChange={toggleSwitch}
/>

<TextInput
onChangeText={newText => setEmailAddress(newText)}
defaultValue={text}
label={<Text>Email address:</Text>}
/>
</Form>
import { Form, TextInput } from '@react-native-ama/forms';
import { SwitchListItem } from '@react-native-ama/react-native';

const ExampleForm = () => {
return (
<Form onSubmit={handleSubmit}>
<TextInput
onChangeText={newText => setFirstName(newText)}
defaultValue={text}
label={<Text>First name:</Text>}
/>

<TextInput
onChangeText={newText => setLastName(newText)}
defaultValue={text}
label={<Text>Last name:</Text>}
/>

<SwitchListItem
label={<Text>Subscribe me to the newsletter</Text>}
value={isSubscribed}
onValueChange={toggleSwitch}
/>

<TextInput
onChangeText={newText => setEmailAddress(newText)}
defaultValue={text}
label={<Text>Email address:</Text>}
/>
<FormSubmit accessibilityLabel="Submit">
<CustomSubmitButton />
</FormSubmit>
</Form>
);
};
```

When the user interacts with this form:
Expand Down Expand Up @@ -188,12 +198,22 @@ The callback to be called when the [`TextInput`](./TextInput.mdx) `returnKeyboar
| -------- |
| callback |

## Methods
### `ref` _(optional)_

The form provider reference provides access to `focusFirstInvalidField` and `focusFieldAt` methods.

| Type | Default |
| ------------------------------ | --------- |
| React.RefObject\<FormActions\> | undefined |

## Methods (`FormActions`)

### `focusFirstInvalidField`

This method lets you manually shift the focus to the first field that has an error.

`focusFirstInvalidField: () => void;`

```
// To manually focus the first invalid field
const focusInvalidField = () => {
Expand All @@ -205,6 +225,12 @@ const focusInvalidField = () => {
</Form>
```

### `focusFieldAt`

This method lets you manually shift the focus to any field controlled by the form. Simply call the method with the fieldNumber reference which is the zero-based index of the field in the list.

`focusFieldAt: (fieldNumber: number) => void;`

## Related guidelines

- [Forms](../guidelines/forms)
132 changes: 73 additions & 59 deletions packages/forms/src/components/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import { InteractionManager } from 'react-native';

export type FormProps = React.PropsWithChildren<{
onSubmit: () => boolean | Promise<boolean>;
ref?: React.RefObject<FormActions>;
ref?: React.RefObject<FormActions>; // need to explicitly type for inference in <Form /> component
}>;

export type FormActions = {
focusFirstInvalidField: () => void;
focusFieldAt: (fieldNumber: number) => void;
};

export const Form = React.forwardRef<FormActions, FormProps>(
Expand All @@ -19,82 +20,95 @@ export const Form = React.forwardRef<FormActions, FormProps>(

const checks = __DEV__ ? useChecks?.() : undefined;

const focusField = (nextField?: Partial<FormRef> | undefined) => {
/**
* Refs passed as prop have another ".current"
*/
const nextRefElement = nextField?.ref?.current?.current
? nextField?.ref.current
: nextField?.ref;
const focusField = React.useCallback(
(nextField?: Partial<FormRef> | undefined) => {
/**
* Refs passed as prop have another ".current"
*/
const nextRefElement = nextField?.ref?.current?.current
? nextField?.ref.current
: nextField?.ref;

const callFocus =
// @ts-ignore
nextRefElement?.current?.focus &&
nextField?.hasFocusCallback &&
nextField?.isEditable;

__DEV__ &&
nextRefElement == null &&
checks?.logResult('nextRefElement', {
message:
'No next field found. Make sure you wrapped your form inside the <Form /> component',
rule: 'NO_UNDEFINED',
});

if (callFocus) {
/**
* On some apps, if we call focus immediately and the field is already focused we lose the focus.
* Same happens if we do not call `focus` if the field is already focused.
*/
const timeoutValue = isFocused(nextRefElement.current) ? 50 : 0;

setTimeout(() => {
nextRefElement?.current?.focus();

__DEV__ &&
nextRefElement &&
checks?.checkFocusTrap({
ref: nextRefElement,
shouldHaveFocus: true,
});
}, timeoutValue);
} else if (nextRefElement?.current) {
setFocus(nextRefElement?.current);
}
},
[checks, setFocus],
);

const callFocus =
// @ts-ignore
nextRefElement?.current?.focus &&
nextField?.hasFocusCallback &&
nextField?.isEditable;
const focusFieldAt = React.useCallback(
(position: number) => {
InteractionManager.runAfterInteractions(() => {
setTimeout(() => {
const fieldWithError = refs.current[position];

focusField(fieldWithError);
}, 0);
});
},
[focusField],
);

const focusFirstInvalidField = React.useCallback(() => {
const fieldWithError = (refs.current || []).findIndex(
fieldRef => fieldRef.hasValidation && fieldRef.hasError,
);

__DEV__ &&
nextRefElement == null &&
checks?.logResult('nextRefElement', {
fieldWithError == null &&
checks?.logResult('Form', {
message:
'No next field found. Make sure you wrapped your form inside the <Form /> component',
'The form validation has failed, but no component with error was found',
rule: 'NO_UNDEFINED',
});

if (callFocus) {
/**
* On some apps, if we call focus immediately and the field is already focused we lose the focus.
* Same happens if we do not call `focus` if the field is already focused.
*/
const timeoutValue = isFocused(nextRefElement.current) ? 50 : 0;

setTimeout(() => {
nextRefElement?.current?.focus();

__DEV__ &&
nextRefElement &&
checks?.checkFocusTrap({
ref: nextRefElement,
shouldHaveFocus: true,
});
}, timeoutValue);
} else if (nextRefElement?.current) {
setFocus(nextRefElement?.current);
}
};
focusFieldAt(fieldWithError);
}, [focusFieldAt, checks]);

const submitForm = async () => {
const submitForm = React.useCallback(async () => {
const isValid = await onSubmit();

if (isValid) {
return;
}

focusFirstInvalidField();
};

const focusFirstInvalidField = () => {
InteractionManager.runAfterInteractions(() => {
setTimeout(() => {
const fieldWithError = (refs.current || []).find(
fieldRef => fieldRef.hasValidation && fieldRef.hasError,
);

__DEV__ &&
fieldWithError == null &&
checks?.logResult('Form', {
message:
'The form validation has failed, but no component with error was found',
rule: 'NO_UNDEFINED',
});

focusField(fieldWithError);
}, 0);
});
};
}, [focusFirstInvalidField, onSubmit]);

React.useImperativeHandle(ref, () => ({
focusFirstInvalidField,
focusFieldAt,
}));

return (
Expand Down
8 changes: 7 additions & 1 deletion packages/forms/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
// Components

import { isFocused } from '@react-native-ama/internal';

import {
FormActions,
FormProps,
Expand All @@ -20,7 +23,7 @@ Form.Submit = FormSubmit;
Form.Field = FormField;
Form.Switch = FormSwitch;

export { Form, FormField, FormSubmit };
export { Form, FormField, FormSubmit, FormSwitch };

// Components
export { TextInput } from './components/TextInput';
Expand All @@ -29,6 +32,9 @@ export { TextInput } from './components/TextInput';
export { useFormField } from './hooks/useFormField';
export { useTextInput } from './hooks/useTextInput';

// utils
export { isFocused };

// Types
export {
type FormProps,
Expand Down
Loading