Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -70,6 +82,7 @@ const TextInputExample = () => {
outlinedLargeText,
outlinedTextPassword,
nameNoPadding,
nameRequired,
flatDenseText,
flatDense,
outlinedDenseText,
Expand Down Expand Up @@ -446,6 +459,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
101 changes: 96 additions & 5 deletions src/components/TextInput/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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 +36,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 +233,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 Expand Up @@ -477,4 +476,96 @@ class TextInput extends React.Component<TextInputProps, State> {
}
}

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);
}

export default withTheme(TextInput);
3 changes: 2 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
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
120 changes: 117 additions & 3 deletions src/components/__tests__/TextInput.test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as React from 'react';
import { StyleSheet } from 'react-native';
import { StyleSheet, Text, View } from 'react-native';
import { render } from 'react-native-testing-library';
import TextInput from '../TextInput/TextInput';
import { red500 } from '../../styles/colors';
import TextInput, { areLabelsEqual } from '../TextInput/TextInput';
import { blue500, red500 } from '../../styles/colors';

const style = StyleSheet.create({
inputStyle: {
Expand Down Expand Up @@ -108,3 +108,117 @@ it('correctly applies height to multiline Outline TextInput', () => {

expect(toJSON()).toMatchSnapshot();
});

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