feat: TextField component#4909
Conversation
|
Hey @wonderlul, thank you for your pull request 🤗. The documentation from this branch can be viewed here. |
146de2a to
a1a20fd
Compare
| StartAccessory={(props: TextFieldAccessoryProps) => ( | ||
| <TextField.Icon {...props} icon="magnify" color={iconMuted} /> | ||
| )} | ||
| EndAccessory={(props: TextFieldAccessoryProps) => ( |
There was a problem hiding this comment.
- Please move this
EndAccessoryto theTextFieldabove and remove this one.
The two are identical except for the missing ripple from the first. I think it does not make sense to exemplify a button without feedback. - Same for the first two
TextFields from "Outlined" below.
There was a problem hiding this comment.
This was intentional. The example is meant to show that accessories are not limited to our built-in helpers (for example TextField.Icon): developers can wire in their own components, including ones with intentionally different interaction patterns — here, a control that doesn’t use a ripple to contrast with the standard affordance.
There was a problem hiding this comment.
In that case why not put some examples of different accessories, not something that is confused with icon buttons, because they are clickable, and should provide a visual feedback; IMHO, keeping extra elements here just for the sake of it confuses users that look at the examples.
What I'm trying to say: why showcase (and implement in the first place) things that you cannot find in the native specs/ guidelines?
| export interface TextFieldAccessoryProps { | ||
| style: StyleProp<ViewStyle>; | ||
| status?: 'error' | 'disabled'; | ||
| multiline: boolean; |
There was a problem hiding this comment.
Is multiline needed here?
There was a problem hiding this comment.
It’s hard to say upfront whether every consumer will need multiline, but exposing it doesn’t really cost us anything and keeps the API honest: accessories are rendered in a context that does depend on whether the field is single- or multi-line. If someone wants to override default accessory layout or behavior (alignment, padding, placement), having multiline on the accessory props avoids workarounds and guessing from inside a custom StartAccessory / EndAccessory.
There was a problem hiding this comment.
I'm extremely curious about what use case you have in mind that needs multiline for accessories. YAGNI.
There was a problem hiding this comment.
I'd say accessories sometimes need different layout when the field is multiline (e.g. top-align an icon with the first line instead of vertically centering it in a tall input, or move an end action to the bottom).
| status, | ||
| }); | ||
|
|
||
| const onPressHandler = editable ? onPress : undefined; |
There was a problem hiding this comment.
The press guard works fine visually since TouchableRipple already handles missing onPress. The problem is accessibility because IconButton sets accessibilityState from its own disabled prop, which is never passed, so screen readers won't know it's disabled. Should pass disabled={!editable} to IconButton.
| $inputStyle, | ||
| { | ||
| fontSize: INPUT_FONT_SIZE, | ||
| color: onSurface, |
There was a problem hiding this comment.
Looking at the native implementation, it should be color: onSurfaceVariant.
Same for suffixStyles, same for outlined/logic.
There was a problem hiding this comment.
Per M3 tokens (InputPrefixColor / InputSuffixColor = OnSurfaceVariant; InputColor = OnSurface), updated prefix/suffix only—left input text on onSurface.
There was a problem hiding this comment.
Sure, my bad for pointing the wrong line number.
|
|
||
| /** | ||
| * Returns the solid background color for the filled field container, or | ||
| * `undefined` when disabled. The disabled tint (`onSurface @ 0.04`) is rendered |
There was a problem hiding this comment.
Please don't use magic numbers because DISABLED_CONTAINER_OPACITY might change.
| @@ -0,0 +1,300 @@ | |||
| import * as React from 'react'; | |||
There was a problem hiding this comment.
I used this example to check against the specs and native implementation but had to extend it locally to check all states.
It would be nice to have an example for disabled fields that contain accessories and suffix/prefix or even some buttons that toggles states for a more compact example page.
| const [filledIconQuery, setFilledIconQuery] = React.useState(''); | ||
| const [outlinedIconQuery, setOutlinedIconQuery] = React.useState(''); | ||
|
|
||
| const ClearFilledSearchAccessory = ({ |
There was a problem hiding this comment.
ClearFilledSearchAccessory, ClearOutlinedSearchAccessory, SearchLeadingAccessory look obsolete - we already have IconButton/TouchableRipple built in TextField.Icon.
There was a problem hiding this comment.
This was intentional. The example is meant to show that accessories are not limited to our built-in helpers (for example TextField.Icon): developers can wire in their own components, including ones with intentionally different interaction patterns — here, a control that doesn’t use a ripple to contrast with the standard affordance.
There was a problem hiding this comment.
In that case why not put some examples of different accessories, not something that is confused with icon buttons, because they are clickable, and should provide a visual feedback; IMHO, keeping extra elements here just for the sake of it confuses users that look at the examples.
What I'm trying to say: why showcase (and implement in the first place) things that you cannot find in the native specs/ guidelines?
| supportingTextProps?.style, | ||
| ]; | ||
|
|
||
| const $inputStyles: StyleProp<TextStyle> = [ |
There was a problem hiding this comment.
At least on Android there's a visual bug with the opacity of the input text.
When disabled, the opacity on the input text appears more than it should be (near invisible).
The fix I found was to extract a $inputWrapperStyles and move the { flex: 1 }, disabled && $disabledStyle to it then place the RN TextInput from TextField.tsx inside of a <View style={$inputWrapperStyles}>.
| @@ -0,0 +1,267 @@ | |||
| import { | |||
There was a problem hiding this comment.
I checked the native implementation:
- prefix should have an end padding of 2
- suffix should have a start padding of 2
| @@ -0,0 +1,233 @@ | |||
| import { | |||
There was a problem hiding this comment.
Multiline issues:
-
The padding breaks when adding a new line to an outlined TextField.
I seeMULTILINE_PADDING_TOPis not used here. -
There's a visual artifact/clipping/redraw when adding new lines for both filled and outlined: if this is hard to fix, we can ignore for now
|
Here's a comment from my friend Claude about duplicate code:
|
| Block | Lines each |
|---|---|
| Props destructuring | ~13 |
isRTL extraction |
1 |
getLabelColor(...) call |
~5 |
getSupportingTextColor(...) call |
~5 |
$animatedLabelTextStyles array |
~9 |
$containerStyles array |
~4 |
$supportingTextStyles array |
~8 |
$counterStyles array |
~8 |
$prefixStyles array |
~6 |
$suffixStyles array |
~6 |
$leadingAccessoryStyles |
~3 |
$trailingAccessoryStyles |
~3 |
| Total | ~71 identical lines |
Outlined has 233 lines, filled has 280 -- so ~71/233 (30%) and ~71/280 (25%) respectively are shared logic.
What's genuinely variant: the $fieldStyles (filled adds backgroundColor), $outlineStyles (border vs bottom-bar), $animatedActiveOutlineStyles (filled-only), $disabledBackgroundStyles (undefined in outlined, overlay in filled), and the labelBackgroundColor extraction
(outlined only).
styles.ts -- low duplication (~10%)
$labelTextStyle is byte-for-byte identical in both files (5 lines). It belongs in the shared ../styles.ts.
Everything else differs meaningfully:
$fieldStyle: same keys,borderRadius(outlined) vsborderTopStart/EndRadius(filled)$outlineStyle: full-perimeter absolute (outlined) vs bottom-only absolute (filled)$containerStyle:alignItems: 'center'(outlined) vs'flex-end'(filled)$labelWrapperStyle: outlined addspaddingHorizontal, filled is empty$disabledBackgroundStyle: filled-only
constants.ts -- minimal duplication (~5%)
LABEL_START_OFFSET_WITHOUT_ACCESSORY resolves to TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL in both -- could live in shared ../constants.ts.
Everything else is variant: LABEL_PADDING_HORIZONTAL and the RTL translate constants are outlined-only; MULTILINE_PADDING_TOP is filled-only; ACTIVE_LABEL_TOP_POSITION uses different formulas; the opacity values differ (0.12 vs 0.04).
utils.ts -- 0% unifiable
Both export getOutlineColor but with incompatible signatures: outlined takes hasError: boolean, filled takes status?: 'error' | 'disabled'. Same name, different contract -- unifying them would require a signature change that ripples into both logic.ts files.
Overall
| File | Outlined lines | Filled lines | Duplicated | % |
|---|---|---|---|---|
logic.ts |
233 | 280 | ~71 | ~28% |
styles.ts |
45 | 54 | ~5 | ~10% |
constants.ts |
49 | 35 | ~2 | ~5% |
utils.ts |
39 | 34 | 0 | 0% |
| Total | 366 | 403 | ~78 | ~20% |
Motivation
File structure
The component is split by variant (
filled/andoutlined/) and a root that wires shared behavior. Each area keeps logic, styles, utils, and constants in separate files. That follows patterns already used elsewhere in the library, but goes one step further so responsibilities stay obvious: variant-specific layout and theming do not drown in shared code, and the public API file stays focused on behavior and types.LeftAccessory/RightAccessoryvsTextInputadornmentsTextInputcomposes leading and trailing content throughleftandright, which are built around icons and affixes (TextInput.Icon,TextInput.Affix) and internal adornment types.TextFieldinstead exposesLeftAccessoryandRightAccessoryas render props (component types). The field passes curated layout and state—notably the merged style for alignment with the field, plus status, multiline, and editable—so accessories stay aligned with the input without re-implementing field internals. That supports arbitrary leading/trailing UI (clear actions, custom buttons, non-icon content) while still inheriting the important structural styles from the component.filled/outlinedinstead offlat/outlinedMaterial Design 3 describes text fields in terms of
filledandoutlinedstyles. The existing TextInput API uses mode:'flat' | 'outlined', where “flat” corresponds to the filled look. The new component names variants filled and outlined so the public API matches MD3 language and is easier to map from the spec and design tools.Style overrides
TextField is built as a small stack of clear layers, and each layer can be adjusted without fighting the rest. The outer pressable wrapper, the field shell (border, background, row that includes accessories), and the inner content wrapper (label + TextInput) each accept dedicated style props (pressableStyle, fieldStyle, containerStyle). The underlying TextInput still uses the normal style prop (and the rest of TextInputProps) for typography, padding, and input-specific layout. Label and helper text can be customized through labelProps and helperProps (including style). Leading/trailing UI uses LeftAccessory / RightAccessory, which receive a prepared style from the field so custom content stays aligned while remaining fully under your control. Together, this gives predictable “override the part you mean” behavior instead of a single opaque style that’s hard to reason about.
TextField instead of TextInput
Material Design 3 uses the term text field for this control. Exporting a second “Paper” input named
TextInputwould also blur the built-in React NativeTextInputin imports and documentation. A dedicatedTextFieldname keeps the design-system component clearly namespaced and aligned with MD3, while the underlying control remains React Native’sTextInputwhere appropriate.Positioning relative to TextInput
TextFieldis intended as the modern replacement path for form text entry in react-native-paper: implementation is structured for clarity and maintainability, and it adopts MD3-oriented theming (including use of thePlatformColor APIwhere it fits platform tokens). Compared to the legacyTextInputstack, this design aims to be easier to follow, less ad hoc in how variants and layout are split, and more efficient in how styles and state are applied—giving teams a refreshed, spec-aligned building block for new work without forcing an immediate break for existingTextInputusers.Order of merging
#4901
Related issue
#4878
#4329
#4235
Test plan
Run the Example app.
filledoutlined