Skip to content

Commit abf631a

Browse files
feat: The label prop for TextInput can now take a component (ReactElement) as well as string (#2991)
Co-authored-by: Luke Walczak <[email protected]>
1 parent c7bedca commit abf631a

File tree

7 files changed

+447
-11
lines changed

7 files changed

+447
-11
lines changed

example/src/Examples/TextInputExample.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
import * as React from 'react';
2-
import { StyleSheet, View, KeyboardAvoidingView, Platform } from 'react-native';
2+
import {
3+
StyleSheet,
4+
View,
5+
KeyboardAvoidingView,
6+
Platform,
7+
Text,
8+
} from 'react-native';
39
import { TextInput, HelperText, useTheme } from 'react-native-paper';
410
import Icon from 'react-native-vector-icons/FontAwesome';
511
import { inputReducer, State } from '../../utils';
612
import ScreenWrapper from '../ScreenWrapper';
7-
import { amber900, pink400, transparent } from '../../../src/styles/colors';
13+
import {
14+
amber900,
15+
pink400,
16+
red500,
17+
transparent,
18+
} from '../../../src/styles/colors';
819

920
const MAX_LENGTH = 20;
1021

@@ -18,6 +29,7 @@ const initialState: State = {
1829
outlinedLargeText: '',
1930
outlinedTextPassword: '',
2031
nameNoPadding: '',
32+
nameRequired: '',
2133
flatDenseText: '',
2234
flatDense: '',
2335
outlinedDenseText: '',
@@ -71,6 +83,7 @@ const TextInputExample = () => {
7183
outlinedLargeText,
7284
outlinedTextPassword,
7385
nameNoPadding,
86+
nameRequired,
7487
flatDenseText,
7588
flatDense,
7689
outlinedDenseText,
@@ -456,6 +469,25 @@ const TextInputExample = () => {
456469
Error: Only letters are allowed
457470
</HelperText>
458471
</View>
472+
<View style={styles.inputContainerStyle}>
473+
<TextInput
474+
label={
475+
<Text>
476+
<Text style={{ color: red500 }}>*</Text> Label as component
477+
</Text>
478+
}
479+
style={styles.noPaddingInput}
480+
placeholder="Enter username, required"
481+
value={nameRequired}
482+
error={!nameRequired}
483+
onChangeText={(nameRequired) =>
484+
inputActionHandler('nameRequired', nameRequired)
485+
}
486+
/>
487+
<HelperText type="error" padding="none" visible={!nameRequired}>
488+
Error: Username is required
489+
</HelperText>
490+
</View>
459491
<View style={styles.inputContainerStyle}>
460492
<TextInput
461493
label="Input with text align center"

example/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type State = {
2121
outlinedLargeText: string;
2222
outlinedTextPassword: string;
2323
nameNoPadding: string;
24+
nameRequired: string;
2425
flatDenseText: string;
2526
flatDense: string;
2627
outlinedDenseText: string;

src/components/TextInput/TextInput.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import {
66
StyleProp,
77
TextStyle,
88
} from 'react-native';
9+
import { areLabelsEqual } from './helpers';
910
import TextInputOutlined from './TextInputOutlined';
1011
import TextInputFlat from './TextInputFlat';
1112
import TextInputIcon from './Adornment/TextInputIcon';
1213
import TextInputAffix from './Adornment/TextInputAffix';
1314
import { withTheme } from '../../core/theming';
14-
import type { RenderProps, State } from './types';
15+
import type { RenderProps, State, TextInputLabelProp } from './types';
1516
import type { $Omit } from '../../types';
1617

1718
const BLUR_ANIMATION_DURATION = 180;
@@ -36,9 +37,9 @@ export type TextInputProps = React.ComponentPropsWithRef<
3637
*/
3738
disabled?: boolean;
3839
/**
39-
* The text to use for the floating label.
40+
* The text or component to use for the floating label.
4041
*/
41-
label?: string;
42+
label?: TextInputLabelProp;
4243
/**
4344
* Placeholder for the input.
4445
*/
@@ -233,9 +234,8 @@ class TextInput extends React.Component<TextInputProps, State> {
233234
const isValueChanged = prevState.value !== this.state.value;
234235
const isLabelLayoutChanged =
235236
prevState.labelLayout !== this.state.labelLayout;
236-
const isLabelChanged = prevProps.label !== this.props.label;
237+
const isLabelChanged = !areLabelsEqual(prevProps.label, this.props.label);
237238
const isErrorChanged = prevProps.error !== this.props.error;
238-
239239
if (
240240
isFocusChanged ||
241241
isValueChanged ||

src/components/TextInput/helpers.tsx

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
FLAT_INPUT_OFFSET,
88
} from './constants';
99
import { AdornmentType, AdornmentSide } from './Adornment/enums';
10+
import type { TextInputLabelProp } from './types';
1011

1112
type PaddingProps = {
1213
height: number | null;
@@ -16,7 +17,7 @@ type PaddingProps = {
1617
topPosition: number;
1718
fontSize: number;
1819
lineHeight?: number;
19-
label?: string | null;
20+
label?: TextInputLabelProp | null;
2021
scale: number;
2122
offset: number;
2223
isAndroid: boolean;
@@ -286,3 +287,95 @@ export const calculateFlatInputHorizontalPadding = ({
286287

287288
return { paddingLeft, paddingRight };
288289
};
290+
291+
export function areLabelsEqual(
292+
label1?: TextInputLabelProp,
293+
label2?: TextInputLabelProp
294+
): boolean {
295+
if (label1 === label2) {
296+
// will also take care of equality for `string` type, or if both are undefined.
297+
return true;
298+
}
299+
300+
// Return true if both of them are falsy.
301+
if (!(label1 || label2)) {
302+
return true;
303+
}
304+
305+
// At this point, both of them cannot be false.
306+
// So, return false if any of them is falsy.
307+
if (!(label1 && label2)) {
308+
return false;
309+
}
310+
311+
// At this point, both of them has to be truthy.
312+
// So, return false if they are not of the same type.
313+
if (typeof label1 !== typeof label2) {
314+
return false;
315+
}
316+
317+
// At this point, both of them has to be of the same datatype.
318+
if (
319+
typeof label1 === 'string' ||
320+
label1 instanceof String ||
321+
// These last two OR checks are only here for Typescript's sake.
322+
typeof label2 === 'string' ||
323+
label2 instanceof String
324+
) {
325+
// They're strings, so they won't be equal; otherwise
326+
// we would have returned 'true' earlier.
327+
return false;
328+
}
329+
330+
// At this point, both of them has to be of the datatype: `React.ReactElement`.
331+
if (label1.type !== label2.type) {
332+
return false;
333+
}
334+
335+
// Preliminary equality check: do they stringify to the same string?
336+
const label1Props = label1.props || {};
337+
const label2Props = label2.props || {};
338+
if (JSON.stringify(label1Props) !== JSON.stringify(label2Props)) {
339+
return false;
340+
}
341+
342+
// We now know they stringify to the same string.
343+
// Return true if both of them DO NOT have children
344+
if (!(label1Props.children || label2Props.children)) {
345+
return true; // since there's nothing else to check
346+
}
347+
348+
// Return false if only one of them has children
349+
if (!(label1Props.children && label2Props.children)) {
350+
return false;
351+
}
352+
353+
// Both have children...
354+
// Handle for when both the children are arrays
355+
const label1IsArray = Array.isArray(label1Props.children);
356+
const label2IsArray = Array.isArray(label2Props.children);
357+
if (label1IsArray && label2IsArray) {
358+
const children1 = label1Props.children as any[];
359+
const children2 = label2Props.children as any[];
360+
if (children1.length !== children2.length) {
361+
return false; // no point proceeding
362+
}
363+
364+
// all the children must also be equal
365+
for (let i = 0; i < children1.length; i++) {
366+
if (!areLabelsEqual(children1[i], children2[i])) {
367+
return false;
368+
}
369+
}
370+
371+
return true;
372+
}
373+
374+
// Only one of them can be an array at this point. If any is array, return false
375+
if (label1IsArray || label2IsArray) {
376+
return false;
377+
}
378+
379+
// both children are not arrays, so recur.
380+
return areLabelsEqual(label1Props.children, label2Props.children);
381+
}

src/components/TextInput/types.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import type {
88
import type { TextInputProps } from './TextInput';
99
import type { $Omit } from './../../types';
1010

11+
export type TextInputLabelProp = string | React.ReactElement;
12+
1113
export type RenderProps = {
1214
ref: (a?: NativeTextInput | null) => void;
1315
onChangeText?: (a: string) => void;
@@ -62,7 +64,7 @@ export type LabelProps = {
6264
labelTranslationXOffset?: number;
6365
placeholderColor: string | null;
6466
backgroundColor?: ColorValue;
65-
label?: string | null;
67+
label?: TextInputLabelProp | null;
6668
hasActiveOutline?: boolean | null;
6769
activeColor: string;
6870
errorColor?: string;

src/components/__tests__/TextInput.test.js

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import * as React from 'react';
2-
import { StyleSheet } from 'react-native';
2+
import { StyleSheet, Text, View } from 'react-native';
33
import { fireEvent, render } from 'react-native-testing-library';
44
import TextInput from '../TextInput/TextInput';
5-
import { red500 } from '../../styles/colors';
5+
import { areLabelsEqual } from '../TextInput/helpers';
6+
import { blue500, red500 } from '../../styles/colors';
67

78
const style = StyleSheet.create({
89
inputStyle: {
@@ -148,3 +149,117 @@ it('correctly applies focused state Outline TextInput', () => {
148149
expect.arrayContaining([expect.objectContaining({ borderWidth: 2 })])
149150
);
150151
});
152+
153+
it('correctly applies a component as the text label', () => {
154+
const { toJSON } = render(
155+
<TextInput
156+
label={<Text style={style.inputStyle}>Flat input</Text>}
157+
placeholder="Type something"
158+
value={'Some test value'}
159+
/>
160+
);
161+
162+
expect(toJSON()).toMatchSnapshot();
163+
});
164+
165+
it('correctly compares labels when both are string', () => {
166+
expect(areLabelsEqual('Comments', 'Comments')).toBe(true);
167+
expect(areLabelsEqual('Comments', 'No Comment')).toBe(false);
168+
});
169+
170+
it('correctly compares labels when one is string and one is a component', () => {
171+
expect(areLabelsEqual(<Text>Comments</Text>, 'Comments')).toBe(false);
172+
});
173+
174+
it('correctly compares labels when both labels are falsy', () => {
175+
// We're treating all falsy values as equivalent
176+
expect(areLabelsEqual()).toBe(true);
177+
expect(areLabelsEqual(undefined, undefined)).toBe(true);
178+
expect(areLabelsEqual(null, null)).toBe(true);
179+
expect(areLabelsEqual(undefined, '')).toBe(true);
180+
expect(areLabelsEqual(null, '')).toBe(true);
181+
expect(areLabelsEqual(undefined, null)).toBe(true);
182+
});
183+
184+
it('correctly compares labels when both labels are components', () => {
185+
// Same component; same props, same children
186+
const component1 = <Text>Comments</Text>;
187+
188+
let component2 = <Text>Comments</Text>;
189+
expect(areLabelsEqual(component1, component2)).toBe(true);
190+
191+
// Same component; same props, different children
192+
component2 = <Text>Another Comment</Text>;
193+
expect(areLabelsEqual(component1, component2)).toBe(false);
194+
195+
// Different component; same props, same children
196+
component2 = <View>Comments</View>;
197+
expect(areLabelsEqual(component1, component2)).toBe(false);
198+
199+
// Same component; different props, same children
200+
component2 = (
201+
<Text multiline style={{ color: red500 }}>
202+
Comments
203+
</Text>
204+
);
205+
expect(areLabelsEqual(component1, component2)).toBe(false);
206+
});
207+
208+
it('correctly compares labels for nested components', () => {
209+
// Same component; same props, same children
210+
const component1 = (
211+
<Text>
212+
<Text style={{ color: red500 }}>*</Text> Comments
213+
</Text>
214+
);
215+
216+
let component2 = (
217+
<Text>
218+
<Text style={{ color: red500 }}>*</Text> Comments
219+
</Text>
220+
);
221+
expect(areLabelsEqual(component1, component2)).toBe(true);
222+
223+
// Same component; same props, different children
224+
component2 = (
225+
<Text>
226+
<Text style={{ color: red500 }}>Comments</Text> continues
227+
</Text>
228+
);
229+
expect(areLabelsEqual(component1, component2)).toBe(false);
230+
231+
// Different component; same props, same children
232+
component2 = (
233+
<View>
234+
<Text style={{ color: red500 }}>*</Text> Comments
235+
</View>
236+
);
237+
expect(areLabelsEqual(component1, component2)).toBe(false);
238+
239+
// Same component; different props, same children
240+
component2 = (
241+
<Text multiline>
242+
<Text style={{ color: red500 }}>*</Text> Comments
243+
</Text>
244+
);
245+
expect(areLabelsEqual(component1, component2)).toBe(false);
246+
247+
// Same component; same props, different number of children
248+
component2 = (
249+
<Text>
250+
<Text style={{ color: red500 }}>*</Text>
251+
</Text>
252+
);
253+
expect(areLabelsEqual(component1, component2)).toBe(false);
254+
255+
// Same component; different props in inner component, same children
256+
component2 = (
257+
<Text>
258+
<Text multiline style={{ color: blue500 }}>
259+
*
260+
</Text>{' '}
261+
Comments
262+
</Text>
263+
);
264+
expect(areLabelsEqual(component1, component2)).toBe(false);
265+
});

0 commit comments

Comments
 (0)