Skip to content
Merged
6 changes: 4 additions & 2 deletions packages/design-system-react-native/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ This guide provides detailed instructions for migrating your project from one ve
**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).
- **`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`**.
- **`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.
Expand All @@ -57,7 +57,9 @@ This guide provides detailed instructions for migrating your project from one ve

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.
Replace **`isReadonly`** with **`isReadOnly`** on **`TextField`**, **`TextFieldSearch`**, and **`Input`** in **`@metamask/design-system-react-native`**. The same **`isReadOnly`** prop name is now used by **`Input`** in **`@metamask/design-system-react`** as well.

For shared wrappers that target both platforms, align to the cross-platform **`Input`** contract: controlled **`value: string`**, **`isReadOnly`**, and **`isStateStylesDisabled`**.

If you passed **`ref`** expecting the **`TextInput`**, switch imperative usage to **`inputRef`** and use **`ref`** only when you need the outer container (layout / measurement).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,15 @@ import {
} from '@metamask/design-system-twrnc-preset';
import { darkTheme } from '@metamask/design-tokens';
import { renderHook } from '@testing-library/react-hooks';
import { act, render, fireEvent } from '@testing-library/react-native';
import { fireEvent, render } from '@testing-library/react-native';
import React from 'react';
import { Platform, TextInput } from 'react-native';
import type { StyleProp, TextStyle } from 'react-native';
import { create } from 'react-test-renderer';
import { Platform } from 'react-native';
import type { TextStyle } from 'react-native';

import { Input } from './Input';

const TEST_ID = 'input';

function flattenStyle(style: StyleProp<TextStyle>): TextStyle[] {
if (style === null || style === undefined) {
return [];
}
if (Array.isArray(style)) {
return style.flatMap((s) => flattenStyle(s as StyleProp<TextStyle>));
}
return [style as TextStyle];
}

function getStyleProp(
style: StyleProp<TextStyle>,
key: keyof TextStyle,
): TextStyle[keyof TextStyle] | undefined {
const styles = flattenStyle(style);
for (let i = styles.length - 1; i >= 0; i--) {
const val = styles[i]?.[key];
if (val !== undefined) {
return val;
}
}
return undefined;
}

describe('Input', () => {
const tw = renderHook(() => useTailwind()).result.current;
Copy link
Copy Markdown
Contributor

@georgewrmarshall georgewrmarshall Apr 30, 2026

Choose a reason for hiding this comment

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

This rewrite is intentionally moving the native Input tests away from implementation-detail helpers and toward contract-level assertions. Using built-in matchers like toHaveStyle, toBeDisabled, and toBeOnTheScreen keeps the tests closer to real usage and makes the coverage signal more trustworthy.


Expand All @@ -51,57 +26,78 @@ describe('Input', () => {
value="Sample"
/>,
);

const input = getByTestId(TEST_ID);
expect(input).toBeDefined();
const styles = flattenStyle(input.props.style);
const expectedFontSize = (tw.style('text-heading-sm') as TextStyle)
.fontSize;
expect(styles).toContainEqual(
expect.objectContaining({ fontSize: expectedFontSize }),
);

expect(input).toBeOnTheScreen();
expect(input).toHaveStyle({ fontSize: expectedFontSize });
});

it('renders correct disabled state when isDisabled is true', () => {
const { getByTestId } = render(
<Input value="" testID={TEST_ID} isDisabled placeholder="Disabled" />,
);

const input = getByTestId(TEST_ID);
expect(input.props.editable).toBe(false);
const styles = flattenStyle(input.props.style);
expect(styles).toContainEqual(
expect.objectContaining({ opacity: tw`opacity-50`.opacity }),
);

expect(input).toBeDisabled();
expect(input).toHaveStyle({ opacity: tw`opacity-50`.opacity });
});

it('applies iOS placeholder lineHeight workaround when placeholder is visible', () => {
if (Platform.OS !== 'ios') {
return;
}

const { getByTestId } = render(
<Input testID={TEST_ID} value="" placeholder="Disabled" />,
);

const input = getByTestId(TEST_ID);
const lineHeight = getStyleProp(input.props.style, 'lineHeight');
expect(Platform.OS === 'ios' ? lineHeight === 0 : lineHeight !== 0).toBe(
true,

expect(input).toHaveStyle({ lineHeight: 0 });
});

it('does not apply placeholder lineHeight workaround outside iOS', () => {
if (Platform.OS === 'ios') {
return;
}

const { getByTestId } = render(
<Input testID={TEST_ID} value="" placeholder="Disabled" />,
);

const input = getByTestId(TEST_ID);

expect(input).not.toHaveStyle({ lineHeight: 0 });
});

it('removes placeholder lineHeight workaround after value changes from empty to non-empty', () => {
const { getByTestId, rerender } = render(
<Input testID={TEST_ID} value="" placeholder="Transition" />,
);

rerender(<Input testID={TEST_ID} value="A" placeholder="Transition" />);

const input = getByTestId(TEST_ID);
expect(getStyleProp(input.props.style, 'lineHeight')).not.toBe(0);

expect(input).not.toHaveStyle({ lineHeight: 0 });
});

it('handles multiline placeholder-to-text transitions without persisting lineHeight', () => {
const { getByTestId, rerender } = render(
<Input testID={TEST_ID} value="" placeholder="Multiline" multiline />,
);

rerender(
<Input testID={TEST_ID} value="A" placeholder="Multiline" multiline />,
);

const input = getByTestId(TEST_ID);
expect(getStyleProp(input.props.style, 'lineHeight')).not.toBe(0);

expect(input).not.toHaveStyle({ lineHeight: 0 });
});

it('does not apply state styles when isStateStylesDisabled is true', () => {
Expand All @@ -114,22 +110,24 @@ describe('Input', () => {
placeholder="Disabled"
/>,
);

const input = getByTestId(TEST_ID);
expect(input.props.editable).toBe(false);
const styles = flattenStyle(input.props.style);
expect(styles).not.toContainEqual(
expect.objectContaining({ opacity: 0.5 }),
);

expect(input).toBeDisabled();
expect(input).not.toHaveStyle({ opacity: 0.5 });
});

it('calls onBlur when input loses focus', () => {
const onBlur = jest.fn();
const { getByTestId } = render(
<Input value="" testID={TEST_ID} onBlur={onBlur} />,
);

const input = getByTestId(TEST_ID);

fireEvent(input, 'focus');
fireEvent(input, 'blur');

expect(onBlur).toHaveBeenCalled();
});

Expand All @@ -138,23 +136,48 @@ describe('Input', () => {
const { getByTestId } = render(
<Input value="" testID={TEST_ID} onFocus={onFocus} />,
);

const input = getByTestId(TEST_ID);

fireEvent(input, 'focus');

expect(onFocus).toHaveBeenCalled();
});

it('defaults autoFocus to false so focus is not stolen on mount', () => {
it('does not apply focused state styles on mount when autoFocus is false', () => {
const { getByTestId } = render(<Input value="" testID={TEST_ID} />);

const input = getByTestId(TEST_ID);
expect(input.props.autoFocus).toBe(false);

expect(input).toHaveStyle(tw`border-transparent`);
expect(input).not.toHaveStyle(tw`border-primary-default`);
});

it('respects autoFocus when set to true', () => {
it('applies focused state styles on mount when autoFocus is true', () => {
const { getByTestId } = render(
<Input value="" testID={TEST_ID} autoFocus />,
);

const input = getByTestId(TEST_ID);
expect(input.props.autoFocus).toBe(true);

expect(input).toHaveStyle(tw`border-primary-default`);
});

it('clears focused state when input becomes disabled', () => {
Copy link
Copy Markdown
Contributor

@georgewrmarshall georgewrmarshall Apr 30, 2026

Choose a reason for hiding this comment

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

This focused-then-disabled test captures the user-visible transition that justified the refactor. It verifies the field does not keep stale focused styling after becoming disabled.

const { getByTestId, rerender } = render(
<Input value="" testID={TEST_ID} placeholder="Focus me" />,
);

const input = getByTestId(TEST_ID);

fireEvent(input, 'focus');
expect(input).toHaveStyle(tw`border-primary-default`);

rerender(
<Input value="" testID={TEST_ID} isDisabled placeholder="Focus me" />,
);

expect(input).not.toHaveStyle(tw`border-primary-default`);
});

it('uses dark theme placeholder color when ThemeProvider has theme dark', () => {
Expand All @@ -163,8 +186,11 @@ describe('Input', () => {
<Input value="" testID={TEST_ID} placeholder="Dark theme" />
</ThemeProvider>,
);

const input = getByTestId(TEST_ID);
expect(input.props.placeholderTextColor).toBe(

expect(input).toHaveProp(
'placeholderTextColor',
darkTheme.colors.text.alternative,
);
});
Expand All @@ -174,9 +200,12 @@ describe('Input', () => {
const { getByTestId } = render(
<Input value="" testID={TEST_ID} isDisabled onBlur={onBlur} />,
);

const input = getByTestId(TEST_ID);

fireEvent(input, 'focus');
fireEvent(input, 'blur');

expect(onBlur).not.toHaveBeenCalled();
});

Expand All @@ -185,8 +214,11 @@ describe('Input', () => {
const { getByTestId } = render(
<Input value="" testID={TEST_ID} isDisabled onFocus={onFocus} />,
);

const input = getByTestId(TEST_ID);

fireEvent(input, 'focus');

expect(onFocus).not.toHaveBeenCalled();
});

Expand All @@ -195,9 +227,12 @@ describe('Input', () => {
const { getByTestId } = render(
<Input value="" testID={TEST_ID} onBlur={onBlur} />,
);

const input = getByTestId(TEST_ID);

fireEvent(input, 'focus');
fireEvent(input, 'blur', { nativeEvent: {} });

expect(onBlur).toHaveBeenCalledTimes(1);
expect(onBlur).toHaveBeenCalledWith(
expect.objectContaining({ nativeEvent: {} }),
Expand All @@ -209,76 +244,25 @@ describe('Input', () => {
const { getByTestId } = render(
<Input value="" testID={TEST_ID} onFocus={onFocus} />,
);

const input = getByTestId(TEST_ID);

fireEvent(input, 'focus', { nativeEvent: {} });

expect(onFocus).toHaveBeenCalledTimes(1);
expect(onFocus).toHaveBeenCalledWith(
expect.objectContaining({ nativeEvent: {} }),
);
});

it('calls onBlur handler when TextInput onBlur prop is invoked', () => {
const onBlur = jest.fn();
const { getByTestId } = render(
<Input value="" testID={TEST_ID} onBlur={onBlur} />,
);
const input = getByTestId(TEST_ID);
const blurEvent = { nativeEvent: { text: '' } };
act(() => {
input.props.onBlur(blurEvent);
});
expect(onBlur).toHaveBeenCalledWith(blurEvent);
});
it('does not throw when focus and blur fire without callbacks', () => {
const { getByTestId } = render(<Input value="" testID={TEST_ID} />);

it('calls onFocus handler when TextInput onFocus prop is invoked', () => {
const onFocus = jest.fn();
const { getByTestId } = render(
<Input value="" testID={TEST_ID} onFocus={onFocus} />,
);
const input = getByTestId(TEST_ID);
const focusEvent = { nativeEvent: { text: '' } };
act(() => {
input.props.onFocus(focusEvent);
});
expect(onFocus).toHaveBeenCalledWith(focusEvent);
});

it('onBlurHandler and onFocusHandler run when invoked via test renderer', () => {
const onBlur = jest.fn();
const onFocus = jest.fn();
const tree = create(
<ThemeProvider theme={Theme.Light}>
<Input value="" testID={TEST_ID} onBlur={onBlur} onFocus={onFocus} />
</ThemeProvider>,
);
const input = tree.root.findByProps({ testID: TEST_ID });
const blurEvent = { nativeEvent: { text: '' } };
const focusEvent = { nativeEvent: { text: '' } };
act(() => {
input.props.onBlur(blurEvent);
});
expect(onBlur).toHaveBeenCalledWith(blurEvent);
act(() => {
input.props.onFocus(focusEvent);
});
expect(onFocus).toHaveBeenCalledWith(focusEvent);
});
fireEvent(input, 'focus');
fireEvent(input, 'blur');

it('handlers run without callbacks (optional chaining branches)', () => {
const tree = create(
<ThemeProvider theme={Theme.Light}>
<Input value="" testID={TEST_ID} />
</ThemeProvider>,
);
const input = tree.root.findByType(TextInput);
const event = { nativeEvent: { text: '' } };
act(() => {
input.props.onBlur(event);
});
act(() => {
input.props.onFocus(event);
});
expect(input.props.onBlur).toBeDefined();
expect(input.props.onFocus).toBeDefined();
expect(input).toBeOnTheScreen();
});
});
Loading
Loading