diff --git a/apps/storybook-react-native/.storybook/storybook.requires.js b/apps/storybook-react-native/.storybook/storybook.requires.js index 265a60480..4b787a567 100644 --- a/apps/storybook-react-native/.storybook/storybook.requires.js +++ b/apps/storybook-react-native/.storybook/storybook.requires.js @@ -89,6 +89,10 @@ const getStories = () => { "./../../packages/design-system-react-native/src/components/Card/Card.stories.tsx": require("../../../packages/design-system-react-native/src/components/Card/Card.stories.tsx"), "./../../packages/design-system-react-native/src/components/Checkbox/Checkbox.stories.tsx": require("../../../packages/design-system-react-native/src/components/Checkbox/Checkbox.stories.tsx"), "./../../packages/design-system-react-native/src/components/HeaderBase/HeaderBase.stories.tsx": require("../../../packages/design-system-react-native/src/components/HeaderBase/HeaderBase.stories.tsx"), + "./../../packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.stories.tsx": require("../../../packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.stories.tsx"), + "./../../packages/design-system-react-native/src/components/HeaderSearch/HeaderSearch.stories.tsx": require("../../../packages/design-system-react-native/src/components/HeaderSearch/HeaderSearch.stories.tsx"), + "./../../packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.stories.tsx": require("../../../packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.stories.tsx"), + "./../../packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.stories.tsx": require("../../../packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.stories.tsx"), "./../../packages/design-system-react-native/src/components/Icon/Icon.stories.tsx": require("../../../packages/design-system-react-native/src/components/Icon/Icon.stories.tsx"), "./../../packages/design-system-react-native/src/components/Input/Input.stories.tsx": require("../../../packages/design-system-react-native/src/components/Input/Input.stories.tsx"), "./../../packages/design-system-react-native/src/components/KeyValueRow/KeyValueRow.stories.tsx": require("../../../packages/design-system-react-native/src/components/KeyValueRow/KeyValueRow.stories.tsx"), @@ -109,6 +113,9 @@ const getStories = () => { "./../../packages/design-system-react-native/src/components/Text/Text.stories.tsx": require("../../../packages/design-system-react-native/src/components/Text/Text.stories.tsx"), "./../../packages/design-system-react-native/src/components/TextButton/TextButton.stories.tsx": require("../../../packages/design-system-react-native/src/components/TextButton/TextButton.stories.tsx"), "./../../packages/design-system-react-native/src/components/TextField/TextField.stories.tsx": require("../../../packages/design-system-react-native/src/components/TextField/TextField.stories.tsx"), + "./../../packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.stories.tsx": require("../../../packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.stories.tsx"), + "./../../packages/design-system-react-native/src/components/TitleStandard/TitleStandard.stories.tsx": require("../../../packages/design-system-react-native/src/components/TitleStandard/TitleStandard.stories.tsx"), + "./../../packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.stories.tsx": require("../../../packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.stories.tsx"), "./../../packages/design-system-react-native/src/components/Toast/Toast.stories.tsx": require("../../../packages/design-system-react-native/src/components/Toast/Toast.stories.tsx"), "./stories/Backgrounds.stories.tsx": require("../stories/Backgrounds.stories.tsx"), "./stories/WalletHome.stories.tsx": require("../stories/WalletHome.stories.tsx"), diff --git a/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.stories.tsx b/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.stories.tsx new file mode 100644 index 000000000..1bf7db8a7 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.stories.tsx @@ -0,0 +1,118 @@ +/* eslint-disable no-console */ +import React from 'react'; + +import { Box } from '../Box'; +import { Icon, IconColor, IconName } from '../Icon'; +import { Text, TextVariant } from '../Text'; + +import HeaderRoot from './HeaderRoot'; + +const HeaderRootMeta = { + title: 'Components/HeaderRoot', + component: HeaderRoot, + argTypes: { + title: { + control: 'text', + }, + twClassName: { + control: 'text', + }, + }, +}; + +export default HeaderRootMeta; + +export const Default = { + args: { + title: 'Header Title', + }, +}; + +export const Title = { + render: () => ( + + Custom node title + + } + /> + ), +}; + +export const WithTitleAccessory = { + render: () => ( + + } + /> + ), +}; + +export const WithChildren = { + render: () => ( + console.log('Close pressed'), + }, + ]} + > + + Custom Title + Subtitle text + + + ), +}; + +export const WithEndAccessory = { + render: () => ( + Custom end} + /> + ), +}; + +export const WithEndButtonIconProps = { + render: () => ( + console.log('Close pressed'), + }, + ]} + /> + ), +}; + +export const MultipleEndButtons = { + render: () => ( + console.log('Search pressed'), + }, + { + iconName: IconName.Close, + onPress: () => console.log('Close pressed'), + }, + ]} + /> + ), +}; diff --git a/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.test.tsx b/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.test.tsx new file mode 100644 index 000000000..28095755a --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.test.tsx @@ -0,0 +1,327 @@ +// Third party dependencies. +import React from 'react'; +import { Text } from 'react-native'; +import { render, fireEvent } from '@testing-library/react-native'; + +// External dependencies. +import { IconName } from '../Icon'; + +// Internal dependencies. +import HeaderRoot from './HeaderRoot'; + +const CONTAINER_TEST_ID = 'header-root-container'; +const LEFT_CHILDREN_TEST_ID = 'header-root-left-children'; +const END_ACCESSORY_TEST_ID = 'header-root-end-accessory'; +const END_BUTTON_TEST_ID = 'header-root-end-button'; + +describe('HeaderRoot', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders container with testID when provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(CONTAINER_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders title in left section when title provided and no children', () => { + const { getByText } = render(); + + expect(getByText('Test Title')).toBeOnTheScreen(); + }); + + it('renders title when title is a React node', () => { + const { getByTestId, queryByTestId } = render( + Node Title} + titleProps={{ testID: 'header-root-title-string-props' }} + />, + ); + + expect(getByTestId('header-root-title-node')).toBeOnTheScreen(); + expect(queryByTestId('header-root-title-string-props')).toBeNull(); + }); + + it('renders title with testID when provided via titleProps', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('header-root-title')).toBeOnTheScreen(); + }); + + it('renders titleAccessory in title row when no children', () => { + const { getByTestId, getByText } = render( + Accessory} + />, + ); + + expect(getByText('Title')).toBeOnTheScreen(); + expect(getByTestId(LEFT_CHILDREN_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders only titleAccessory when title is empty and children not provided', () => { + const { getByTestId } = render( + Only Accessory + } + />, + ); + + expect(getByTestId(LEFT_CHILDREN_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders only titleAccessory when title is undefined', () => { + const { getByTestId } = render( + Accessory Only + } + />, + ); + + expect(getByTestId(LEFT_CHILDREN_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders children in left section when children provided', () => { + const { getByTestId, queryByText } = render( + + Custom Content + , + ); + + expect(getByTestId(LEFT_CHILDREN_TEST_ID)).toBeOnTheScreen(); + expect(getByTestId(CONTAINER_TEST_ID)).toBeOnTheScreen(); + expect(queryByText('Ignored Title')).not.toBeOnTheScreen(); + }); + + it('prioritizes children over title when both provided', () => { + const { getByText, queryByText } = render( + + Children Text + , + ); + + expect(getByText('Children Text')).toBeOnTheScreen(); + expect(queryByText('Title Text')).not.toBeOnTheScreen(); + }); + + it('renders title row when children is null', () => { + const { getByText } = render( + {null}, + ); + + expect(getByText('Title When Children Null')).toBeOnTheScreen(); + }); + + it('renders nothing in left section when no children and no title or titleAccessory', () => { + const { getByTestId, queryByText } = render( + , + ); + + expect(getByTestId(CONTAINER_TEST_ID)).toBeOnTheScreen(); + expect(queryByText('Title')).not.toBeOnTheScreen(); + }); + }); + + describe('end section', () => { + it('renders endAccessory when provided', () => { + const { getByTestId } = render( + End Content} + />, + ); + + expect(getByTestId(END_ACCESSORY_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders single ButtonIcon when endButtonIconProps has one item', () => { + const onPressMock = jest.fn(); + const { getByTestId } = render( + , + ); + + expect(getByTestId(END_BUTTON_TEST_ID)).toBeOnTheScreen(); + }); + + it('calls onPress when end ButtonIcon is pressed', () => { + const onPressMock = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(END_BUTTON_TEST_ID)); + + expect(onPressMock).toHaveBeenCalledTimes(1); + }); + + it('renders multiple ButtonIcons when endButtonIconProps has multiple items', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('end-button-close')).toBeOnTheScreen(); + expect(getByTestId('end-button-search')).toBeOnTheScreen(); + }); + + it('does not render end section when no endAccessory and no endButtonIconProps', () => { + const { queryByTestId } = render(); + + expect(queryByTestId(END_ACCESSORY_TEST_ID)).toBeNull(); + expect(queryByTestId(END_BUTTON_TEST_ID)).toBeNull(); + }); + + it('does not render end ButtonIcons when endButtonIconProps is empty array', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(END_BUTTON_TEST_ID)).toBeNull(); + }); + + it('prioritizes endAccessory over endButtonIconProps', () => { + const { getByTestId, queryByTestId } = render( + Custom End} + endButtonIconProps={[ + { + iconName: IconName.Close, + onPress: jest.fn(), + testID: END_BUTTON_TEST_ID, + }, + ]} + />, + ); + + expect(getByTestId(END_ACCESSORY_TEST_ID)).toBeOnTheScreen(); + expect(queryByTestId(END_BUTTON_TEST_ID)).toBeNull(); + }); + }); + + describe('includesTopInset', () => { + it('applies marginTop style when includesTopInset is true', () => { + const { getByTestId } = render( + , + ); + + const container = getByTestId(CONTAINER_TEST_ID); + + expect(container.props.style).toEqual( + expect.arrayContaining([ + expect.anything(), + expect.objectContaining({ marginTop: 0 }), + ]), + ); + }); + + it('does not apply marginTop when includesTopInset is false', () => { + const { getByTestId } = render( + , + ); + + const container = getByTestId(CONTAINER_TEST_ID); + const marginTopStyle = container.props.style?.find( + (s: object) => s && typeof s === 'object' && 'marginTop' in s, + ); + + expect(marginTopStyle).toBeUndefined(); + }); + }); + + describe('style and twClassName', () => { + it('applies custom style to container', () => { + const customStyle = { backgroundColor: 'red' }; + const { getByTestId } = render( + , + ); + + const container = getByTestId(CONTAINER_TEST_ID); + + expect(container.props.style).toContainEqual(customStyle); + }); + + it('merges twClassName with base styles', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(CONTAINER_TEST_ID)).toBeOnTheScreen(); + }); + }); + + describe('titleProps', () => { + it('spreads titleProps to title Text when title is set', () => { + const { getByTestId } = render( + , + ); + + const title = getByTestId('title-with-props'); + + expect(title).toBeOnTheScreen(); + expect(title.props.accessibilityLabel).toBe('Main title'); + }); + }); +}); diff --git a/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.tsx b/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.tsx new file mode 100644 index 000000000..17d2f9ee6 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.tsx @@ -0,0 +1,103 @@ +// Third party dependencies. +import React from 'react'; +import { View } from 'react-native'; + +// External dependencies. +import { Box, BoxAlignItems, BoxFlexDirection } from '../Box'; +import { ButtonIcon, ButtonIconSize } from '../ButtonIcon'; +import { TextVariant } from '../Text'; +import { TextOrChildren } from '../temp-components/TextOrChildren'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +// Internal dependencies. +import { HeaderRootProps } from './HeaderRoot.types'; + +const HeaderRoot: React.FC = ({ + children, + title, + titleProps, + titleAccessory, + endAccessory, + endButtonIconProps, + includesTopInset = false, + style, + testID, + twClassName, + ...viewProps +}) => { + const tw = useTailwind(); + const insets = useSafeAreaInsets(); + + const renderEndContent = () => { + if (endAccessory) { + return endAccessory; + } + if (endButtonIconProps && endButtonIconProps.length > 0) { + const reversedProps = endButtonIconProps + .map((props, originalIndex) => ({ props, originalIndex })) + .reverse(); + return reversedProps.map(({ props, originalIndex }) => ( + + )); + } + return null; + }; + + const hasEndContent = + endAccessory || (endButtonIconProps && endButtonIconProps.length > 0); + + const renderLeftSection = () => { + if (children != null && children !== undefined) { + return children; + } + if (title != null || titleAccessory != null) { + return ( + + {title != null && title !== '' && ( + + {title} + + )} + {titleAccessory} + + ); + } + return null; + }; + + return ( + + {renderLeftSection()} + {hasEndContent && ( + {renderEndContent()} + )} + + ); +}; + +HeaderRoot.displayName = 'HeaderRoot'; + +export default HeaderRoot; diff --git a/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.types.ts b/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.types.ts new file mode 100644 index 000000000..11a5694be --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.types.ts @@ -0,0 +1,63 @@ +// Third party dependencies. +import { ViewProps, StyleProp, ViewStyle } from 'react-native'; +import { ReactNode } from 'react'; + +// External dependencies. +import type { ButtonIconProps } from '../ButtonIcon'; +import type { TextProps } from '../Text'; + +/** + * HeaderRoot component props. + * Left section renders either children or a title row (mutually exclusive). + * End section matches HeaderBase (endAccessory or endButtonIconProps). + */ +export type HeaderRootProps = ViewProps & { + /** + * Optional custom content for the left section. + * When provided, title/titleAccessory are not rendered (mutually exclusive). + */ + children?: ReactNode; + /** + * Optional content displayed after the title in the title row. + * Only used when children is not provided and title or titleAccessory is set. + */ + titleAccessory?: ReactNode; + /** + * Optional main title. Can be a string or a React node. + * Only used when children is not provided. + * When string: rendered via TextOrChildren with TextVariant.HeadingLg; titleProps apply. + * When node: rendered via TextOrChildren as-is; titleProps are not applied to the node. + */ + title?: ReactNode; + /** + * Optional props passed to the Text component when title is a string (TextOrChildren textProps). + */ + titleProps?: Partial; + /** + * Optional content to be displayed in the end section. + * Takes priority over endButtonIconProps if both are provided. + */ + endAccessory?: ReactNode; + /** + * Optional array of ButtonIcon props to render multiple ButtonIcons as end accessories. + * Rendered in reverse order (first item appears rightmost). + * Only used if endAccessory is not provided. + */ + endButtonIconProps?: ButtonIconProps[]; + /** + * Optional prop to include the top inset so the header is visible below the device safe area. + */ + includesTopInset?: boolean; + /** + * Optional style for the header container. + */ + style?: StyleProp; + /** + * Optional test ID for the header container. + */ + testID?: string; + /** + * Optional Tailwind class names for the header container. + */ + twClassName?: string; +}; diff --git a/packages/design-system-react-native/src/components/HeaderRoot/README.md b/packages/design-system-react-native/src/components/HeaderRoot/README.md new file mode 100644 index 000000000..92b4b2a3d --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderRoot/README.md @@ -0,0 +1,54 @@ +# HeaderRoot + +HeaderRoot is a header component with a left section (custom children or title row) and an end section (endAccessory or endButtonIconProps). It supports optional top safe area inset. + +```tsx +import { HeaderRoot } from '@metamask/design-system-react-native'; + +; +``` + +## Props + +Extends React Native [ViewProps](https://reactnative.dev/docs/view). Key props: + +| PROP | TYPE | REQUIRED | DEFAULT | +| -------------------- | -------------------- | -------- | ------------------------------------------- | +| `children` | `ReactNode` | No | `undefined` | +| `title` | `ReactNode` | No | `undefined` | +| `titleAccessory` | `ReactNode` | No | `undefined` | +| `titleProps` | `Partial` | No | `undefined` (only when `title` is a string) | +| `endAccessory` | `ReactNode` | No | `undefined` | +| `endButtonIconProps` | `ButtonIconProps[]` | No | `undefined` | +| `includesTopInset` | `boolean` | No | `false` | +| `twClassName` | `string` | No | `undefined` | + +## Usage + +```tsx +import { + HeaderRoot, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; + + + +Custom title} + endButtonIconProps={[{ iconName: IconName.Close, onPress: onClose }]} +/> + +} + endButtonIconProps={[ + { iconName: IconName.Close, onPress: onClose }, + ]} +/> + + +``` diff --git a/packages/design-system-react-native/src/components/HeaderRoot/index.ts b/packages/design-system-react-native/src/components/HeaderRoot/index.ts new file mode 100644 index 000000000..baa9d3f03 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderRoot/index.ts @@ -0,0 +1,2 @@ +export { default } from './HeaderRoot'; +export type { HeaderRootProps } from './HeaderRoot.types'; diff --git a/packages/design-system-react-native/src/components/HeaderSearch/HeaderSearch.stories.tsx b/packages/design-system-react-native/src/components/HeaderSearch/HeaderSearch.stories.tsx new file mode 100644 index 000000000..656fbd181 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderSearch/HeaderSearch.stories.tsx @@ -0,0 +1,79 @@ +/* eslint-disable no-console */ +import React, { useState } from 'react'; + +import HeaderSearch from './HeaderSearch'; +import { HeaderSearchVariant } from './HeaderSearch.types'; + +const HeaderSearchMeta = { + title: 'Components/HeaderSearch', + component: HeaderSearch, + argTypes: { + variant: { + control: 'select', + options: [HeaderSearchVariant.Screen, HeaderSearchVariant.Inline], + }, + twClassName: { + control: 'text', + }, + }, +}; + +export default HeaderSearchMeta; + +export const Default = { + args: { + placeholder: 'Search...', + }, + render: (args: { placeholder: string }) => ( + console.log('Back pressed')} + textFieldSearchProps={{ + value: '', + onChangeText: () => {}, + onPressClearButton: () => {}, + placeholder: args.placeholder, + }} + /> + ), +}; + +const ScreenStory = () => { + const [value, setValue] = useState(''); + return ( + console.log('Back pressed')} + textFieldSearchProps={{ + value, + onChangeText: setValue, + onPressClearButton: () => setValue(''), + placeholder: 'Search tokens, sites, URLs', + }} + /> + ); +}; + +export const Screen = { + render: () => , +}; + +const InlineStory = () => { + const [value, setValue] = useState(''); + return ( + console.log('Cancel pressed')} + textFieldSearchProps={{ + value, + onChangeText: setValue, + onPressClearButton: () => setValue(''), + placeholder: 'Search tokens, sites, URLs', + }} + /> + ); +}; + +export const Inline = { + render: () => , +}; diff --git a/packages/design-system-react-native/src/components/HeaderSearch/HeaderSearch.test.tsx b/packages/design-system-react-native/src/components/HeaderSearch/HeaderSearch.test.tsx new file mode 100644 index 000000000..6f7ccbd88 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderSearch/HeaderSearch.test.tsx @@ -0,0 +1,203 @@ +// Third party dependencies. +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; + +// Internal dependencies. +import HeaderSearch from './HeaderSearch'; +import { HeaderSearchVariant } from './HeaderSearch.types'; + +const mockTextFieldSearchProps = { + value: '', + onChangeText: jest.fn(), + onPressClearButton: jest.fn(), + placeholder: 'Search...', +}; + +describe('HeaderSearch', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Screen variant', () => { + it('renders correctly with screen variant', () => { + const onPressBackButton = jest.fn(); + const { getByTestId } = render( + , + ); + + expect(getByTestId('back-button')).toBeOnTheScreen(); + }); + + it('renders with testID', () => { + const onPressBackButton = jest.fn(); + const { getByTestId } = render( + , + ); + + expect(getByTestId('header-search')).toBeOnTheScreen(); + }); + + it('calls onPressBackButton when back button is pressed', () => { + const onPressBackButton = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('back-button')); + + expect(onPressBackButton).toHaveBeenCalledTimes(1); + }); + + it('forwards backButtonProps to ButtonIcon', () => { + const onPressBackButton = jest.fn(); + const { getByTestId } = render( + , + ); + + expect(getByTestId('custom-back-button')).toBeOnTheScreen(); + }); + + it('renders TextFieldSearch with provided props', () => { + const onPressBackButton = jest.fn(); + const { getByPlaceholderText } = render( + , + ); + + expect(getByPlaceholderText('Custom placeholder')).toBeOnTheScreen(); + }); + }); + + describe('Inline variant', () => { + it('renders correctly with inline variant', () => { + const onPressCancelButton = jest.fn(); + const { getByText } = render( + , + ); + + expect(getByText('Cancel')).toBeOnTheScreen(); + }); + + it('renders with testID', () => { + const onPressCancelButton = jest.fn(); + const { getByTestId } = render( + , + ); + + expect(getByTestId('header-search-inline')).toBeOnTheScreen(); + }); + + it('calls onPressCancelButton when cancel button is pressed', () => { + const onPressCancelButton = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('cancel-button')); + + expect(onPressCancelButton).toHaveBeenCalledTimes(1); + }); + + it('forwards cancelButtonProps to Button', () => { + const onPressCancelButton = jest.fn(); + const { getByTestId } = render( + , + ); + + expect(getByTestId('custom-cancel-button')).toBeOnTheScreen(); + }); + + it('renders TextFieldSearch with provided props', () => { + const onPressCancelButton = jest.fn(); + const { getByPlaceholderText } = render( + , + ); + + expect(getByPlaceholderText('Search inline')).toBeOnTheScreen(); + }); + }); + + describe('BoxProps forwarding', () => { + it('forwards twClassName to container for screen variant', () => { + const onPressBackButton = jest.fn(); + const { getByTestId } = render( + , + ); + + expect(getByTestId('container')).toBeOnTheScreen(); + }); + + it('forwards twClassName to container for inline variant', () => { + const onPressCancelButton = jest.fn(); + const { getByTestId } = render( + , + ); + + expect(getByTestId('container')).toBeOnTheScreen(); + }); + }); +}); diff --git a/packages/design-system-react-native/src/components/HeaderSearch/HeaderSearch.tsx b/packages/design-system-react-native/src/components/HeaderSearch/HeaderSearch.tsx new file mode 100644 index 000000000..4016af7b1 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderSearch/HeaderSearch.tsx @@ -0,0 +1,141 @@ +// Third party dependencies. +import React from 'react'; + +// External dependencies. +import { Box } from '../Box'; +import { Button, ButtonVariant } from '../Button'; +import { ButtonIcon, ButtonIconSize } from '../ButtonIcon'; +import { IconName } from '../Icon'; + +// Internal dependencies. +import { TextFieldSearch } from '../TextFieldSearch'; +import type { TextFieldSearchProps } from '../TextFieldSearch'; +import { + HeaderSearchInlineProps, + HeaderSearchProps, + HeaderSearchScreenProps, + HeaderSearchVariant, +} from './HeaderSearch.types'; + +const CANCEL_LABEL = 'Cancel'; + +/** + * HeaderSearch is a header component that combines a search field + * with either a back button (screen variant) or cancel button (inline variant). + * + * @example + * // Screen variant with back button + * setSearchText(''), + * placeholder: 'Search...', + * }} + * /> + * + * @example + * // Inline variant with cancel button + * setSearchText(''), + * placeholder: 'Search...', + * }} + * /> + */ +const HeaderSearch: React.FC = (props) => { + const { + variant, + textFieldSearchProps, + twClassName = '', + ...boxProps + } = props; + + const baseTwClassName = 'h-14 flex-row items-center'; + + if (variant === HeaderSearchVariant.Screen) { + const { onPressBackButton, backButtonProps } = + props as HeaderSearchScreenProps; + const screenBoxProps = boxProps as Omit< + HeaderSearchScreenProps, + | 'variant' + | 'textFieldSearchProps' + | 'twClassName' + | 'onPressBackButton' + | 'backButtonProps' + | 'onPressCancelButton' + | 'cancelButtonProps' + >; + + return ( + + + + + + + ); + } + + // Inline variant + const { onPressCancelButton, cancelButtonProps } = + props as HeaderSearchInlineProps; + const inlineBoxProps = boxProps as Omit< + HeaderSearchInlineProps, + | 'variant' + | 'textFieldSearchProps' + | 'twClassName' + | 'onPressCancelButton' + | 'cancelButtonProps' + | 'onPressBackButton' + | 'backButtonProps' + >; + + return ( + + + + + + + ); +}; + +HeaderSearch.displayName = 'HeaderSearch'; + +export default HeaderSearch; diff --git a/packages/design-system-react-native/src/components/HeaderSearch/HeaderSearch.types.ts b/packages/design-system-react-native/src/components/HeaderSearch/HeaderSearch.types.ts new file mode 100644 index 000000000..fe7efbd21 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderSearch/HeaderSearch.types.ts @@ -0,0 +1,49 @@ +// External dependencies. +import type { BoxProps } from '../Box'; +import type { ButtonIconProps } from '../ButtonIcon'; +import type { ButtonProps } from '../Button'; + +// Internal dependencies. +import type { TextFieldSearchProps } from '../TextFieldSearch'; + +export const HeaderSearchVariant = { + Screen: 'screen', + Inline: 'inline', +} as const; + +export type HeaderSearchVariant = + (typeof HeaderSearchVariant)[keyof typeof HeaderSearchVariant]; + +type HeaderSearchBaseProps = Omit & { + /** + * Props to pass to the TextFieldSearch component. + */ + textFieldSearchProps: Omit; +}; + +/** + * Screen variant props. + * Renders a back button (ArrowLeft) on the left side. + */ +export type HeaderSearchScreenProps = HeaderSearchBaseProps & { + variant: typeof HeaderSearchVariant.Screen; + onPressBackButton: () => void; + backButtonProps?: Omit; +}; + +/** + * Inline variant props. + * Renders a cancel button on the right side. + */ +export type HeaderSearchInlineProps = HeaderSearchBaseProps & { + variant: typeof HeaderSearchVariant.Inline; + onPressCancelButton: () => void; + cancelButtonProps?: Omit; +}; + +/** + * HeaderSearch component props. + */ +export type HeaderSearchProps = + | HeaderSearchScreenProps + | HeaderSearchInlineProps; diff --git a/packages/design-system-react-native/src/components/HeaderSearch/README.md b/packages/design-system-react-native/src/components/HeaderSearch/README.md new file mode 100644 index 000000000..7368c22ac --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderSearch/README.md @@ -0,0 +1,69 @@ +# HeaderSearch + +HeaderSearch is a header component that combines a search field with either a back button (screen variant) or cancel button (inline variant). + +```tsx +import { + HeaderSearch, + HeaderSearchVariant, +} from '@metamask/design-system-react-native'; + + setSearchText(''), + placeholder: 'Search...', + }} +/>; +``` + +## Props + +Discriminated union by `variant`: + +### Screen variant + +| PROP | TYPE | REQUIRED | +| ---------------------- | ------------------------------------------------ | -------- | +| `variant` | `HeaderSearchVariant.Screen` | Yes | +| `textFieldSearchProps` | `Omit` | Yes | +| `onPressBackButton` | `() => void` | Yes | +| `backButtonProps` | `Omit` | No | + +### Inline variant + +| PROP | TYPE | REQUIRED | +| ---------------------- | ------------------------------------- | -------- | +| `variant` | `HeaderSearchVariant.Inline` | Yes | +| `textFieldSearchProps` | `Omit` | Yes | +| `onPressCancelButton` | `() => void` | Yes | +| `cancelButtonProps` | `Omit` | No | + +## Usage + +```tsx + navigation.goBack()} + textFieldSearchProps={{ + value: query, + onChangeText: setQuery, + onPressClearButton: () => setQuery(''), + placeholder: 'Search', + }} +/> + + setQuery(''), + placeholder: 'Search...', + }} +/> +``` diff --git a/packages/design-system-react-native/src/components/HeaderSearch/index.ts b/packages/design-system-react-native/src/components/HeaderSearch/index.ts new file mode 100644 index 000000000..81ba7adaf --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderSearch/index.ts @@ -0,0 +1,7 @@ +export { default } from './HeaderSearch'; +export { + HeaderSearchVariant, + type HeaderSearchProps, + type HeaderSearchScreenProps, + type HeaderSearchInlineProps, +} from './HeaderSearch.types'; diff --git a/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.stories.tsx b/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.stories.tsx new file mode 100644 index 000000000..fc1ccc962 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.stories.tsx @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from '@storybook/react-native'; +import React from 'react'; + +import { Box } from '../Box'; +import { IconName } from '../Icon'; +import { Text, TextVariant } from '../Text'; + +import HeaderStandard from './HeaderStandard'; +import type { HeaderStandardProps } from './HeaderStandard.types'; + +const meta: Meta = { + title: 'Components/HeaderStandard', + component: HeaderStandard, + argTypes: { + title: { control: 'text' }, + subtitle: { control: 'text' }, + twClassName: { control: 'text' }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: 'Header Title', + }, +}; + +export const Subtitle: Story = { + args: { + title: 'Settings', + subtitle: 'Account Settings', + onBack: () => null, + }, +}; + +export const OnBack: Story = { + args: { + title: 'Settings', + onBack: () => null, + }, +}; + +export const OnClose: Story = { + args: { + title: 'Modal Title', + onClose: () => null, + }, +}; + +export const BackAndClose: Story = { + args: { + title: 'Settings', + onBack: () => null, + onClose: () => null, + }, +}; + +export const EndButtonIconProps: Story = { + args: { + title: 'Search', + onBack: () => null, + onClose: () => null, + endButtonIconProps: [ + { + iconName: IconName.Search, + onPress: () => null, + }, + ], + }, +}; + +export const Children: Story = { + render: () => ( + null}> + + Custom Title + Subtitle text + + + ), +}; diff --git a/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.test.tsx b/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.test.tsx new file mode 100644 index 000000000..7d4b7bf01 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.test.tsx @@ -0,0 +1,391 @@ +// Third party dependencies. +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import { Text } from 'react-native'; + +// External dependencies. +import { IconName } from '../Icon'; + +// Internal dependencies. +import HeaderStandard from './HeaderStandard'; + +const CONTAINER_TEST_ID = 'header-standard-container'; +const TITLE_TEST_ID = 'header-standard-title'; +const BACK_BUTTON_TEST_ID = 'header-standard-back-button'; +const CLOSE_BUTTON_TEST_ID = 'header-standard-close-button'; +const START_ACCESSORY_TEST_ID = 'start-accessory-wrapper'; +const END_ACCESSORY_TEST_ID = 'end-accessory-wrapper'; + +describe('HeaderStandard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('title and subtitle', () => { + it('renders with title', () => { + const { getByText } = render(); + + expect(getByText('Test Title')).toBeOnTheScreen(); + }); + + it('renders title with testID when provided via titleProps', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TITLE_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders container with testID when provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(CONTAINER_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders custom children instead of title', () => { + const { getByText, queryByText } = render( + + Custom Content + , + ); + + expect(getByText('Custom Content')).toBeOnTheScreen(); + expect(queryByText('Ignored Title')).not.toBeOnTheScreen(); + }); + + it('renders children when both title and children provided', () => { + const { getByText, queryByText } = render( + + Children Text + , + ); + + expect(getByText('Children Text')).toBeOnTheScreen(); + expect(queryByText('Title Text')).not.toBeOnTheScreen(); + }); + + it('renders subtitle when provided', () => { + const { getByText } = render( + , + ); + + expect(getByText('Test Subtitle')).toBeOnTheScreen(); + }); + + it('does not render subtitle when not provided', () => { + const { queryByText } = render(); + + expect(queryByText('Test Subtitle')).not.toBeOnTheScreen(); + }); + + it('renders subtitle with testID when provided via subtitleProps', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('subtitle-test-id')).toBeOnTheScreen(); + }); + + it('renders both title and subtitle together', () => { + const { getByText } = render( + , + ); + + expect(getByText('Main Title')).toBeOnTheScreen(); + expect(getByText('Supporting Text')).toBeOnTheScreen(); + }); + + it('renders title when passed as React node', () => { + const TITLE_NODE_TEST_ID = 'custom-title-node'; + const { getByTestId, getByText } = render( + Custom Title Node} + />, + ); + + expect(getByTestId(TITLE_NODE_TEST_ID)).toBeOnTheScreen(); + expect(getByText('Custom Title Node')).toBeOnTheScreen(); + }); + + it('renders subtitle when passed as React node', () => { + const SUBTITLE_NODE_TEST_ID = 'custom-subtitle-node'; + const { getByTestId, getByText } = render( + Custom Subtitle Node + } + />, + ); + + expect(getByTestId(SUBTITLE_NODE_TEST_ID)).toBeOnTheScreen(); + expect(getByText('Custom Subtitle Node')).toBeOnTheScreen(); + }); + + it('renders both title and subtitle as React nodes', () => { + const TITLE_NODE_TEST_ID = 'title-node'; + const SUBTITLE_NODE_TEST_ID = 'subtitle-node'; + const { getByTestId } = render( + Node Title} + subtitle={Node Subtitle} + />, + ); + + expect(getByTestId(TITLE_NODE_TEST_ID)).toBeOnTheScreen(); + expect(getByTestId(SUBTITLE_NODE_TEST_ID)).toBeOnTheScreen(); + }); + }); + + describe('back button', () => { + it('renders back button when onBack provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(BACK_BUTTON_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders back button when backButtonProps provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(BACK_BUTTON_TEST_ID)).toBeOnTheScreen(); + }); + + it('calls onBack when back button pressed', () => { + const onBack = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(BACK_BUTTON_TEST_ID)); + + expect(onBack).toHaveBeenCalledTimes(1); + }); + + it('calls backButtonProps.onPress when back button pressed', () => { + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(BACK_BUTTON_TEST_ID)); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('uses backButtonProps.onPress over onBack when both provided', () => { + const onBack = jest.fn(); + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(BACK_BUTTON_TEST_ID)); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(onBack).not.toHaveBeenCalled(); + }); + + it('does not render start accessory when no back button props provided', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(START_ACCESSORY_TEST_ID)).not.toBeOnTheScreen(); + }); + + it('renders startButtonIconProps when provided', () => { + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + expect(getByTestId('custom-start-button')).toBeOnTheScreen(); + }); + + it('startButtonIconProps takes priority over onBack', () => { + const onBack = jest.fn(); + const onPress = jest.fn(); + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('custom-start-button')).toBeOnTheScreen(); + expect(queryByTestId(BACK_BUTTON_TEST_ID)).not.toBeOnTheScreen(); + }); + }); + + describe('close button', () => { + it('renders close button when onClose provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(CLOSE_BUTTON_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders close button when closeButtonProps provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(CLOSE_BUTTON_TEST_ID)).toBeOnTheScreen(); + }); + + it('calls onClose when close button pressed', () => { + const onClose = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(CLOSE_BUTTON_TEST_ID)); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls closeButtonProps.onPress when close button pressed', () => { + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(CLOSE_BUTTON_TEST_ID)); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('uses closeButtonProps.onPress over onClose when both provided', () => { + const onClose = jest.fn(); + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(CLOSE_BUTTON_TEST_ID)); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('does not render end accessory when no close button props provided', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(END_ACCESSORY_TEST_ID)).not.toBeOnTheScreen(); + }); + }); + + describe('props forwarding', () => { + it('renders start accessory when onBack is provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(START_ACCESSORY_TEST_ID)).toBeOnTheScreen(); + }); + + it('forwards endButtonIconProps and adds close button', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(END_ACCESSORY_TEST_ID)).toBeOnTheScreen(); + expect(getByTestId(CLOSE_BUTTON_TEST_ID)).toBeOnTheScreen(); + }); + + it('accepts custom testID', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('custom-header')).toBeOnTheScreen(); + }); + }); +}); diff --git a/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.tsx b/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.tsx new file mode 100644 index 000000000..2c0335b6c --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.tsx @@ -0,0 +1,114 @@ +// Third party dependencies. +import React, { useMemo } from 'react'; + +// External dependencies. +import { Box, BoxAlignItems } from '../Box'; +import type { ButtonIconProps } from '../ButtonIcon'; +import { IconName } from '../Icon'; +import { FontWeight, TextColor, TextVariant } from '../Text'; + +// Internal dependencies. +import { HeaderBase } from '../HeaderBase'; +import { TextOrChildren } from '../temp-components/TextOrChildren'; +import type { HeaderStandardProps } from './HeaderStandard.types'; + +/** Centered title and optional back/close actions; extends HeaderBase. */ +const HeaderStandard: React.FC = ({ + title, + titleProps, + subtitle, + subtitleProps, + children, + onBack, + backButtonProps, + onClose, + closeButtonProps, + endButtonIconProps, + startButtonIconProps, + twClassName = '', + testID, + ...headerBaseProps +}) => { + const resolvedStartButtonIconProps = useMemo(() => { + if (startButtonIconProps) { + return startButtonIconProps; + } + if (onBack || backButtonProps) { + return { + iconName: IconName.ArrowLeft, + ...(backButtonProps || {}), + onPress: backButtonProps?.onPress ?? onBack, + } as ButtonIconProps; + } + return undefined; + }, [onBack, backButtonProps, startButtonIconProps]); + + const resolvedEndButtonIconProps = useMemo(() => { + const props: ButtonIconProps[] = []; + + if (onClose || closeButtonProps) { + const closeProps: ButtonIconProps = { + iconName: IconName.Close, + ...(closeButtonProps || {}), + onPress: closeButtonProps?.onPress ?? onClose, + }; + props.push(closeProps); + } + + if (endButtonIconProps) { + props.push(...endButtonIconProps); + } + return props.length > 0 ? props : undefined; + }, [endButtonIconProps, onClose, closeButtonProps]); + + const renderContent = () => { + if (children) { + return children; + } + if (title) { + return ( + + + {title} + + {subtitle && ( + + + {subtitle} + + + )} + + ); + } + return null; + }; + + return ( + + {renderContent()} + + ); +}; + +HeaderStandard.displayName = 'HeaderStandard'; + +export default HeaderStandard; diff --git a/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.types.ts b/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.types.ts new file mode 100644 index 000000000..3e08ca195 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandard/HeaderStandard.types.ts @@ -0,0 +1,59 @@ +// External dependencies. +import React from 'react'; + +import type { ButtonIconProps } from '../ButtonIcon'; +import type { TextProps } from '../Text'; + +// Internal dependencies. +import type { HeaderBaseProps } from '../HeaderBase'; + +/** + * HeaderStandard component props. + */ +export type HeaderStandardProps = HeaderBaseProps & { + /** + * Title to display in the header. Can be a string or a React node. + * Used as children if children prop is not provided. + * When string: rendered with TextVariant.BodyMd and FontWeight.Bold by default; titleProps apply. + * When node: rendered as-is; titleProps are not applied. + */ + title?: string | React.ReactNode; + /** + * Additional props to pass to the title Text component. + * Props are spread to the Text component and can override default values. + * Only applied when title is a string. + */ + titleProps?: Partial; + /** + * Subtitle to display below the title. Can be a string or a React node. + * When string: rendered with TextVariant.BodySm and TextColor.TextAlternative by default; subtitleProps apply. + * When node: rendered inside the same -mt-0.5 container; subtitleProps are not applied. + */ + subtitle?: string | React.ReactNode; + /** + * Additional props to pass to the subtitle Text component. + * Props are spread to the Text component and can override default values. + * Only applied when subtitle is a string. + */ + subtitleProps?: Partial; + /** + * Callback when the back button is pressed. + * If provided, a back button will be rendered as startButtonIconProps. + */ + onBack?: () => void; + /** + * Additional props to pass to the back ButtonIcon. + * If provided, a back button will be rendered as startButtonIconProps with these props spread. + */ + backButtonProps?: Omit; + /** + * Callback when the close button is pressed. + * If provided, a close button will be added to endButtonIconProps. + */ + onClose?: () => void; + /** + * Additional props to pass to the close ButtonIcon. + * If provided, a close button will be added to endButtonIconProps with these props spread. + */ + closeButtonProps?: Omit; +}; diff --git a/packages/design-system-react-native/src/components/HeaderStandard/README.md b/packages/design-system-react-native/src/components/HeaderStandard/README.md new file mode 100644 index 000000000..70e66bbb8 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandard/README.md @@ -0,0 +1,187 @@ +# HeaderStandard + +HeaderStandard is a header component with a centered title and optional back and close actions. It extends [HeaderBase](../HeaderBase/README.md) with `title`, `subtitle`, and shortcut props that map to [ButtonIcon](../ButtonIcon/README.md) actions. + +```tsx +import { HeaderStandard } from '@metamask/design-system-react-native'; + +; +``` + +## Props + +Inherits [HeaderBaseProps](../HeaderBase/README.md) (`variant`, `startAccessory`, `endAccessory`, `testID`, `style`, `twClassName`, and other `View` props). Additional props: + +### `title` + +Primary label in the header center. If `children` is set, `title` is ignored. + +When `title` is a string, it is rendered with [Text](../Text/README.md) using `TextVariant.BodyMd` and bold weight by default; use `titleProps` to override. + +| TYPE | REQUIRED | DEFAULT | +| --------------------- | -------- | ----------- | +| `string \| ReactNode` | No | `undefined` | + +```tsx + + +Custom} +/> +``` + +### `titleProps` + +Props forwarded to the design-system `Text` component when `title` is a string. + +| TYPE | REQUIRED | DEFAULT | +| -------------------- | -------- | ----------- | +| `Partial` | No | `undefined` | + +```tsx + +``` + +### `subtitle` + +Secondary line below the title. Omitted when not provided. + +When `subtitle` is a string, it uses `TextVariant.BodySm` and `TextColor.TextAlternative` by default; use `subtitleProps` to override. + +| TYPE | REQUIRED | DEFAULT | +| --------------------- | -------- | ----------- | +| `string \| ReactNode` | No | `undefined` | + +```tsx + +``` + +### `subtitleProps` + +Props forwarded to `Text` when `subtitle` is a string. + +| TYPE | REQUIRED | DEFAULT | +| -------------------- | -------- | ----------- | +| `Partial` | No | `undefined` | + +```tsx + +``` + +### `children` + +Custom center content. When provided, replaces the default title and subtitle layout. + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx + + + Custom layout + + +``` + +### `onBack` + +If set, renders a start [ButtonIcon](../ButtonIcon/README.md) with a back arrow. The press handler is `backButtonProps.onPress` when both are provided. + +| TYPE | REQUIRED | DEFAULT | +| ------------ | -------- | ----------- | +| `() => void` | No | `undefined` | + +```tsx + navigation.goBack()} /> +``` + +### `backButtonProps` + +Props for the back `ButtonIcon` (excluding `iconName`, which is fixed). Supplying this object also shows the back button; use `onPress` for the handler if `onBack` is not used. + +| TYPE | REQUIRED | DEFAULT | +| ----------------------------------- | -------- | ----------- | +| `Omit` | No | `undefined` | + +```tsx + +``` + +### `onClose` + +If set, appends a close `ButtonIcon` to the end actions. + +| TYPE | REQUIRED | DEFAULT | +| ------------ | -------- | ----------- | +| `() => void` | No | `undefined` | + +```tsx + setVisible(false)} /> +``` + +### `closeButtonProps` + +Props for the close `ButtonIcon` (excluding `iconName`). `onPress` takes precedence over `onClose` when both are set. + +| TYPE | REQUIRED | DEFAULT | +| ----------------------------------- | -------- | ----------- | +| `Omit` | No | `undefined` | + +```tsx + +``` + +### `twClassName` + +Tailwind classes merged onto the root [HeaderBase](../HeaderBase/README.md) container. The component applies horizontal padding by default. + +| TYPE | REQUIRED | DEFAULT | +| -------- | -------- | ----------- | +| `string` | No | `undefined` | + +```tsx + +``` + +## Usage + +```tsx + navigation.goBack()} /> + + setModalVisible(false)} +/> + + +``` + +## Accessibility + +- Set `testID` on the header via [HeaderBase](../HeaderBase/README.md) props for integration tests. +- Use `titleProps` / `subtitleProps` to pass `accessibilityLabel` or `testID` onto string titles and subtitles. +- Back and close controls are `ButtonIcon` instances; pass `accessibilityLabel`, `accessibilityHint`, and `testID` through `backButtonProps` and `closeButtonProps`. + +## References + +[MetaMask Design System Guides](https://www.notion.so/MetaMask-Design-System-Guides-Design-f86ecc914d6b4eb6873a122b83c12940) diff --git a/packages/design-system-react-native/src/components/HeaderStandard/index.ts b/packages/design-system-react-native/src/components/HeaderStandard/index.ts new file mode 100644 index 000000000..40b8b5477 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandard/index.ts @@ -0,0 +1,2 @@ +export { default } from './HeaderStandard'; +export type { HeaderStandardProps } from './HeaderStandard.types'; diff --git a/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.stories.tsx b/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.stories.tsx new file mode 100644 index 000000000..565a1b3d9 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from '@storybook/react-native'; +import React from 'react'; +// eslint-disable-next-line import-x/default +import Animated from 'react-native-reanimated'; + +import { Box } from '../Box'; +import { Text, TextVariant } from '../Text'; +import { TitleStandard } from '../TitleStandard'; + +import { HeaderStandardAnimated } from './HeaderStandardAnimated'; +import type { HeaderStandardAnimatedProps } from './HeaderStandardAnimated.types'; +import { useHeaderStandardAnimated } from './useHeaderStandardAnimated'; + +const noop = () => undefined; + +const meta: Meta = { + title: 'Components/HeaderStandardAnimated', + component: HeaderStandardAnimated, + argTypes: { + title: { control: 'text' }, + subtitle: { control: 'text' }, + twClassName: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +const SampleContent = ({ itemCount = 20 }: { itemCount?: number }) => ( + <> + {Array.from({ length: itemCount }).map((_, index) => ( + + Item {index + 1} + + This is sample content to demonstrate scrolling behavior. + + + ))} + +); + +function DefaultStory() { + const { scrollY, onScroll, setTitleSectionHeight, titleSectionHeightSv } = + useHeaderStandardAnimated(); + + return ( + + + + setTitleSectionHeight(e.nativeEvent.layout.height)} + > + + + + + + ); +} + +function SubtitleStory() { + const { scrollY, onScroll, setTitleSectionHeight, titleSectionHeightSv } = + useHeaderStandardAnimated(); + + return ( + + + + setTitleSectionHeight(e.nativeEvent.layout.height)} + > + + + + + + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Subtitle: Story = { + render: () => , +}; diff --git a/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.test.tsx b/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.test.tsx new file mode 100644 index 000000000..fa7020a78 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.test.tsx @@ -0,0 +1,347 @@ +// Third party dependencies. +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; +import type { SharedValue } from 'react-native-reanimated'; + +// External dependencies. +import { IconName } from '../Icon'; + +// Internal dependencies. +import { HeaderStandardAnimated } from './HeaderStandardAnimated'; + +jest.mock('react-native-reanimated', () => { + const Reanimated = jest.requireActual('react-native-reanimated/mock'); + // eslint-disable-next-line jest/prefer-spy-on -- mock factory assigns implementations on reanimated/mock + Reanimated.useSharedValue = jest.fn((initial: number) => ({ + value: initial, + })); + // eslint-disable-next-line jest/prefer-spy-on + Reanimated.useAnimatedStyle = jest.fn((fn: () => object) => fn()); + return Reanimated; +}); + +const CONTAINER_TEST_ID = 'header-standard-animated-container'; +const TITLE_TEST_ID = 'header-standard-animated-title'; +const SUBTITLE_TEST_ID = 'header-standard-animated-subtitle'; +const BACK_BUTTON_TEST_ID = 'header-standard-animated-back-button'; +const CLOSE_BUTTON_TEST_ID = 'header-standard-animated-close-button'; + +const createMockSharedValue = (initial: number): SharedValue => { + const ref = { value: initial }; + return { + get value() { + return ref.value; + }, + set value(v: number) { + ref.value = v; + }, + get: () => ref.value, + set: (v: number | ((prev: number) => number)) => { + ref.value = typeof v === 'function' ? v(ref.value) : v; + }, + addListener: jest.fn(), + removeListener: jest.fn(), + modify: jest.fn(), + }; +}; + +const defaultProps = { + scrollY: createMockSharedValue(0), + titleSectionHeight: createMockSharedValue(100), +}; + +describe('HeaderStandardAnimated', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders with title', () => { + const { getByText } = render( + , + ); + + expect(getByText('Test Title')).toBeOnTheScreen(); + }); + + it('renders title with testID when provided via titleProps', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TITLE_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders container with testID when provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(CONTAINER_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders subtitle when provided', () => { + const { getByText } = render( + , + ); + + expect(getByText('Test Subtitle')).toBeOnTheScreen(); + }); + + it('does not render subtitle when not provided', () => { + const { queryByText } = render( + , + ); + + expect(queryByText('Test Subtitle')).not.toBeOnTheScreen(); + }); + + it('renders subtitle with testID when provided via subtitleProps', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(SUBTITLE_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders both title and subtitle together', () => { + const { getByText } = render( + , + ); + + expect(getByText('Main Title')).toBeOnTheScreen(); + expect(getByText('Supporting Text')).toBeOnTheScreen(); + }); + }); + + describe('back button', () => { + it('renders back button when onBack provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(BACK_BUTTON_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders back button when backButtonProps provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(BACK_BUTTON_TEST_ID)).toBeOnTheScreen(); + }); + + it('calls onBack when back button pressed', () => { + const onBack = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(BACK_BUTTON_TEST_ID)); + + expect(onBack).toHaveBeenCalledTimes(1); + }); + + it('calls backButtonProps.onPress when back button pressed', () => { + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(BACK_BUTTON_TEST_ID)); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('uses backButtonProps.onPress over onBack when both provided', () => { + const onBack = jest.fn(); + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(BACK_BUTTON_TEST_ID)); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(onBack).not.toHaveBeenCalled(); + }); + + it('renders startButtonIconProps when provided', () => { + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + expect(getByTestId('custom-start-button')).toBeOnTheScreen(); + }); + + it('startButtonIconProps takes priority over onBack', () => { + const onBack = jest.fn(); + const onPress = jest.fn(); + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('custom-start-button')).toBeOnTheScreen(); + expect(queryByTestId(BACK_BUTTON_TEST_ID)).not.toBeOnTheScreen(); + }); + }); + + describe('close button', () => { + it('renders close button when onClose provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(CLOSE_BUTTON_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders close button when closeButtonProps provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(CLOSE_BUTTON_TEST_ID)).toBeOnTheScreen(); + }); + + it('calls onClose when close button pressed', () => { + const onClose = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(CLOSE_BUTTON_TEST_ID)); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls closeButtonProps.onPress when close button pressed', () => { + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(CLOSE_BUTTON_TEST_ID)); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('uses closeButtonProps.onPress over onClose when both provided', () => { + const onClose = jest.fn(); + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(CLOSE_BUTTON_TEST_ID)); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + + describe('props forwarding', () => { + it('accepts custom testID', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('custom-header')).toBeOnTheScreen(); + }); + }); +}); diff --git a/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.tsx b/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.tsx new file mode 100644 index 000000000..1eb93311d --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.tsx @@ -0,0 +1,81 @@ +// Third party dependencies. +import React from 'react'; +// eslint-disable-next-line import-x/default +import Animated, { + useAnimatedStyle, + useDerivedValue, + withTiming, +} from 'react-native-reanimated'; + +// External dependencies. +import { Box, BoxAlignItems } from '../Box'; +import HeaderStandard from '../HeaderStandard'; +import { TextOrChildren } from '../temp-components/TextOrChildren'; +import { FontWeight, TextColor, TextVariant } from '../Text'; + +// Internal dependencies. +import type { HeaderStandardAnimatedProps } from './HeaderStandardAnimated.types'; + +export const HeaderStandardAnimated = ({ + title, + titleProps, + subtitle, + subtitleProps, + scrollY, + titleSectionHeight, + twClassName = '', + ...headerStandardProps +}: HeaderStandardAnimatedProps) => { + const compactTitleProgress = useDerivedValue(() => { + const hasMeasured = titleSectionHeight.value > 0; + const isFullyHidden = + hasMeasured && scrollY.value >= titleSectionHeight.value; + return withTiming(isFullyHidden ? 1 : 0, { duration: 150 }); + }); + + const centerAnimatedStyle = useAnimatedStyle(() => { + const progress = compactTitleProgress.value; + return { + opacity: progress, + transform: [{ translateY: (1 - progress) * 8 }], + }; + }); + + const content = title ? ( + + + {title} + + {subtitle && ( + + + {subtitle} + + + )} + + ) : null; + + return ( + + {content} + + ); +}; + +HeaderStandardAnimated.displayName = 'HeaderStandardAnimated'; diff --git a/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.types.ts b/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.types.ts new file mode 100644 index 000000000..dff6ee5f3 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandardAnimated/HeaderStandardAnimated.types.ts @@ -0,0 +1,27 @@ +// External dependencies. +import type { SharedValue } from 'react-native-reanimated'; +import { useAnimatedScrollHandler } from 'react-native-reanimated'; + +// Internal dependencies. +import type { HeaderStandardProps } from '../HeaderStandard/HeaderStandard.types'; + +export type HeaderStandardAnimatedProps = Omit< + HeaderStandardProps, + 'children' +> & { + /** + * Shared value for scroll offset from the ScrollView. + */ + scrollY: SharedValue; + /** + * Shared value for the height of the title section (first child of ScrollView). + */ + titleSectionHeight: SharedValue; +}; + +export type UseHeaderStandardAnimatedReturn = { + scrollY: SharedValue; + titleSectionHeightSv: SharedValue; + setTitleSectionHeight: (height: number) => void; + onScroll: ReturnType; +}; diff --git a/packages/design-system-react-native/src/components/HeaderStandardAnimated/README.md b/packages/design-system-react-native/src/components/HeaderStandardAnimated/README.md new file mode 100644 index 000000000..38a401e9b --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandardAnimated/README.md @@ -0,0 +1,92 @@ +# HeaderStandardAnimated + +HeaderStandardAnimated extends [HeaderStandard](../HeaderStandard/README.md) with scroll-driven animation: a compact center title fades in when the user scrolls past the measured title section. Use with `useHeaderStandardAnimated` and `Animated.ScrollView` from `react-native-reanimated` so scroll updates run on the UI thread. + +```tsx +import { + HeaderStandardAnimated, + useHeaderStandardAnimated, +} from '@metamask/design-system-react-native'; +import Animated from 'react-native-reanimated'; + +const { scrollY, titleSectionHeightSv, setTitleSectionHeight, onScroll } = + useHeaderStandardAnimated(); + +; + + setTitleSectionHeight(e.nativeEvent.layout.height)}> + + +; +``` + +## Props + +Extends [HeaderStandard](../HeaderStandard/README.md) props **except** `children` (the animated center content is provided internally). Additional props: + +### `scrollY` + +Shared value for vertical scroll offset from the scroll view (`contentOffset.y`). + +| TYPE | REQUIRED | +| --------------------- | -------- | +| `SharedValue` | Yes | + +### `titleSectionHeight` + +Shared value for the height of the title section (first scrollable region), typically from `onLayout` on the wrapper around your page title. + +| TYPE | REQUIRED | +| --------------------- | -------- | +| `SharedValue` | Yes | + +### Other props + +`title`, `subtitle`, `titleProps`, `subtitleProps`, `onBack`, `backButtonProps`, `onClose`, `closeButtonProps`, and other [HeaderStandard](../HeaderStandard/README.md) props behave the same as HeaderStandard. `twClassName` is merged with a default background class on the header. + +## useHeaderStandardAnimated + +Hook that returns: + +| Property | Description | +| ----------------------- | ----------------------------------------------------- | +| `scrollY` | Shared value for scroll offset (starts at `0`) | +| `titleSectionHeightSv` | Shared value for title section height (starts at `0`) | +| `setTitleSectionHeight` | Call with `height` from `onLayout` | +| `onScroll` | Animated scroll handler for `Animated.ScrollView` | + +## Usage + +```tsx +const { scrollY, onScroll, setTitleSectionHeight, titleSectionHeightSv } = + useHeaderStandardAnimated(); + + + + + setTitleSectionHeight(e.nativeEvent.layout.height)}> + + + {/* page body */} + +; +``` + +## Accessibility + +- Provide clear string `title` and `subtitle` when possible so assistive technologies read the same labels as the animated header. +- Pass `testID`, `titleProps`, and `subtitleProps` (including `accessibilityLabel` / `accessibilityRole` as needed) for tests and custom announcements. + +## References + +[MetaMask Design System Guides](https://www.notion.so/MetaMask-Design-System-Guides-Design-f86ecc914d6b4eb6873a122b83c12940) diff --git a/packages/design-system-react-native/src/components/HeaderStandardAnimated/index.ts b/packages/design-system-react-native/src/components/HeaderStandardAnimated/index.ts new file mode 100644 index 000000000..007d54e94 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandardAnimated/index.ts @@ -0,0 +1,6 @@ +export { HeaderStandardAnimated } from './HeaderStandardAnimated'; +export { useHeaderStandardAnimated } from './useHeaderStandardAnimated'; +export type { + HeaderStandardAnimatedProps, + UseHeaderStandardAnimatedReturn, +} from './HeaderStandardAnimated.types'; diff --git a/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.test.ts b/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.test.ts new file mode 100644 index 000000000..bf0e3cdd6 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.test.ts @@ -0,0 +1,83 @@ +// Third party dependencies. +import { renderHook, act } from '@testing-library/react-native'; + +// Internal dependencies. +import { useHeaderStandardAnimated } from './useHeaderStandardAnimated'; + +const createScrollEvent = (contentOffsetY: number) => ({ + contentOffset: { y: contentOffsetY, x: 0 }, +}); + +describe('useHeaderStandardAnimated', () => { + describe('return value', () => { + it('returns scrollY, titleSectionHeightSv, setTitleSectionHeight, and onScroll', () => { + const { result } = renderHook(() => useHeaderStandardAnimated()); + + expect(result.current).toHaveProperty('scrollY'); + expect(result.current).toHaveProperty('titleSectionHeightSv'); + expect(result.current).toHaveProperty('setTitleSectionHeight'); + expect(result.current).toHaveProperty('onScroll'); + expect(typeof result.current.setTitleSectionHeight).toBe('function'); + expect(typeof result.current.onScroll).toBe('function'); + }); + + it('initializes scrollY with value 0', () => { + const { result } = renderHook(() => useHeaderStandardAnimated()); + + expect(result.current.scrollY.value).toBe(0); + }); + + it('initializes titleSectionHeightSv with value 0', () => { + const { result } = renderHook(() => useHeaderStandardAnimated()); + + expect(result.current.titleSectionHeightSv.value).toBe(0); + }); + }); + + describe('setTitleSectionHeight', () => { + it('updates titleSectionHeightSv.value when called', () => { + const { result } = renderHook(() => useHeaderStandardAnimated()); + + act(() => { + result.current.setTitleSectionHeight(120); + }); + + expect(result.current.titleSectionHeightSv.value).toBe(120); + }); + + it('updates titleSectionHeightSv.value on multiple calls', () => { + const { result } = renderHook(() => useHeaderStandardAnimated()); + + act(() => { + result.current.setTitleSectionHeight(50); + }); + expect(result.current.titleSectionHeightSv.value).toBe(50); + + act(() => { + result.current.setTitleSectionHeight(200); + }); + expect(result.current.titleSectionHeightSv.value).toBe(200); + }); + }); + + describe('onScroll', () => { + it('returns onScroll handler that accepts event with contentOffset and does not throw', () => { + // scrollY.value update from contentOffset.y is not asserted here because the hook + // receives the real react-native-reanimated in this test environment; the behavior + // is implemented in the hook and may be covered by integration tests. + const { result } = renderHook(() => useHeaderStandardAnimated()); + + expect(typeof result.current.onScroll).toBe('function'); + + expect(() => { + act(() => { + result.current.onScroll( + createScrollEvent(75) as unknown as Parameters< + ReturnType['onScroll'] + >[0], + ); + }); + }).not.toThrow(); + }); + }); +}); diff --git a/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.ts b/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.ts new file mode 100644 index 000000000..f9066a0e9 --- /dev/null +++ b/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.ts @@ -0,0 +1,35 @@ +// Third party dependencies. +import { useCallback } from 'react'; +import { + useSharedValue, + useAnimatedScrollHandler, +} from 'react-native-reanimated'; + +// Internal dependencies. +import type { UseHeaderStandardAnimatedReturn } from './HeaderStandardAnimated.types'; + +export const useHeaderStandardAnimated = + (): UseHeaderStandardAnimatedReturn => { + const scrollYValue = useSharedValue(0); + const titleSectionHeightSv = useSharedValue(0); + + const setTitleSectionHeight = useCallback( + (height: number) => { + titleSectionHeightSv.value = height; + }, + [titleSectionHeightSv], + ); + + const onScroll = useAnimatedScrollHandler({ + onScroll: (scrollEvent) => { + scrollYValue.value = scrollEvent.contentOffset.y; + }, + }); + + return { + scrollY: scrollYValue, + titleSectionHeightSv, + setTitleSectionHeight, + onScroll, + }; + }; diff --git a/packages/design-system-react-native/src/components/TextFieldSearch/README.md b/packages/design-system-react-native/src/components/TextFieldSearch/README.md new file mode 100644 index 000000000..023589c7b --- /dev/null +++ b/packages/design-system-react-native/src/components/TextFieldSearch/README.md @@ -0,0 +1,55 @@ +# TextFieldSearch + +TextFieldSearch is an input component that allows users to enter text to search. It extends the [TextField](../TextField/README.md) component with a search icon and optional clear button. + +```tsx +import { TextFieldSearch } from '@metamask/design-system-react-native'; + +const [searchText, setSearchText] = useState(''); + + setSearchText('')} + placeholder="Search..." +/>; +``` + +## Props + +This component extends [TextField](../TextField/README.md) props. + +### Clear Button Behavior + +The clear button is automatically shown when the input has a value. No additional prop is needed to control its visibility. + +### `onPressClearButton` + +Function to trigger when pressing the clear button. + +| TYPE | REQUIRED | +| -------- | -------- | +| Function | Yes | + +### `clearButtonProps` + +Optional prop to pass any additional props to the clear button (e.g. [ButtonIconProps](../ButtonIcon/README.md)). + +| TYPE | REQUIRED | +| ------------------------ | -------- | +| Partial | No | + +## Usage + +```tsx +import { TextFieldSearch } from '@metamask/design-system-react-native'; + +const [searchText, setSearchText] = useState(''); + + setSearchText('')} + placeholder="Search..." +/>; +``` 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 new file mode 100644 index 000000000..33d18dabf --- /dev/null +++ b/packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.stories.tsx @@ -0,0 +1,120 @@ +import type { Meta, StoryObj } from '@storybook/react-native'; +import { useEffect, useState } from 'react'; +import { View } from 'react-native'; + +import { TextFieldSearch } from './TextFieldSearch'; +import type { TextFieldSearchProps } from './TextFieldSearch.types'; + +const noop = () => undefined; + +function ControlledTextFieldSearch(props: TextFieldSearchProps) { + const [value, setValue] = useState(props.value ?? ''); + useEffect(() => { + setValue(props.value ?? ''); + }, [props.value]); + + return ( + { + setValue(''); + props.onPressClearButton(); + }} + /> + ); +} + +const meta: Meta = { + title: 'Components/TextFieldSearch', + component: TextFieldSearch, + argTypes: { + isError: { + control: 'boolean', + }, + isDisabled: { + control: 'boolean', + }, + isReadonly: { + control: 'boolean', + }, + placeholder: { + control: 'text', + }, + value: { + control: 'text', + }, + twClassName: { + control: 'text', + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + value: '', + placeholder: 'Search', + onPressClearButton: noop, + }, + render: (args) => , +}; + +export const IsError: Story = { + render: () => ( + + + + + ), +}; + +export const IsDisabled: Story = { + render: () => ( + + + + + ), +}; + +export const IsReadonly: Story = { + args: { + placeholder: 'Search readonly', + value: 'Search query', + isReadonly: true, + onPressClearButton: noop, + }, + render: (args) => , +}; + +export const WithValue: Story = { + args: { + placeholder: 'Search...', + value: 'Search text', + onPressClearButton: noop, + }, + render: (args) => , +}; diff --git a/packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.test.tsx b/packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.test.tsx new file mode 100644 index 000000000..37f9a2621 --- /dev/null +++ b/packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.test.tsx @@ -0,0 +1,46 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { TextFieldSearch } from './TextFieldSearch'; + +describe('TextFieldSearch', () => { + const mockOnPressClearButton = jest.fn(); + + beforeEach(() => { + mockOnPressClearButton.mockClear(); + }); + + it('renders on screen', () => { + render( + , + ); + + expect(screen.getByTestId('textfieldsearch')).toBeOnTheScreen(); + }); + + it('calls onPressClearButton when clear button is pressed and value exists', () => { + render( + , + ); + + fireEvent.press(screen.getByTestId('clear-button')); + + expect(mockOnPressClearButton).toHaveBeenCalledTimes(1); + }); + + it('does not render clear button when value is empty', () => { + render( + , + ); + + expect(screen.queryByTestId('clear-button')).toBeNull(); + }); +}); diff --git a/packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.tsx b/packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.tsx new file mode 100644 index 000000000..0e73fb784 --- /dev/null +++ b/packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.tsx @@ -0,0 +1,52 @@ +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import React, { forwardRef, useCallback } from 'react'; +import type { TextInput } from 'react-native'; + +import { ButtonIcon, ButtonIconSize } from '../ButtonIcon'; +import { Icon, IconColor, IconName, IconSize } from '../Icon'; +import { TextField } from '../TextField'; + +import type { TextFieldSearchProps } from './TextFieldSearch.types'; + +export const TextFieldSearch = forwardRef( + ({ onPressClearButton, clearButtonProps, value, style, ...props }, ref) => { + const tw = useTailwind(); + const containerStyle = tw.style('rounded-full'); + + const searchIcon = ( + + ); + + const clearButtonHandler = useCallback(() => { + onPressClearButton(); + }, [onPressClearButton]); + + const clearButton = ( + + ); + + return ( + + ); + }, +); + +TextFieldSearch.displayName = 'TextFieldSearch'; diff --git a/packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.types.ts b/packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.types.ts new file mode 100644 index 000000000..d8c2d783e --- /dev/null +++ b/packages/design-system-react-native/src/components/TextFieldSearch/TextFieldSearch.types.ts @@ -0,0 +1,18 @@ +// External dependencies. +import type { ButtonIconProps } from '../ButtonIcon'; +import type { TextFieldProps } from '../TextField'; + +/** + * TextFieldSearch component props. + */ +export type TextFieldSearchProps = TextFieldProps & { + /** + * Optional prop to pass any additional props to the clear button. + */ + clearButtonProps?: Partial; + /** + * Function to trigger when pressing the clear button. + * The clear button is automatically shown when the input has a value. + */ + onPressClearButton: () => void; +}; diff --git a/packages/design-system-react-native/src/components/TextFieldSearch/index.ts b/packages/design-system-react-native/src/components/TextFieldSearch/index.ts new file mode 100644 index 000000000..59703bf4e --- /dev/null +++ b/packages/design-system-react-native/src/components/TextFieldSearch/index.ts @@ -0,0 +1,2 @@ +export { TextFieldSearch } from './TextFieldSearch'; +export type { TextFieldSearchProps } from './TextFieldSearch.types'; diff --git a/packages/design-system-react-native/src/components/TitleStandard/README.md b/packages/design-system-react-native/src/components/TitleStandard/README.md new file mode 100644 index 000000000..d3d10ab5d --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleStandard/README.md @@ -0,0 +1,104 @@ +# TitleStandard + +TitleStandard displays a title with optional accessories in a left-aligned layout: optional top label or top accessory, a heading title with optional inline accessory, and optional bottom label or bottom accessory. + +```tsx +import { TitleStandard } from '@metamask/design-system-react-native'; + +} +/>; +``` + +## Props + +### `title` + +Main title text, rendered with `TextVariant.HeadingLg`. + +| TYPE | REQUIRED | DEFAULT | +| -------- | -------- | ----------- | +| `string` | No | `undefined` | + +### `titleAccessory` + +Optional node rendered to the right of the title (for example an info icon). + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +### `topLabel` / `topAccessory` + +Content above the title row. When `topLabel` is set, it is shown as body small alternative text and takes priority over `topAccessory`. + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `string` | No | `undefined` | +| `ReactNode` | No | `undefined` | + +### `bottomLabel` / `bottomAccessory` + +Content below the title row. When `bottomLabel` is set, it is shown as body small alternative text and takes priority over `bottomAccessory`. + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `string` | No | `undefined` | +| `ReactNode` | No | `undefined` | + +### `titleProps` / `topLabelProps` / `bottomLabelProps` + +Optional props merged into the corresponding [Text](../Text/README.md) nodes. + +| TYPE | REQUIRED | DEFAULT | +| -------------------- | -------- | ----------- | +| `Partial` | No | `undefined` | + +### `testID` + +Optional test ID for the root container. + +| TYPE | REQUIRED | DEFAULT | +| -------- | -------- | ----------- | +| `string` | No | `undefined` | + +### `twClassName` + +Optional Tailwind classes for the root container (merged with defaults). + +| TYPE | REQUIRED | DEFAULT | +| -------- | -------- | ----------- | +| `string` | No | `undefined` | + +## Usage + +```tsx +import { TitleStandard } from '@metamask/design-system-react-native'; + + + + + + + +} +/> +``` + +## Accessibility + +- Prefer meaningful `title` and label strings so screen readers announce the full context. +- Pass `testID` and `titleProps` / `topLabelProps` / `bottomLabelProps` (including `accessibilityLabel` where needed) when wiring automated tests or custom accessibility labels. + +## References + +[MetaMask Design System Guides](https://www.notion.so/MetaMask-Design-System-Guides-Design-f86ecc914d6b4eb6873a122b83c12940) diff --git a/packages/design-system-react-native/src/components/TitleStandard/TitleStandard.stories.tsx b/packages/design-system-react-native/src/components/TitleStandard/TitleStandard.stories.tsx new file mode 100644 index 000000000..91d31fa1e --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleStandard/TitleStandard.stories.tsx @@ -0,0 +1,177 @@ +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import type { Meta, StoryObj } from '@storybook/react-native'; +import React from 'react'; +import { View } from 'react-native'; + +import { Box } from '../Box'; +import { BoxHorizontal } from '../BoxHorizontal'; +import { Icon, IconColor, IconName, IconSize } from '../Icon'; +import { TextVariant } from '../Text'; + +import { TitleStandard } from './TitleStandard'; +import type { TitleStandardProps } from './TitleStandard.types'; + +const meta: Meta = { + title: 'Components/TitleStandard', + component: TitleStandard, + argTypes: { + title: { control: 'text' }, + topLabel: { control: 'text' }, + bottomLabel: { control: 'text' }, + twClassName: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + topLabel: 'Send', + title: '$4.42', + }, + render: (args) => { + const tw = useTailwind(); + return ( + + + + ); + }, +}; + +export const Title: Story = { + args: { + title: '$1,234.56', + }, + render: (args) => { + const tw = useTailwind(); + return ( + + + + ); + }, +}; + +export const TopLabel: Story = { + args: { + topLabel: 'Total Balance', + title: '$5,432.10', + }, + render: (args) => { + const tw = useTailwind(); + return ( + + + + ); + }, +}; + +export const BottomLabel: Story = { + args: { + topLabel: 'Send', + title: '$4.42', + bottomLabel: '0.002 ETH', + }, + render: (args) => { + const tw = useTailwind(); + return ( + + + + ); + }, +}; + +export const TitleAccessory: Story = { + render: () => { + const tw = useTailwind(); + return ( + + + + + } + /> + + ); + }, +}; + +export const TopAccessory: Story = { + render: (args) => { + const tw = useTailwind(); + return ( + + + } + textProps={{ variant: TextVariant.BodySm }} + > + Sending to + + } + title="0x1234...5678" + /> + + ); + }, +}; + +export const BottomAccessory: Story = { + render: (args) => { + const tw = useTailwind(); + return ( + + } + textProps={{ variant: TextVariant.BodySm }} + > + ~$0.50 fee + + } + /> + + ); + }, +}; + +export const FullExample: Story = { + render: (args) => { + const tw = useTailwind(); + return ( + + + + + } + bottomLabel="0.002 ETH" + /> + + ); + }, +}; diff --git a/packages/design-system-react-native/src/components/TitleStandard/TitleStandard.test.tsx b/packages/design-system-react-native/src/components/TitleStandard/TitleStandard.test.tsx new file mode 100644 index 000000000..2b13c683f --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleStandard/TitleStandard.test.tsx @@ -0,0 +1,158 @@ +// Third party dependencies. +import { render } from '@testing-library/react-native'; +import React from 'react'; +import { Text } from 'react-native'; + +// Internal dependencies. +import { TitleStandard } from './TitleStandard'; + +const TEST_IDS = { + CONTAINER: 'title-standard-container', + TITLE: 'title-standard-title', + TOP_LABEL: 'title-standard-top-label', + BOTTOM_LABEL: 'title-standard-bottom-label', +}; + +describe('TitleStandard', () => { + describe('rendering', () => { + it('renders with title', () => { + const { getByText } = render(); + + expect(getByText('$4.42')).toBeOnTheScreen(); + }); + + it('renders container with testID when provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.CONTAINER)).toBeOnTheScreen(); + }); + + it('renders title with testID when provided via titleProps', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.TITLE)).toBeOnTheScreen(); + }); + }); + + describe('topLabel and topAccessory', () => { + it('renders topLabel', () => { + const { getByText } = render( + , + ); + + expect(getByText('Send')).toBeOnTheScreen(); + }); + + it('renders topLabel with testID when provided via topLabelProps', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.TOP_LABEL)).toBeOnTheScreen(); + }); + + it('renders topAccessory when no topLabel', () => { + const { getByText } = render( + Custom Top} />, + ); + + expect(getByText('Custom Top')).toBeOnTheScreen(); + }); + + it('topLabel takes priority over topAccessory', () => { + const { getByText, queryByText } = render( + Accessory} + />, + ); + + expect(getByText('Label Priority')).toBeOnTheScreen(); + expect(queryByText('Accessory')).toBeNull(); + }); + }); + + describe('bottomLabel and bottomAccessory', () => { + it('renders bottomLabel', () => { + const { getByText } = render( + , + ); + + expect(getByText('0.002 ETH')).toBeOnTheScreen(); + }); + + it('renders bottomLabel with testID when provided via bottomLabelProps', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.BOTTOM_LABEL)).toBeOnTheScreen(); + }); + + it('renders bottomAccessory when no bottomLabel', () => { + const { getByText } = render( + Custom Bottom} + />, + ); + + expect(getByText('Custom Bottom')).toBeOnTheScreen(); + }); + + it('bottomLabel takes priority over bottomAccessory', () => { + const { getByText, queryByText } = render( + Accessory} + />, + ); + + expect(getByText('Label Priority')).toBeOnTheScreen(); + expect(queryByText('Accessory')).toBeNull(); + }); + }); + + describe('titleAccessory', () => { + it('renders titleAccessory next to title', () => { + const { getByText } = render( + Info} />, + ); + + expect(getByText('$4.42')).toBeOnTheScreen(); + expect(getByText('Info')).toBeOnTheScreen(); + }); + }); + + describe('full component', () => { + it('renders all elements together', () => { + const { getByText } = render( + i} + bottomLabel="0.002 ETH" + />, + ); + + expect(getByText('Send')).toBeOnTheScreen(); + expect(getByText('$4.42')).toBeOnTheScreen(); + expect(getByText('i')).toBeOnTheScreen(); + expect(getByText('0.002 ETH')).toBeOnTheScreen(); + }); + }); +}); diff --git a/packages/design-system-react-native/src/components/TitleStandard/TitleStandard.tsx b/packages/design-system-react-native/src/components/TitleStandard/TitleStandard.tsx new file mode 100644 index 000000000..69195bc1d --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleStandard/TitleStandard.tsx @@ -0,0 +1,76 @@ +// Third party dependencies. +import React from 'react'; + +// External dependencies. +import { Box } from '../Box'; +import { BoxHorizontal } from '../BoxHorizontal'; +import { FontWeight, Text, TextColor, TextVariant } from '../Text'; + +// Internal dependencies. +import type { TitleStandardProps } from './TitleStandard.types'; + +export const TitleStandard = ({ + title, + titleAccessory, + topAccessory, + topLabel, + bottomAccessory, + bottomLabel, + titleProps, + topLabelProps, + bottomLabelProps, + testID, + twClassName = '', +}: TitleStandardProps) => { + const hasTopContent = topAccessory || topLabel; + const hasBottomContent = bottomAccessory || bottomLabel; + + return ( + + {hasTopContent && ( + + {topLabel ? ( + + {topLabel} + + ) : ( + topAccessory + )} + + )} + + + {title} + + + {hasBottomContent && ( + + {bottomLabel ? ( + + {bottomLabel} + + ) : ( + bottomAccessory + )} + + )} + + ); +}; + +TitleStandard.displayName = 'TitleStandard'; diff --git a/packages/design-system-react-native/src/components/TitleStandard/TitleStandard.types.ts b/packages/design-system-react-native/src/components/TitleStandard/TitleStandard.types.ts new file mode 100644 index 000000000..c720e2255 --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleStandard/TitleStandard.types.ts @@ -0,0 +1,30 @@ +// External dependencies. +import type { TitleStandardPropsShared } from '@metamask/design-system-shared'; + +import type { TextProps } from '../Text'; + +/** + * TitleStandard component props. + */ +export type TitleStandardProps = TitleStandardPropsShared & { + /** + * Optional props to pass to the title Text component. + */ + titleProps?: Partial; + /** + * Optional props to pass to the topLabel Text component. + */ + topLabelProps?: Partial; + /** + * Optional props to pass to the bottomLabel Text component. + */ + bottomLabelProps?: Partial; + /** + * Optional test ID for the component. + */ + testID?: string; + /** + * Optional Tailwind class name to apply to the container. + */ + twClassName?: string; +}; diff --git a/packages/design-system-react-native/src/components/TitleStandard/index.ts b/packages/design-system-react-native/src/components/TitleStandard/index.ts new file mode 100644 index 000000000..422cfee30 --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleStandard/index.ts @@ -0,0 +1,3 @@ +export type { TitleStandardPropsShared } from '@metamask/design-system-shared'; +export { TitleStandard } from './TitleStandard'; +export type { TitleStandardProps } from './TitleStandard.types'; diff --git a/packages/design-system-react-native/src/components/TitleSubpage/README.md b/packages/design-system-react-native/src/components/TitleSubpage/README.md new file mode 100644 index 000000000..88effab10 --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleSubpage/README.md @@ -0,0 +1,101 @@ +# TitleSubpage + +TitleSubpage displays a title with optional accessories: an optional `startAccessory` to the left, a heading title with optional inline `titleAccessory`, and optional bottom label or `bottomAccessory`. + +```tsx +import { TitleSubpage } from '@metamask/design-system-react-native'; + +} + title="Token Name" + bottomLabel="$1,234.56" +/>; +``` + +## Props + +### `title` + +Main title text, rendered with `TextVariant.HeadingMd`. + +| TYPE | REQUIRED | DEFAULT | +| -------- | -------- | ----------- | +| `string` | No | `undefined` | + +### `titleAccessory` + +Optional node rendered to the right of the title (for example an info icon). + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +### `startAccessory` + +Optional node rendered to the left of the title and bottom content, vertically aligned with the text block. The root row uses gap spacing between the start column and the main column. + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +### `bottomLabel` / `bottomAccessory` + +Content below the title row. When `bottomLabel` is set, it is shown as body small alternative text and takes priority over `bottomAccessory`. + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `string` | No | `undefined` | +| `ReactNode` | No | `undefined` | + +### `titleProps` / `bottomLabelProps` + +Optional props merged into the corresponding [Text](../Text/README.md) nodes. + +| TYPE | REQUIRED | DEFAULT | +| -------------------- | -------- | ----------- | +| `Partial` | No | `undefined` | + +### `testID` + +Optional test ID for the root container. + +| TYPE | REQUIRED | DEFAULT | +| -------- | -------- | ----------- | +| `string` | No | `undefined` | + +### `twClassName` + +Optional Tailwind classes for the root container (merged with defaults). + +| TYPE | REQUIRED | DEFAULT | +| -------- | -------- | ----------- | +| `string` | No | `undefined` | + +## Usage + +```tsx +import { TitleSubpage } from '@metamask/design-system-react-native'; + + + +} + title="Ethereum" + bottomLabel="$1,234.56" +/> + +} + bottomLabel="Subtitle" +/> +``` + +## Accessibility + +- Prefer meaningful `title` and `bottomLabel` strings so screen readers announce the full context. +- Pass `testID` and `titleProps` / `bottomLabelProps` (including `accessibilityLabel` where needed) for tests or custom labels. + +## References + +[MetaMask Design System Guides](https://www.notion.so/MetaMask-Design-System-Guides-Design-f86ecc914d6b4eb6873a122b83c12940) diff --git a/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.stories.tsx b/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.stories.tsx new file mode 100644 index 000000000..00908e3aa --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.stories.tsx @@ -0,0 +1,145 @@ +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import type { Meta, StoryObj } from '@storybook/react-native'; +import React from 'react'; +import { View } from 'react-native'; + +import { AvatarToken, AvatarTokenSize } from '../AvatarToken'; +import { SAMPLE_AVATARTOKEN_URIS } from '../AvatarToken/AvatarToken.dev'; +import { Box } from '../Box'; +import { BoxHorizontal } from '../BoxHorizontal'; +import { Icon, IconColor, IconName, IconSize } from '../Icon'; +import { TextVariant } from '../Text'; + +import { TitleSubpage } from './TitleSubpage'; +import type { TitleSubpageProps } from './TitleSubpage.types'; + +const meta: Meta = { + title: 'Components/TitleSubpage', + component: TitleSubpage, + argTypes: { + title: { control: 'text' }, + bottomLabel: { control: 'text' }, + twClassName: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: 'Token Name', + bottomLabel: '$1,234.56', + }, + render: (args) => { + const tw = useTailwind(); + return ( + + + + ); + }, +}; + +export const Title: Story = { + args: { + title: 'Token Name', + }, + render: (args) => { + const tw = useTailwind(); + return ( + + + + ); + }, +}; + +export const StartAccessory: Story = { + render: () => { + const tw = useTailwind(); + return ( + + + } + title="Wrapped Ethereum" + bottomLabel="$3,456.78" + /> + + ); + }, +}; + +export const TitleAccessory: Story = { + render: () => { + const tw = useTailwind(); + return ( + + + + + } + bottomLabel="$1,234.56" + /> + + ); + }, +}; + +export const BottomAccessory: Story = { + render: () => { + const tw = useTailwind(); + return ( + + } + textProps={{ variant: TextVariant.BodySm }} + > + ~$0.50 fee + + } + /> + + ); + }, +}; + +export const FullExample: Story = { + render: () => { + const tw = useTailwind(); + return ( + + + } + title="Wrapped Ethereum" + titleAccessory={ + + + + } + bottomLabel="$3,456.78" + /> + + ); + }, +}; diff --git a/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.test.tsx b/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.test.tsx new file mode 100644 index 000000000..4ff5d07e5 --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.test.tsx @@ -0,0 +1,142 @@ +// Third party dependencies. +import { render } from '@testing-library/react-native'; +import React from 'react'; +import { Text } from 'react-native'; + +// Internal dependencies. +import { TitleSubpage } from './TitleSubpage'; + +const TEST_IDS = { + CONTAINER: 'title-subpage-container', + TITLE: 'title-subpage-title', + BOTTOM_LABEL: 'title-subpage-bottom-label', +}; + +describe('TitleSubpage', () => { + describe('rendering', () => { + it('renders with title', () => { + const { getByText } = render(); + + expect(getByText('Token Name')).toBeOnTheScreen(); + }); + + it('renders container with testID when provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.CONTAINER)).toBeOnTheScreen(); + }); + + it('renders title with testID when provided via titleProps', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.TITLE)).toBeOnTheScreen(); + }); + }); + + describe('startAccessory', () => { + it('renders startAccessory when provided', () => { + const { getByText } = render( + Avatar} + />, + ); + + expect(getByText('Avatar')).toBeOnTheScreen(); + }); + + it('renders startAccessory alongside title', () => { + const { getByText } = render( + Avatar} + />, + ); + + expect(getByText('Avatar')).toBeOnTheScreen(); + expect(getByText('Token Name')).toBeOnTheScreen(); + }); + }); + + describe('bottomLabel and bottomAccessory', () => { + it('renders bottomLabel', () => { + const { getByText } = render( + , + ); + + expect(getByText('$1,234.56')).toBeOnTheScreen(); + }); + + it('renders bottomLabel with testID when provided via bottomLabelProps', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.BOTTOM_LABEL)).toBeOnTheScreen(); + }); + + it('renders bottomAccessory when no bottomLabel', () => { + const { getByText } = render( + Custom Bottom} + />, + ); + + expect(getByText('Custom Bottom')).toBeOnTheScreen(); + }); + + it('bottomLabel takes priority over bottomAccessory', () => { + const { getByText, queryByText } = render( + Accessory} + />, + ); + + expect(getByText('Label Priority')).toBeOnTheScreen(); + expect(queryByText('Accessory')).toBeNull(); + }); + }); + + describe('titleAccessory', () => { + it('renders titleAccessory next to title', () => { + const { getByText } = render( + Info} />, + ); + + expect(getByText('Token Name')).toBeOnTheScreen(); + expect(getByText('Info')).toBeOnTheScreen(); + }); + }); + + describe('full component', () => { + it('renders all elements together', () => { + const { getByText } = render( + Avatar} + title="Token Name" + titleAccessory={i} + bottomLabel="$1,234.56" + />, + ); + + expect(getByText('Avatar')).toBeOnTheScreen(); + expect(getByText('Token Name')).toBeOnTheScreen(); + expect(getByText('i')).toBeOnTheScreen(); + expect(getByText('$1,234.56')).toBeOnTheScreen(); + }); + }); +}); diff --git a/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.tsx b/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.tsx new file mode 100644 index 000000000..d651fe2b6 --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.tsx @@ -0,0 +1,62 @@ +// Third party dependencies. +import React from 'react'; + +// External dependencies. +import { Box, BoxAlignItems, BoxFlexDirection } from '../Box'; +import { BoxHorizontal } from '../BoxHorizontal'; +import { FontWeight, Text, TextColor, TextVariant } from '../Text'; + +// Internal dependencies. +import type { TitleSubpageProps } from './TitleSubpage.types'; + +export const TitleSubpage = ({ + title, + titleAccessory, + startAccessory, + bottomAccessory, + bottomLabel, + titleProps, + bottomLabelProps, + testID, + twClassName = '', +}: TitleSubpageProps) => ( + + {startAccessory} + + + + {title} + + + {(bottomAccessory || bottomLabel) && ( + + {bottomLabel ? ( + + {bottomLabel} + + ) : ( + bottomAccessory + )} + + )} + + +); + +TitleSubpage.displayName = 'TitleSubpage'; diff --git a/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.types.ts b/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.types.ts new file mode 100644 index 000000000..242500c60 --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.types.ts @@ -0,0 +1,26 @@ +// External dependencies. +import type { TitleSubpagePropsShared } from '@metamask/design-system-shared'; + +import type { TextProps } from '../Text'; + +/** + * TitleSubpage component props. + */ +export type TitleSubpageProps = TitleSubpagePropsShared & { + /** + * Optional props to pass to the title Text component. + */ + titleProps?: Partial; + /** + * Optional props to pass to the bottomLabel Text component. + */ + bottomLabelProps?: Partial; + /** + * Optional test ID for the component. + */ + testID?: string; + /** + * Optional Tailwind class name to apply to the container. + */ + twClassName?: string; +}; diff --git a/packages/design-system-react-native/src/components/TitleSubpage/index.ts b/packages/design-system-react-native/src/components/TitleSubpage/index.ts new file mode 100644 index 000000000..d4da6b2dd --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleSubpage/index.ts @@ -0,0 +1,3 @@ +export type { TitleSubpagePropsShared } from '@metamask/design-system-shared'; +export { TitleSubpage } from './TitleSubpage'; +export type { TitleSubpageProps } from './TitleSubpage.types'; diff --git a/packages/design-system-react-native/src/components/index.ts b/packages/design-system-react-native/src/components/index.ts index 86b5ba498..c37329340 100644 --- a/packages/design-system-react-native/src/components/index.ts +++ b/packages/design-system-react-native/src/components/index.ts @@ -127,6 +127,18 @@ export type { CheckboxProps } from './Checkbox'; export { HeaderBase, HeaderBaseVariant } from './HeaderBase'; export type { HeaderBaseProps } from './HeaderBase'; +export { default as HeaderStandard } from './HeaderStandard'; +export type { HeaderStandardProps } from './HeaderStandard'; + +export { + HeaderStandardAnimated, + useHeaderStandardAnimated, +} from './HeaderStandardAnimated'; +export type { + HeaderStandardAnimatedProps, + UseHeaderStandardAnimatedReturn, +} from './HeaderStandardAnimated'; + export { Icon, IconColor, IconName, IconSize } from './Icon'; export type { IconProps } from './Icon'; @@ -176,6 +188,21 @@ export type { TextProps } from './Text'; export { TextField } from './TextField'; export type { TextFieldProps } from './TextField'; +export { TextFieldSearch } from './TextFieldSearch'; +export type { TextFieldSearchProps } from './TextFieldSearch'; + +export { TitleStandard } from './TitleStandard'; +export type { + TitleStandardProps, + TitleStandardPropsShared, +} from './TitleStandard'; + +export { TitleSubpage } from './TitleSubpage'; +export type { + TitleSubpageProps, + TitleSubpagePropsShared, +} from './TitleSubpage'; + export { TextOrChildren } from './temp-components/TextOrChildren'; export type { TextOrChildrenProps } from './temp-components/TextOrChildren'; diff --git a/packages/design-system-shared/src/index.ts b/packages/design-system-shared/src/index.ts index 3123fced0..e9b267762 100644 --- a/packages/design-system-shared/src/index.ts +++ b/packages/design-system-shared/src/index.ts @@ -22,6 +22,12 @@ export { type BannerBasePropsShared } from './types/BannerBase'; // TextOrChildren types (ADR-0004) export { type TextOrChildrenPropsShared } from './types/TextOrChildren'; +// TitleStandard types (ADR-0004) +export { type TitleStandardPropsShared } from './types/TitleStandard'; + +// TitleSubpage types (ADR-0004) +export { type TitleSubpagePropsShared } from './types/TitleSubpage'; + // BoxHorizontal types (ADR-0004) export { type BoxHorizontalPropsShared } from './types/BoxHorizontal'; diff --git a/packages/design-system-shared/src/types/TitleStandard/TitleStandard.types.ts b/packages/design-system-shared/src/types/TitleStandard/TitleStandard.types.ts new file mode 100644 index 000000000..0ad786427 --- /dev/null +++ b/packages/design-system-shared/src/types/TitleStandard/TitleStandard.types.ts @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react'; + +/** + * TitleStandard component shared props (ADR-0004). + * Platform-independent properties shared across React and React Native implementations. + */ +export type TitleStandardPropsShared = { + /** + * Main title text. The React Native implementation renders this with TextVariant.HeadingLg. + */ + title?: string; + /** + * Optional accessory rendered inline to the right of the title. + */ + titleAccessory?: ReactNode; + /** + * Optional accessory rendered in its own row above the title. + * If topLabel is provided, topLabel takes priority. + */ + topAccessory?: ReactNode; + /** + * Optional label rendered above the title. Takes priority over topAccessory. + */ + topLabel?: string; + /** + * Optional accessory rendered below the title. + * If bottomLabel is provided, bottomLabel takes priority. + */ + bottomAccessory?: ReactNode; + /** + * Optional label rendered below the title. Takes priority over bottomAccessory. + */ + bottomLabel?: string; +}; diff --git a/packages/design-system-shared/src/types/TitleStandard/index.ts b/packages/design-system-shared/src/types/TitleStandard/index.ts new file mode 100644 index 000000000..25c27b58a --- /dev/null +++ b/packages/design-system-shared/src/types/TitleStandard/index.ts @@ -0,0 +1 @@ +export type { TitleStandardPropsShared } from './TitleStandard.types'; diff --git a/packages/design-system-shared/src/types/TitleSubpage/TitleSubpage.types.ts b/packages/design-system-shared/src/types/TitleSubpage/TitleSubpage.types.ts new file mode 100644 index 000000000..338b916ac --- /dev/null +++ b/packages/design-system-shared/src/types/TitleSubpage/TitleSubpage.types.ts @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react'; + +/** + * TitleSubpage component shared props (ADR-0004). + * Platform-independent properties shared across React and React Native implementations. + */ +export type TitleSubpagePropsShared = { + /** + * Main title text. The React Native implementation renders this with TextVariant.HeadingMd. + */ + title?: string; + /** + * Optional accessory rendered inline to the right of the title. + */ + titleAccessory?: ReactNode; + /** + * Optional accessory rendered to the left of the title and bottom content. + */ + startAccessory?: ReactNode; + /** + * Optional accessory rendered below the title. + * If bottomLabel is provided, bottomLabel takes priority. + */ + bottomAccessory?: ReactNode; + /** + * Optional label rendered below the title. Takes priority over bottomAccessory. + */ + bottomLabel?: string; +}; diff --git a/packages/design-system-shared/src/types/TitleSubpage/index.ts b/packages/design-system-shared/src/types/TitleSubpage/index.ts new file mode 100644 index 000000000..e8cd6b321 --- /dev/null +++ b/packages/design-system-shared/src/types/TitleSubpage/index.ts @@ -0,0 +1 @@ +export type { TitleSubpagePropsShared } from './TitleSubpage.types';