Skip to content

Commit

Permalink
Update Form to reduce re-renders and add focusFieldAt function to…
Browse files Browse the repository at this point in the history
… ref (#221)

* update Form to use callbacks and add focusFieldAt to ref

* export utility isFocused

* update Form example

* Add ref and additional FormAction

* Update shared example to use ref

* include ref explicitly in type for inference

* export `FormSwitch` directly

* Add optional and escape
  • Loading branch information
JDMathew authored Aug 6, 2024
1 parent fdca4ee commit b4f3942
Show file tree
Hide file tree
Showing 4 changed files with 137 additions and 90 deletions.
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

0 comments on commit b4f3942

Please sign in to comment.