From abf631a4d595831fcd12be03008be059bb5aeeec Mon Sep 17 00:00:00 2001 From: Soma Mbadiwe Date: Fri, 10 Dec 2021 06:19:52 -0500 Subject: [PATCH] feat: The `label` prop for `TextInput` can now take a component (`ReactElement`) as well as `string` (#2991) Co-authored-by: Luke Walczak --- example/src/Examples/TextInputExample.tsx | 36 +++- example/utils/index.ts | 1 + src/components/TextInput/TextInput.tsx | 10 +- src/components/TextInput/helpers.tsx | 95 ++++++++- src/components/TextInput/types.tsx | 4 +- src/components/__tests__/TextInput.test.js | 119 ++++++++++- .../__snapshots__/TextInput.test.js.snap | 193 ++++++++++++++++++ 7 files changed, 447 insertions(+), 11 deletions(-) diff --git a/example/src/Examples/TextInputExample.tsx b/example/src/Examples/TextInputExample.tsx index de05e0e640..d60bf3d69c 100644 --- a/example/src/Examples/TextInputExample.tsx +++ b/example/src/Examples/TextInputExample.tsx @@ -1,10 +1,21 @@ import * as React from 'react'; -import { StyleSheet, View, KeyboardAvoidingView, Platform } from 'react-native'; +import { + StyleSheet, + View, + KeyboardAvoidingView, + Platform, + Text, +} from 'react-native'; import { TextInput, HelperText, useTheme } from 'react-native-paper'; import Icon from 'react-native-vector-icons/FontAwesome'; import { inputReducer, State } from '../../utils'; import ScreenWrapper from '../ScreenWrapper'; -import { amber900, pink400, transparent } from '../../../src/styles/colors'; +import { + amber900, + pink400, + red500, + transparent, +} from '../../../src/styles/colors'; const MAX_LENGTH = 20; @@ -18,6 +29,7 @@ const initialState: State = { outlinedLargeText: '', outlinedTextPassword: '', nameNoPadding: '', + nameRequired: '', flatDenseText: '', flatDense: '', outlinedDenseText: '', @@ -71,6 +83,7 @@ const TextInputExample = () => { outlinedLargeText, outlinedTextPassword, nameNoPadding, + nameRequired, flatDenseText, flatDense, outlinedDenseText, @@ -456,6 +469,25 @@ const TextInputExample = () => { Error: Only letters are allowed + + + * Label as component + + } + style={styles.noPaddingInput} + placeholder="Enter username, required" + value={nameRequired} + error={!nameRequired} + onChangeText={(nameRequired) => + inputActionHandler('nameRequired', nameRequired) + } + /> + + Error: Username is required + + { const isValueChanged = prevState.value !== this.state.value; const isLabelLayoutChanged = prevState.labelLayout !== this.state.labelLayout; - const isLabelChanged = prevProps.label !== this.props.label; + const isLabelChanged = !areLabelsEqual(prevProps.label, this.props.label); const isErrorChanged = prevProps.error !== this.props.error; - if ( isFocusChanged || isValueChanged || diff --git a/src/components/TextInput/helpers.tsx b/src/components/TextInput/helpers.tsx index 1e037c2f00..4c10c97d6d 100644 --- a/src/components/TextInput/helpers.tsx +++ b/src/components/TextInput/helpers.tsx @@ -7,6 +7,7 @@ import { FLAT_INPUT_OFFSET, } from './constants'; import { AdornmentType, AdornmentSide } from './Adornment/enums'; +import type { TextInputLabelProp } from './types'; type PaddingProps = { height: number | null; @@ -16,7 +17,7 @@ type PaddingProps = { topPosition: number; fontSize: number; lineHeight?: number; - label?: string | null; + label?: TextInputLabelProp | null; scale: number; offset: number; isAndroid: boolean; @@ -286,3 +287,95 @@ export const calculateFlatInputHorizontalPadding = ({ return { paddingLeft, paddingRight }; }; + +export function areLabelsEqual( + label1?: TextInputLabelProp, + label2?: TextInputLabelProp +): boolean { + if (label1 === label2) { + // will also take care of equality for `string` type, or if both are undefined. + return true; + } + + // Return true if both of them are falsy. + if (!(label1 || label2)) { + return true; + } + + // At this point, both of them cannot be false. + // So, return false if any of them is falsy. + if (!(label1 && label2)) { + return false; + } + + // At this point, both of them has to be truthy. + // So, return false if they are not of the same type. + if (typeof label1 !== typeof label2) { + return false; + } + + // At this point, both of them has to be of the same datatype. + if ( + typeof label1 === 'string' || + label1 instanceof String || + // These last two OR checks are only here for Typescript's sake. + typeof label2 === 'string' || + label2 instanceof String + ) { + // They're strings, so they won't be equal; otherwise + // we would have returned 'true' earlier. + return false; + } + + // At this point, both of them has to be of the datatype: `React.ReactElement`. + if (label1.type !== label2.type) { + return false; + } + + // Preliminary equality check: do they stringify to the same string? + const label1Props = label1.props || {}; + const label2Props = label2.props || {}; + if (JSON.stringify(label1Props) !== JSON.stringify(label2Props)) { + return false; + } + + // We now know they stringify to the same string. + // Return true if both of them DO NOT have children + if (!(label1Props.children || label2Props.children)) { + return true; // since there's nothing else to check + } + + // Return false if only one of them has children + if (!(label1Props.children && label2Props.children)) { + return false; + } + + // Both have children... + // Handle for when both the children are arrays + const label1IsArray = Array.isArray(label1Props.children); + const label2IsArray = Array.isArray(label2Props.children); + if (label1IsArray && label2IsArray) { + const children1 = label1Props.children as any[]; + const children2 = label2Props.children as any[]; + if (children1.length !== children2.length) { + return false; // no point proceeding + } + + // all the children must also be equal + for (let i = 0; i < children1.length; i++) { + if (!areLabelsEqual(children1[i], children2[i])) { + return false; + } + } + + return true; + } + + // Only one of them can be an array at this point. If any is array, return false + if (label1IsArray || label2IsArray) { + return false; + } + + // both children are not arrays, so recur. + return areLabelsEqual(label1Props.children, label2Props.children); +} diff --git a/src/components/TextInput/types.tsx b/src/components/TextInput/types.tsx index b45f817aa0..aa47c3a420 100644 --- a/src/components/TextInput/types.tsx +++ b/src/components/TextInput/types.tsx @@ -8,6 +8,8 @@ import type { import type { TextInputProps } from './TextInput'; import type { $Omit } from './../../types'; +export type TextInputLabelProp = string | React.ReactElement; + export type RenderProps = { ref: (a?: NativeTextInput | null) => void; onChangeText?: (a: string) => void; @@ -62,7 +64,7 @@ export type LabelProps = { labelTranslationXOffset?: number; placeholderColor: string | null; backgroundColor?: ColorValue; - label?: string | null; + label?: TextInputLabelProp | null; hasActiveOutline?: boolean | null; activeColor: string; errorColor?: string; diff --git a/src/components/__tests__/TextInput.test.js b/src/components/__tests__/TextInput.test.js index 82d0327086..f1571d2a0e 100644 --- a/src/components/__tests__/TextInput.test.js +++ b/src/components/__tests__/TextInput.test.js @@ -1,8 +1,9 @@ import * as React from 'react'; -import { StyleSheet } from 'react-native'; +import { StyleSheet, Text, View } from 'react-native'; import { fireEvent, render } from 'react-native-testing-library'; import TextInput from '../TextInput/TextInput'; -import { red500 } from '../../styles/colors'; +import { areLabelsEqual } from '../TextInput/helpers'; +import { blue500, red500 } from '../../styles/colors'; const style = StyleSheet.create({ inputStyle: { @@ -148,3 +149,117 @@ it('correctly applies focused state Outline TextInput', () => { expect.arrayContaining([expect.objectContaining({ borderWidth: 2 })]) ); }); + +it('correctly applies a component as the text label', () => { + const { toJSON } = render( + Flat input} + placeholder="Type something" + value={'Some test value'} + /> + ); + + expect(toJSON()).toMatchSnapshot(); +}); + +it('correctly compares labels when both are string', () => { + expect(areLabelsEqual('Comments', 'Comments')).toBe(true); + expect(areLabelsEqual('Comments', 'No Comment')).toBe(false); +}); + +it('correctly compares labels when one is string and one is a component', () => { + expect(areLabelsEqual(Comments, 'Comments')).toBe(false); +}); + +it('correctly compares labels when both labels are falsy', () => { + // We're treating all falsy values as equivalent + expect(areLabelsEqual()).toBe(true); + expect(areLabelsEqual(undefined, undefined)).toBe(true); + expect(areLabelsEqual(null, null)).toBe(true); + expect(areLabelsEqual(undefined, '')).toBe(true); + expect(areLabelsEqual(null, '')).toBe(true); + expect(areLabelsEqual(undefined, null)).toBe(true); +}); + +it('correctly compares labels when both labels are components', () => { + // Same component; same props, same children + const component1 = Comments; + + let component2 = Comments; + expect(areLabelsEqual(component1, component2)).toBe(true); + + // Same component; same props, different children + component2 = Another Comment; + expect(areLabelsEqual(component1, component2)).toBe(false); + + // Different component; same props, same children + component2 = Comments; + expect(areLabelsEqual(component1, component2)).toBe(false); + + // Same component; different props, same children + component2 = ( + + Comments + + ); + expect(areLabelsEqual(component1, component2)).toBe(false); +}); + +it('correctly compares labels for nested components', () => { + // Same component; same props, same children + const component1 = ( + + * Comments + + ); + + let component2 = ( + + * Comments + + ); + expect(areLabelsEqual(component1, component2)).toBe(true); + + // Same component; same props, different children + component2 = ( + + Comments continues + + ); + expect(areLabelsEqual(component1, component2)).toBe(false); + + // Different component; same props, same children + component2 = ( + + * Comments + + ); + expect(areLabelsEqual(component1, component2)).toBe(false); + + // Same component; different props, same children + component2 = ( + + * Comments + + ); + expect(areLabelsEqual(component1, component2)).toBe(false); + + // Same component; same props, different number of children + component2 = ( + + * + + ); + expect(areLabelsEqual(component1, component2)).toBe(false); + + // Same component; different props in inner component, same children + component2 = ( + + + * + {' '} + Comments + + ); + expect(areLabelsEqual(component1, component2)).toBe(false); +}); diff --git a/src/components/__tests__/__snapshots__/TextInput.test.js.snap b/src/components/__tests__/__snapshots__/TextInput.test.js.snap index dde1a764c8..c9fa6cd821 100644 --- a/src/components/__tests__/__snapshots__/TextInput.test.js.snap +++ b/src/components/__tests__/__snapshots__/TextInput.test.js.snap @@ -1,5 +1,198 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`correctly applies a component as the text label 1`] = ` + + + + + + + Flat input + + + + + Flat input + + + + + + +`; + exports[`correctly applies default textAlign based on default RTL 1`] = `