diff --git a/packages/design-system-react-native/src/components/Input/Input.test.tsx b/packages/design-system-react-native/src/components/Input/Input.test.tsx index 678c7f051..fe91c003b 100644 --- a/packages/design-system-react-native/src/components/Input/Input.test.tsx +++ b/packages/design-system-react-native/src/components/Input/Input.test.tsx @@ -43,35 +43,67 @@ describe('Input', () => { const input = getByTestId(TEST_ID); expect(input).toBeDisabled(); - expect(input).toHaveStyle({ opacity: tw`opacity-50`.opacity }); + const expectedOpacity = (tw.style('opacity-50') as TextStyle).opacity; + expect(input).toHaveStyle({ opacity: expectedOpacity }); }); - it('applies iOS placeholder lineHeight workaround when placeholder is visible', () => { - if (Platform.OS !== 'ios') { - return; - } + describe('iOS placeholder lineHeight workaround', () => { + const originalOS = Platform.OS; + afterEach(() => { + Platform.OS = originalOS; + }); + + it('applies iOS placeholder lineHeight workaround when placeholder is visible and multiline is false', () => { + Platform.OS = 'ios'; + + const { getByTestId } = render( + , + ); + + const input = getByTestId(TEST_ID); + + expect(input).toHaveStyle({ lineHeight: 0 }); + }); + + it('does not apply placeholder lineHeight workaround outside iOS', () => { + Platform.OS = 'android'; + + const { getByTestId } = render( + , + ); + + const input = getByTestId(TEST_ID); + + expect(input).not.toHaveStyle({ lineHeight: 0 }); + }); + }); + + it('when multiline is true, does not apply lineHeight zero for visible placeholder', () => { const { getByTestId } = render( - , + , ); const input = getByTestId(TEST_ID); - expect(input).toHaveStyle({ lineHeight: 0 }); + expect(input).not.toHaveStyle({ lineHeight: 0 }); }); - it('does not apply placeholder lineHeight workaround outside iOS', () => { - if (Platform.OS === 'ios') { - return; - } - + it('when multiline is true, applies BodyMd paragraph lineHeight', () => { const { getByTestId } = render( - , + , ); const input = getByTestId(TEST_ID); + const expectedLineHeight = (tw.style('text-body-md') as TextStyle) + .lineHeight; - expect(input).not.toHaveStyle({ lineHeight: 0 }); + expect(input).toHaveStyle({ lineHeight: expectedLineHeight }); }); it('removes placeholder lineHeight workaround after value changes from empty to non-empty', () => { 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 8032fe19d..a592ec51b 100644 --- a/packages/design-system-react-native/src/components/Input/Input.tsx +++ b/packages/design-system-react-native/src/components/Input/Input.tsx @@ -30,6 +30,7 @@ export const Input = forwardRef( onBlur, onFocus, autoFocus = false, + multiline, ...props }, ref, @@ -37,6 +38,7 @@ export const Input = forwardRef( const [isFocused, setIsFocused] = useState(autoFocus); const tw = useTailwind(); const theme = useTheme(); + const isMultiline = multiline === true; const placeholderTextColor = useMemo( () => @@ -56,7 +58,30 @@ export const Input = forwardRef( // scoped to the placeholder-visible state without affecting typed text. const isPlaceholderVisible = hasPlaceholder && value === ''; - const inputStyle = useMemo( + // Multiline field styles + const multilineChromeStyle = useMemo( + () => + tw.style( + 'text-default', + 'bg-default', + 'border', + 'border-transparent', + !isStateStylesDisabled && isDisabled && 'opacity-50', + !isStateStylesDisabled && + !isDisabled && + isFocused && + 'border-primary-default', + twClassName, + ), + [isStateStylesDisabled, isDisabled, isFocused, twClassName, tw], + ); + const multilineTypographyStyle = useMemo( + () => tw.style(`text-${textVariant}`, fontClass), + [textVariant, fontClass, tw], + ); + + // Single-line field styles + const singleLineChromeStyle = useMemo( () => tw.style( fontClass, @@ -80,11 +105,26 @@ export const Input = forwardRef( tw, ], ); - - const variantTextStyle = useMemo( + const singleLineTypographyStyle = useMemo( () => MAP_TEXT_VARIANT_INPUT_METRICS[textVariant], [textVariant], ); + // iOS-only single-line placeholder fix: native TextInput can render + // placeholder text vertically offset. Do not use on Android (lineHeight 0 + // collapses text) or on multiline (breaks paragraph layout). + const iosSingleLinePlaceholderLineHeightFix = Platform.OS === 'ios' && + isPlaceholderVisible && { lineHeight: 0 as const }; + + const resolvedStyle = ( + isMultiline + ? [multilineChromeStyle, multilineTypographyStyle, style] + : [ + singleLineChromeStyle, + singleLineTypographyStyle, + iosSingleLinePlaceholderLineHeightFix, + style, + ] + ).filter(Boolean); useEffect(() => { if (isDisabled || isReadOnly) { @@ -107,21 +147,12 @@ export const Input = forwardRef( }, [onFocus], ); - const resolvedStyle = [ - inputStyle, - variantTextStyle, - // iOS-only workaround: when a placeholder is visible, native iOS - // TextInput can render placeholder text vertically offset. - // Keep this iOS-only because lineHeight: 0 can collapse text on Android. - Platform.OS === 'ios' && - isPlaceholderVisible && { lineHeight: 0 as const }, - style, - ].filter(Boolean); return ( ; +``` + +## Props + +### `value` + +Required controlled value for the TextArea. + +| TYPE | REQUIRED | DEFAULT | +| -------- | -------- | ------- | +| `string` | Yes | N/A | + +```tsx +import { TextArea } from '@metamask/design-system-react-native'; + +