Skip to content

feat: TextField component#4909

Open
wonderlul wants to merge 4 commits intocallstack:v6from
wonderlul:feat/TextField-v6
Open

feat: TextField component#4909
wonderlul wants to merge 4 commits intocallstack:v6from
wonderlul:feat/TextField-v6

Conversation

@wonderlul
Copy link
Copy Markdown
Collaborator

Motivation

File structure
The component is split by variant (filled/ and outlined/) 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 / RightAccessory vs TextInput adornments
TextInput composes leading and trailing content through left and right, which are built around icons and affixes (TextInput.Icon, TextInput.Affix) and internal adornment types. TextField instead exposes LeftAccessory and RightAccessory as 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 / outlined instead of flat / outlined
Material Design 3 describes text fields in terms of filled and outlined styles. 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 TextInput would also blur the built-in React Native TextInput in imports and documentation. A dedicated TextField name keeps the design-system component clearly namespaced and aligned with MD3, while the underlying control remains React Native’s TextInput where appropriate.

Positioning relative to TextInput
TextField is 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 the PlatformColor API where it fits platform tokens). Compared to the legacy TextInput stack, 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 existing TextInput users.

Order of merging

#4901

Related issue

#4878
#4329
#4235

Test plan

Run the Example app.

filled
text-field-filled

outlined
text-field-outlined

@callstack-bot
Copy link
Copy Markdown

callstack-bot commented Apr 30, 2026

Hey @wonderlul, thank you for your pull request 🤗. The documentation from this branch can be viewed here.

@wonderlul wonderlul force-pushed the feat/TextField-v6 branch from 146de2a to a1a20fd Compare May 4, 2026 10:27
StartAccessory={(props: TextFieldAccessoryProps) => (
<TextField.Icon {...props} icon="magnify" color={iconMuted} />
)}
EndAccessory={(props: TextFieldAccessoryProps) => (
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Please move this EndAccessory to the TextField above 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

@adrcotfas adrcotfas May 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is multiline needed here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm extremely curious about what use case you have in mind that needs multiline for accessories. YAGNI.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the native implementation, it should be color: onSurfaceVariant.
Same for suffixStyles, same for outlined/logic.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per M3 tokens (InputPrefixColor / InputSuffixColor = OnSurfaceVariant; InputColor = OnSurface), updated prefix/suffix only—left input text on onSurface.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, my bad for pointing the wrong line number.

Comment thread src/components/TextField/utils.ts Outdated

/**
* Returns the solid background color for the filled field container, or
* `undefined` when disabled. The disabled tint (`onSurface @ 0.04`) is rendered
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't use magic numbers because DISABLED_CONTAINER_OPACITY might change.

@@ -0,0 +1,300 @@
import * as React from 'react';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 = ({
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ClearFilledSearchAccessory, ClearOutlinedSearchAccessory, SearchLeadingAccessory look obsolete - we already have IconButton/TouchableRipple built in TextField.Icon.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

@adrcotfas adrcotfas May 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> = [
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiline issues:

  • The padding breaks when adding a new line to an outlined TextField.
    I see MULTILINE_PADDING_TOP is 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

@adrcotfas
Copy link
Copy Markdown
Collaborator

Here's a comment from my friend Claude about duplicate code:

logic.ts -- highest duplication (~28%)

The most significant overlap. Both files share these sections verbatim:

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) vs borderTopStart/EndRadius (filled)
  • $outlineStyle: full-perimeter absolute (outlined) vs bottom-only absolute (filled)
  • $containerStyle: alignItems: 'center' (outlined) vs 'flex-end' (filled)
  • $labelWrapperStyle: outlined adds paddingHorizontal, 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%

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants