Skip to content

Commit 43ece4f

Browse files
committed
feat: counter
1 parent 04515e4 commit 43ece4f

8 files changed

Lines changed: 234 additions & 9 deletions

File tree

example/src/Examples/TextFieldExample.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ const TextFieldExample = () => {
143143
value={filledNotes}
144144
onChangeText={setFilledNotes}
145145
multiline
146+
counter
147+
maxLength={200}
146148
pressableStyle={styles.field}
147149
/>
148150
<TextField
@@ -245,6 +247,8 @@ const TextFieldExample = () => {
245247
value={outlinedNotes}
246248
onChangeText={setOutlinedNotes}
247249
multiline
250+
counter
251+
maxLength={200}
248252
pressableStyle={styles.field}
249253
/>
250254
<TextField

src/components/TextField/TextField.tsx

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from 'react-native';
1414

1515
import { useTextField } from './logic';
16+
import { $addendumStyle } from './styles';
1617
import type { InternalTheme, ThemeProp } from '../../types';
1718

1819
export type TextFieldVariant = 'filled' | 'outlined';
@@ -68,6 +69,15 @@ export interface TextFieldProps extends TextInputProps {
6869
* Pass any additional props directly to the supporting text `Text` component.
6970
*/
7071
supportingTextProps?: TextProps;
72+
/**
73+
* When `true`, displays a character counter below the input on the trailing
74+
* side, showing `currentLength/maxLength`. Requires `maxLength` to be set.
75+
*/
76+
counter?: boolean;
77+
/**
78+
* Pass any additional props directly to the counter `Text` component.
79+
*/
80+
counterProps?: TextProps;
7181
/**
7282
* A short text string displayed at the start of the input (e.g. `"$"`).
7383
*/
@@ -176,6 +186,8 @@ function TextField(props: TextFieldProps) {
176186
prefixProps,
177187
suffix,
178188
suffixProps,
189+
counter,
190+
counterProps,
179191
...textInputProps
180192
} = props;
181193

@@ -184,6 +196,7 @@ function TextField(props: TextFieldProps) {
184196
disabled,
185197
hasPrefix,
186198
hasSuffix,
199+
hasCounter,
187200
hasError,
188201
$pressableStyles,
189202
$leadingAccessoryStyles,
@@ -199,10 +212,12 @@ function TextField(props: TextFieldProps) {
199212
$prefixStyles,
200213
$suffixStyles,
201214
$supportingTextStyles,
215+
$counterStyles,
202216
$placeholderTextColor,
203217
$selectionColor,
204218
$cursorColor,
205219
placeholder,
220+
counterText,
206221
LeadingAccessory,
207222
TrailingAccessory,
208223
focusInput,
@@ -301,15 +316,23 @@ function TextField(props: TextFieldProps) {
301316
)}
302317
</View>
303318

304-
{!!supportingText && (
305-
<Text
306-
aria-live={hasError ? 'assertive' : 'polite'}
307-
{...supportingTextProps}
308-
style={$supportingTextStyles}
309-
>
310-
{supportingText}
311-
</Text>
312-
)}
319+
<View style={$addendumStyle}>
320+
{!!supportingText && (
321+
<Text
322+
aria-live={hasError ? 'assertive' : 'polite'}
323+
{...supportingTextProps}
324+
style={$supportingTextStyles}
325+
>
326+
{supportingText}
327+
</Text>
328+
)}
329+
330+
{hasCounter && (
331+
<Text aria-live="polite" {...counterProps} style={$counterStyles}>
332+
{counterText}
333+
</Text>
334+
)}
335+
</View>
313336
</Pressable>
314337
);
315338
}

src/components/TextField/filled/logic.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
isWeb,
1414
} from '../constants';
1515
import {
16+
$counterStyle,
1617
$disabledStyle,
1718
$inputStyle,
1819
$leadingAccessoryStyle,
@@ -47,6 +48,7 @@ export const getFilledTextFieldData = (
4748
const {
4849
labelProps,
4950
supportingTextProps,
51+
counterProps,
5052
style: $inputStyleOverride,
5153
fieldStyle: $fieldStyleOverride,
5254
containerStyle: $containerStyleOverride,
@@ -195,6 +197,16 @@ export const getFilledTextFieldData = (
195197
supportingTextProps?.style,
196198
];
197199

200+
const $counterStyles: StyleProp<TextStyle> = [
201+
$counterStyle,
202+
{
203+
color: supportingTextColor,
204+
writingDirection: isRTL ? 'rtl' : 'ltr',
205+
},
206+
disabled && $disabledStyle,
207+
counterProps?.style,
208+
];
209+
198210
const $inputStyles: StyleProp<TextStyle> = [
199211
$inputStyle,
200212
{
@@ -258,6 +270,7 @@ export const getFilledTextFieldData = (
258270
$animatedActiveOutlineStyles,
259271
$containerStyles,
260272
$supportingTextStyles,
273+
$counterStyles,
261274
$inputStyles,
262275
$prefixStyles,
263276
$suffixStyles,

src/components/TextField/logic.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export const useTextField = (props: TextFieldProps) => {
6767
const hasError = props.status === 'error';
6868
const hasPrefix = !!props.prefix && isFloating;
6969
const hasSuffix = !!props.suffix && isFloating;
70+
const hasCounter = props.counter && !!props.maxLength;
7071

7172
// =======================
7273
// THEME TOKENS
@@ -137,6 +138,7 @@ export const useTextField = (props: TextFieldProps) => {
137138
const TrailingAccessory = isRTL ? props.StartAccessory : props.EndAccessory;
138139
// https://github.com/facebook/react-native/issues/31573
139140
const placeholder = isFocused ? props.placeholder : ' ';
141+
const counterText = `${props.value?.length ?? 0}/${props.maxLength}`;
140142

141143
// =======================
142144
// STYLES
@@ -146,12 +148,14 @@ export const useTextField = (props: TextFieldProps) => {
146148

147149
const data = {
148150
hasPrefix,
151+
hasCounter,
149152
$pressableStyles,
150153
$placeholderTextColor,
151154
$selectionColor,
152155
$cursorColor,
153156
$animatedActiveOutlineStyles: undefined,
154157
placeholder,
158+
counterText,
155159
LeadingAccessory,
156160
TrailingAccessory,
157161
onFocusHandler,

src/components/TextField/outlined/logic.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88

99
import { INPUT_FONT_SIZE, isWeb } from '../constants';
1010
import {
11+
$counterStyle,
1112
$disabledStyle,
1213
$supportingTextStyle,
1314
$inputStyle,
@@ -37,6 +38,7 @@ export const getOutlinedTextFieldData = (
3738
const {
3839
labelProps,
3940
supportingTextProps,
41+
counterProps,
4042
style: $inputStyleOverride,
4143
fieldStyle: $fieldStyleOverride,
4244
containerStyle: $containerStyleOverride,
@@ -125,6 +127,16 @@ export const getOutlinedTextFieldData = (
125127
supportingTextProps?.style,
126128
];
127129

130+
const $counterStyles: StyleProp<TextStyle> = [
131+
$counterStyle,
132+
{
133+
color: supportingTextColor,
134+
writingDirection: isRTL ? 'rtl' : 'ltr',
135+
},
136+
disabled && $disabledStyle,
137+
counterProps?.style,
138+
];
139+
128140
const $animatedLabelWrapperStyles: StyleProp<
129141
Animated.WithAnimatedObject<ViewStyle> | ViewStyle
130142
> = [
@@ -211,6 +223,7 @@ export const getOutlinedTextFieldData = (
211223
$outlineStyles,
212224
$containerStyles,
213225
$supportingTextStyles,
226+
$counterStyles,
214227
$inputStyles,
215228
$prefixStyles,
216229
$suffixStyles,

src/components/TextField/styles.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,27 @@ export const $inputStyle: StyleProp<TextStyle> = {
1818
fontWeight: '400',
1919
};
2020

21+
export const $addendumStyle: ViewStyle = {
22+
flexDirection: 'row',
23+
};
24+
2125
export const $supportingTextStyle: TextStyle = {
26+
flex: 1,
2227
marginTop: SUPPORTING_TEXT_MARGIN_TOP,
2328
paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL,
2429
fontSize: SUPPORTING_TEXT_FONT_SIZE,
2530
fontWeight: '400',
2631
textAlign: 'left',
2732
};
2833

34+
export const $counterStyle: TextStyle = {
35+
marginTop: SUPPORTING_TEXT_MARGIN_TOP,
36+
paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL,
37+
fontSize: SUPPORTING_TEXT_FONT_SIZE,
38+
fontWeight: '400',
39+
textAlign: 'right',
40+
};
41+
2942
export const $trailingAccessoryStyle: ViewStyle = {
3043
width: ACCESSORY_SIZE,
3144
marginEnd: TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL,

src/components/__tests__/TextField.test.tsx

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,86 @@ it('does not pass TextField-only props through to TextInput', () => {
282282
expect(input.props.prefixProps).toBeUndefined();
283283
expect(input.props.suffix).toBeUndefined();
284284
expect(input.props.suffixProps).toBeUndefined();
285+
expect(input.props.counter).toBeUndefined();
286+
expect(input.props.counterProps).toBeUndefined();
287+
});
288+
289+
it('shows a character counter when counter is true and maxLength is set (filled)', () => {
290+
const { getByText, queryByText } = render(
291+
<TextField
292+
label="Bio"
293+
value="hello"
294+
onChangeText={() => {}}
295+
counter
296+
maxLength={100}
297+
/>
298+
);
299+
300+
expect(getByText('5/100')).toBeTruthy();
301+
expect(queryByText('0/100')).toBeNull();
302+
});
303+
304+
it('shows a character counter when counter is true and maxLength is set (outlined)', () => {
305+
const { getByText } = render(
306+
<TextField
307+
variant="outlined"
308+
label="Bio"
309+
value=""
310+
onChangeText={() => {}}
311+
counter
312+
maxLength={50}
313+
/>
314+
);
315+
316+
expect(getByText('0/50')).toBeTruthy();
317+
});
318+
319+
it('updates the character counter when the value changes', () => {
320+
const { getByText, rerender } = render(
321+
<TextField
322+
label="Bio"
323+
value="a"
324+
onChangeText={() => {}}
325+
counter
326+
maxLength={10}
327+
/>
328+
);
329+
330+
expect(getByText('1/10')).toBeTruthy();
331+
332+
rerender(
333+
<TextField
334+
label="Bio"
335+
value="abcd"
336+
onChangeText={() => {}}
337+
counter
338+
maxLength={10}
339+
/>
340+
);
341+
342+
expect(getByText('4/10')).toBeTruthy();
343+
});
344+
345+
it('does not show a character counter when counter is false', () => {
346+
const { queryByText } = render(
347+
<TextField
348+
label="Bio"
349+
value="hello"
350+
onChangeText={() => {}}
351+
maxLength={100}
352+
/>
353+
);
354+
355+
expect(queryByText('5/100')).toBeNull();
356+
});
357+
358+
it('does not show a character counter when maxLength is missing', () => {
359+
const { queryByText } = render(
360+
<TextField label="Bio" value="hello" onChangeText={() => {}} counter />
361+
);
362+
363+
expect(queryByText('5/100')).toBeNull();
364+
expect(queryByText(/\//)).toBeNull();
285365
});
286366

287367
it('invokes onFocus and onBlur on the TextInput', () => {
@@ -419,6 +499,39 @@ it('applies supportingTextProps to the supporting Text', () => {
419499
expect(getByTestId('supporting-text').props.children).toBe('Hint');
420500
});
421501

502+
it('applies counterProps to the counter Text', () => {
503+
const { getByTestId } = render(
504+
<TextField
505+
label="Bio"
506+
value="hi"
507+
onChangeText={() => {}}
508+
counter
509+
maxLength={80}
510+
counterProps={{ testID: 'counter-text' }}
511+
/>
512+
);
513+
514+
expect(getByTestId('counter-text').props.children).toBe('2/80');
515+
});
516+
517+
it('does not apply supportingTextProps style to the counter Text', () => {
518+
const { getByTestId } = render(
519+
<TextField
520+
label="Bio"
521+
value="x"
522+
onChangeText={() => {}}
523+
counter
524+
maxLength={10}
525+
supportingTextProps={{ style: { fontSize: 9 } }}
526+
counterProps={{ testID: 'counter-text' }}
527+
/>
528+
);
529+
530+
expect(
531+
StyleSheet.flatten(getByTestId('counter-text').props.style).fontSize
532+
).not.toBe(9);
533+
});
534+
422535
it('applies RTL text alignment and writing direction to the TextInput (filled)', () => {
423536
I18nManager.isRTL = true;
424537

0 commit comments

Comments
 (0)