From c81be37a46b4e3aa0a8ded7a3c65f0bfd22bc032 Mon Sep 17 00:00:00 2001 From: jameswilddev Date: Wed, 24 Nov 2021 09:45:04 +0000 Subject: [PATCH] Add text inputs. --- .../index.tsx | 103 +++ .../readme.md | 113 ++++ .../createNullableTextInputComponent/unit.tsx | 624 ++++++++++++++++++ .../index.tsx | 103 +++ .../readme.md | 113 ++++ .../createRequiredTextInputComponent/unit.tsx | 567 ++++++++++++++++ index.ts | 2 + readme.md | 2 + 8 files changed, 1627 insertions(+) create mode 100644 components/createNullableTextInputComponent/index.tsx create mode 100644 components/createNullableTextInputComponent/readme.md create mode 100644 components/createNullableTextInputComponent/unit.tsx create mode 100644 components/createRequiredTextInputComponent/index.tsx create mode 100644 components/createRequiredTextInputComponent/readme.md create mode 100644 components/createRequiredTextInputComponent/unit.tsx diff --git a/components/createNullableTextInputComponent/index.tsx b/components/createNullableTextInputComponent/index.tsx new file mode 100644 index 00000000..fcecbd21 --- /dev/null +++ b/components/createNullableTextInputComponent/index.tsx @@ -0,0 +1,103 @@ +import * as React from "react"; +import type { ControlStyle } from "../.."; +import { createInputComponent } from "../createInputComponent"; + +/** + * Creates a new input component pre-configured as a nullable text input. + * @param controlStyle The style of the component to create. + * @param leftIcon The icon to show on the left side, if any, else, null. + * @param rightIcon The icon to show on the right side, if any, else, null. + * @param minimumLength When non-null, entered values must be greater for + * validation to succeed. + * @param maximumLength When non-null, entered values must be greater or equal + * for validation to succeed. + * @returns The created component. + */ +export const createNullableTextInputComponent = ( + controlStyle: ControlStyle, + leftIcon: null | React.ReactNode | JSX.Element, + rightIcon: null | React.ReactNode | JSX.Element, + minimumLength: null | number, + maximumLength: null | number +): React.FunctionComponent<{ + /** + * The value to edit. When undefined, it is treated as an invalid empty + * string. + */ + readonly value: undefined | null | string; + + /** + * Invoked when the user edits the text in the box. + * @param parsed The value parsed, or undefined should it not be parseable. + * @param complete True when the user has finished editing, otherwise, false. + */ + onChange(parsed: undefined | null | string, complete: boolean): void; + + /** + * When true, the text box is rendered semi-transparently and does not accept + * focus or input. + */ + readonly disabled: boolean; + + /** + * Text to be shown when no value has been entered. + */ + readonly placeholder: string; + + /** + * The value entered must not appear in this list. + */ + readonly unique: ReadonlyArray; +}> => { + const NullableTextInputComponent = createInputComponent< + null | string, + ReadonlyArray + >( + (value) => (value === null ? `` : value.trim().replace(/\s+/g, ` `)), + (unparsed, context) => { + if (unparsed.trim() === ``) { + return null; + } else { + const parsed = unparsed.trim().replace(/\s+/g, ` `); + + if (minimumLength !== null && parsed.length < minimumLength) { + return undefined; + } else if (maximumLength !== null && parsed.length > maximumLength) { + return undefined; + } else { + const match = parsed.toLowerCase(); + + for (const option of context) { + if (option.trim().replace(/\s+/g, ` `).toLowerCase() === match) { + return undefined; + } + } + + return parsed; + } + } + }, + controlStyle, + false, + `off`, + `default`, + false, + false + ); + + return ({ value, onChange, disabled, placeholder, unique }) => ( + { + /* No-op. */ + }} + /> + ); +}; diff --git a/components/createNullableTextInputComponent/readme.md b/components/createNullableTextInputComponent/readme.md new file mode 100644 index 00000000..15bfc225 --- /dev/null +++ b/components/createNullableTextInputComponent/readme.md @@ -0,0 +1,113 @@ +# `react-native-app-helpers/createNullableTextInputComponent` + +Creates a new input component pre-configured as a nullable text input. + +## Usage + +```tsx +import { createNullableTextInputComponent } from "react-native-app-helpers"; + +const ExampleInput = createNullableTextInputComponent( + { + fontFamily: `Example Font Family`, + fontSize: 37, + paddingVertical: 12, + paddingHorizontal: 29, + blurredValid: { + textColor: `#FFEE00`, + placeholderColor: `#E7AA32`, + backgroundColor: `#32AE12`, + radius: 5, + border: { + width: 4, + color: `#FF00FF`, + }, + }, + blurredInvalid: { + textColor: `#99FE88`, + placeholderColor: `#CACA3A`, + backgroundColor: `#259284`, + radius: 10, + border: { + width: 6, + color: `#9A9A8E`, + }, + }, + focusedValid: { + textColor: `#55EA13`, + placeholderColor: `#273346`, + backgroundColor: `#CABA99`, + radius: 3, + border: { + width: 5, + color: `#646464`, + }, + }, + focusedInvalid: { + textColor: `#ABAADE`, + placeholderColor: `#47ADAD`, + backgroundColor: `#32AA88`, + radius: 47, + border: { + width: 12, + color: `#98ADAA`, + }, + }, + disabledValid: { + textColor: `#AE2195`, + placeholderColor: `#FFAAEE`, + backgroundColor: `#772728`, + radius: 100, + border: { + width: 14, + color: `#5E5E5E`, + }, + }, + disabledInvalid: { + textColor: `#340297`, + placeholderColor: `#233832`, + backgroundColor: `#938837`, + radius: 2, + border: { + width: 19, + color: `#573829`, + }, + }, + }, + Shown to the left, + Shown to the right, + null, + -14, + null, + 3, +); + +const ExampleScreen = () => { + // Useful for realtime submit button updates. + const [incompleteValue, setIncompleteValue] = React.useState(undefined); + + // Useful for persistence. + const [completeValue, setCompleteValue] = React.useState(undefined); + + return ( + + { + if (complete) { + setCompleteValue(value); + } else { + setIncompleteValue(value); + } + }} + disabled={false} + placeholder="Shown when no text has been entered" + unique={[`Not`, `In`, `This`, `List`]} + /> + Incomplete: {incompleteValue} + Complete: {completeValue} + Submitted: {submittedValue} + + ); +} +``` diff --git a/components/createNullableTextInputComponent/unit.tsx b/components/createNullableTextInputComponent/unit.tsx new file mode 100644 index 00000000..b776ea05 --- /dev/null +++ b/components/createNullableTextInputComponent/unit.tsx @@ -0,0 +1,624 @@ +import * as React from "react"; +import { Text } from "react-native"; +import { + createNullableTextInputComponent, + ControlStyle, + unwrapRenderedFunctionComponent, +} from "../.."; + +test(`renders as expected without bounds`, () => { + const controlStyle: ControlStyle = { + fontFamily: `Example Font Family`, + fontSize: 37, + paddingVertical: 12, + paddingHorizontal: 29, + blurredValid: { + textColor: `#FFEE00`, + placeholderColor: `#E7AA32`, + backgroundColor: `#32AE12`, + radius: 5, + border: { + width: 4, + color: `#FF00FF`, + }, + }, + blurredInvalid: { + textColor: `#99FE88`, + placeholderColor: `#CACA3A`, + backgroundColor: `#259284`, + radius: 10, + border: { + width: 6, + color: `#9A9A8E`, + }, + }, + focusedValid: { + textColor: `#55EA13`, + placeholderColor: `#273346`, + backgroundColor: `#CABA99`, + radius: 3, + border: { + width: 5, + color: `#646464`, + }, + }, + focusedInvalid: { + textColor: `#ABAADE`, + placeholderColor: `#47ADAD`, + backgroundColor: `#32AA88`, + radius: 47, + border: { + width: 12, + color: `#98ADAA`, + }, + }, + disabledValid: { + textColor: `#AE2195`, + placeholderColor: `#FFAAEE`, + backgroundColor: `#772728`, + radius: 100, + border: { + width: 14, + color: `#5E5E5E`, + }, + }, + disabledInvalid: { + textColor: `#340297`, + placeholderColor: `#233832`, + backgroundColor: `#938837`, + radius: 2, + border: { + width: 19, + color: `#573829`, + }, + }, + }; + const onChange = jest.fn(); + const Component = createNullableTextInputComponent( + controlStyle, + Example Left Icon, + Example Right Icon, + null, + null + ); + + const rendered = unwrapRenderedFunctionComponent( + + ); + + expect(rendered.type).toBeAFunctionWithTheStaticProperties({ + inputComponent: { + stringify: expect.any(Function), + tryParse: expect.any(Function), + controlStyle, + multiLine: false, + autoComplete: `off`, + keyboardType: `default`, + autoFocus: false, + keepFocusOnSubmit: false, + }, + }); + + expect(rendered.props).toEqual({ + leftIcon: Example Left Icon, + rightIcon: Example Right Icon, + value: `Example String`, + onChange, + disabled: true, + placeholder: `Example Placeholder`, + context: [`Example Unique A`, `Example Unique B`, `Example Unique C`], + secureTextEntry: false, + onSubmit: expect.any(Function), + }); + + expect(rendered.type.inputComponent.stringify(null)).toEqual(``); + expect( + rendered.type.inputComponent.stringify( + ` \n \r \t Example \t \r \n String \n \r \t` + ) + ).toEqual(`Example String`); + + expect( + rendered.type.inputComponent.tryParse(``, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeNull(); + expect( + rendered.type.inputComponent.tryParse(` \n \r \t `, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeNull(); + expect( + rendered.type.inputComponent.tryParse(``, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ``, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeNull(); + expect( + rendered.type.inputComponent.tryParse(` \n \r \t `, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ``, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeNull(); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t Unique \t \r \n B \n \r \t`, + [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ] + ) + ).toBeUndefined(); + expect( + rendered.type.inputComponent.tryParse(`Example String`, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toEqual(`Example String`); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t Example \t \r \n String \n \r \t`, + [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ] + ) + ).toEqual(`Example String`); + + rendered.props.onSubmit(); + + expect(onChange).not.toHaveBeenCalled(); +}); + +test(`renders as expected with a minimum length`, () => { + const controlStyle: ControlStyle = { + fontFamily: `Example Font Family`, + fontSize: 37, + paddingVertical: 12, + paddingHorizontal: 29, + blurredValid: { + textColor: `#FFEE00`, + placeholderColor: `#E7AA32`, + backgroundColor: `#32AE12`, + radius: 5, + border: { + width: 4, + color: `#FF00FF`, + }, + }, + blurredInvalid: { + textColor: `#99FE88`, + placeholderColor: `#CACA3A`, + backgroundColor: `#259284`, + radius: 10, + border: { + width: 6, + color: `#9A9A8E`, + }, + }, + focusedValid: { + textColor: `#55EA13`, + placeholderColor: `#273346`, + backgroundColor: `#CABA99`, + radius: 3, + border: { + width: 5, + color: `#646464`, + }, + }, + focusedInvalid: { + textColor: `#ABAADE`, + placeholderColor: `#47ADAD`, + backgroundColor: `#32AA88`, + radius: 47, + border: { + width: 12, + color: `#98ADAA`, + }, + }, + disabledValid: { + textColor: `#AE2195`, + placeholderColor: `#FFAAEE`, + backgroundColor: `#772728`, + radius: 100, + border: { + width: 14, + color: `#5E5E5E`, + }, + }, + disabledInvalid: { + textColor: `#340297`, + placeholderColor: `#233832`, + backgroundColor: `#938837`, + radius: 2, + border: { + width: 19, + color: `#573829`, + }, + }, + }; + const onChange = jest.fn(); + const Component = createNullableTextInputComponent( + controlStyle, + Example Left Icon, + Example Right Icon, + 14, + null + ); + + const rendered = unwrapRenderedFunctionComponent( + + ); + + expect(rendered.type).toBeAFunctionWithTheStaticProperties({ + inputComponent: { + stringify: expect.any(Function), + tryParse: expect.any(Function), + controlStyle, + multiLine: false, + autoComplete: `off`, + keyboardType: `default`, + autoFocus: false, + keepFocusOnSubmit: false, + }, + }); + + expect(rendered.props).toEqual({ + leftIcon: Example Left Icon, + rightIcon: Example Right Icon, + value: `Example String`, + onChange, + disabled: true, + placeholder: `Example Placeholder`, + context: [`Example Unique A`, `Example Unique B`, `Example Unique C`], + secureTextEntry: false, + onSubmit: expect.any(Function), + }); + + expect(rendered.type.inputComponent.stringify(null)).toEqual(``); + expect( + rendered.type.inputComponent.stringify( + ` \n \r \t Example \t \r \n String \n \r \t` + ) + ).toEqual(`Example String`); + + expect( + rendered.type.inputComponent.tryParse(``, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeNull(); + expect( + rendered.type.inputComponent.tryParse(` \n \r \t `, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeNull(); + expect( + rendered.type.inputComponent.tryParse(``, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ``, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeNull(); + expect( + rendered.type.inputComponent.tryParse(` \n \r \t `, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ``, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeNull(); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t Example \n \r \t Unique \t \r \n B \n \r \t`, + [ + ` \t \r \n Example \n \r \t Unique \t \t \n A \n \r \t `, + ` \t \r \n Example \n \r \t Unique \t \t \n B \n \r \t `, + ` \t \r \n Example \n \r \t Unique \t \t \n C \n \r \t `, + ] + ) + ).toBeUndefined(); + expect( + rendered.type.inputComponent.tryParse(`ExampleString`, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeUndefined(); + expect( + rendered.type.inputComponent.tryParse(`Example String`, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toEqual(`Example String`); + expect( + rendered.type.inputComponent.tryParse(`Exemplar String`, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toEqual(`Exemplar String`); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t ExampleString \n \r \t`, + [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ] + ) + ).toBeUndefined(); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t Example \t \r \n String \n \r \t`, + [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ] + ) + ).toEqual(`Example String`); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t Exemplar \t \r \n String \n \r \t`, + [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ] + ) + ).toEqual(`Exemplar String`); + + rendered.props.onSubmit(); + + expect(onChange).not.toHaveBeenCalled(); +}); + +test(`renders as expected with a maximum length`, () => { + const controlStyle: ControlStyle = { + fontFamily: `Example Font Family`, + fontSize: 37, + paddingVertical: 12, + paddingHorizontal: 29, + blurredValid: { + textColor: `#FFEE00`, + placeholderColor: `#E7AA32`, + backgroundColor: `#32AE12`, + radius: 5, + border: { + width: 4, + color: `#FF00FF`, + }, + }, + blurredInvalid: { + textColor: `#99FE88`, + placeholderColor: `#CACA3A`, + backgroundColor: `#259284`, + radius: 10, + border: { + width: 6, + color: `#9A9A8E`, + }, + }, + focusedValid: { + textColor: `#55EA13`, + placeholderColor: `#273346`, + backgroundColor: `#CABA99`, + radius: 3, + border: { + width: 5, + color: `#646464`, + }, + }, + focusedInvalid: { + textColor: `#ABAADE`, + placeholderColor: `#47ADAD`, + backgroundColor: `#32AA88`, + radius: 47, + border: { + width: 12, + color: `#98ADAA`, + }, + }, + disabledValid: { + textColor: `#AE2195`, + placeholderColor: `#FFAAEE`, + backgroundColor: `#772728`, + radius: 100, + border: { + width: 14, + color: `#5E5E5E`, + }, + }, + disabledInvalid: { + textColor: `#340297`, + placeholderColor: `#233832`, + backgroundColor: `#938837`, + radius: 2, + border: { + width: 19, + color: `#573829`, + }, + }, + }; + const onChange = jest.fn(); + const Component = createNullableTextInputComponent( + controlStyle, + Example Left Icon, + Example Right Icon, + null, + 14 + ); + + const rendered = unwrapRenderedFunctionComponent( + + ); + + expect(rendered.type).toBeAFunctionWithTheStaticProperties({ + inputComponent: { + stringify: expect.any(Function), + tryParse: expect.any(Function), + controlStyle, + multiLine: false, + autoComplete: `off`, + keyboardType: `default`, + autoFocus: false, + keepFocusOnSubmit: false, + }, + }); + + expect(rendered.props).toEqual({ + leftIcon: Example Left Icon, + rightIcon: Example Right Icon, + value: `Example String`, + onChange, + disabled: true, + placeholder: `Example Placeholder`, + context: [`Example Unique A`, `Example Unique B`, `Example Unique C`], + secureTextEntry: false, + onSubmit: expect.any(Function), + }); + + expect(rendered.type.inputComponent.stringify(null)).toEqual(``); + expect( + rendered.type.inputComponent.stringify( + ` \n \r \t Example \t \r \n String \n \r \t` + ) + ).toEqual(`Example String`); + + expect( + rendered.type.inputComponent.tryParse(``, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeNull(); + expect( + rendered.type.inputComponent.tryParse(` \n \r \t `, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeNull(); + expect( + rendered.type.inputComponent.tryParse(``, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ``, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeNull(); + expect( + rendered.type.inputComponent.tryParse(` \n \r \t `, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ``, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeNull(); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t Unique \t \r \n B \n \r \t`, + [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ] + ) + ).toBeUndefined(); + expect( + rendered.type.inputComponent.tryParse(`ExampleString`, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toEqual(`ExampleString`); + expect( + rendered.type.inputComponent.tryParse(`Example String`, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toEqual(`Example String`); + expect( + rendered.type.inputComponent.tryParse(`Exemplar String`, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeUndefined(); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t ExampleString \n \r \t`, + [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ] + ) + ).toEqual(`ExampleString`); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t Example \t \r \n String \n \r \t`, + [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ] + ) + ).toEqual(`Example String`); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t Exemplar \t \r \n String \n \r \t`, + [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ] + ) + ).toBeUndefined(); + + rendered.props.onSubmit(); + + expect(onChange).not.toHaveBeenCalled(); +}); diff --git a/components/createRequiredTextInputComponent/index.tsx b/components/createRequiredTextInputComponent/index.tsx new file mode 100644 index 00000000..1ba83dd6 --- /dev/null +++ b/components/createRequiredTextInputComponent/index.tsx @@ -0,0 +1,103 @@ +import * as React from "react"; +import type { ControlStyle } from "../.."; +import { createInputComponent } from "../createInputComponent"; + +/** + * Creates a new input component pre-configured as a required text input. + * @param controlStyle The style of the component to create. + * @param leftIcon The icon to show on the left side, if any, else, null. + * @param rightIcon The icon to show on the right side, if any, else, null. + * @param minimumLength When non-null, entered values must be greater for + * validation to succeed. + * @param maximumLength When non-null, entered values must be greater or equal + * for validation to succeed. + * @returns The created component. + */ +export const createRequiredTextInputComponent = ( + controlStyle: ControlStyle, + leftIcon: null | React.ReactNode | JSX.Element, + rightIcon: null | React.ReactNode | JSX.Element, + minimumLength: null | number, + maximumLength: null | number +): React.FunctionComponent<{ + /** + * The value to edit. When undefined, it is treated as an invalid empty + * string. + */ + readonly value: undefined | string; + + /** + * Invoked when the user edits the text in the box. + * @param parsed The value parsed, or undefined should it not be parseable. + * @param complete True when the user has finished editing, otherwise, false. + */ + onChange(parsed: undefined | string, complete: boolean): void; + + /** + * When true, the text box is rendered semi-transparently and does not accept + * focus or input. + */ + readonly disabled: boolean; + + /** + * Text to be shown when no value has been entered. + */ + readonly placeholder: string; + + /** + * The value entered must not appear in this list. + */ + readonly unique: ReadonlyArray; +}> => { + const RequiredTextInputComponent = createInputComponent< + string, + ReadonlyArray + >( + (value) => value.trim().replace(/\s+/g, ` `), + (unparsed, context) => { + if (unparsed.trim() === ``) { + return undefined; + } else { + const parsed = unparsed.trim().replace(/\s+/g, ` `); + + if (minimumLength !== null && parsed.length < minimumLength) { + return undefined; + } else if (maximumLength !== null && parsed.length > maximumLength) { + return undefined; + } else { + const match = parsed.toLowerCase(); + + for (const option of context) { + if (option.trim().replace(/\s+/g, ` `).toLowerCase() === match) { + return undefined; + } + } + + return parsed; + } + } + }, + controlStyle, + false, + `off`, + `default`, + false, + false + ); + + return ({ value, onChange, disabled, placeholder, unique }) => ( + { + /* No-op. */ + }} + /> + ); +}; diff --git a/components/createRequiredTextInputComponent/readme.md b/components/createRequiredTextInputComponent/readme.md new file mode 100644 index 00000000..15bfc225 --- /dev/null +++ b/components/createRequiredTextInputComponent/readme.md @@ -0,0 +1,113 @@ +# `react-native-app-helpers/createNullableTextInputComponent` + +Creates a new input component pre-configured as a nullable text input. + +## Usage + +```tsx +import { createNullableTextInputComponent } from "react-native-app-helpers"; + +const ExampleInput = createNullableTextInputComponent( + { + fontFamily: `Example Font Family`, + fontSize: 37, + paddingVertical: 12, + paddingHorizontal: 29, + blurredValid: { + textColor: `#FFEE00`, + placeholderColor: `#E7AA32`, + backgroundColor: `#32AE12`, + radius: 5, + border: { + width: 4, + color: `#FF00FF`, + }, + }, + blurredInvalid: { + textColor: `#99FE88`, + placeholderColor: `#CACA3A`, + backgroundColor: `#259284`, + radius: 10, + border: { + width: 6, + color: `#9A9A8E`, + }, + }, + focusedValid: { + textColor: `#55EA13`, + placeholderColor: `#273346`, + backgroundColor: `#CABA99`, + radius: 3, + border: { + width: 5, + color: `#646464`, + }, + }, + focusedInvalid: { + textColor: `#ABAADE`, + placeholderColor: `#47ADAD`, + backgroundColor: `#32AA88`, + radius: 47, + border: { + width: 12, + color: `#98ADAA`, + }, + }, + disabledValid: { + textColor: `#AE2195`, + placeholderColor: `#FFAAEE`, + backgroundColor: `#772728`, + radius: 100, + border: { + width: 14, + color: `#5E5E5E`, + }, + }, + disabledInvalid: { + textColor: `#340297`, + placeholderColor: `#233832`, + backgroundColor: `#938837`, + radius: 2, + border: { + width: 19, + color: `#573829`, + }, + }, + }, + Shown to the left, + Shown to the right, + null, + -14, + null, + 3, +); + +const ExampleScreen = () => { + // Useful for realtime submit button updates. + const [incompleteValue, setIncompleteValue] = React.useState(undefined); + + // Useful for persistence. + const [completeValue, setCompleteValue] = React.useState(undefined); + + return ( + + { + if (complete) { + setCompleteValue(value); + } else { + setIncompleteValue(value); + } + }} + disabled={false} + placeholder="Shown when no text has been entered" + unique={[`Not`, `In`, `This`, `List`]} + /> + Incomplete: {incompleteValue} + Complete: {completeValue} + Submitted: {submittedValue} + + ); +} +``` diff --git a/components/createRequiredTextInputComponent/unit.tsx b/components/createRequiredTextInputComponent/unit.tsx new file mode 100644 index 00000000..5b21ff2a --- /dev/null +++ b/components/createRequiredTextInputComponent/unit.tsx @@ -0,0 +1,567 @@ +import * as React from "react"; +import { Text } from "react-native"; +import { + createRequiredTextInputComponent, + ControlStyle, + unwrapRenderedFunctionComponent, +} from "../.."; + +test(`renders as expected without bounds`, () => { + const controlStyle: ControlStyle = { + fontFamily: `Example Font Family`, + fontSize: 37, + paddingVertical: 12, + paddingHorizontal: 29, + blurredValid: { + textColor: `#FFEE00`, + placeholderColor: `#E7AA32`, + backgroundColor: `#32AE12`, + radius: 5, + border: { + width: 4, + color: `#FF00FF`, + }, + }, + blurredInvalid: { + textColor: `#99FE88`, + placeholderColor: `#CACA3A`, + backgroundColor: `#259284`, + radius: 10, + border: { + width: 6, + color: `#9A9A8E`, + }, + }, + focusedValid: { + textColor: `#55EA13`, + placeholderColor: `#273346`, + backgroundColor: `#CABA99`, + radius: 3, + border: { + width: 5, + color: `#646464`, + }, + }, + focusedInvalid: { + textColor: `#ABAADE`, + placeholderColor: `#47ADAD`, + backgroundColor: `#32AA88`, + radius: 47, + border: { + width: 12, + color: `#98ADAA`, + }, + }, + disabledValid: { + textColor: `#AE2195`, + placeholderColor: `#FFAAEE`, + backgroundColor: `#772728`, + radius: 100, + border: { + width: 14, + color: `#5E5E5E`, + }, + }, + disabledInvalid: { + textColor: `#340297`, + placeholderColor: `#233832`, + backgroundColor: `#938837`, + radius: 2, + border: { + width: 19, + color: `#573829`, + }, + }, + }; + const onChange = jest.fn(); + const Component = createRequiredTextInputComponent( + controlStyle, + Example Left Icon, + Example Right Icon, + null, + null + ); + + const rendered = unwrapRenderedFunctionComponent( + + ); + + expect(rendered.type).toBeAFunctionWithTheStaticProperties({ + inputComponent: { + stringify: expect.any(Function), + tryParse: expect.any(Function), + controlStyle, + multiLine: false, + autoComplete: `off`, + keyboardType: `default`, + autoFocus: false, + keepFocusOnSubmit: false, + }, + }); + + expect(rendered.props).toEqual({ + leftIcon: Example Left Icon, + rightIcon: Example Right Icon, + value: `Example String`, + onChange, + disabled: true, + placeholder: `Example Placeholder`, + context: [`Example Unique A`, `Example Unique B`, `Example Unique C`], + secureTextEntry: false, + onSubmit: expect.any(Function), + }); + + expect( + rendered.type.inputComponent.stringify( + ` \n \r \t Example \t \r \n String \n \r \t` + ) + ).toEqual(`Example String`); + + expect( + rendered.type.inputComponent.tryParse(``, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeUndefined(); + expect( + rendered.type.inputComponent.tryParse(` \n \r \t `, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeUndefined(); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t Unique \t \r \n B \n \r \t`, + [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ] + ) + ).toBeUndefined(); + expect( + rendered.type.inputComponent.tryParse(`Example String`, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toEqual(`Example String`); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t Example \t \r \n String \n \r \t`, + [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ] + ) + ).toEqual(`Example String`); + + rendered.props.onSubmit(); + + expect(onChange).not.toHaveBeenCalled(); +}); + +test(`renders as expected with a minimum length`, () => { + const controlStyle: ControlStyle = { + fontFamily: `Example Font Family`, + fontSize: 37, + paddingVertical: 12, + paddingHorizontal: 29, + blurredValid: { + textColor: `#FFEE00`, + placeholderColor: `#E7AA32`, + backgroundColor: `#32AE12`, + radius: 5, + border: { + width: 4, + color: `#FF00FF`, + }, + }, + blurredInvalid: { + textColor: `#99FE88`, + placeholderColor: `#CACA3A`, + backgroundColor: `#259284`, + radius: 10, + border: { + width: 6, + color: `#9A9A8E`, + }, + }, + focusedValid: { + textColor: `#55EA13`, + placeholderColor: `#273346`, + backgroundColor: `#CABA99`, + radius: 3, + border: { + width: 5, + color: `#646464`, + }, + }, + focusedInvalid: { + textColor: `#ABAADE`, + placeholderColor: `#47ADAD`, + backgroundColor: `#32AA88`, + radius: 47, + border: { + width: 12, + color: `#98ADAA`, + }, + }, + disabledValid: { + textColor: `#AE2195`, + placeholderColor: `#FFAAEE`, + backgroundColor: `#772728`, + radius: 100, + border: { + width: 14, + color: `#5E5E5E`, + }, + }, + disabledInvalid: { + textColor: `#340297`, + placeholderColor: `#233832`, + backgroundColor: `#938837`, + radius: 2, + border: { + width: 19, + color: `#573829`, + }, + }, + }; + const onChange = jest.fn(); + const Component = createRequiredTextInputComponent( + controlStyle, + Example Left Icon, + Example Right Icon, + 14, + null + ); + + const rendered = unwrapRenderedFunctionComponent( + + ); + + expect(rendered.type).toBeAFunctionWithTheStaticProperties({ + inputComponent: { + stringify: expect.any(Function), + tryParse: expect.any(Function), + controlStyle, + multiLine: false, + autoComplete: `off`, + keyboardType: `default`, + autoFocus: false, + keepFocusOnSubmit: false, + }, + }); + + expect(rendered.props).toEqual({ + leftIcon: Example Left Icon, + rightIcon: Example Right Icon, + value: `Example String`, + onChange, + disabled: true, + placeholder: `Example Placeholder`, + context: [`Example Unique A`, `Example Unique B`, `Example Unique C`], + secureTextEntry: false, + onSubmit: expect.any(Function), + }); + + expect( + rendered.type.inputComponent.stringify( + ` \n \r \t Example \t \r \n String \n \r \t` + ) + ).toEqual(`Example String`); + + expect( + rendered.type.inputComponent.tryParse(``, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeUndefined(); + expect( + rendered.type.inputComponent.tryParse(` \n \r \t `, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeUndefined(); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t Example \n \r \t Unique \t \r \n B \n \r \t`, + [ + ` \t \r \n Example \n \r \t Unique \t \t \n A \n \r \t `, + ` \t \r \n Example \n \r \t Unique \t \t \n B \n \r \t `, + ` \t \r \n Example \n \r \t Unique \t \t \n C \n \r \t `, + ] + ) + ).toBeUndefined(); + expect( + rendered.type.inputComponent.tryParse(`ExampleString`, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeUndefined(); + expect( + rendered.type.inputComponent.tryParse(`Example String`, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toEqual(`Example String`); + expect( + rendered.type.inputComponent.tryParse(`Exemplar String`, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toEqual(`Exemplar String`); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t ExampleString \n \r \t`, + [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ] + ) + ).toBeUndefined(); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t Example \t \r \n String \n \r \t`, + [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ] + ) + ).toEqual(`Example String`); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t Exemplar \t \r \n String \n \r \t`, + [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ] + ) + ).toEqual(`Exemplar String`); + + rendered.props.onSubmit(); + + expect(onChange).not.toHaveBeenCalled(); +}); + +test(`renders as expected with a maximum length`, () => { + const controlStyle: ControlStyle = { + fontFamily: `Example Font Family`, + fontSize: 37, + paddingVertical: 12, + paddingHorizontal: 29, + blurredValid: { + textColor: `#FFEE00`, + placeholderColor: `#E7AA32`, + backgroundColor: `#32AE12`, + radius: 5, + border: { + width: 4, + color: `#FF00FF`, + }, + }, + blurredInvalid: { + textColor: `#99FE88`, + placeholderColor: `#CACA3A`, + backgroundColor: `#259284`, + radius: 10, + border: { + width: 6, + color: `#9A9A8E`, + }, + }, + focusedValid: { + textColor: `#55EA13`, + placeholderColor: `#273346`, + backgroundColor: `#CABA99`, + radius: 3, + border: { + width: 5, + color: `#646464`, + }, + }, + focusedInvalid: { + textColor: `#ABAADE`, + placeholderColor: `#47ADAD`, + backgroundColor: `#32AA88`, + radius: 47, + border: { + width: 12, + color: `#98ADAA`, + }, + }, + disabledValid: { + textColor: `#AE2195`, + placeholderColor: `#FFAAEE`, + backgroundColor: `#772728`, + radius: 100, + border: { + width: 14, + color: `#5E5E5E`, + }, + }, + disabledInvalid: { + textColor: `#340297`, + placeholderColor: `#233832`, + backgroundColor: `#938837`, + radius: 2, + border: { + width: 19, + color: `#573829`, + }, + }, + }; + const onChange = jest.fn(); + const Component = createRequiredTextInputComponent( + controlStyle, + Example Left Icon, + Example Right Icon, + null, + 14 + ); + + const rendered = unwrapRenderedFunctionComponent( + + ); + + expect(rendered.type).toBeAFunctionWithTheStaticProperties({ + inputComponent: { + stringify: expect.any(Function), + tryParse: expect.any(Function), + controlStyle, + multiLine: false, + autoComplete: `off`, + keyboardType: `default`, + autoFocus: false, + keepFocusOnSubmit: false, + }, + }); + + expect(rendered.props).toEqual({ + leftIcon: Example Left Icon, + rightIcon: Example Right Icon, + value: `Example String`, + onChange, + disabled: true, + placeholder: `Example Placeholder`, + context: [`Example Unique A`, `Example Unique B`, `Example Unique C`], + secureTextEntry: false, + onSubmit: expect.any(Function), + }); + + expect( + rendered.type.inputComponent.stringify( + ` \n \r \t Example \t \r \n String \n \r \t` + ) + ).toEqual(`Example String`); + + expect( + rendered.type.inputComponent.tryParse(``, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeUndefined(); + expect( + rendered.type.inputComponent.tryParse(` \n \r \t `, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeUndefined(); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t Unique \t \r \n B \n \r \t`, + [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ] + ) + ).toBeUndefined(); + expect( + rendered.type.inputComponent.tryParse(`ExampleString`, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toEqual(`ExampleString`); + expect( + rendered.type.inputComponent.tryParse(`Example String`, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toEqual(`Example String`); + expect( + rendered.type.inputComponent.tryParse(`Exemplar String`, [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ]) + ).toBeUndefined(); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t ExampleString \n \r \t`, + [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ] + ) + ).toEqual(`ExampleString`); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t Example \t \r \n String \n \r \t`, + [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ] + ) + ).toEqual(`Example String`); + expect( + rendered.type.inputComponent.tryParse( + ` \n \r \t Exemplar \t \r \n String \n \r \t`, + [ + ` \t \r \n Unique \t \t \n A \n \r \t `, + ` \t \r \n Unique \t \t \n B \n \r \t `, + ` \t \r \n Unique \t \t \n C \n \r \t `, + ] + ) + ).toBeUndefined(); + + rendered.props.onSubmit(); + + expect(onChange).not.toHaveBeenCalled(); +}); diff --git a/index.ts b/index.ts index dc2da062..af48ca82 100644 --- a/index.ts +++ b/index.ts @@ -16,11 +16,13 @@ export { createImageBackgroundComponent } from "./components/createImageBackgrou export { createInputComponent } from "./components/createInputComponent"; export { createNullableFloatInputComponent } from "./components/createNullableFloatInputComponent"; export { createNullableIntegerInputComponent } from "./components/createNullableIntegerInputComponent"; +export { createNullableTextInputComponent } from "./components/createNullableTextInputComponent"; export { createOfflineTableComponent } from "./components/createOfflineTableComponent"; export { createPaddingComponent } from "./components/createPaddingComponent"; export { createProportionalRowComponent } from "./components/createProportionalRowComponent"; export { createRequiredFloatInputComponent } from "./components/createRequiredFloatInputComponent"; export { createRequiredIntegerInputComponent } from "./components/createRequiredIntegerInputComponent"; +export { createRequiredTextInputComponent } from "./components/createRequiredTextInputComponent"; export { createSearchableMultiSelectComponent } from "./components/createSearchableMultiSelectComponent"; export { createSearchableSelectComponent } from "./components/createSearchableSelectComponent"; export { createSessionStoreManagerComponent } from "./components/createSessionStoreManagerComponent"; diff --git a/readme.md b/readme.md index e366814e..3569e232 100644 --- a/readme.md +++ b/readme.md @@ -34,11 +34,13 @@ import { createTextComponent } from "react-native-app-helpers"; - [createInputComponent](./components/createInputComponent/readme.md) - [createNullableFloatInputComponent](./components/createNullableFloatInputComponent/readme.md) - [createNullableIntegerInputComponent](./components/createNullableIntegerInputComponent/readme.md) +- [createNullableTextInputComponent](./components/createNullableTextInputComponent/readme.md) - [createOfflineTableComponent](./components/createOfflineTableComponent/readme.md) - [createPaddingComponent](./components/createPaddingComponent/readme.md) - [createProportionalRowComponent](./components/createProportionalRowComponent/readme.md) - [createRequiredFloatInputComponent](./components/createRequiredFloatInputComponent/readme.md) - [createRequiredIntegerInputComponent](./components/createRequiredIntegerInputComponent/readme.md) +- [createRequiredTextInputComponent](./components/createRequiredTextInputComponent/readme.md) - [createSearchableMultiSelectComponent](./components/createSearchableMultiSelectComponent/readme.md) - [createSearchableSelectComponent](./components/createSearchableSelectComponent/readme.md) - [createSessionStoreManagerComponent](./components/createSessionStoreManagerComponent/readme.md)