From b4f3942841cb4ea7ffe3a6cf394778d5a941956e Mon Sep 17 00:00:00 2001 From: Justin D Mathew Date: Tue, 6 Aug 2024 15:30:25 -0700 Subject: [PATCH] Update `Form` to reduce re-renders and add `focusFieldAt` function to 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 --- examples/shared/src/screens/Form.screen.tsx | 5 +- packages/forms/docs/Form.md | 82 +++++++----- packages/forms/src/components/Form.tsx | 132 +++++++++++--------- packages/forms/src/index.ts | 8 +- 4 files changed, 137 insertions(+), 90 deletions(-) diff --git a/examples/shared/src/screens/Form.screen.tsx b/examples/shared/src/screens/Form.screen.tsx index 3b2795f0..55ab7fb5 100644 --- a/examples/shared/src/screens/Form.screen.tsx +++ b/examples/shared/src/screens/Form.screen.tsx @@ -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'; @@ -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(null); const [invalidFields, setInvalidFields] = React.useState<{ lastName: boolean; firstName: boolean; @@ -36,7 +37,7 @@ export const FormScreen = () => { return ( -
+ ` 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 - {...} + {...} ``` ## Example ```jsx -
- setFirstName(newText)} - defaultValue={text} - label={First name:} - /> - - setLastName(newText)} - defaultValue={text} - label={Last name:} - /> - - Subscribe me to the newsletter} - value={isSubscribed} - onValueChange={toggleSwitch} - /> - - setEmailAddress(newText)} - defaultValue={text} - label={Email address:} - /> - +import { Form, TextInput } from '@react-native-ama/forms'; +import { SwitchListItem } from '@react-native-ama/react-native'; + +const ExampleForm = () => { + return ( +
+ setFirstName(newText)} + defaultValue={text} + label={First name:} + /> + + setLastName(newText)} + defaultValue={text} + label={Last name:} + /> + + Subscribe me to the newsletter} + value={isSubscribed} + onValueChange={toggleSwitch} + /> + + setEmailAddress(newText)} + defaultValue={text} + label={Email address:} + /> + + + + + ); +}; ``` When the user interacts with this form: @@ -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\ | 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 = () => { @@ -205,6 +225,12 @@ const focusInvalidField = () => { ``` +### `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) diff --git a/packages/forms/src/components/Form.tsx b/packages/forms/src/components/Form.tsx index 2b1de013..bc5e6b78 100644 --- a/packages/forms/src/components/Form.tsx +++ b/packages/forms/src/components/Form.tsx @@ -5,11 +5,12 @@ import { InteractionManager } from 'react-native'; export type FormProps = React.PropsWithChildren<{ onSubmit: () => boolean | Promise; - ref?: React.RefObject; + ref?: React.RefObject; // need to explicitly type for inference in
component }>; export type FormActions = { focusFirstInvalidField: () => void; + focusFieldAt: (fieldNumber: number) => void; }; export const Form = React.forwardRef( @@ -19,51 +20,83 @@ export const Form = React.forwardRef( const checks = __DEV__ ? useChecks?.() : undefined; - const focusField = (nextField?: Partial | 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 | 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 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 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) { @@ -71,30 +104,11 @@ export const Form = React.forwardRef( } 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 ( diff --git a/packages/forms/src/index.ts b/packages/forms/src/index.ts index fa531949..dd88aba2 100644 --- a/packages/forms/src/index.ts +++ b/packages/forms/src/index.ts @@ -1,4 +1,7 @@ // Components + +import { isFocused } from '@react-native-ama/internal'; + import { FormActions, FormProps, @@ -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'; @@ -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,