diff --git a/packages/design-system-react-native/MIGRATION.md b/packages/design-system-react-native/MIGRATION.md
index 48241e8f0..68e600a36 100644
--- a/packages/design-system-react-native/MIGRATION.md
+++ b/packages/design-system-react-native/MIGRATION.md
@@ -93,6 +93,73 @@ These tokens had no backing CSS custom property, so any usage was already produc
- Any reference to the removed entries will produce a TypeScript error after upgrading.
+---
+
+#### TextField and TextFieldSearch: layered props (`inputProps` and root `Box`)
+
+**What changed:**
+
+- **`TextField`** is a root **`Box`** (a styled **`View`**) with an inner **`Input`**. Props that belong on the native text control must be passed in **`inputProps`** (for example `keyboardType`, `secureTextEntry`, `returnKeyType`, `autoCapitalize`, `accessibilityLabel`, `accessibilityState`).
+- **`placeholder`**, **`isReadOnly`**, **`onFocus`**, and **`onBlur`** are owned at the **`TextField` / `TextFieldSearch` top level** and forwarded to the inner `Input`. Do not pass them only through **`inputProps`**. The prop **`isReadonly`** was renamed to **`isReadOnly`** (aligned with React / React Native spelling).
+- **`placeholderTextColor`** is not supported on the public **`TextField`** API; the inner **`Input`** sets placeholder color from the theme.
+- Remaining top-level props on **`TextField`** are **`BoxProps`** (layout and **`View`** props from React Native), except for keys reserved by **`TextField`** (see the exported type **`TextFieldProps`** in **`@metamask/design-system-react-native`**). **`hitSlop`**, **`onPress`**, and other **`Pressable`**-only APIs are not supported on the root; tap-to-focus on the chrome is removedβusers focus by tapping the **`Input`** / **`TextInput`**.
+- **`TextFieldSearchProps`** extends **`TextFieldProps`**; the same layering applies. **`onPressClearButton`**, **`clearButtonProps`**, **`startAccessory`**, **`endAccessory`**, and **`style`** behavior are unchanged.
+- **`ref`** on **`TextField`** / **`TextFieldSearch`** refers to the **root** **`Box`** (**`View`**). Use **`inputRef`** for the inner **`TextInput`** (for example **`focus()`** / **`blur()`**).
+- Top-level **`testID`** applies to the **wrapper** **`Box`**. To query the editable **`TextInput`** in E2E tests, use **`inputProps.testID`** (or accessibility / placeholder queries).
+
+**Migration:**
+
+Move inner `TextInput` props from the root into **`inputProps`**. Keep **`placeholder`**, **`onFocus`**, and **`onBlur`** on the component root when you use them.
+
+Replace **`isReadonly`** with **`isReadOnly`** on **`TextField`**, **`TextFieldSearch`**, and **`Input`** in **`@metamask/design-system-react-native`**. The **`Input`** in **`@metamask/design-system-react`** keeps the **`isReadonly`** prop name.
+
+If you passed **`ref`** expecting the **`TextInput`**, switch imperative usage to **`inputRef`** and use **`ref`** only when you need the outer container (layout / measurement).
+
+```tsx
+// Before (0.19.0) β native TextInput props on TextField
+
+
+// After (0.20.0)
+
+```
+
+If you relied on **`hitSlop`** or a larger tap target on the field chrome, wrap **`TextField`** in your own **`Pressable`** (or enlarge the inner input via **`inputProps`**) at the app level.
+
+Remove **`placeholderTextColor`** from **`TextField`** call sites; rely on theme behavior from **`Input`**.
+
+**Impact:**
+
+- Any **`TextField`** or **`TextFieldSearch`** usage that spread or passed **`TextInput`** props on the root must move those keys into **`inputProps`**, except for the props **`TextField`** owns (**`value`**, **`onChangeText`**, **`placeholder`**, **`isReadOnly`**, **`onFocus`**, **`onBlur`**, **`isDisabled`**, **`autoFocus`**, **`isError`**, accessories, **`inputElement`**, **`inputRef`**, **`testID`**, **`style`**, **`twClassName`**) and valid **`BoxProps`** / **`View`** props you pass at the top level.
+- Call sites that passed **`Pressable`**-only props (**`hitSlop`**, root **`onPress`**, root **`disabled`**) must be updated: the root is no longer a **`Pressable`**.
+- Type-only consumers can extend or intersect **`TextFieldProps`** from **`@metamask/design-system-react-native`** for typed wrappers or form helpers. Derive the inner input prop bag with **`TextFieldProps['inputProps']`** when needed.
+
+#### Input: theme `placeholderTextColor` always wins
+
+**What changed:**
+
+**`Input`** used to pass **`placeholderTextColor`** on the native **`TextInput`** **before** **`{...props}`**, so a **`placeholderTextColor`** included in **`props`** could override the theme-derived color. **`Input`** now passes **`placeholderTextColor`** **after** **`{...props}`**, so the **theme token for placeholder text is always applied** and **is not overridden** by caller props.
+
+**Impact:**
+
+- Passing **`placeholderTextColor`** on **`Input`** has **no effect** on the rendered placeholder tint; remove dead props if you had any.
+- **`TextField`** already omits **`placeholderTextColor`** from its public API and forwards inner **`Input`** behavior only.
+
### From version 0.18.0 to 0.19.0
#### HeaderRoot: `titleAccessory` no longer renders without `title`
@@ -2307,7 +2374,7 @@ These props work identically in both versions β no migration needed:
| `onBlur` | `(e) => void` | Blur handler (skipped when disabled) |
| `isError` | `boolean` | Error border state |
| `isDisabled` | `boolean` | Disabled state (opacity + no interaction) |
-| `isReadonly` | `boolean` | Read-only state |
+| `isReadOnly` | `boolean` | Read-only state |
| `autoFocus` | `boolean` | Auto-focus on mount |
| `startAccessory` | `ReactNode` | Content before the input |
| `endAccessory` | `ReactNode` | Content after the input |
diff --git a/packages/design-system-react-native/src/components/Input/Input.stories.tsx b/packages/design-system-react-native/src/components/Input/Input.stories.tsx
index 8cafe5630..8856bb764 100644
--- a/packages/design-system-react-native/src/components/Input/Input.stories.tsx
+++ b/packages/design-system-react-native/src/components/Input/Input.stories.tsx
@@ -26,7 +26,7 @@ const meta: Meta = {
isDisabled: {
control: 'boolean',
},
- isReadonly: {
+ isReadOnly: {
control: 'boolean',
},
isStateStylesDisabled: {
@@ -87,11 +87,11 @@ export const IsDisabled: Story = {
),
};
-export const IsReadonly: Story = {
+export const IsReadOnly: Story = {
render: () => (
-
+
),
};
diff --git a/packages/design-system-react-native/src/components/Input/Input.tsx b/packages/design-system-react-native/src/components/Input/Input.tsx
index bbae21503..786e6c338 100644
--- a/packages/design-system-react-native/src/components/Input/Input.tsx
+++ b/packages/design-system-react-native/src/components/Input/Input.tsx
@@ -23,7 +23,7 @@ export const Input = forwardRef(
textVariant = TextVariant.BodyMd,
isStateStylesDisabled,
isDisabled = false,
- isReadonly = false,
+ isReadOnly = false,
value,
placeholder,
twClassName,
@@ -121,12 +121,12 @@ export const Input = forwardRef(
return (
;
+```
+
+### `onChangeText`
+
+Optional callback when the text changes.
+
+| TYPE | REQUIRED | DEFAULT |
+| ------------------------ | -------- | ----------- |
+| `(text: string) => void` | No | `undefined` |
+
+```tsx
+import { TextField } from '@metamask/design-system-react-native';
+
+ {}} placeholder="Change handler" />;
+```
+
+### `placeholder`
+
+Optional placeholder string for the inner input.
+
+| TYPE | REQUIRED | DEFAULT |
+| -------- | -------- | ----------- |
+| `string` | No | `undefined` |
+
+```tsx
+import { TextField } from '@metamask/design-system-react-native';
+
+;
+```
+
+### `isReadOnly`
+
+When true, the inner input is not editable.
+
+| TYPE | REQUIRED | DEFAULT |
+| --------- | -------- | ------- |
+| `boolean` | No | `false` |
+
+```tsx
+import { TextField } from '@metamask/design-system-react-native';
+
+;
+```
+
+### `onFocus`
+
+Optional handler when the inner input receives focus. TextField composes this with its own focus border behavior. Do not pass `onFocus` through `inputProps`; use this prop instead.
+
+| TYPE | REQUIRED | DEFAULT |
+| ---------- | -------- | ----------- |
+| `function` | No | `undefined` |
+
+```tsx
+import { TextField } from '@metamask/design-system-react-native';
+
+ {}} />;
+```
+
+### `onBlur`
+
+Optional handler when the inner input loses focus. TextField composes this with its own focus border behavior. Do not pass `onBlur` through `inputProps`; use this prop instead.
+
+| TYPE | REQUIRED | DEFAULT |
+| ---------- | -------- | ----------- |
+| `function` | No | `undefined` |
+
+```tsx
+import { TextField } from '@metamask/design-system-react-native';
+
+ {}} />;
+```
+
+### `inputProps`
+
+Additional props forwarded to the inner [Input](../Input/Input.tsx) / `TextInput`. Do not pass `placeholder`, `isReadOnly`, `onFocus`, or `onBlur` here; use the TextField-level props above. `placeholderTextColor` is omitted from the type; the inner `Input` sets it from the theme. For screen readers, set `inputProps.accessibilityLabel` and `inputProps.accessibilityHint` (for example the hint can state that a value is required). You can use `inputProps.testID` to target the native `TextInput` in E2E tests.
+
+| TYPE | REQUIRED | DEFAULT |
+| -------------------------------------------------------------------- | -------- | ----------- |
+| `TextFieldProps['inputProps']` (see `TextFieldProps` in the package) | No | `undefined` |
+
+```tsx
+import { TextField } from '@metamask/design-system-react-native';
+
+ {}}
+ placeholder="Search"
+ inputProps={{
+ autoCapitalize: 'none',
+ returnKeyType: 'search',
+ }}
+/>;
+```
+
+### `inputRef`
-The field uses a fixed **48px** row height with a single-line inner input.
+Ref to the inner `TextInput`. The componentβs `ref` (from `forwardRef`) points at the root [Box](../Box/Box.tsx) (`View`).
+
+| TYPE | REQUIRED | DEFAULT |
+| ---------------- | -------- | ----------- |
+| `Ref` | No | `undefined` |
+
+```tsx
+import { createRef } from 'react';
+import { TextField } from '@metamask/design-system-react-native';
+import type { TextInput } from 'react-native';
+
+const inputRef = createRef();
+
+;
+```
### `isError`
-Optional boolean to show the error state. Changes the border color to indicate an error.
+When true, the field shows an error state (container border).
| TYPE | REQUIRED | DEFAULT |
| --------- | -------- | ------- |
| `boolean` | No | `false` |
```tsx
-
+import { TextField } from '@metamask/design-system-react-native';
+
+;
```
### `isDisabled`
-Optional boolean to disable the TextField. Reduces opacity and prevents interaction.
+When true, the field applies reduced opacity and forwards disabled state to the inner `Input` (non-editable).
+
+| TYPE | REQUIRED | DEFAULT |
+| --------- | -------- | ------- |
+| `boolean` | No | `false` |
+
+```tsx
+import { TextField } from '@metamask/design-system-react-native';
+
+;
+```
+
+### `autoFocus`
+
+When true, the inner input requests focus on mount.
| TYPE | REQUIRED | DEFAULT |
| --------- | -------- | ------- |
| `boolean` | No | `false` |
```tsx
-
+import { TextField } from '@metamask/design-system-react-native';
+
+;
```
### `startAccessory`
-Optional content to display before the Input. For E2E, set `testID` on the accessory (or wrap it in your own `View`).
+Optional content rendered before the inner input. For E2E, set `testID` on the accessory or wrap it in your own `View`.
| TYPE | REQUIRED | DEFAULT |
| ----------- | -------- | ----------- |
| `ReactNode` | No | `undefined` |
```tsx
+import { TextField } from '@metamask/design-system-react-native';
import { Text } from 'react-native';
π} placeholder="Search..." />;
@@ -64,13 +193,14 @@ import { Text } from 'react-native';
### `endAccessory`
-Optional content to display after the Input. For E2E, set `testID` on the accessory (or wrap it in your own `View`).
+Optional content rendered after the inner input. For E2E, set `testID` on the accessory or wrap it in your own `View`.
| TYPE | REQUIRED | DEFAULT |
| ----------- | -------- | ----------- |
| `ReactNode` | No | `undefined` |
```tsx
+import { TextField } from '@metamask/design-system-react-native';
import { Text } from 'react-native';
} />;
```
+### `testID`
+
+Optional test id for the root `Box`. The inner `TextInput` does not inherit this id; pass `inputProps.testID` if your tests must query the editable control directly.
+
+| TYPE | REQUIRED | DEFAULT |
+| -------- | -------- | ----------- |
+| `string` | No | `undefined` |
+
+```tsx
+import { TextField } from '@metamask/design-system-react-native';
+
+;
+```
+
+### Layout and accessibility (`Box` / `View`)
+
+The root `Box` sets `accessible={false}` so assistive technologies focus the inner `TextInput`. Prefer **`inputProps`** for `accessibilityLabel`, `accessibilityHint`, and other [TextInput](https://reactnative.dev/docs/textinput) accessibility props.
+
+Use top-level `Box` / `View` props for layout and pointer handling (`pointerEvents`, margins, hit areas via wrappers, etc.). Keys reserved by TextField (`style`, `twClassName`, `testID`, `children`, `accessible`, and keys owned by the TextField API surface) are not passed through from this intersection.
+
+```tsx
+import { TextField } from '@metamask/design-system-react-native';
+
+;
+```
+
### `twClassName`
-Use the `twClassName` prop to add Tailwind CSS classes to the container. These classes will be merged with the component's default classes using `twMerge`, allowing you to:
+Use the `twClassName` prop to add Tailwind CSS classes to the component. These classes will be merged with the component's default classes using `twMerge`, allowing you to:
- Add new styles that don't exist in the default component
- Override the component's default styles when needed
@@ -108,16 +272,20 @@ Use the `twClassName` prop to add Tailwind CSS classes to the container. These c
```tsx
import { TextField } from '@metamask/design-system-react-native';
-// Add additional styles (avoid layout/height changes without design system review)
+// Add additional styles
// Override default styles
-
+
```
### `style`
-Use the `style` prop to customize the container's appearance with React Native styles. For consistent styling, prefer using `twClassName` with Tailwind classes when possible. Use `style` with `tw.style()` for conditionals or dynamic values.
+Use the `style` prop to customize the component's appearance with React Native styles. For consistent styling, prefer using `twClassName` with Tailwind classes when possible. Use `style` with `tw.style()` for conditionals or dynamic values.
| TYPE | REQUIRED | DEFAULT |
| ---------------------- | -------- | ----------- |
diff --git a/packages/design-system-react-native/src/components/TextField/TextField.stories.tsx b/packages/design-system-react-native/src/components/TextField/TextField.stories.tsx
index 8f621ed91..05017edc6 100644
--- a/packages/design-system-react-native/src/components/TextField/TextField.stories.tsx
+++ b/packages/design-system-react-native/src/components/TextField/TextField.stories.tsx
@@ -1,6 +1,8 @@
import type { Meta, StoryObj } from '@storybook/react-native';
import { useEffect, useState } from 'react';
-import { Text, View } from 'react-native';
+import { View } from 'react-native';
+
+import { Icon, IconName, IconSize } from '../Icon';
import { TextField } from './TextField';
import type { TextFieldProps } from './TextField.types';
@@ -24,13 +26,13 @@ const meta: Meta = {
isDisabled: {
control: 'boolean',
},
- isReadonly: {
+ isReadOnly: {
control: 'boolean',
},
- placeholder: {
+ value: {
control: 'text',
},
- value: {
+ placeholder: {
control: 'text',
},
twClassName: {
@@ -74,7 +76,7 @@ export const StartAccessory: Story = {
π}
+ startAccessory={}
/>
),
};
@@ -84,7 +86,7 @@ export const EndAccessory: Story = {
β}
+ endAccessory={}
/>
),
};
diff --git a/packages/design-system-react-native/src/components/TextField/TextField.test.tsx b/packages/design-system-react-native/src/components/TextField/TextField.test.tsx
index 86e117b1f..c3167446d 100644
--- a/packages/design-system-react-native/src/components/TextField/TextField.test.tsx
+++ b/packages/design-system-react-native/src/components/TextField/TextField.test.tsx
@@ -3,7 +3,6 @@ import { renderHook } from '@testing-library/react-hooks';
import { render, fireEvent } from '@testing-library/react-native';
import React, { createRef } from 'react';
import { TextInput, View } from 'react-native';
-import type { StyleProp, ViewStyle } from 'react-native';
import { act, create } from 'react-test-renderer';
import { Input } from '../Input';
@@ -12,473 +11,484 @@ import { TextField } from './TextField';
const ROOT_TEST_ID = 'textfield';
-function flattenStyle(style: StyleProp): ViewStyle[] {
- if (style === null || style === undefined) {
- return [];
- }
- if (Array.isArray(style)) {
- return style.flatMap((s) => flattenStyle(s as StyleProp));
- }
- return [style as ViewStyle];
-}
-
describe('TextField', () => {
- const tw = renderHook(() => useTailwind()).result.current;
-
- // ββ Rendering ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ let tw: ReturnType;
- it('renders with default props', () => {
- const { getByTestId } = render(
- ,
- );
- expect(getByTestId(ROOT_TEST_ID)).toBeDefined();
+ beforeAll(() => {
+ tw = renderHook(() => useTailwind()).result.current;
});
- it('passes testID to the root element', () => {
- const { getByTestId } = render(
- ,
- );
- expect(getByTestId('custom-test-id')).toBeDefined();
- });
+ describe('rendering', () => {
+ it('renders with default props', () => {
+ const { getByTestId } = render(
+ ,
+ );
- it('renders custom inputElement when provided', () => {
- const { getByTestId } = render(
- }
- />,
- );
- expect(getByTestId('custom-input')).toBeDefined();
- });
+ expect(getByTestId(ROOT_TEST_ID)).toBeOnTheScreen();
+ });
- it('forwards props to the inner Input', () => {
- const { getByPlaceholderText } = render(
- ,
- );
- expect(getByPlaceholderText('forwarded-placeholder')).toBeDefined();
- });
+ it('passes testID to the root element', () => {
+ const { getByTestId } = render(
+ ,
+ );
- it('defaults inner Input to single-line (numberOfLines and multiline)', () => {
- const tree = create(
- ,
- );
- const inputNode = tree.root.findByType(Input);
- expect(inputNode.props.numberOfLines).toBe(1);
- expect(inputNode.props.multiline).toBe(false);
- });
+ expect(getByTestId('custom-test-id')).toBeOnTheScreen();
+ });
- it('forwards secureTextEntry to the inner Input', () => {
- const { getByPlaceholderText } = render(
- ,
- );
- expect(getByPlaceholderText('secure').props.secureTextEntry).toBe(true);
- });
+ it('renders custom inputElement when provided', () => {
+ const { getByTestId } = render(
+ }
+ />,
+ );
- // ββ Ref forwarding ββββββββββββββββββββββββββββββββββββββββββββββββ
+ expect(getByTestId('custom-input')).toBeOnTheScreen();
+ });
- it('exposes TextInput ref via forwardRef', () => {
- const ref = createRef();
- render();
- expect(ref.current).toBeDefined();
- expect(ref.current).toBeInstanceOf(TextInput);
- });
+ it('forwards inputProps to the inner Input', () => {
+ const { getByPlaceholderText } = render(
+ ,
+ );
+
+ expect(getByPlaceholderText('forwarded-placeholder')).toHaveProp(
+ 'keyboardType',
+ 'number-pad',
+ );
+ });
- it('allows calling focus() via the forwarded ref', () => {
- const ref = createRef();
- render();
- // Should not throw
- expect(() => ref.current?.focus()).not.toThrow();
+ it('merges inputProps.twClassName with TextField inner Input layout classes', () => {
+ const tree = create(
+ ,
+ );
+ const inputNode = tree.root.findByType(Input);
+
+ expect(inputNode.props.twClassName).toContain('mt-2');
+ expect(inputNode.props.twClassName).toContain('flex-1');
+ expect(inputNode.props.twClassName).toContain('min-h-0');
+ });
});
- // ββ Height (48px spec) βββββββββββββββββββββββββββββββββββββββββββββ
-
- it('applies fixed 48px row height', () => {
- const { getByTestId } = render(
- ,
- );
- const root = getByTestId(ROOT_TEST_ID);
- const styles = flattenStyle(root.props.style);
- const expectedHeight = (tw.style('h-12') as ViewStyle).height;
- expect(styles).toContainEqual(
- expect.objectContaining({ height: expectedHeight }),
- );
- });
+ describe('single-line input', () => {
+ it('sets numberOfLines to 1 on the inner input', () => {
+ const { getByPlaceholderText } = render(
+ ,
+ );
+
+ expect(getByPlaceholderText('single-line')).toHaveProp(
+ 'numberOfLines',
+ 1,
+ );
+ });
- // ββ Error state ββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
- it('shows error border when isError is true', () => {
- const { getByTestId } = render(
- ,
- );
- const root = getByTestId(ROOT_TEST_ID);
- const styles = flattenStyle(root.props.style);
- const errorBorder = tw.style('border-error-default') as ViewStyle;
- expect(styles).toContainEqual(
- expect.objectContaining({ borderColor: errorBorder.borderColor }),
- );
+ it('sets multiline to false on the inner input', () => {
+ const { getByPlaceholderText } = render(
+ ,
+ );
+
+ expect(getByPlaceholderText('single-line')).toHaveProp(
+ 'multiline',
+ false,
+ );
+ });
});
- it('keeps error border when focused and isError', () => {
- const { getByTestId, getByPlaceholderText } = render(
- ,
- );
- fireEvent(getByPlaceholderText('error-focus'), 'focus');
- const root = getByTestId(ROOT_TEST_ID);
- const styles = flattenStyle(root.props.style);
- const errorBorder = tw.style('border-error-default') as ViewStyle;
- expect(styles).toContainEqual(
- expect.objectContaining({ borderColor: errorBorder.borderColor }),
- );
- });
+ describe('onChangeText', () => {
+ it('notifies when the text changes', () => {
+ const onChangeText = jest.fn();
+ const { getByPlaceholderText } = render(
+ ,
+ );
- // ββ Disabled state βββββββββββββββββββββββββββββββββββββββββββββββββ
-
- it('applies opacity when isDisabled is true', () => {
- const { getByTestId } = render(
- ,
- );
- const root = getByTestId(ROOT_TEST_ID);
- const styles = flattenStyle(root.props.style);
- expect(styles).toContainEqual(
- expect.objectContaining({ opacity: tw`opacity-50`.opacity }),
- );
- });
+ fireEvent.changeText(getByPlaceholderText('change-me'), 'ab');
- it('does not apply opacity when isDisabled is false', () => {
- const { getByTestId } = render(
- ,
- );
- const root = getByTestId(ROOT_TEST_ID);
- const styles = flattenStyle(root.props.style);
- const hasOpacity50 = styles.some(
- (s) => s.opacity === tw`opacity-50`.opacity,
- );
- expect(hasOpacity50).toBe(false);
+ expect(onChangeText).toHaveBeenCalledTimes(1);
+ expect(onChangeText).toHaveBeenCalledWith('ab');
+ });
});
- it('does not show focus border when disabled even if isFocused is true', () => {
- const { getByTestId } = render(
- ,
- );
- const root = getByTestId(ROOT_TEST_ID);
- const styles = flattenStyle(root.props.style);
- const focusBorder = tw.style('border-primary-default') as ViewStyle;
- const hasFocusBorder = styles.some(
- (s) => s.borderColor === focusBorder.borderColor,
- );
- expect(hasFocusBorder).toBe(false);
- });
+ describe('Input props', () => {
+ it('forwards secureTextEntry to the inner Input', () => {
+ const { getByPlaceholderText } = render(
+ ,
+ );
+
+ expect(getByPlaceholderText('secure')).toHaveProp(
+ 'secureTextEntry',
+ true,
+ );
+ });
- // ββ Accessories ββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
- it('renders startAccessory when provided', () => {
- const { getByTestId } = render(
- }
- />,
- );
- expect(getByTestId('start-accessory')).toBeDefined();
- });
+ it('forwards isReadOnly to the inner Input', () => {
+ const { getByPlaceholderText } = render(
+ ,
+ );
- it('renders endAccessory when provided', () => {
- const { getByTestId } = render(
- }
- />,
- );
- expect(getByTestId('end-accessory')).toBeDefined();
+ expect(getByPlaceholderText('readonly-test')).toHaveProp(
+ 'editable',
+ false,
+ );
+ });
});
- it('does not render accessories when not provided', () => {
- const { queryByTestId } = render(
- ,
- );
- expect(queryByTestId('start-accessory')).toBeNull();
- expect(queryByTestId('end-accessory')).toBeNull();
- });
+ describe('ref', () => {
+ it('exposes the root View ref via forwardRef', () => {
+ const ref = createRef();
+ render(
+ ,
+ );
+
+ expect(ref.current).not.toBeNull();
+ expect(ref.current).toBeInstanceOf(View);
+ });
- // ββ Focus / Blur handlers βββββββββββββββββββββββββββββββββββββββββ
+ it('exposes the inner TextInput via inputRef', () => {
+ const inputRef = createRef();
+ render();
- it('calls onFocus when input receives focus', () => {
- const onFocus = jest.fn();
- const { getByPlaceholderText } = render(
- ,
- );
- fireEvent(getByPlaceholderText('focus-test'), 'focus');
- expect(onFocus).toHaveBeenCalledTimes(1);
- });
+ expect(inputRef.current).not.toBeNull();
+ expect(inputRef.current).toBeInstanceOf(TextInput);
+ });
- it('calls onBlur when input loses focus', () => {
- const onBlur = jest.fn();
- const { getByPlaceholderText } = render(
- ,
- );
- fireEvent(getByPlaceholderText('blur-test'), 'focus');
- fireEvent(getByPlaceholderText('blur-test'), 'blur');
- expect(onBlur).toHaveBeenCalledTimes(1);
- });
+ it('allows calling focus() via inputRef', () => {
+ const inputRef = createRef();
+ render(
+ ,
+ );
- it('does not call onFocus when disabled', () => {
- const onFocus = jest.fn();
- const { getByPlaceholderText } = render(
- ,
- );
- fireEvent(getByPlaceholderText('disabled-focus'), 'focus');
- expect(onFocus).not.toHaveBeenCalled();
+ expect(() => inputRef.current?.focus()).not.toThrow();
+ });
});
- it('does not call onBlur when disabled', () => {
- const onBlur = jest.fn();
- const { getByPlaceholderText } = render(
- ,
- );
- fireEvent(getByPlaceholderText('disabled-blur'), 'focus');
- fireEvent(getByPlaceholderText('disabled-blur'), 'blur');
- expect(onBlur).not.toHaveBeenCalled();
- });
+ describe('container styles', () => {
+ it('applies fixed 48px row height', () => {
+ const { getByTestId } = render(
+ ,
+ );
- it('passes event argument to onFocus callback', () => {
- const onFocus = jest.fn();
- const { getByPlaceholderText } = render(
- ,
- );
- fireEvent(getByPlaceholderText('event-focus'), 'focus', {
- nativeEvent: {},
- });
- expect(onFocus).toHaveBeenCalledWith(
- expect.objectContaining({ nativeEvent: {} }),
- );
- });
+ expect(getByTestId(ROOT_TEST_ID)).toHaveStyle(tw`h-12`);
+ });
- it('passes event argument to onBlur callback', () => {
- const onBlur = jest.fn();
- const { getByPlaceholderText } = render(
- ,
- );
- fireEvent(getByPlaceholderText('event-blur'), 'focus');
- fireEvent(getByPlaceholderText('event-blur'), 'blur', {
- nativeEvent: {},
- });
- expect(onBlur).toHaveBeenCalledWith(
- expect.objectContaining({ nativeEvent: {} }),
- );
- });
+ it('shows error border when isError is true', () => {
+ const { getByTestId } = render(
+ ,
+ );
- it('handles focus without onFocus callback (optional chaining)', () => {
- const { getByPlaceholderText } = render(
- ,
- );
- // Should not throw when no onFocus is provided
- expect(() => {
- fireEvent(getByPlaceholderText('no-focus-cb'), 'focus');
- }).not.toThrow();
- });
+ expect(getByTestId(ROOT_TEST_ID)).toHaveStyle(tw`border-error-default`);
+ });
- it('handles blur without onBlur callback (optional chaining)', () => {
- const { getByPlaceholderText } = render(
- ,
- );
- // Should not throw when no onBlur is provided
- expect(() => {
- fireEvent(getByPlaceholderText('no-blur-cb'), 'focus');
- fireEvent(getByPlaceholderText('no-blur-cb'), 'blur');
- }).not.toThrow();
- });
+ it('keeps error border when focused and isError', () => {
+ const { getByTestId, getByPlaceholderText } = render(
+ ,
+ );
- // ββ Disabled handler branches (direct invocation) ββββββββββββββββββ
-
- it('onBlurHandler is a no-op when isDisabled is true', () => {
- const onBlur = jest.fn();
- const tree = create(
- ,
- );
- const inputNode = tree.root.findByType(Input);
- // inputNode.props.onBlur IS TextField's onBlurHandler
- act(() => {
- inputNode.props.onBlur({ nativeEvent: {} });
- });
- expect(onBlur).not.toHaveBeenCalled();
- });
+ fireEvent(getByPlaceholderText('error-focus'), 'focus');
- it('onFocusHandler is a no-op when isDisabled is true', () => {
- const onFocus = jest.fn();
- const tree = create(
- ,
- );
- const inputNode = tree.root.findByType(Input);
- // inputNode.props.onFocus IS TextField's onFocusHandler
- act(() => {
- inputNode.props.onFocus({ nativeEvent: {} });
- });
- expect(onFocus).not.toHaveBeenCalled();
- });
+ expect(getByTestId(ROOT_TEST_ID)).toHaveStyle(tw`border-error-default`);
+ });
- // ββ Focus border styling ββββββββββββββββββββββββββββββββββββββββββ
-
- it('applies focus border when focused', () => {
- const { getByTestId, getByPlaceholderText } = render(
- ,
- );
- fireEvent(getByPlaceholderText('focus-border'), 'focus');
- const root = getByTestId(ROOT_TEST_ID);
- const styles = flattenStyle(root.props.style);
- const focusBorder = tw.style('border-default') as ViewStyle;
- expect(styles).toContainEqual(
- expect.objectContaining({ borderColor: focusBorder.borderColor }),
- );
- });
+ it('applies opacity when isDisabled is true', () => {
+ const { getByTestId } = render(
+ ,
+ );
- it('reverts to muted resting border after blur', () => {
- const { getByTestId, getByPlaceholderText } = render(
- ,
- );
- fireEvent(getByPlaceholderText('blur-border'), 'focus');
- fireEvent(getByPlaceholderText('blur-border'), 'blur');
- const root = getByTestId(ROOT_TEST_ID);
- const styles = flattenStyle(root.props.style);
- const mutedBorder = tw.style('border-muted') as ViewStyle;
- expect(styles).toContainEqual(
- expect.objectContaining({ borderColor: mutedBorder.borderColor }),
- );
- });
+ expect(getByTestId(ROOT_TEST_ID)).toHaveStyle(tw`opacity-50`);
+ });
- // ββ autoFocus ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
- it('starts with focus border when autoFocus is true', () => {
- const { getByTestId } = render(
- ,
- );
- const root = getByTestId(ROOT_TEST_ID);
- const styles = flattenStyle(root.props.style);
- const focusBorder = tw.style('border-default') as ViewStyle;
- expect(styles).toContainEqual(
- expect.objectContaining({ borderColor: focusBorder.borderColor }),
- );
- });
+ it('omits disabled opacity when isDisabled is false', () => {
+ const { getByTestId } = render(
+ ,
+ );
- // ββ onPress (tap-to-focus) βββββββββββββββββββββββββββββββββββββββββ
-
- it('focuses the input when the container is pressed', () => {
- const ref = createRef();
- const onFocus = jest.fn();
- const { getByTestId } = render(
- ,
- );
- expect(ref.current).not.toBeNull();
- const focusSpy = jest.spyOn(ref.current as TextInput, 'focus');
- fireEvent.press(getByTestId(ROOT_TEST_ID));
- expect(focusSpy).toHaveBeenCalled();
- focusSpy.mockRestore();
- });
+ expect(getByTestId(ROOT_TEST_ID)).not.toHaveStyle(tw`opacity-50`);
+ });
- it('does not focus the input when pressed while disabled', () => {
- const ref = createRef();
- const { getByTestId } = render(
- ,
- );
- expect(ref.current).not.toBeNull();
- const focusSpy = jest.spyOn(ref.current as TextInput, 'focus');
- fireEvent.press(getByTestId(ROOT_TEST_ID));
- expect(focusSpy).not.toHaveBeenCalled();
- focusSpy.mockRestore();
- });
+ it('uses muted border when disabled even if autoFocus is true', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId(ROOT_TEST_ID)).toHaveStyle(tw`border-muted`);
+ expect(getByTestId(ROOT_TEST_ID)).not.toHaveStyle(tw`border-default`);
+ });
+
+ it('applies focus border when focused', () => {
+ const { getByTestId, getByPlaceholderText } = render(
+ ,
+ );
+
+ fireEvent(getByPlaceholderText('focus-border'), 'focus');
+
+ expect(getByTestId(ROOT_TEST_ID)).toHaveStyle(tw`border-default`);
+ });
- it('does not crash when pressed with custom inputElement (no inputRef)', () => {
- const { getByTestId } = render(
- }
- />,
- );
- // Pressing should not throw even when inputRef.current is null
- expect(() => {
- fireEvent.press(getByTestId(ROOT_TEST_ID));
- }).not.toThrow();
+ it('reverts to muted resting border after blur', () => {
+ const { getByTestId, getByPlaceholderText } = render(
+ ,
+ );
+
+ fireEvent(getByPlaceholderText('blur-border'), 'focus');
+ fireEvent(getByPlaceholderText('blur-border'), 'blur');
+
+ expect(getByTestId(ROOT_TEST_ID)).toHaveStyle(tw`border-muted`);
+ });
+
+ it('starts with focus border when autoFocus is true', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId(ROOT_TEST_ID)).toHaveStyle(tw`border-default`);
+ });
+
+ it('applies twClassName to the container', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId(ROOT_TEST_ID)).toHaveStyle(tw`mt-4`);
+ });
+
+ it('merges custom style prop with container styles', () => {
+ const customStyle = { marginBottom: 20 };
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId(ROOT_TEST_ID)).toHaveStyle({ marginBottom: 20 });
+ });
});
- // ββ twClassName ββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
- it('applies twClassName to the container', () => {
- const { getByTestId } = render(
- ,
- );
- const root = getByTestId(ROOT_TEST_ID);
- const styles = flattenStyle(root.props.style);
- const expectedMargin = (tw.style('mt-4') as ViewStyle).marginTop;
- expect(styles).toContainEqual(
- expect.objectContaining({ marginTop: expectedMargin }),
- );
+ describe('accessories', () => {
+ it('renders startAccessory when provided', () => {
+ const { getByTestId } = render(
+ }
+ />,
+ );
+
+ expect(getByTestId('start-accessory')).toBeOnTheScreen();
+ });
+
+ it('renders endAccessory when provided', () => {
+ const { getByTestId } = render(
+ }
+ />,
+ );
+
+ expect(getByTestId('end-accessory')).toBeOnTheScreen();
+ });
+
+ it('omits accessories when not provided', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(queryByTestId('start-accessory')).toBeNull();
+ expect(queryByTestId('end-accessory')).toBeNull();
+ });
});
- // ββ style prop βββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
- it('merges custom style prop with container styles', () => {
- const customStyle = { marginBottom: 20 };
- const { getByTestId } = render(
- ,
- );
- const root = getByTestId(ROOT_TEST_ID);
- const styles = flattenStyle(root.props.style);
- expect(styles).toContainEqual(
- expect.objectContaining({ marginBottom: 20 }),
- );
+ describe('focus and blur', () => {
+ it('calls onFocus when input receives focus', () => {
+ const onFocus = jest.fn();
+ const { getByPlaceholderText } = render(
+ ,
+ );
+
+ fireEvent(getByPlaceholderText('focus-test'), 'focus');
+
+ expect(onFocus).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onBlur when input loses focus', () => {
+ const onBlur = jest.fn();
+ const { getByPlaceholderText } = render(
+ ,
+ );
+
+ fireEvent(getByPlaceholderText('blur-test'), 'focus');
+ fireEvent(getByPlaceholderText('blur-test'), 'blur');
+
+ expect(onBlur).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call onFocus when disabled', () => {
+ const onFocus = jest.fn();
+ const { getByPlaceholderText } = render(
+ ,
+ );
+
+ fireEvent(getByPlaceholderText('disabled-focus'), 'focus');
+
+ expect(onFocus).not.toHaveBeenCalled();
+ });
+
+ it('does not call onBlur when disabled', () => {
+ const onBlur = jest.fn();
+ const { getByPlaceholderText } = render(
+ ,
+ );
+
+ fireEvent(getByPlaceholderText('disabled-blur'), 'focus');
+ fireEvent(getByPlaceholderText('disabled-blur'), 'blur');
+
+ expect(onBlur).not.toHaveBeenCalled();
+ });
+
+ it('no-ops TextField blur wiring when disabled if the handler is invoked directly', () => {
+ const onBlur = jest.fn();
+ const tree = create(
+ ,
+ );
+ const inputNode = tree.root.findByType(Input);
+ act(() => {
+ inputNode.props.onBlur({ nativeEvent: {} });
+ });
+ expect(onBlur).not.toHaveBeenCalled();
+ });
+
+ it('no-ops TextField focus wiring when disabled if the handler is invoked directly', () => {
+ const onFocus = jest.fn();
+ const tree = create(
+ ,
+ );
+ const inputNode = tree.root.findByType(Input);
+ act(() => {
+ inputNode.props.onFocus({ nativeEvent: {} });
+ });
+ expect(onFocus).not.toHaveBeenCalled();
+ });
+
+ it('passes event argument to onFocus callback', () => {
+ const onFocus = jest.fn();
+ const { getByPlaceholderText } = render(
+ ,
+ );
+
+ fireEvent(getByPlaceholderText('event-focus'), 'focus', {
+ nativeEvent: {},
+ });
+
+ expect(onFocus).toHaveBeenCalledWith(
+ expect.objectContaining({ nativeEvent: {} }),
+ );
+ });
+
+ it('passes event argument to onBlur callback', () => {
+ const onBlur = jest.fn();
+ const { getByPlaceholderText } = render(
+ ,
+ );
+
+ fireEvent(getByPlaceholderText('event-blur'), 'focus');
+ fireEvent(getByPlaceholderText('event-blur'), 'blur', {
+ nativeEvent: {},
+ });
+
+ expect(onBlur).toHaveBeenCalledWith(
+ expect.objectContaining({ nativeEvent: {} }),
+ );
+ });
+
+ it('handles focus when onFocus is omitted', () => {
+ const { getByPlaceholderText } = render(
+ ,
+ );
+
+ expect(() => {
+ fireEvent(getByPlaceholderText('no-focus-cb'), 'focus');
+ }).not.toThrow();
+ });
+
+ it('handles blur when onBlur is omitted', () => {
+ const { getByPlaceholderText } = render(
+ ,
+ );
+
+ expect(() => {
+ fireEvent(getByPlaceholderText('no-blur-cb'), 'focus');
+ fireEvent(getByPlaceholderText('no-blur-cb'), 'blur');
+ }).not.toThrow();
+ });
});
- // ββ isReadonly forwarding ββββββββββββββββββββββββββββββββββββββββββ
+ describe('disabled state', () => {
+ it('disables the inner Input when isDisabled is true', () => {
+ const { getByPlaceholderText } = render(
+ ,
+ );
- it('forwards isReadonly to the inner Input', () => {
- const { getByPlaceholderText } = render(
- ,
- );
- const input = getByPlaceholderText('readonly-test');
- expect(input.props.editable).toBe(false);
+ expect(getByPlaceholderText('disabled-input')).toHaveProp(
+ 'editable',
+ false,
+ );
+ });
});
});
diff --git a/packages/design-system-react-native/src/components/TextField/TextField.tsx b/packages/design-system-react-native/src/components/TextField/TextField.tsx
index 13ad28194..cd00729ed 100644
--- a/packages/design-system-react-native/src/components/TextField/TextField.tsx
+++ b/packages/design-system-react-native/src/components/TextField/TextField.tsx
@@ -1,90 +1,48 @@
import { TextVariant } from '@metamask/design-system-shared';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import {
- forwardRef,
- useCallback,
- useImperativeHandle,
- useMemo,
- useRef,
- useState,
-} from 'react';
-import { Pressable, TextInput, View } from 'react-native';
+import { forwardRef, useCallback, useState } from 'react';
+import { View } from 'react-native';
+import { Box } from '../Box';
import { Input } from '../Input';
+import type { InputProps } from '../Input/Input.types';
import type { TextFieldProps } from './TextField.types';
-function getContainerBorderColorClass(
- isDisabled: boolean,
- isFocused: boolean,
- isError: boolean,
-): string {
- if (isDisabled) {
- return 'border-muted';
- }
- if (isError) {
- return 'border-error-default';
- }
- if (isFocused) {
- return 'border-default';
- }
- return 'border-muted';
-}
-
-export const TextField = forwardRef(
+export const TextField = forwardRef(
(
{
- style,
- startAccessory,
- endAccessory,
+ value,
+ onChangeText,
+ placeholder,
+ isReadOnly,
+ onBlur,
+ onFocus,
+ autoFocus = false,
+ inputProps,
+ inputRef,
+ isDisabled = false,
isError = false,
inputElement,
- isDisabled = false,
- autoFocus = false,
+ startAccessory,
+ endAccessory,
+ style,
twClassName,
- onBlur,
- onFocus,
testID,
...props
},
ref,
) => {
const [isFocused, setIsFocused] = useState(autoFocus);
- const inputRef = useRef(null);
const tw = useTailwind();
- useImperativeHandle(
- ref,
- () => inputRef.current,
- [],
- );
-
- const borderColorClass = getContainerBorderColorClass(
- isDisabled,
- isFocused,
- isError,
- );
-
- const containerStyle = useMemo(
- () =>
- tw.style(
- 'flex-row',
- 'items-center',
- 'gap-3',
- 'rounded-lg',
- 'h-12',
- 'border',
- borderColorClass,
- 'px-4',
- 'bg-muted',
- isDisabled && 'opacity-50',
- twClassName,
- ),
- [borderColorClass, isDisabled, twClassName, tw],
- );
+ const {
+ twClassName: inputTwClassNameFromProps,
+ ...inputRestWithoutTwClassName
+ } = inputProps ?? {};
const onBlurHandler = useCallback(
- (e: Parameters>[0]) => {
+ (e: Parameters>[0]) => {
if (!isDisabled) {
setIsFocused(false);
onBlur?.(e);
@@ -94,7 +52,7 @@ export const TextField = forwardRef(
);
const onFocusHandler = useCallback(
- (e: Parameters>[0]) => {
+ (e: Parameters>[0]) => {
if (!isDisabled) {
setIsFocused(true);
onFocus?.(e);
@@ -103,41 +61,58 @@ export const TextField = forwardRef(
[isDisabled, onFocus],
);
- const onPressHandler = useCallback(() => {
- if (!isDisabled && inputRef.current) {
- inputRef.current.focus();
- }
- }, [isDisabled]);
-
return (
-
{startAccessory}
-
- {inputElement ?? (
-
- )}
-
+ {inputElement || (
+
+ )}
{endAccessory}
-
+
);
},
);
+
+TextField.displayName = 'TextField';
diff --git a/packages/design-system-react-native/src/components/TextField/TextField.types.ts b/packages/design-system-react-native/src/components/TextField/TextField.types.ts
index 8766c24e5..d70fc63a0 100644
--- a/packages/design-system-react-native/src/components/TextField/TextField.types.ts
+++ b/packages/design-system-react-native/src/components/TextField/TextField.types.ts
@@ -1,39 +1,82 @@
-import type { ReactNode } from 'react';
-import type { StyleProp, ViewStyle } from 'react-native';
+import type { TextFieldPropsShared } from '@metamask/design-system-shared';
+import type { Ref } from 'react';
+import type { StyleProp, TextInput, ViewStyle } from 'react-native';
+import type { BoxProps } from '../Box/Box.types';
import type { InputProps } from '../Input/Input.types';
/**
- * TextField component props.
+ * Additional props merged onto the inner `Input` (`../Input/Input.tsx`).
+ *
+ * TextField owns `value`, `onChangeText`, `placeholder`, `isReadOnly`, `onFocus`,
+ * `onBlur`, `isDisabled`, `autoFocus`, typography, and inner layout (merged with
+ * any `twClassName` you pass here). `placeholderTextColor` is omitted (Input sets
+ * it from theme).
*/
-export type TextFieldProps = Omit<
+type TextFieldInputProps = Omit<
InputProps,
- 'textVariant' | 'isStateStylesDisabled' | 'style'
-> & {
+ | 'autoFocus'
+ | 'isDisabled'
+ | 'isReadOnly'
+ | 'isStateStylesDisabled'
+ | 'onBlur'
+ | 'onChangeText'
+ | 'onFocus'
+ | 'placeholder'
+ | 'placeholderTextColor'
+ | 'style'
+ | 'textVariant'
+ | 'value'
+>;
+
+/**
+ * React Native `TextField` props between `TextFieldPropsShared` and the root
+ * `Box`: typed focus/blur handlers, `inputProps`, container styling, and
+ * `testID`.
+ */
+export type TextFieldBaseProps = TextFieldPropsShared & {
/**
- * Optional content to display before the Input.
+ * Called when the inner input receives focus (composed with TextField border state).
*/
- startAccessory?: ReactNode;
+ onFocus?: InputProps['onFocus'];
/**
- * Optional content to display after the Input.
+ * Called when the inner input loses focus (composed with TextField border state).
*/
- endAccessory?: ReactNode;
+ onBlur?: InputProps['onBlur'];
/**
- * Optional boolean to show the error state.
- *
- * @default false
+ * Additional props for the inner `Input`. Do not pass `placeholder`, `isReadOnly`, `onFocus`, or `onBlur` here;
+ * use the TextField-level props above. For accessibility, prefer `accessibilityLabel` and `accessibilityHint` on
+ * `inputProps` (for example hint text can note that a field is required). `placeholderTextColor` is omitted from the
+ * type; the inner `Input` sets it from the theme.
*/
- isError?: boolean;
+ inputProps?: TextFieldInputProps;
/**
- * Optional prop to replace the default Input with a custom element.
+ * Ref to the inner `TextInput`. The component `ref` targets the root `Box` (`View`).
*/
- inputElement?: ReactNode;
+ inputRef?: Ref;
/**
- * Optional prop to add twrnc overriding classNames.
+ * Optional twrnc classes for the root `Box`.
*/
twClassName?: string;
/**
- * Optional prop to customize the container style.
+ * Optional style for the root `Box`.
*/
style?: StyleProp;
+ /**
+ * Optional test id for the root `Box`.
+ */
+ testID?: string;
};
+
+type TextFieldReservedBoxKeys =
+ | keyof TextFieldBaseProps
+ | 'accessible'
+ | 'children'
+ | 'style';
+
+/**
+ * TextField props: `TextFieldBaseProps` plus remaining `BoxProps` at the top
+ * level, excluding keys reserved by TextField.
+ */
+export type TextFieldProps = TextFieldBaseProps &
+ Omit;
diff --git a/packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.stories.tsx b/packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.stories.tsx
index 33d18dabf..75042a589 100644
--- a/packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.stories.tsx
+++ b/packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.stories.tsx
@@ -36,18 +36,21 @@ const meta: Meta = {
isDisabled: {
control: 'boolean',
},
- isReadonly: {
+ isReadOnly: {
control: 'boolean',
},
- placeholder: {
+ value: {
control: 'text',
},
- value: {
+ placeholder: {
control: 'text',
},
twClassName: {
control: 'text',
},
+ inputProps: {
+ control: 'object',
+ },
},
};
@@ -100,11 +103,11 @@ export const IsDisabled: Story = {
),
};
-export const IsReadonly: Story = {
+export const IsReadOnly: Story = {
args: {
placeholder: 'Search readonly',
value: 'Search query',
- isReadonly: true,
+ isReadOnly: true,
onPressClearButton: noop,
},
render: (args) => ,
diff --git a/packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.tsx b/packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.tsx
index db3e04491..de8abb0fc 100644
--- a/packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.tsx
+++ b/packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.tsx
@@ -1,6 +1,6 @@
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import React, { forwardRef, useCallback } from 'react';
-import type { TextInput } from 'react-native';
+import type { View } from 'react-native';
import { ButtonIcon, ButtonIconSize } from '../ButtonIcon';
import { Icon, IconColor, IconName, IconSize } from '../Icon';
@@ -8,7 +8,7 @@ import { TextField } from '../TextField';
import type { TextFieldSearchProps } from './TextFieldSearch.types';
-export const TextFieldSearch = forwardRef(
+export const TextFieldSearch = forwardRef(
(
{
onPressClearButton,
diff --git a/packages/design-system-shared/src/index.ts b/packages/design-system-shared/src/index.ts
index 0aa5644b3..bb7e5a945 100644
--- a/packages/design-system-shared/src/index.ts
+++ b/packages/design-system-shared/src/index.ts
@@ -139,6 +139,9 @@ export {
type TextPropsShared,
} from './types/Text';
+// TextField types (ADR-0004)
+export { type TextFieldPropsShared } from './types/TextField';
+
// AvatarFavicon types (ADR-0004)
export {
AvatarFaviconSize,
diff --git a/packages/design-system-shared/src/types/TextField/TextField.types.ts b/packages/design-system-shared/src/types/TextField/TextField.types.ts
new file mode 100644
index 000000000..1f2d6c49f
--- /dev/null
+++ b/packages/design-system-shared/src/types/TextField/TextField.types.ts
@@ -0,0 +1,46 @@
+import type { ReactNode } from 'react';
+
+/**
+ * TextField shared props (ADR-0004).
+ *
+ * Platform-independent fields for a controlled text field and optional chrome
+ * (accessories, custom input slot). Styling, `testID`, native-only `inputProps`,
+ * and typed focus/blur handlers stay on the platform layer.
+ */
+export type TextFieldPropsShared = {
+ /** Controlled value. */
+ value: string;
+ /**
+ * Called when the text changes. Uses React Native `TextInput` naming; web
+ * implementations may map this from the native input change event.
+ */
+ onChangeText?: (text: string) => void;
+ /** Placeholder shown when `value` is empty. */
+ placeholder?: string;
+ /** When true, the value cannot be edited. */
+ isReadOnly?: boolean;
+ /**
+ * When true, interaction and editing are disabled.
+ *
+ * @default false
+ */
+ isDisabled?: boolean;
+ /**
+ * When true, the field shows an error state (for example border treatment).
+ *
+ * @default false
+ */
+ isError?: boolean;
+ /**
+ * When true, the field requests focus on mount.
+ *
+ * @default false
+ */
+ autoFocus?: boolean;
+ /** Optional content before the input. */
+ startAccessory?: ReactNode;
+ /** Optional content after the input. */
+ endAccessory?: ReactNode;
+ /** Replaces the default field input implementation. */
+ inputElement?: ReactNode;
+};
diff --git a/packages/design-system-shared/src/types/TextField/index.ts b/packages/design-system-shared/src/types/TextField/index.ts
new file mode 100644
index 000000000..28616ba1a
--- /dev/null
+++ b/packages/design-system-shared/src/types/TextField/index.ts
@@ -0,0 +1 @@
+export type { TextFieldPropsShared } from './TextField.types';