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