From b17fc3ec61dcf5fd147d53e43e43ff3701589139 Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Tue, 14 Apr 2026 21:13:15 -0700 Subject: [PATCH 01/11] Aligned TextField with cursor rules --- .../src/components/TextField/README.md | 53 +- .../components/TextField/TextField.test.tsx | 821 +++++++++--------- .../src/components/TextField/TextField.tsx | 72 +- .../components/TextField/TextField.types.ts | 14 +- 4 files changed, 474 insertions(+), 486 deletions(-) diff --git a/packages/design-system-react-native/src/components/TextField/README.md b/packages/design-system-react-native/src/components/TextField/README.md index 4979236cb..49f94493c 100644 --- a/packages/design-system-react-native/src/components/TextField/README.md +++ b/packages/design-system-react-native/src/components/TextField/README.md @@ -1,6 +1,6 @@ # TextField -TextField is a controlled-only boxed input that can include accessories before or after the input. +TextField is a controlled-only boxed input. The outer row is a fixed **48px** height with a single-line inner field, and optional content can appear before or after the input. ```tsx import { TextField } from '@metamask/design-system-react-native'; @@ -10,7 +10,7 @@ import { TextField } from '@metamask/design-system-react-native'; ## Props -This component extends [Input](../Input/Input.tsx) props (which extends React Native's [TextInput](https://reactnative.dev/docs/textinput)), excluding `textVariant` and `isStateStylesDisabled`. +This component extends [Input](../Input/Input.tsx) props (which extends React Native’s [TextInput](https://reactnative.dev/docs/textinput)), excluding `textVariant` and `isStateStylesDisabled`, which TextField owns. The root is a `Pressable` (tap-to-focus); use `pressableProps` for extra `Pressable` attributes. ### `value` @@ -20,43 +20,44 @@ Required controlled value for the TextField. | -------- | -------- | ------- | | `string` | Yes | N/A | -### Layout - -The field uses a fixed **48px** row height with a single-line inner input. - ### `isError` -Optional boolean to show the error state. Changes the border color to indicate an error. +Optional boolean to show the error state. Updates the container border color. | 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. +Optional boolean to disable the TextField. Applies reduced opacity, disables the root `Pressable`, and forwards disabled state to the inner `Input`. | 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 +65,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'; } />; ``` +### `pressableProps` + +Optional props passed to the root `Pressable`. `onPress`, `disabled`, `style`, and `children` are reserved by TextField. + +| TYPE | REQUIRED | DEFAULT | +| ------------------------------------------------------------------------ | -------- | ----------- | +| `Omit` | No | `undefined` | + +```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 @@ -117,15 +138,15 @@ import { TextField } from '@metamask/design-system-react-native'; ### `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 | | ---------------------- | -------- | ----------- | | `StyleProp` | No | `undefined` | ```tsx -import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { TextField } from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; export const ConditionalExample = ({ isActive }: { isActive: boolean }) => { const tw = useTailwind(); 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..606b3cfb8 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,482 +3,457 @@ 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'; 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; + let tw: ReturnType; - // ── Rendering ────────────────────────────────────────────────────── - - 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 props to the inner Input', () => { + const { getByPlaceholderText } = render( + , + ); - it('allows calling focus() via the forwarded ref', () => { - const ref = createRef(); - render(); - // Should not throw - expect(() => ref.current?.focus()).not.toThrow(); + expect(getByPlaceholderText('forwarded-placeholder')).toBeOnTheScreen(); + }); }); - // ── 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('Input props', () => { + it('forwards secureTextEntry to the inner Input', () => { + 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 }), - ); - }); + expect(getByPlaceholderText('secure')).toHaveProp( + 'secureTextEntry', + true, + ); + }); - 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); - }); + it('forwards isReadonly to the inner Input', () => { + const { getByPlaceholderText } = render( + , + ); - 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); + expect(getByPlaceholderText('readonly-test')).toHaveProp( + 'editable', + false, + ); + }); }); - // ── Accessories ──────────────────────────────────────────────────── - - it('renders startAccessory when provided', () => { - const { getByTestId } = render( - } - />, - ); - expect(getByTestId('start-accessory')).toBeDefined(); - }); + describe('ref', () => { + it('exposes TextInput ref via forwardRef', () => { + const ref = createRef(); + render(); - it('renders endAccessory when provided', () => { - const { getByTestId } = render( - } - />, - ); - expect(getByTestId('end-accessory')).toBeDefined(); - }); + expect(ref.current).not.toBeNull(); + expect(ref.current).toBeInstanceOf(TextInput); + }); + + it('allows calling focus() via the forwarded ref', () => { + const ref = createRef(); + render(); - it('does not render accessories when not provided', () => { - const { queryByTestId } = render( - , - ); - expect(queryByTestId('start-accessory')).toBeNull(); - expect(queryByTestId('end-accessory')).toBeNull(); + expect(() => ref.current?.focus()).not.toThrow(); + }); }); - // ── Focus / Blur handlers ───────────────────────────────────────── + describe('container styles', () => { + it('applies fixed 48px row height', () => { + const { getByTestId } = 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(getByTestId(ROOT_TEST_ID)).toHaveStyle(tw`h-12`); + }); - 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('shows error border when isError is true', () => { + const { getByTestId } = 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(getByTestId(ROOT_TEST_ID)).toHaveStyle(tw`border-error-default`); + }); - 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('keeps error border when focused and isError', () => { + const { getByTestId, getByPlaceholderText } = 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: {} }), - ); - }); + fireEvent(getByPlaceholderText('error-focus'), 'focus'); - 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: {} }), - ); - }); + expect(getByTestId(ROOT_TEST_ID)).toHaveStyle(tw`border-error-default`); + }); - 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(); - }); + it('applies opacity when isDisabled is true', () => { + const { getByTestId } = render( + , + ); - 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(); - }); + expect(getByTestId(ROOT_TEST_ID)).toHaveStyle(tw`opacity-50`); + }); - // ── 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(); - }); + it('omits disabled opacity when isDisabled is false', () => { + const { getByTestId } = render( + , + ); - 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)).not.toHaveStyle(tw`opacity-50`); + }); - // ── 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('uses muted border when disabled even if autoFocus 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`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('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`); + }); - // ── 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('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 }); + }); }); - // ── 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(); + 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(); + }); }); - 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(); + 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('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(); + }); }); - 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(() => { + describe('pressable root', () => { + it('disables the root Pressable when isDisabled is true', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(ROOT_TEST_ID)).toBeDisabled(); + }); + + it('forwards pressableProps to the root Pressable', () => { + const hitSlop = { top: 4, bottom: 4, left: 4, right: 4 }; + const { getByTestId } = render( + , + ); + + expect(getByTestId(ROOT_TEST_ID)).toHaveProp('hitSlop', hitSlop); + }); + + 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)); - }).not.toThrow(); - }); - // ── 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 }), - ); - }); + expect(focusSpy).toHaveBeenCalled(); + focusSpy.mockRestore(); + }); - // ── 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 }), - ); - }); + 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)); - // ── isReadonly forwarding ────────────────────────────────────────── + expect(focusSpy).not.toHaveBeenCalled(); + focusSpy.mockRestore(); + }); - it('forwards isReadonly to the inner Input', () => { - const { getByPlaceholderText } = render( - , - ); - const input = getByPlaceholderText('readonly-test'); - expect(input.props.editable).toBe(false); + it('handles press when inputElement replaces the default Input', () => { + const { getByTestId } = render( + } + />, + ); + + expect(() => { + fireEvent.press(getByTestId(ROOT_TEST_ID)); + }).not.toThrow(); + }); }); }); 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..27be75a14 100644 --- a/packages/design-system-react-native/src/components/TextField/TextField.tsx +++ b/packages/design-system-react-native/src/components/TextField/TextField.tsx @@ -4,33 +4,16 @@ import { forwardRef, useCallback, useImperativeHandle, - useMemo, useRef, useState, } from 'react'; -import { Pressable, TextInput, View } from 'react-native'; +import { Pressable, TextInput } from 'react-native'; +import { Box } from '../Box'; import { Input } from '../Input'; 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( ( { @@ -45,6 +28,7 @@ export const TextField = forwardRef( onBlur, onFocus, testID, + pressableProps, ...props }, ref, @@ -59,30 +43,6 @@ export const TextField = forwardRef( [], ); - 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 onBlurHandler = useCallback( (e: Parameters>[0]) => { if (!isDisabled) { @@ -111,13 +71,33 @@ export const TextField = forwardRef( return ( {startAccessory} - + {inputElement ?? ( ( multiline={false} /> )} - + {endAccessory} ); 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..d848b17a6 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,10 +1,15 @@ import type { ReactNode } from 'react'; -import type { StyleProp, ViewStyle } from 'react-native'; +import type { PressableProps, StyleProp, ViewStyle } from 'react-native'; import type { InputProps } from '../Input/Input.types'; /** * TextField component props. + * + * Inherits [Input](../Input/Input.tsx) props for the inner text input, excluding + * `textVariant` and `isStateStylesDisabled`, which are owned by TextField. The + * outer container is a `Pressable` (tap-to-focus); use `pressableProps` for + * additional Pressable-specific attributes. */ export type TextFieldProps = Omit< InputProps, @@ -36,4 +41,11 @@ export type TextFieldProps = Omit< * Optional prop to customize the container style. */ style?: StyleProp; + /** + * Optional props forwarded to the root `Pressable` wrapper. + */ + pressableProps?: Omit< + PressableProps, + 'onPress' | 'disabled' | 'style' | 'children' + >; }; From dd4f64891b1300f8625900a9ee2a445325f52869 Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Tue, 14 Apr 2026 21:18:21 -0700 Subject: [PATCH 02/11] Updated stories end and start accessories --- .../src/components/TextField/TextField.stories.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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..fcfaa0f58 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,7 +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'; @@ -74,7 +75,7 @@ export const StartAccessory: Story = { πŸ”} + startAccessory={} /> ), }; @@ -84,7 +85,7 @@ export const EndAccessory: Story = { βœ•} + endAccessory={} /> ), }; From 9d5ee9b9bcb615298a5fe7949b9227adc241037f Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Tue, 14 Apr 2026 21:37:30 -0700 Subject: [PATCH 03/11] Fixed lint errors --- .../src/components/TextField/TextField.stories.tsx | 1 + 1 file changed, 1 insertion(+) 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 fcfaa0f58..e668cb940 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 @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { View } from 'react-native'; import { Icon, IconName, IconSize } from '../Icon'; + import { TextField } from './TextField'; import type { TextFieldProps } from './TextField.types'; From 990ff1d51fa24d8257a83165580a62e0efa62fe8 Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Tue, 14 Apr 2026 23:05:49 -0700 Subject: [PATCH 04/11] Updated TextField test suite --- .../src/components/TextField/README.md | 8 ++-- .../components/TextField/TextField.test.tsx | 37 +++++++++++++++++++ .../components/TextField/TextField.types.ts | 2 +- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/design-system-react-native/src/components/TextField/README.md b/packages/design-system-react-native/src/components/TextField/README.md index 49f94493c..defac218b 100644 --- a/packages/design-system-react-native/src/components/TextField/README.md +++ b/packages/design-system-react-native/src/components/TextField/README.md @@ -99,11 +99,11 @@ import { TextInput } from 'react-native'; ### `pressableProps` -Optional props passed to the root `Pressable`. `onPress`, `disabled`, `style`, and `children` are reserved by TextField. +Optional props passed to the root `Pressable`. `onPress`, `disabled`, `style`, `children`, and `accessible` are reserved by TextField. -| TYPE | REQUIRED | DEFAULT | -| ------------------------------------------------------------------------ | -------- | ----------- | -| `Omit` | No | `undefined` | +| TYPE | REQUIRED | DEFAULT | +| ---------------------------------------------------------------------------------------- | -------- | ----------- | +| `Omit` | No | `undefined` | ```tsx import { TextField } from '@metamask/design-system-react-native'; 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 606b3cfb8..029302c6f 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,6 +3,9 @@ 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 { act, create } from 'react-test-renderer'; + +import { Input } from '../Input'; import { TextField } from './TextField'; @@ -324,6 +327,40 @@ describe('TextField', () => { 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( 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 d848b17a6..0b8f6c1a3 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 @@ -46,6 +46,6 @@ export type TextFieldProps = Omit< */ pressableProps?: Omit< PressableProps, - 'onPress' | 'disabled' | 'style' | 'children' + 'onPress' | 'disabled' | 'style' | 'children' | 'accessible' >; }; From 0709cd3b73d1c319d9f2bbee1bdd338b31199e6d Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Thu, 16 Apr 2026 00:48:44 -0700 Subject: [PATCH 05/11] Aligned DSRN TextField with react version --- .../design-system-react-native/MIGRATION.md | 73 ++++++++ .../src/components/Input/Input.tsx | 2 +- .../src/components/TextField/README.md | 161 ++++++++++++++++-- .../TextField/TextField.stories.tsx | 7 +- .../components/TextField/TextField.test.tsx | 65 +++++-- .../src/components/TextField/TextField.tsx | 39 +++-- .../components/TextField/TextField.types.ts | 82 ++++++--- .../src/components/TextField/index.ts | 3 +- .../TextFieldSearch.stories.tsx | 7 +- .../src/components/index.ts | 6 +- packages/design-system-shared/src/index.ts | 3 + .../src/types/TextField/TextField.types.ts | 46 +++++ .../src/types/TextField/index.ts | 1 + 13 files changed, 411 insertions(+), 84 deletions(-) create mode 100644 packages/design-system-shared/src/types/TextField/TextField.types.ts create mode 100644 packages/design-system-shared/src/types/TextField/index.ts diff --git a/packages/design-system-react-native/MIGRATION.md b/packages/design-system-react-native/MIGRATION.md index 8f37e29ea..be5d19ab9 100644 --- a/packages/design-system-react-native/MIGRATION.md +++ b/packages/design-system-react-native/MIGRATION.md @@ -17,6 +17,7 @@ This guide provides detailed instructions for migrating your project from one ve - [Icon Component](#icon-component) - [Checkbox Component](#checkbox-component) - [Version Updates](#version-updates) + - [From version 0.19.0 to 0.20.0](#from-version-0190-to-0200) - [From version 0.18.0 to 0.19.0](#from-version-0180-to-0190) - [From version 0.16.0 to 0.17.0](#from-version-0160-to-0170) - [From version 0.15.0 to 0.16.0](#from-version-0150-to-0160) @@ -28,6 +29,78 @@ This guide provides detailed instructions for migrating your project from one ve ## Version Updates +### From version 0.19.0 to 0.20.0 + +#### TextField and TextFieldSearch: layered props (`inputProps` and root `Pressable`) + +**What changed:** + +- **`TextField`** is a root **`Pressable`** 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`**. +- **`placeholderTextColor`** is not supported on the public **`TextField`** API; the inner **`Input`** sets placeholder color from the theme. +- **`Pressable`**-compatible props (for example **`hitSlop`**, **`accessibilityHint`** on the container) are passed at the **top level** on **`TextField`**. There is no separate `pressableProps` bag. +- Cross-platform field definitions live in **`TextFieldPropsShared`** in **`@metamask/design-system-shared`** (also re-exported from **`@metamask/design-system-react-native`**). +- **`TextFieldSearchProps`** extends **`TextFieldProps`**; the same layering applies. **`onPressClearButton`**, **`clearButtonProps`**, **`startAccessory`**, **`endAccessory`**, and **`style`** behavior are unchanged. + +**Migration:** + +Move inner `TextInput` props from the root into **`inputProps`**. Keep **`placeholder`**, **`onFocus`**, and **`onBlur`** on the component root when you use them. + +```tsx +// Before (0.19.0) β€” native TextInput props on TextField + + +// After (0.20.0) + +``` + +Pass **`hitSlop`** (and other `Pressable` props) on **`TextField`** itself: + +```tsx +// After (0.20.0) + +``` + +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`**, **`testID`**, **`style`**, **`twClassName`**). +- Type-only consumers can extend or intersect **`TextFieldPropsShared`** from **`@metamask/design-system-shared`** for shared forms code. + +#### 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` 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..2986a5830 100644 --- a/packages/design-system-react-native/src/components/Input/Input.tsx +++ b/packages/design-system-react-native/src/components/Input/Input.tsx @@ -121,9 +121,9 @@ 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 a required field, use `inputProps.accessibilityState={{ required: true }}` (and related accessibility props as needed). + +| TYPE | REQUIRED | DEFAULT | +| ------------------------------------------------------- | -------- | ----------- | +| `Omit` (see `TextFieldInputProps` types) | No | `undefined` | + +```tsx +import { TextField } from '@metamask/design-system-react-native'; + + {}} + placeholder="Search" + inputProps={{ + autoCapitalize: 'none', + returnKeyType: 'search', + }} +/>; +``` + ### `isError` -Optional boolean to show the error state. Updates the container border color. +When true, the field shows an error state (container border). | TYPE | REQUIRED | DEFAULT | | --------- | -------- | ------- | @@ -36,7 +132,7 @@ import { TextField } from '@metamask/design-system-react-native'; ### `isDisabled` -Optional boolean to disable the TextField. Applies reduced opacity, disables the root `Pressable`, and forwards disabled state to the inner `Input`. +When true, the field applies reduced opacity, disables the root `Pressable`, and forwards disabled state to the inner `Input`. | TYPE | REQUIRED | DEFAULT | | --------- | -------- | ------- | @@ -48,6 +144,20 @@ 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 rendered before the inner input. For E2E, set `testID` on the accessory or wrap it in your own `View`. @@ -84,7 +194,7 @@ import { Text } from 'react-native'; ### `inputElement` -Optional node that replaces the default `Input`. When you use this, the forwarded ref still targets the default `TextInput` type, but there may be no native input to focus when the container is pressed. +Optional node that replaces the default `Input`. The forwarded ref still targets `TextInput`, but there may be no native input to focus when the container is pressed. | TYPE | REQUIRED | DEFAULT | | ----------- | -------- | ----------- | @@ -97,13 +207,9 @@ import { TextInput } from 'react-native'; } />; ``` -### `pressableProps` - -Optional props passed to the root `Pressable`. `onPress`, `disabled`, `style`, `children`, and `accessible` are reserved by TextField. +### Top-level `Pressable` props -| TYPE | REQUIRED | DEFAULT | -| ---------------------------------------------------------------------------------------- | -------- | ----------- | -| `Omit` | No | `undefined` | +Pass `Pressable`-compatible props at the top level (for example `hitSlop`). `onPress`, `disabled`, `style`, `children`, and `accessible` are controlled by TextField. ```tsx import { TextField } from '@metamask/design-system-react-native'; @@ -111,13 +217,27 @@ import { TextField } from '@metamask/design-system-react-native'; ; ``` +### `testID` + +Optional test id for the root `Pressable`. + +| TYPE | REQUIRED | DEFAULT | +| -------- | -------- | ----------- | +| `string` | No | `undefined` | + +```tsx +import { TextField } from '@metamask/design-system-react-native'; + +; +``` + ### `twClassName` -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: +Use the `twClassName` prop to add Tailwind CSS classes to the root container. 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 @@ -129,25 +249,30 @@ Use the `twClassName` prop to add Tailwind CSS classes to the component. 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 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. +Use the `style` prop to customize the root `Pressable` 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 | | ---------------------- | -------- | ----------- | | `StyleProp` | No | `undefined` | ```tsx -import { TextField } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { TextField } from '@metamask/design-system-react-native'; + export const ConditionalExample = ({ isActive }: { isActive: boolean }) => { const tw = useTailwind(); 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 e668cb940..e370a91d9 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 @@ -29,15 +29,18 @@ const meta: Meta = { isReadonly: { control: 'boolean', }, - placeholder: { + value: { control: 'text', }, - value: { + placeholder: { control: 'text', }, twClassName: { control: 'text', }, + inputProps: { + control: 'object', + }, }, }; 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 029302c6f..bdaf2e59b 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 @@ -47,12 +47,19 @@ describe('TextField', () => { expect(getByTestId('custom-input')).toBeOnTheScreen(); }); - it('forwards props to the inner Input', () => { + it('forwards inputProps to the inner Input', () => { const { getByPlaceholderText } = render( - , + , ); - expect(getByPlaceholderText('forwarded-placeholder')).toBeOnTheScreen(); + expect(getByPlaceholderText('forwarded-placeholder')).toHaveProp( + 'keyboardType', + 'number-pad', + ); }); }); @@ -62,8 +69,10 @@ describe('TextField', () => { , ); @@ -78,8 +87,10 @@ describe('TextField', () => { , ); @@ -90,10 +101,32 @@ describe('TextField', () => { }); }); + describe('onChangeText', () => { + it('notifies when the text changes', () => { + const onChangeText = jest.fn(); + const { getByPlaceholderText } = render( + , + ); + + fireEvent.changeText(getByPlaceholderText('change-me'), 'ab'); + + expect(onChangeText).toHaveBeenCalledTimes(1); + expect(onChangeText).toHaveBeenCalledWith('ab'); + }); + }); + describe('Input props', () => { it('forwards secureTextEntry to the inner Input', () => { const { getByPlaceholderText } = render( - , + , ); expect(getByPlaceholderText('secure')).toHaveProp( @@ -300,8 +333,8 @@ describe('TextField', () => { , ); @@ -316,8 +349,8 @@ describe('TextField', () => { , ); @@ -333,8 +366,8 @@ describe('TextField', () => { , ); const inputNode = tree.root.findByType(Input); @@ -350,8 +383,8 @@ describe('TextField', () => { , ); const inputNode = tree.root.findByType(Input); @@ -423,14 +456,10 @@ describe('TextField', () => { expect(getByTestId(ROOT_TEST_ID)).toBeDisabled(); }); - it('forwards pressableProps to the root Pressable', () => { + it('forwards Pressable-compatible props from the root to the Pressable', () => { const hitSlop = { top: 4, bottom: 4, left: 4, right: 4 }; const { getByTestId } = render( - , + , ); expect(getByTestId(ROOT_TEST_ID)).toHaveProp('hitSlop', hitSlop); 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 27be75a14..c3f121932 100644 --- a/packages/design-system-react-native/src/components/TextField/TextField.tsx +++ b/packages/design-system-react-native/src/components/TextField/TextField.tsx @@ -12,24 +12,29 @@ import { Pressable, TextInput } from 'react-native'; import { Box } from '../Box'; import { Input } from '../Input'; +import type { InputProps } from '../Input/Input.types'; import type { TextFieldProps } from './TextField.types'; export const TextField = forwardRef( ( { - style, - startAccessory, - endAccessory, + value, + onChangeText, + placeholder, + isReadonly, + onBlur, + onFocus, + autoFocus = false, + inputProps, + isDisabled = false, isError = false, inputElement, - isDisabled = false, - autoFocus = false, + startAccessory, + endAccessory, + style, twClassName, - onBlur, - onFocus, testID, - pressableProps, - ...props + ...restProps }, ref, ) => { @@ -37,6 +42,8 @@ export const TextField = forwardRef( const inputRef = useRef(null); const tw = useTailwind(); + const inputRest = inputProps ?? {}; + useImperativeHandle( ref, () => inputRef.current, @@ -44,7 +51,7 @@ export const TextField = forwardRef( ); const onBlurHandler = useCallback( - (e: Parameters>[0]) => { + (e: Parameters>[0]) => { if (!isDisabled) { setIsFocused(false); onBlur?.(e); @@ -54,7 +61,7 @@ export const TextField = forwardRef( ); const onFocusHandler = useCallback( - (e: Parameters>[0]) => { + (e: Parameters>[0]) => { if (!isDisabled) { setIsFocused(true); onFocus?.(e); @@ -71,7 +78,7 @@ export const TextField = forwardRef( return ( ( {inputElement ?? ( ( ); }, ); + +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 0b8f6c1a3..4e859b3af 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,51 +1,77 @@ -import type { ReactNode } from 'react'; +import type { TextFieldPropsShared } from '@metamask/design-system-shared'; import type { PressableProps, StyleProp, ViewStyle } from 'react-native'; import type { InputProps } from '../Input/Input.types'; /** - * TextField component props. + * Additional props merged onto the inner `Input` (`../Input/Input.tsx`). * - * Inherits [Input](../Input/Input.tsx) props for the inner text input, excluding - * `textVariant` and `isStateStylesDisabled`, which are owned by TextField. The - * outer container is a `Pressable` (tap-to-focus); use `pressableProps` for - * additional Pressable-specific attributes. + * TextField owns `value`, `onChangeText`, `placeholder`, `isReadonly`, `onFocus`, + * `onBlur`, `isDisabled`, `autoFocus`, typography, and inner layout. + * `placeholderTextColor` is omitted (Input sets it from theme). */ -export type TextFieldProps = Omit< +export type TextFieldInputProps = Omit< InputProps, - 'textVariant' | 'isStateStylesDisabled' | 'style' -> & { - /** - * Optional content to display before the Input. - */ - startAccessory?: ReactNode; + | 'autoFocus' + | 'isDisabled' + | 'isReadonly' + | 'isStateStylesDisabled' + | 'onBlur' + | 'onChangeText' + | 'onFocus' + | 'placeholder' + | 'placeholderTextColor' + | 'style' + | 'textVariant' + | 'twClassName' + | 'value' +>; + +/** + * React Native `TextField` props between `TextFieldPropsShared` and the root + * `Pressable`: typed focus/blur handlers, `inputProps`, container styling, and + * `testID`. + */ +export type TextFieldBaseProps = TextFieldPropsShared & { /** - * Optional content to display after the Input. + * Called when the inner input receives focus (composed with TextField border state). */ - endAccessory?: ReactNode; + onFocus?: InputProps['onFocus']; /** - * Optional boolean to show the error state. - * - * @default false + * Called when the inner input loses focus (composed with TextField border state). */ - isError?: boolean; + onBlur?: InputProps['onBlur']; /** - * Optional prop to replace the default Input with a custom element. + * Additional props for the inner `Input`. Use `accessibilityState={{ required: true }}` when + * the field is required. Do not pass `placeholder`, `isReadonly`, `onFocus`, or `onBlur` here; + * use the TextField-level props above. */ - inputElement?: ReactNode; + inputProps?: TextFieldInputProps; /** - * Optional prop to add twrnc overriding classNames. + * Optional twrnc classes for the container Pressable. */ twClassName?: string; /** - * Optional prop to customize the container style. + * Optional style for the container Pressable. */ style?: StyleProp; /** - * Optional props forwarded to the root `Pressable` wrapper. + * Optional test id for the root Pressable. */ - pressableProps?: Omit< - PressableProps, - 'onPress' | 'disabled' | 'style' | 'children' | 'accessible' - >; + testID?: string; }; + +type TextFieldReservedPressableKeys = + | keyof TextFieldBaseProps + | 'accessible' + | 'children' + | 'disabled' + | 'onPress' + | 'style'; + +/** + * TextField props: `TextFieldBaseProps` plus remaining `Pressable` props at the + * top level (tap-to-focus wrapper), excluding keys reserved by TextField. + */ +export type TextFieldProps = TextFieldBaseProps & + Omit; diff --git a/packages/design-system-react-native/src/components/TextField/index.ts b/packages/design-system-react-native/src/components/TextField/index.ts index 409978ada..c8478df5f 100644 --- a/packages/design-system-react-native/src/components/TextField/index.ts +++ b/packages/design-system-react-native/src/components/TextField/index.ts @@ -1,2 +1,3 @@ export { TextField } from './TextField'; -export type { TextFieldProps } from './TextField.types'; +export { type TextFieldPropsShared } from '@metamask/design-system-shared'; +export type { TextFieldInputProps, TextFieldProps } from './TextField.types'; 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..f317d9f60 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 @@ -39,15 +39,18 @@ const meta: Meta = { isReadonly: { control: 'boolean', }, - placeholder: { + value: { control: 'text', }, - value: { + placeholder: { control: 'text', }, twClassName: { control: 'text', }, + inputProps: { + control: 'object', + }, }, }; diff --git a/packages/design-system-react-native/src/components/index.ts b/packages/design-system-react-native/src/components/index.ts index e057985d2..e0d8ccee7 100644 --- a/packages/design-system-react-native/src/components/index.ts +++ b/packages/design-system-react-native/src/components/index.ts @@ -196,7 +196,11 @@ export { export type { TextProps } from './Text'; export { TextField } from './TextField'; -export type { TextFieldProps } from './TextField'; +export type { + TextFieldInputProps, + TextFieldProps, + TextFieldPropsShared, +} from './TextField'; export { TextFieldSearch } from './TextFieldSearch'; export type { TextFieldSearchProps } from './TextFieldSearch'; diff --git a/packages/design-system-shared/src/index.ts b/packages/design-system-shared/src/index.ts index 3868af23e..c31ddeeac 100644 --- a/packages/design-system-shared/src/index.ts +++ b/packages/design-system-shared/src/index.ts @@ -103,6 +103,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..3e95e2ee0 --- /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'; From d6a9c47d3295b470e7bdd1d36511386719058015 Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Thu, 16 Apr 2026 12:06:53 -0700 Subject: [PATCH 06/11] Updated TextField to be wrapped by Box not Pressable --- .../design-system-react-native/MIGRATION.md | 20 ++--- .../src/components/TextField/README.md | 43 +++++------ .../components/TextField/TextField.test.tsx | 77 ++----------------- .../src/components/TextField/TextField.tsx | 18 ++--- .../components/TextField/TextField.types.ts | 21 +++-- 5 files changed, 49 insertions(+), 130 deletions(-) diff --git a/packages/design-system-react-native/MIGRATION.md b/packages/design-system-react-native/MIGRATION.md index be5d19ab9..319f8ef40 100644 --- a/packages/design-system-react-native/MIGRATION.md +++ b/packages/design-system-react-native/MIGRATION.md @@ -31,14 +31,14 @@ This guide provides detailed instructions for migrating your project from one ve ### From version 0.19.0 to 0.20.0 -#### TextField and TextFieldSearch: layered props (`inputProps` and root `Pressable`) +#### TextField and TextFieldSearch: layered props (`inputProps` and root `Box`) **What changed:** -- **`TextField`** is a root **`Pressable`** 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`). +- **`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`**. - **`placeholderTextColor`** is not supported on the public **`TextField`** API; the inner **`Input`** sets placeholder color from the theme. -- **`Pressable`**-compatible props (for example **`hitSlop`**, **`accessibilityHint`** on the container) are passed at the **top level** on **`TextField`**. There is no separate `pressableProps` bag. +- Remaining top-level props on **`TextField`** are **`BoxProps`** (layout and **`View`** props from React Native), except for keys reserved by **`TextField`** (see type **`TextFieldProps`**). **`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`**. - Cross-platform field definitions live in **`TextFieldPropsShared`** in **`@metamask/design-system-shared`** (also re-exported from **`@metamask/design-system-react-native`**). - **`TextFieldSearchProps`** extends **`TextFieldProps`**; the same layering applies. **`onPressClearButton`**, **`clearButtonProps`**, **`startAccessory`**, **`endAccessory`**, and **`style`** behavior are unchanged. @@ -70,22 +70,14 @@ Move inner `TextInput` props from the root into **`inputProps`**. Keep **`placeh /> ``` -Pass **`hitSlop`** (and other `Pressable` props) on **`TextField`** itself: - -```tsx -// 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`**, **`testID`**, **`style`**, **`twClassName`**). +- 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`**, **`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 **`TextFieldPropsShared`** from **`@metamask/design-system-shared`** for shared forms code. #### Input: theme `placeholderTextColor` always wins diff --git a/packages/design-system-react-native/src/components/TextField/README.md b/packages/design-system-react-native/src/components/TextField/README.md index 8fbe39089..bbc7fc8dd 100644 --- a/packages/design-system-react-native/src/components/TextField/README.md +++ b/packages/design-system-react-native/src/components/TextField/README.md @@ -1,6 +1,6 @@ # TextField -TextField is used to render a controlled, single-line text input inside a fixed-height row with optional leading and trailing content. The root is a `Pressable` (tap-to-focus); inner [TextInput](https://reactnative.dev/docs/textinput) behavior is exposed through **`inputProps`**, and remaining [Pressable](https://reactnative.dev/docs/pressable) props can be passed at the top level. Shared design fields are defined as **`TextFieldPropsShared`** in `@metamask/design-system-shared`. +TextField is used to render a controlled, single-line text input inside a fixed-height row with optional leading and trailing content. ```tsx import { TextField } from '@metamask/design-system-react-native'; @@ -68,7 +68,7 @@ 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. +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 | | ---------- | -------- | ----------- | @@ -82,7 +82,7 @@ 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. +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 | | ---------- | -------- | ----------- | @@ -132,7 +132,7 @@ import { TextField } from '@metamask/design-system-react-native'; ### `isDisabled` -When true, the field applies reduced opacity, disables the root `Pressable`, and forwards disabled state to the inner `Input`. +When true, the field applies reduced opacity and forwards disabled state to the inner `Input` (non-editable). | TYPE | REQUIRED | DEFAULT | | --------- | -------- | ------- | @@ -194,7 +194,7 @@ import { Text } from 'react-native'; ### `inputElement` -Optional node that replaces the default `Input`. The forwarded ref still targets `TextInput`, but there may be no native input to focus when the container is pressed. +Optional node that replaces the default `Input`. The forwarded ref still targets `TextInput` when the default input is used; with a custom `inputElement`, ensure your control is focusable if users need keyboard entry. | TYPE | REQUIRED | DEFAULT | | ----------- | -------- | ----------- | @@ -207,37 +207,37 @@ import { TextInput } from 'react-native'; } />; ``` -### Top-level `Pressable` props +### `testID` + +Optional test id for the root `Box`. -Pass `Pressable`-compatible props at the top level (for example `hitSlop`). `onPress`, `disabled`, `style`, `children`, and `accessible` are controlled by TextField. +| TYPE | REQUIRED | DEFAULT | +| -------- | -------- | ----------- | +| `string` | No | `undefined` | ```tsx import { TextField } from '@metamask/design-system-react-native'; -; +; ``` -### `testID` - -Optional test id for the root `Pressable`. +### Layout and accessibility (`Box` / `View`) -| TYPE | REQUIRED | DEFAULT | -| -------- | -------- | ----------- | -| `string` | No | `undefined` | +Pass `BoxProps` and React Native `View` props at the top level for layout and accessibility on the root container (for example `accessibilityHint`, `pointerEvents`). Keys reserved by TextField (`style`, `twClassName`, `testID`, `children`, `accessible`, and all keys owned by `TextFieldBaseProps`) are not passed through from this intersection. Prefer either Tailwind via `twClassName` or explicit `Box` layout props, and avoid conflicting layout when mixing both. ```tsx import { TextField } from '@metamask/design-system-react-native'; -; +; ``` ### `twClassName` -Use the `twClassName` prop to add Tailwind CSS classes to the root 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 @@ -262,7 +262,7 @@ import { TextField } from '@metamask/design-system-react-native'; ### `style` -Use the `style` prop to customize the root `Pressable` 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 | | ---------------------- | -------- | ----------- | @@ -270,7 +270,6 @@ Use the `style` prop to customize the root `Pressable` with React Native styles. ```tsx import { useTailwind } from '@metamask/design-system-twrnc-preset'; - import { TextField } from '@metamask/design-system-react-native'; export const ConditionalExample = ({ isActive }: { isActive: boolean }) => { 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 bdaf2e59b..778e6b570 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 @@ -447,79 +447,16 @@ describe('TextField', () => { }); }); - describe('pressable root', () => { - it('disables the root Pressable when isDisabled is true', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId(ROOT_TEST_ID)).toBeDisabled(); - }); - - it('forwards Pressable-compatible props from the root to the Pressable', () => { - const hitSlop = { top: 4, bottom: 4, left: 4, right: 4 }; - const { getByTestId } = render( - , - ); - - expect(getByTestId(ROOT_TEST_ID)).toHaveProp('hitSlop', hitSlop); - }); - - 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(); - }); - - it('does not focus the input when pressed while disabled', () => { - const ref = createRef(); - const { getByTestId } = render( - , + describe('disabled state', () => { + it('disables the inner Input when isDisabled is true', () => { + const { getByPlaceholderText } = 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('handles press when inputElement replaces the default Input', () => { - const { getByTestId } = render( - } - />, + expect(getByPlaceholderText('disabled-input')).toHaveProp( + 'editable', + false, ); - - expect(() => { - fireEvent.press(getByTestId(ROOT_TEST_ID)); - }).not.toThrow(); }); }); }); 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 c3f121932..a77c58405 100644 --- a/packages/design-system-react-native/src/components/TextField/TextField.tsx +++ b/packages/design-system-react-native/src/components/TextField/TextField.tsx @@ -7,7 +7,7 @@ import { useRef, useState, } from 'react'; -import { Pressable, TextInput } from 'react-native'; +import { TextInput } from 'react-native'; import { Box } from '../Box'; import { Input } from '../Input'; @@ -70,17 +70,11 @@ export const TextField = forwardRef( [isDisabled, onFocus], ); - const onPressHandler = useCallback(() => { - if (!isDisabled && inputRef.current) { - inputRef.current.focus(); - } - }, [isDisabled]); - return ( - ( ), style, ]} - onPress={onPressHandler} - accessible={false} > {startAccessory} @@ -119,7 +111,7 @@ export const TextField = forwardRef( onBlur={onBlurHandler} onFocus={onFocusHandler} isStateStylesDisabled - // Row is `h-12` (48px) with `border` on the Pressable (1px top + bottom). Inner TextInput + // Row is `h-12` (48px) with `border` on the root Box (1px top + bottom). Inner TextInput // uses 46px height so the field matches a 48px-tall control without vertical overflow. twClassName="h-[46px] bg-transparent border-0" numberOfLines={1} @@ -128,7 +120,7 @@ export const TextField = forwardRef( )} {endAccessory} - + ); }, ); 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 4e859b3af..6cdeceae4 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,6 +1,7 @@ import type { TextFieldPropsShared } from '@metamask/design-system-shared'; -import type { PressableProps, StyleProp, ViewStyle } from 'react-native'; +import type { StyleProp, ViewStyle } from 'react-native'; +import type { BoxProps } from '../Box/Box.types'; import type { InputProps } from '../Input/Input.types'; /** @@ -29,7 +30,7 @@ export type TextFieldInputProps = Omit< /** * React Native `TextField` props between `TextFieldPropsShared` and the root - * `Pressable`: typed focus/blur handlers, `inputProps`, container styling, and + * `Box`: typed focus/blur handlers, `inputProps`, container styling, and * `testID`. */ export type TextFieldBaseProps = TextFieldPropsShared & { @@ -48,30 +49,28 @@ export type TextFieldBaseProps = TextFieldPropsShared & { */ inputProps?: TextFieldInputProps; /** - * Optional twrnc classes for the container Pressable. + * Optional twrnc classes for the root `Box`. */ twClassName?: string; /** - * Optional style for the container Pressable. + * Optional style for the root `Box`. */ style?: StyleProp; /** - * Optional test id for the root Pressable. + * Optional test id for the root `Box`. */ testID?: string; }; -type TextFieldReservedPressableKeys = +type TextFieldReservedBoxKeys = | keyof TextFieldBaseProps | 'accessible' | 'children' - | 'disabled' - | 'onPress' | 'style'; /** - * TextField props: `TextFieldBaseProps` plus remaining `Pressable` props at the - * top level (tap-to-focus wrapper), excluding keys reserved by TextField. + * TextField props: `TextFieldBaseProps` plus remaining `BoxProps` at the top + * level, excluding keys reserved by TextField. */ export type TextFieldProps = TextFieldBaseProps & - Omit; + Omit; From 964c3b658d09e8b457bae87a278c6cbb0c45ecfc Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Thu, 16 Apr 2026 12:46:02 -0700 Subject: [PATCH 07/11] Removed unnecessary wrapping box for input --- .../src/components/TextField/TextField.tsx | 51 ++++++++++--------- .../components/TextField/TextField.types.ts | 6 +-- 2 files changed, 30 insertions(+), 27 deletions(-) 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 a77c58405..eb576a56d 100644 --- a/packages/design-system-react-native/src/components/TextField/TextField.tsx +++ b/packages/design-system-react-native/src/components/TextField/TextField.tsx @@ -42,7 +42,10 @@ export const TextField = forwardRef( const inputRef = useRef(null); const tw = useTailwind(); - const inputRest = inputProps ?? {}; + const { + twClassName: inputTwClassNameFromProps, + ...inputRestWithoutTwClassName + } = inputProps ?? {}; useImperativeHandle( ref, @@ -96,29 +99,29 @@ export const TextField = forwardRef( ]} > {startAccessory} - - {inputElement ?? ( - - )} - + {inputElement ? ( + inputElement + ) : ( + + )} {endAccessory} ); 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 6cdeceae4..16cd54191 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 @@ -8,8 +8,9 @@ import type { InputProps } from '../Input/Input.types'; * Additional props merged onto the inner `Input` (`../Input/Input.tsx`). * * TextField owns `value`, `onChangeText`, `placeholder`, `isReadonly`, `onFocus`, - * `onBlur`, `isDisabled`, `autoFocus`, typography, and inner layout. - * `placeholderTextColor` is omitted (Input sets it from theme). + * `onBlur`, `isDisabled`, `autoFocus`, typography, and inner layout (merged with + * any `twClassName` you pass here). `placeholderTextColor` is omitted (Input sets + * it from theme). */ export type TextFieldInputProps = Omit< InputProps, @@ -24,7 +25,6 @@ export type TextFieldInputProps = Omit< | 'placeholderTextColor' | 'style' | 'textVariant' - | 'twClassName' | 'value' >; From 2c22a6dc2adcefddbdc39bc237275d515b177193 Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Thu, 16 Apr 2026 13:03:07 -0700 Subject: [PATCH 08/11] Fixed lint issues --- .../src/components/TextField/TextField.test.tsx | 15 +++++++++++++++ .../src/components/TextField/TextField.tsx | 6 ++---- 2 files changed, 17 insertions(+), 4 deletions(-) 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 778e6b570..4dacc7127 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 @@ -61,6 +61,21 @@ describe('TextField', () => { 'number-pad', ); }); + + 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'); + }); }); describe('single-line input', () => { 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 eb576a56d..29eb63b5f 100644 --- a/packages/design-system-react-native/src/components/TextField/TextField.tsx +++ b/packages/design-system-react-native/src/components/TextField/TextField.tsx @@ -11,8 +11,8 @@ import { TextInput } from 'react-native'; import { Box } from '../Box'; import { Input } from '../Input'; - import type { InputProps } from '../Input/Input.types'; + import type { TextFieldProps } from './TextField.types'; export const TextField = forwardRef( @@ -99,9 +99,7 @@ export const TextField = forwardRef( ]} > {startAccessory} - {inputElement ? ( - inputElement - ) : ( + {inputElement || ( Date: Fri, 24 Apr 2026 08:24:30 -0700 Subject: [PATCH 09/11] Removed TextFieldPropsShared from export --- packages/design-system-react-native/MIGRATION.md | 2 +- .../src/components/TextField/index.ts | 1 - packages/design-system-react-native/src/components/index.ts | 6 +----- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/design-system-react-native/MIGRATION.md b/packages/design-system-react-native/MIGRATION.md index 139ba6dcb..258d1e080 100644 --- a/packages/design-system-react-native/MIGRATION.md +++ b/packages/design-system-react-native/MIGRATION.md @@ -41,7 +41,7 @@ This guide provides detailed instructions for migrating your project from one ve - **`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`**. - **`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 type **`TextFieldProps`**). **`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`**. -- Cross-platform field definitions live in **`TextFieldPropsShared`** in **`@metamask/design-system-shared`** (also re-exported from **`@metamask/design-system-react-native`**). +- Cross-platform field definitions live in **`TextFieldPropsShared`** in **`@metamask/design-system-shared`**. - **`TextFieldSearchProps`** extends **`TextFieldProps`**; the same layering applies. **`onPressClearButton`**, **`clearButtonProps`**, **`startAccessory`**, **`endAccessory`**, and **`style`** behavior are unchanged. **Migration:** diff --git a/packages/design-system-react-native/src/components/TextField/index.ts b/packages/design-system-react-native/src/components/TextField/index.ts index c8478df5f..0be354949 100644 --- a/packages/design-system-react-native/src/components/TextField/index.ts +++ b/packages/design-system-react-native/src/components/TextField/index.ts @@ -1,3 +1,2 @@ export { TextField } from './TextField'; -export { type TextFieldPropsShared } from '@metamask/design-system-shared'; export type { TextFieldInputProps, TextFieldProps } from './TextField.types'; diff --git a/packages/design-system-react-native/src/components/index.ts b/packages/design-system-react-native/src/components/index.ts index e0d8ccee7..f157c117e 100644 --- a/packages/design-system-react-native/src/components/index.ts +++ b/packages/design-system-react-native/src/components/index.ts @@ -196,11 +196,7 @@ export { export type { TextProps } from './Text'; export { TextField } from './TextField'; -export type { - TextFieldInputProps, - TextFieldProps, - TextFieldPropsShared, -} from './TextField'; +export type { TextFieldInputProps, TextFieldProps } from './TextField'; export { TextFieldSearch } from './TextFieldSearch'; export type { TextFieldSearchProps } from './TextFieldSearch'; From 60b5f5b14c1ad80b69f41963c0890da997661462 Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Tue, 28 Apr 2026 15:47:50 -0700 Subject: [PATCH 10/11] Addressed review comments --- .../design-system-react-native/MIGRATION.md | 17 +++++--- .../src/components/Input/Input.stories.tsx | 6 +-- .../src/components/Input/Input.tsx | 4 +- .../src/components/Input/Input.types.ts | 2 +- .../src/components/Input/README.md | 2 +- .../src/components/TextField/README.md | 43 ++++++++++++++----- .../TextField/TextField.stories.tsx | 5 +-- .../components/TextField/TextField.test.tsx | 37 +++++++++++----- .../src/components/TextField/TextField.tsx | 29 ++++--------- .../components/TextField/TextField.types.ts | 20 ++++++--- .../src/components/TextField/index.ts | 2 +- .../TextFieldSearch.stories.tsx | 6 +-- .../TextFieldSearch/TextFieldSearch.tsx | 4 +- .../src/components/index.ts | 2 +- .../src/types/TextField/TextField.types.ts | 2 +- 15 files changed, 109 insertions(+), 72 deletions(-) diff --git a/packages/design-system-react-native/MIGRATION.md b/packages/design-system-react-native/MIGRATION.md index 1ad0b7a22..68e600a36 100644 --- a/packages/design-system-react-native/MIGRATION.md +++ b/packages/design-system-react-native/MIGRATION.md @@ -100,16 +100,21 @@ These tokens had no backing CSS custom property, so any usage was already produc **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`**. +- **`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 type **`TextFieldProps`**). **`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`**. -- Cross-platform field definitions live in **`TextFieldPropsShared`** in **`@metamask/design-system-shared`**. +- 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 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 2986a5830..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, @@ -126,7 +126,7 @@ export const Input = forwardRef( placeholderTextColor={placeholderTextColor} value={value} style={resolvedStyle} - editable={!isDisabled && !isReadonly} + editable={!isDisabled && !isReadOnly} autoFocus={autoFocus} onBlur={onBlurHandler} onFocus={onFocusHandler} diff --git a/packages/design-system-react-native/src/components/Input/Input.types.ts b/packages/design-system-react-native/src/components/Input/Input.types.ts index 759ffec9a..95536374c 100644 --- a/packages/design-system-react-native/src/components/Input/Input.types.ts +++ b/packages/design-system-react-native/src/components/Input/Input.types.ts @@ -26,7 +26,7 @@ export type InputProps = Omit< * * @default false */ - isReadonly?: boolean; + isReadOnly?: boolean; /** * Optional boolean to disable state styles. * diff --git a/packages/design-system-react-native/src/components/Input/README.md b/packages/design-system-react-native/src/components/Input/README.md index 358eaed8b..53ad8d734 100644 --- a/packages/design-system-react-native/src/components/Input/README.md +++ b/packages/design-system-react-native/src/components/Input/README.md @@ -43,7 +43,7 @@ Optional boolean to disable Input. | --------- | -------- | ------- | | `boolean` | No | `false` | -### `isReadonly` +### `isReadOnly` Optional boolean to show readonly input. diff --git a/packages/design-system-react-native/src/components/TextField/README.md b/packages/design-system-react-native/src/components/TextField/README.md index bbc7fc8dd..07034f3a0 100644 --- a/packages/design-system-react-native/src/components/TextField/README.md +++ b/packages/design-system-react-native/src/components/TextField/README.md @@ -52,7 +52,7 @@ import { TextField } from '@metamask/design-system-react-native'; ; ``` -### `isReadonly` +### `isReadOnly` When true, the inner input is not editable. @@ -63,7 +63,7 @@ When true, the inner input is not editable. ```tsx import { TextField } from '@metamask/design-system-react-native'; -; +; ``` ### `onFocus` @@ -96,11 +96,11 @@ 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 a required field, use `inputProps.accessibilityState={{ required: true }}` (and related accessibility props as needed). +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 | -| ------------------------------------------------------- | -------- | ----------- | -| `Omit` (see `TextFieldInputProps` types) | No | `undefined` | +| TYPE | REQUIRED | DEFAULT | +| -------------------------------------------------------------------- | -------- | ----------- | +| `TextFieldProps['inputProps']` (see `TextFieldProps` in the package) | No | `undefined` | ```tsx import { TextField } from '@metamask/design-system-react-native'; @@ -116,6 +116,24 @@ import { TextField } from '@metamask/design-system-react-native'; />; ``` +### `inputRef` + +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` When true, the field shows an error state (container border). @@ -194,7 +212,7 @@ import { Text } from 'react-native'; ### `inputElement` -Optional node that replaces the default `Input`. The forwarded ref still targets `TextInput` when the default input is used; with a custom `inputElement`, ensure your control is focusable if users need keyboard entry. +Optional node that replaces the default `Input`. `inputRef` is only forwarded when the default `Input` is rendered; with a custom `inputElement`, attach your own ref to the control if you need imperative focus or measurement. | TYPE | REQUIRED | DEFAULT | | ----------- | -------- | ----------- | @@ -209,7 +227,7 @@ import { TextInput } from 'react-native'; ### `testID` -Optional test id for the root `Box`. +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 | | -------- | -------- | ----------- | @@ -223,7 +241,9 @@ import { TextField } from '@metamask/design-system-react-native'; ### Layout and accessibility (`Box` / `View`) -Pass `BoxProps` and React Native `View` props at the top level for layout and accessibility on the root container (for example `accessibilityHint`, `pointerEvents`). Keys reserved by TextField (`style`, `twClassName`, `testID`, `children`, `accessible`, and all keys owned by `TextFieldBaseProps`) are not passed through from this intersection. Prefer either Tailwind via `twClassName` or explicit `Box` layout props, and avoid conflicting layout when mixing both. +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'; @@ -231,7 +251,10 @@ import { TextField } from '@metamask/design-system-react-native'; ; ``` 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 e370a91d9..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 @@ -26,7 +26,7 @@ const meta: Meta = { isDisabled: { control: 'boolean', }, - isReadonly: { + isReadOnly: { control: 'boolean', }, value: { @@ -38,9 +38,6 @@ const meta: Meta = { twClassName: { control: 'text', }, - inputProps: { - control: 'object', - }, }, }; 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 4dacc7127..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 @@ -150,9 +150,9 @@ describe('TextField', () => { ); }); - it('forwards isReadonly to the inner Input', () => { + it('forwards isReadOnly to the inner Input', () => { const { getByPlaceholderText } = render( - , + , ); expect(getByPlaceholderText('readonly-test')).toHaveProp( @@ -163,19 +163,36 @@ describe('TextField', () => { }); describe('ref', () => { - it('exposes TextInput ref via forwardRef', () => { - const ref = createRef(); - render(); + it('exposes the root View ref via forwardRef', () => { + const ref = createRef(); + render( + , + ); expect(ref.current).not.toBeNull(); - expect(ref.current).toBeInstanceOf(TextInput); + expect(ref.current).toBeInstanceOf(View); }); - it('allows calling focus() via the forwarded ref', () => { - const ref = createRef(); - render(); + it('exposes the inner TextInput via inputRef', () => { + const inputRef = createRef(); + render(); + + expect(inputRef.current).not.toBeNull(); + expect(inputRef.current).toBeInstanceOf(TextInput); + }); + + it('allows calling focus() via inputRef', () => { + const inputRef = createRef(); + render( + , + ); - expect(() => ref.current?.focus()).not.toThrow(); + expect(() => inputRef.current?.focus()).not.toThrow(); }); }); 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 29eb63b5f..0ae261b2b 100644 --- a/packages/design-system-react-native/src/components/TextField/TextField.tsx +++ b/packages/design-system-react-native/src/components/TextField/TextField.tsx @@ -1,13 +1,7 @@ import { TextVariant } from '@metamask/design-system-shared'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { - forwardRef, - useCallback, - useImperativeHandle, - useRef, - useState, -} from 'react'; -import { TextInput } from 'react-native'; +import { forwardRef, useCallback, useState } from 'react'; +import { TextInput, View } from 'react-native'; import { Box } from '../Box'; import { Input } from '../Input'; @@ -15,17 +9,18 @@ import type { InputProps } from '../Input/Input.types'; import type { TextFieldProps } from './TextField.types'; -export const TextField = forwardRef( +export const TextField = forwardRef( ( { value, onChangeText, placeholder, - isReadonly, + isReadOnly, onBlur, onFocus, autoFocus = false, inputProps, + inputRef, isDisabled = false, isError = false, inputElement, @@ -34,12 +29,11 @@ export const TextField = forwardRef( style, twClassName, testID, - ...restProps + ...props }, ref, ) => { const [isFocused, setIsFocused] = useState(autoFocus); - const inputRef = useRef(null); const tw = useTailwind(); const { @@ -47,12 +41,6 @@ export const TextField = forwardRef( ...inputRestWithoutTwClassName } = inputProps ?? {}; - useImperativeHandle( - ref, - () => inputRef.current, - [], - ); - const onBlurHandler = useCallback( (e: Parameters>[0]) => { if (!isDisabled) { @@ -75,7 +63,8 @@ export const TextField = forwardRef( return ( ( value={value} onChangeText={onChangeText} placeholder={placeholder} - isReadonly={isReadonly} + isReadOnly={isReadOnly} textVariant={TextVariant.BodyMd} isDisabled={isDisabled} autoFocus={autoFocus} 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 16cd54191..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,5 +1,6 @@ import type { TextFieldPropsShared } from '@metamask/design-system-shared'; -import type { StyleProp, ViewStyle } from 'react-native'; +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'; @@ -7,16 +8,16 @@ import type { InputProps } from '../Input/Input.types'; /** * Additional props merged onto the inner `Input` (`../Input/Input.tsx`). * - * TextField owns `value`, `onChangeText`, `placeholder`, `isReadonly`, `onFocus`, + * 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 TextFieldInputProps = Omit< +type TextFieldInputProps = Omit< InputProps, | 'autoFocus' | 'isDisabled' - | 'isReadonly' + | 'isReadOnly' | 'isStateStylesDisabled' | 'onBlur' | 'onChangeText' @@ -43,11 +44,16 @@ export type TextFieldBaseProps = TextFieldPropsShared & { */ onBlur?: InputProps['onBlur']; /** - * Additional props for the inner `Input`. Use `accessibilityState={{ required: true }}` when - * the field is required. Do not pass `placeholder`, `isReadonly`, `onFocus`, or `onBlur` here; - * use the TextField-level props above. + * 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. */ inputProps?: TextFieldInputProps; + /** + * Ref to the inner `TextInput`. The component `ref` targets the root `Box` (`View`). + */ + inputRef?: Ref; /** * Optional twrnc classes for the root `Box`. */ diff --git a/packages/design-system-react-native/src/components/TextField/index.ts b/packages/design-system-react-native/src/components/TextField/index.ts index 0be354949..409978ada 100644 --- a/packages/design-system-react-native/src/components/TextField/index.ts +++ b/packages/design-system-react-native/src/components/TextField/index.ts @@ -1,2 +1,2 @@ export { TextField } from './TextField'; -export type { TextFieldInputProps, TextFieldProps } from './TextField.types'; +export type { TextFieldProps } from './TextField.types'; 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 f317d9f60..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,7 +36,7 @@ const meta: Meta = { isDisabled: { control: 'boolean', }, - isReadonly: { + isReadOnly: { control: 'boolean', }, value: { @@ -103,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-react-native/src/components/index.ts b/packages/design-system-react-native/src/components/index.ts index 2cddcbbfb..68f69acca 100644 --- a/packages/design-system-react-native/src/components/index.ts +++ b/packages/design-system-react-native/src/components/index.ts @@ -196,7 +196,7 @@ export { export type { TextProps } from './Text'; export { TextField } from './TextField'; -export type { TextFieldInputProps, TextFieldProps } from './TextField'; +export type { TextFieldProps } from './TextField'; export { TextFieldSearch } from './TextFieldSearch'; export type { TextFieldSearchProps } from './TextFieldSearch'; diff --git a/packages/design-system-shared/src/types/TextField/TextField.types.ts b/packages/design-system-shared/src/types/TextField/TextField.types.ts index 3e95e2ee0..1f2d6c49f 100644 --- a/packages/design-system-shared/src/types/TextField/TextField.types.ts +++ b/packages/design-system-shared/src/types/TextField/TextField.types.ts @@ -18,7 +18,7 @@ export type TextFieldPropsShared = { /** Placeholder shown when `value` is empty. */ placeholder?: string; /** When true, the value cannot be edited. */ - isReadonly?: boolean; + isReadOnly?: boolean; /** * When true, interaction and editing are disabled. * From 150de64f2d8a939631f92d20c15d3116e75a777e Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Tue, 28 Apr 2026 15:53:58 -0700 Subject: [PATCH 11/11] Fixed lint errors --- .../src/components/TextField/TextField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0ae261b2b..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,7 +1,7 @@ import { TextVariant } from '@metamask/design-system-shared'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { forwardRef, useCallback, useState } from 'react'; -import { TextInput, View } from 'react-native'; +import { View } from 'react-native'; import { Box } from '../Box'; import { Input } from '../Input';