Skip to content

Commit

Permalink
feat: The label prop for TextInput can now take a component (`Rea…
Browse files Browse the repository at this point in the history
…ctElement`) as well as `string` (#2991)

Co-authored-by: Luke Walczak <[email protected]>
  • Loading branch information
smbadiwe and lukewalczak authored Dec 10, 2021
1 parent c7bedca commit abf631a
Show file tree
Hide file tree
Showing 7 changed files with 447 additions and 11 deletions.
36 changes: 34 additions & 2 deletions example/src/Examples/TextInputExample.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -18,6 +29,7 @@ const initialState: State = {
outlinedLargeText: '',
outlinedTextPassword: '',
nameNoPadding: '',
nameRequired: '',
flatDenseText: '',
flatDense: '',
outlinedDenseText: '',
Expand Down Expand Up @@ -71,6 +83,7 @@ const TextInputExample = () => {
outlinedLargeText,
outlinedTextPassword,
nameNoPadding,
nameRequired,
flatDenseText,
flatDense,
outlinedDenseText,
Expand Down Expand Up @@ -456,6 +469,25 @@ const TextInputExample = () => {
Error: Only letters are allowed
</HelperText>
</View>
<View style={styles.inputContainerStyle}>
<TextInput
label={
<Text>
<Text style={{ color: red500 }}>*</Text> Label as component
</Text>
}
style={styles.noPaddingInput}
placeholder="Enter username, required"
value={nameRequired}
error={!nameRequired}
onChangeText={(nameRequired) =>
inputActionHandler('nameRequired', nameRequired)
}
/>
<HelperText type="error" padding="none" visible={!nameRequired}>
Error: Username is required
</HelperText>
</View>
<View style={styles.inputContainerStyle}>
<TextInput
label="Input with text align center"
Expand Down
1 change: 1 addition & 0 deletions example/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type State = {
outlinedLargeText: string;
outlinedTextPassword: string;
nameNoPadding: string;
nameRequired: string;
flatDenseText: string;
flatDense: string;
outlinedDenseText: string;
Expand Down
10 changes: 5 additions & 5 deletions src/components/TextInput/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import {
StyleProp,
TextStyle,
} from 'react-native';
import { areLabelsEqual } from './helpers';
import TextInputOutlined from './TextInputOutlined';
import TextInputFlat from './TextInputFlat';
import TextInputIcon from './Adornment/TextInputIcon';
import TextInputAffix from './Adornment/TextInputAffix';
import { withTheme } from '../../core/theming';
import type { RenderProps, State } from './types';
import type { RenderProps, State, TextInputLabelProp } from './types';
import type { $Omit } from '../../types';

const BLUR_ANIMATION_DURATION = 180;
Expand All @@ -36,9 +37,9 @@ export type TextInputProps = React.ComponentPropsWithRef<
*/
disabled?: boolean;
/**
* The text to use for the floating label.
* The text or component to use for the floating label.
*/
label?: string;
label?: TextInputLabelProp;
/**
* Placeholder for the input.
*/
Expand Down Expand Up @@ -233,9 +234,8 @@ class TextInput extends React.Component<TextInputProps, State> {
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 ||
Expand Down
95 changes: 94 additions & 1 deletion src/components/TextInput/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,7 +17,7 @@ type PaddingProps = {
topPosition: number;
fontSize: number;
lineHeight?: number;
label?: string | null;
label?: TextInputLabelProp | null;
scale: number;
offset: number;
isAndroid: boolean;
Expand Down Expand Up @@ -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);
}
4 changes: 3 additions & 1 deletion src/components/TextInput/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
119 changes: 117 additions & 2 deletions src/components/__tests__/TextInput.test.js
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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(
<TextInput
label={<Text style={style.inputStyle}>Flat input</Text>}
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(<Text>Comments</Text>, '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 = <Text>Comments</Text>;

let component2 = <Text>Comments</Text>;
expect(areLabelsEqual(component1, component2)).toBe(true);

// Same component; same props, different children
component2 = <Text>Another Comment</Text>;
expect(areLabelsEqual(component1, component2)).toBe(false);

// Different component; same props, same children
component2 = <View>Comments</View>;
expect(areLabelsEqual(component1, component2)).toBe(false);

// Same component; different props, same children
component2 = (
<Text multiline style={{ color: red500 }}>
Comments
</Text>
);
expect(areLabelsEqual(component1, component2)).toBe(false);
});

it('correctly compares labels for nested components', () => {
// Same component; same props, same children
const component1 = (
<Text>
<Text style={{ color: red500 }}>*</Text> Comments
</Text>
);

let component2 = (
<Text>
<Text style={{ color: red500 }}>*</Text> Comments
</Text>
);
expect(areLabelsEqual(component1, component2)).toBe(true);

// Same component; same props, different children
component2 = (
<Text>
<Text style={{ color: red500 }}>Comments</Text> continues
</Text>
);
expect(areLabelsEqual(component1, component2)).toBe(false);

// Different component; same props, same children
component2 = (
<View>
<Text style={{ color: red500 }}>*</Text> Comments
</View>
);
expect(areLabelsEqual(component1, component2)).toBe(false);

// Same component; different props, same children
component2 = (
<Text multiline>
<Text style={{ color: red500 }}>*</Text> Comments
</Text>
);
expect(areLabelsEqual(component1, component2)).toBe(false);

// Same component; same props, different number of children
component2 = (
<Text>
<Text style={{ color: red500 }}>*</Text>
</Text>
);
expect(areLabelsEqual(component1, component2)).toBe(false);

// Same component; different props in inner component, same children
component2 = (
<Text>
<Text multiline style={{ color: blue500 }}>
*
</Text>{' '}
Comments
</Text>
);
expect(areLabelsEqual(component1, component2)).toBe(false);
});
Loading

0 comments on commit abf631a

Please sign in to comment.