diff --git a/apps/storybook-react-native/.storybook/storybook.requires.js b/apps/storybook-react-native/.storybook/storybook.requires.js index 599589f3e..405be4a2d 100644 --- a/apps/storybook-react-native/.storybook/storybook.requires.js +++ b/apps/storybook-react-native/.storybook/storybook.requires.js @@ -115,6 +115,7 @@ const getStories = () => { "./../../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/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/TitleSubpage/README.md b/packages/design-system-react-native/src/components/TitleSubpage/README.md new file mode 100644 index 000000000..2c52f37a7 --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleSubpage/README.md @@ -0,0 +1,444 @@ +# TitleSubpage + +TitleSubpage lays out a required identity block (leading `titleAvatar` beside a title stack), an optional subtitle, an optional amount row, optional bottom rows, and optional inline accessories per row. On React Native, `titleAvatar` is rendered in a 40×40 centered slot as the `startAccessory` of the identity `BoxRow`. + +```tsx +import { TitleSubpage, Box } from '@metamask/design-system-react-native'; + + + } + title="Send" +/>; +``` + +## Props + +### `titleAvatar` + +Leading visual for the identity row (required). Rendered in a 40×40 box with content centered horizontally and vertically, as the `startAccessory` of the identity `BoxRow`. Typically pass an avatar or token component (for example `AvatarToken`). + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ------- | +| `ReactNode` | Yes | — | + +```tsx +import { TitleSubpage, Box } from '@metamask/design-system-react-native'; + + + } + title="Send" + amount="$4.42" +/>; +``` + +### `title` + +Title row (required). When `title` is a string, it uses `TextVariant.HeadingSm` and `TextColor.TextDefault` (merged with `titleProps`). Pass a `ReactNode` for custom layout. The row also renders when only `titleEndAccessory` is renderable (for example `title={false}` with an end accessory). + +Legacy `TitleStandard` `topLabel` maps to `title` on `TitleSubpage`. The old main-line value (large amount) maps to `amount`, not `title`. + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ------- | +| `ReactNode` | Yes | — | + +```tsx +import { TitleSubpage, Box } from '@metamask/design-system-react-native'; + + + } + title="Send" + amount="$4.42" + bottomLabel="0.002 ETH" +/>; +``` + +### `titleEndAccessory` + +Optional node to the right of `title` in the title row (same pattern as `amountEndAccessory`). + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { + TitleSubpage, + Box, + Icon, + IconName, + IconSize, +} from '@metamask/design-system-react-native'; + + + } + title="Send" + titleEndAccessory={ + + + + } + amount="$4.42" +/>; +``` + +### `subtitle` + +Optional subtitle row between the title and the amount. When `subtitle` is a string, it uses `TextVariant.BodySm`, medium weight, and `TextColor.TextAlternative` (merged with `subtitleProps`). Pass a `ReactNode` for custom layout. The row also renders when only `subtitleEndAccessory` is renderable (for example `subtitle={false}` with an end accessory). + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { TitleSubpage, Box } from '@metamask/design-system-react-native'; + + + } + title="Send" + subtitle="Account 1" + amount="$4.42" +/>; +``` + +### `subtitleEndAccessory` + +Optional node to the right of `subtitle` in the subtitle row (same pattern as `titleEndAccessory`). + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { + TitleSubpage, + Box, + Icon, + IconName, + IconSize, +} from '@metamask/design-system-react-native'; + + + } + title="Send" + subtitle="Account 1" + subtitleEndAccessory={ + + + + } + amount="$4.42" +/>; +``` + +### `amount` + +Optional primary amount line below the title and optional subtitle. The amount row renders when `amount` or `amountEndAccessory` is renderable. When `amount` is a string, it is wrapped with display typography (`TextVariant.DisplayLg` and `amountProps`); other `ReactNode` values render as provided. + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { TitleSubpage, Box } from '@metamask/design-system-react-native'; + + + } + title="Balance" + amount="$1,234.56" +/>; +``` + +### `amountEndAccessory` + +Optional node rendered to the right of the amount (for example an info icon). + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { + TitleSubpage, + Box, + Icon, + IconName, + IconSize, +} from '@metamask/design-system-react-native'; + + + } + title="Send" + amount="$4.42" + amountEndAccessory={ + + + + } +/>; +``` + +### `bottomLabel` + +Optional bottom label row. When `bottomLabel` is a string, it uses `TextVariant.BodySm`, medium weight, and `TextColor.TextAlternative` (merged with `bottomLabelProps`). If `bottomLabel` or `bottomLabelEndAccessory` is renderable, that row is shown instead of `bottomAccessory`. + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { TitleSubpage, Box } from '@metamask/design-system-react-native'; + + + } + title="Send" + amount="$4.42" + bottomLabel="0.002 ETH" +/>; +``` + +### `bottomLabelEndAccessory` + +Optional node to the right of `bottomLabel` in the bottom label row (for example a `Text` label or an icon). + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { + TitleSubpage, + Box, + Text, + TextColor, + FontWeight, + TextVariant, +} from '@metamask/design-system-react-native'; + + + } + title="USD Coin" + subtitle="USDC" + amount="$1.0001" + bottomLabel="+$0.000126 (+0.01%)" + bottomLabelEndAccessory={ + + Today + + } + bottomLabelProps={{ color: TextColor.SuccessDefault }} +/>; +``` + +### `bottomAccessory` + +Optional custom bottom row when neither `bottomLabel` nor `bottomLabelEndAccessory` is renderable. Renders without default label typography; compose layout inside the node. + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { + TitleSubpage, + Box, + BoxFlexDirection, + BoxAlignItems, + Icon, + IconName, + IconSize, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; + + + } + title="Send" + amount="$4.42" + bottomAccessory={ + + + ~$0.50 fee + + } +/>; +``` + +### `amountProps` + +Optional props merged into the amount `Text` when `amount` is a string. Use for `testID` or typography overrides. + +| TYPE | REQUIRED | DEFAULT | +| -------------------- | -------- | ----------- | +| `Partial` | No | `undefined` | + +```tsx +import { TitleSubpage, Box } from '@metamask/design-system-react-native'; + + + } + title="Send" + amount="$4.42" + amountProps={{ testID: 'title-subpage-amount' }} +/>; +``` + +### `titleProps` + +Optional props merged into the title row `Text` when `title` is a string. + +| TYPE | REQUIRED | DEFAULT | +| -------------------- | -------- | ----------- | +| `Partial` | No | `undefined` | + +```tsx +import { TitleSubpage, Box } from '@metamask/design-system-react-native'; + + + } + title="Send" + titleProps={{ testID: 'title-subpage-title' }} + amount="$4.42" +/>; +``` + +### `subtitleProps` + +Optional props merged into the subtitle row `Text` when `subtitle` is a string. + +| TYPE | REQUIRED | DEFAULT | +| -------------------- | -------- | ----------- | +| `Partial` | No | `undefined` | + +```tsx +import { TitleSubpage, Box } from '@metamask/design-system-react-native'; + + + } + title="Send" + subtitle="Account 1" + subtitleProps={{ testID: 'title-subpage-subtitle' }} + amount="$4.42" +/>; +``` + +### `bottomLabelProps` + +Optional props merged into the bottom label `Text` when `bottomLabel` is a string. + +| TYPE | REQUIRED | DEFAULT | +| -------------------- | -------- | ----------- | +| `Partial` | No | `undefined` | + +```tsx +import { TitleSubpage, Box } from '@metamask/design-system-react-native'; + + + } + title="Send" + amount="$4.42" + bottomLabel="0.002 ETH" + bottomLabelProps={{ testID: 'title-subpage-bottom' }} +/>; +``` + +### `twClassName` + +Use the `twClassName` prop to add Tailwind CSS classes to the component. These classes will be merged with the component's default classes using `twMerge`, allowing you to: + +- Add new styles that don't exist in the default component +- Override the component's default styles when needed + +| TYPE | REQUIRED | DEFAULT | +| -------- | -------- | ----------- | +| `string` | No | `undefined` | + +```tsx +import { TitleSubpage, Box } from '@metamask/design-system-react-native'; + +// Add additional styles +} + twClassName="mt-4" + title="Send" + amount="$4.42" +/> + +// Override default styles +} + twClassName="px-6" + title="Send" + amount="$4.42" +/> +``` + +### `style` + +Use the `style` prop to customize the component's appearance with React Native styles. For consistent styling, prefer using `twClassName` with Tailwind classes when possible. Use `style` with `tw.style()` for conditionals or dynamic values. + +| TYPE | REQUIRED | DEFAULT | +| ---------------------- | -------- | ----------- | +| `StyleProp` | No | `undefined` | + +```tsx +import { useTailwind } from '@metamask/design-system-twrnc-preset'; + +import { TitleSubpage, Box } from '@metamask/design-system-react-native'; + +export const ConditionalExample = ({ isActive }: { isActive: boolean }) => { + const tw = useTailwind(); + + return ( + + } + title="Send" + amount="$4.42" + style={tw.style('opacity-90', isActive && 'opacity-100')} + /> + ); +}; +``` + +## 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..77be92eff --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.stories.tsx @@ -0,0 +1,235 @@ +import type { Meta, StoryObj } from '@storybook/react-native'; +import React from 'react'; + +import UsdcSVG from '../../assets/token-icons/usdc.svg'; +import { AvatarToken, AvatarTokenSize } from '../AvatarToken'; +import { Box, BoxAlignItems, BoxFlexDirection } from '../Box'; +import { Icon, IconName, IconSize, IconColor } from '../Icon'; +import { Text, TextColor, FontWeight, TextVariant } from '../Text'; + +import { TitleSubpage } from './TitleSubpage'; +import type { TitleSubpageProps } from './TitleSubpage.types'; + +/** + * Token avatar for stories using bundled USDC artwork. + * + * @returns The USDC `AvatarToken` for story defaults. + */ +const StoryTitleAvatar = () => ( + +); + +const USDC_TITLE = 'USD Coin'; +const USDC_SUBTITLE = 'USDC'; +const USDC_AMOUNT = '$1.0001'; +const USDC_PRICE_CHANGE_BOTTOM_LABEL = '+$0.000126 (+0.01%)'; + +const TodayBottomLabelEndAccessory = () => ( + + Today + +); + +/** + * Pill badge: dot + label (e.g. network), for `titleEndAccessory`. + * TODO: Temporary until a Tag component exists. + * + * @returns Story-only testnet badge UI. + */ +const TestnetBadge = () => ( + + + + Testnet + + +); + +const meta: Meta = { + title: 'Components/TitleSubpage', + component: TitleSubpage, + args: { + titleAvatar: , + title: USDC_TITLE, + subtitle: USDC_SUBTITLE, + amount: USDC_AMOUNT, + twClassName: '', + }, + argTypes: { + title: { + control: 'text', + }, + amount: { + control: 'text', + }, + subtitle: { + control: 'text', + }, + bottomLabel: { + control: 'text', + }, + twClassName: { control: 'text' }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( + } + bottomLabelProps={{ color: TextColor.SuccessDefault }} + /> + ), +}; + +export const Amount: Story = { + render: () => ( + } + title={USDC_TITLE} + subtitle={USDC_SUBTITLE} + amount={USDC_AMOUNT} + /> + ), +}; + +export const AmountAccessory: Story = { + render: () => ( + } + title={USDC_TITLE} + subtitle={USDC_SUBTITLE} + amount={USDC_AMOUNT} + amountEndAccessory={ + + + + } + /> + ), +}; + +export const Title: Story = { + render: () => ( + } title={USDC_TITLE} /> + ), +}; + +export const TitleAccessory: Story = { + render: () => ( + } + title={USDC_TITLE} + titleEndAccessory={} + /> + ), +}; + +export const Subtitle: Story = { + render: () => ( + } + title={USDC_TITLE} + subtitle={USDC_SUBTITLE} + /> + ), +}; + +export const SubtitleAccessory: Story = { + render: () => ( + } + title={USDC_TITLE} + subtitle={USDC_SUBTITLE} + subtitleEndAccessory={ + + + + } + /> + ), +}; + +export const BottomLabel: Story = { + render: (args) => ( + + ), +}; + +export const BottomLabelAccessory: Story = { + render: () => ( + } + title={USDC_TITLE} + subtitle={USDC_SUBTITLE} + amount={USDC_AMOUNT} + bottomLabel={USDC_PRICE_CHANGE_BOTTOM_LABEL} + bottomLabelEndAccessory={} + bottomLabelProps={{ color: TextColor.SuccessDefault }} + /> + ), +}; + +export const BottomAccessory: Story = { + render: () => ( + } + title={USDC_TITLE} + subtitle={USDC_SUBTITLE} + amount={USDC_AMOUNT} + bottomAccessory={ + + + + Stablecoin prices can deviate from $1. Verify the asset and network + before you trade. + + + } + /> + ), +}; 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..1170a9d21 --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.test.tsx @@ -0,0 +1,461 @@ +// Third party dependencies. +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { renderHook } from '@testing-library/react-hooks'; +import { render } from '@testing-library/react-native'; +import React from 'react'; +import { Text } from 'react-native'; + +// Internal dependencies. +import { TitleSubpage } from './TitleSubpage'; + +const CONTAINER_TEST_ID = 'title-subpage-container'; +const AMOUNT_TEST_ID = 'title-subpage-amount'; +const TITLE_ROW_TEST_ID = 'title-subpage-title'; +const SUBTITLE_ROW_TEST_ID = 'title-subpage-subtitle'; +const BOTTOM_LABEL_TEST_ID = 'title-subpage-bottom-label'; +const TITLE_AVATAR_TEST_ID = 'title-subpage-title-avatar'; + +const defaultTitleAvatar = ; + +describe('TitleSubpage', () => { + let tw: ReturnType; + + beforeAll(() => { + tw = renderHook(() => useTailwind()).result.current; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders string title', () => { + const { getByText } = render( + , + ); + + expect(getByText('Section')).toBeOnTheScreen(); + }); + + it('renders titleAvatar in the identity row', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TITLE_AVATAR_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders string amount when provided', () => { + const { getByText } = render( + , + ); + + expect(getByText('Send')).toBeOnTheScreen(); + expect(getByText('$4.42')).toBeOnTheScreen(); + }); + + it('renders React node amount', () => { + const { getByTestId } = render( + Custom amount} + />, + ); + + expect(getByTestId('title-subpage-amount-node')).toBeOnTheScreen(); + }); + + it('renders container with testID when provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(CONTAINER_TEST_ID)).toBeOnTheScreen(); + }); + + it('forwards amountProps testID to amount Text when amount is a string', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(AMOUNT_TEST_ID)).toBeOnTheScreen(); + }); + }); + + describe('when title is provided', () => { + it('renders title and amount', () => { + const { getByText } = render( + Custom Top} + amount="$4.42" + />, + ); + + expect(getByText('Custom Top')).toBeOnTheScreen(); + expect(getByText('$4.42')).toBeOnTheScreen(); + }); + + it('renders title and titleEndAccessory', () => { + const { getByText } = render( + Title extra} + />, + ); + + expect(getByText('Step 1')).toBeOnTheScreen(); + expect(getByText('Title extra')).toBeOnTheScreen(); + }); + + it('forwards titleProps testID to title row Text when title is a string', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TITLE_ROW_TEST_ID)).toBeOnTheScreen(); + }); + }); + + describe('when title is false', () => { + it('does not render title node', () => { + const showTitle = false; + const { getByText, queryByTestId } = render( + Top + ) : ( + false + ) + } + amount="$4.42" + />, + ); + + expect(getByText('$4.42')).toBeOnTheScreen(); + expect(queryByTestId('title-subpage-title-slot')).not.toBeOnTheScreen(); + }); + }); + + describe('when titleEndAccessory is false', () => { + it('renders title only', () => { + const { getByText } = render( + , + ); + + expect(getByText('Hi')).toBeOnTheScreen(); + expect(getByText('$4.42')).toBeOnTheScreen(); + }); + }); + + describe('when subtitle is provided', () => { + it('renders string subtitle between title and amount', () => { + const { getByText } = render( + , + ); + + expect(getByText('Send')).toBeOnTheScreen(); + expect(getByText('Account 1')).toBeOnTheScreen(); + expect(getByText('$4.42')).toBeOnTheScreen(); + }); + + it('renders subtitle and subtitleEndAccessory', () => { + const { getByText } = render( + Sub extra} + />, + ); + + expect(getByText('Extra context')).toBeOnTheScreen(); + expect(getByText('Sub extra')).toBeOnTheScreen(); + }); + + it('forwards subtitleProps testID to subtitle row Text when subtitle is a string', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(SUBTITLE_ROW_TEST_ID)).toBeOnTheScreen(); + }); + }); + + describe('when subtitle is false', () => { + it('does not render subtitle node but shows accessory when subtitleEndAccessory is provided', () => { + const showSubtitle = false; + const { getByText, queryByTestId } = render( + Sub + ) : ( + false + ) + } + amount="$4.42" + subtitleEndAccessory={Only sub accessory} + />, + ); + + expect(getByText('Send')).toBeOnTheScreen(); + expect(getByText('$4.42')).toBeOnTheScreen(); + expect(getByText('Only sub accessory')).toBeOnTheScreen(); + expect( + queryByTestId('title-subpage-subtitle-slot'), + ).not.toBeOnTheScreen(); + }); + }); + + describe('when amount is false', () => { + it('does not render amount node', () => { + const showAmount = false; + const { getByText, queryByTestId } = render( + $1 + ) : ( + false + ) + } + />, + ); + + expect(getByText('Send')).toBeOnTheScreen(); + expect(queryByTestId('title-subpage-amount-slot')).not.toBeOnTheScreen(); + }); + }); + + describe('when bottomLabel is provided', () => { + it('renders bottomLabel text', () => { + const { getByText } = render( + , + ); + + expect(getByText('0.002 ETH')).toBeOnTheScreen(); + }); + + it('renders bottomLabel and bottomLabelEndAccessory', () => { + const { getByText } = render( + Fee info} + />, + ); + + expect(getByText('0.002 ETH')).toBeOnTheScreen(); + expect(getByText('Fee info')).toBeOnTheScreen(); + }); + + it('forwards bottomLabelProps testID to bottom label Text', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(BOTTOM_LABEL_TEST_ID)).toBeOnTheScreen(); + }); + }); + + describe('when bottomAccessory is provided', () => { + it('renders bottomAccessory when bottomLabel is omitted', () => { + const { getByText } = render( + Custom Bottom} + />, + ); + + expect(getByText('Custom Bottom')).toBeOnTheScreen(); + }); + }); + + describe('when bottomLabel and bottomAccessory are both provided', () => { + it('renders only bottomLabel', () => { + const { getByText, queryByText } = render( + Accessory} + />, + ); + + expect(getByText('Label Priority')).toBeOnTheScreen(); + expect(queryByText('Accessory')).not.toBeOnTheScreen(); + }); + }); + + describe('when only bottomLabelEndAccessory is provided', () => { + it('renders bottom label row with accessory and not bottomAccessory', () => { + const { getByText, queryByText } = render( + Only accessory} + bottomAccessory={Full row} + />, + ); + + expect(getByText('Only accessory')).toBeOnTheScreen(); + expect(queryByText('Full row')).not.toBeOnTheScreen(); + }); + }); + + describe('when amountEndAccessory is provided', () => { + it('renders amount and amountEndAccessory', () => { + const { getByText } = render( + Info} + />, + ); + + expect(getByText('$4.42')).toBeOnTheScreen(); + expect(getByText('Info')).toBeOnTheScreen(); + }); + + it('renders amountEndAccessory when amount is an empty string', () => { + const { getByText } = render( + Accessory only} + />, + ); + + expect(getByText('Accessory only')).toBeOnTheScreen(); + }); + }); + + describe('when amountEndAccessory is false', () => { + it('renders amount only', () => { + const { getByText } = render( + , + ); + + expect(getByText('$4.42')).toBeOnTheScreen(); + }); + }); + + describe('when title, amountEndAccessory, and bottomLabel are provided', () => { + it('renders all slots', () => { + const { getByText } = render( + Send} + amount="$4.42" + amountEndAccessory={i} + bottomLabel="0.002 ETH" + />, + ); + + expect(getByText('Send')).toBeOnTheScreen(); + expect(getByText('$4.42')).toBeOnTheScreen(); + expect(getByText('i')).toBeOnTheScreen(); + expect(getByText('0.002 ETH')).toBeOnTheScreen(); + }); + }); + + describe('style and twClassName', () => { + it('applies custom style to root container', () => { + const customStyle = { opacity: 0.5 }; + const { getByTestId } = render( + , + ); + + expect(getByTestId(CONTAINER_TEST_ID)).toHaveStyle(customStyle); + }); + + it('merges twClassName with base styles', () => { + const { getByTestId } = render( + , + ); + + const container = getByTestId(CONTAINER_TEST_ID); + + expect(container).toHaveStyle(tw`bg-default`); + }); + }); +}); 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..5ad21676c --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.tsx @@ -0,0 +1,171 @@ +// Third party dependencies. +import { isReactNodeRenderable } from '@metamask/design-system-shared'; +import React from 'react'; + +// Internal dependencies. +import { BoxAlignItems, BoxJustifyContent } from '../../types'; +import { Box } from '../Box'; +import { BoxRow } from '../BoxRow'; +import { TextVariant, TextColor, FontWeight } from '../Text'; + +import type { TitleSubpageProps } from './TitleSubpage.types'; + +/** + * Displays a required identity row (avatar + title stack) with optional subtitle, amount, inline accessories, and bottom rows in a left-aligned layout. + * Remaining `View` props are forwarded to the root `Box`. + * + * @param props - Component props + * @param props.title - Title row content (required) + * @param props.titleAvatar - Leading visual for the identity row (required); rendered in a 40×40 centered slot + * @param props.titleEndAccessory - Optional inline accessory to the right of `title` + * @param props.subtitle - Optional subtitle row below the title and above the amount + * @param props.subtitleEndAccessory - Optional inline accessory to the right of `subtitle` + * @param props.amount - Optional primary amount below the title + * @param props.amountEndAccessory - Optional inline accessory to the right of the amount + * @param props.bottomAccessory - Optional custom bottom row when the bottom label row is not shown + * @param props.bottomLabel - Optional secondary label below the amount row + * @param props.bottomLabelEndAccessory - Optional inline accessory to the right of `bottomLabel` + * @param props.titleProps - Optional props merged into title row `Text` when `title` is a string + * @param props.subtitleProps - Optional props merged into subtitle row `Text` when `subtitle` is a string + * @param props.amountProps - Optional props merged into amount `Text` when `amount` is a string + * @param props.bottomLabelProps - Optional props merged into bottom label `Text` when `bottomLabel` is a string + * @param props.twClassName - Optional Tailwind classes on the root container + * + * @returns The rendered TitleSubpage layout. + */ +export const TitleSubpage: React.FC = ({ + amount, + amountEndAccessory, + title, + titleAvatar, + titleEndAccessory, + subtitle, + subtitleEndAccessory, + bottomAccessory, + bottomLabel, + bottomLabelEndAccessory, + amountProps, + titleProps, + subtitleProps, + bottomLabelProps, + twClassName = '', + ...props +}) => { + const amountEndAccessoryNode = isReactNodeRenderable(amountEndAccessory) + ? amountEndAccessory + : undefined; + + const titleEndAccessoryNode = isReactNodeRenderable(titleEndAccessory) + ? titleEndAccessory + : undefined; + + const subtitleEndAccessoryNode = isReactNodeRenderable(subtitleEndAccessory) + ? subtitleEndAccessory + : undefined; + + const bottomLabelEndAccessoryNode = isReactNodeRenderable( + bottomLabelEndAccessory, + ) + ? bottomLabelEndAccessory + : undefined; + + const renderTitleRow = + isReactNodeRenderable(title) || isReactNodeRenderable(titleEndAccessory); + const renderSubtitleRow = + isReactNodeRenderable(subtitle) || + isReactNodeRenderable(subtitleEndAccessory); + const renderAmountRow = + isReactNodeRenderable(amount) || isReactNodeRenderable(amountEndAccessory); + const renderBottomLabelRow = + isReactNodeRenderable(bottomLabel) || + isReactNodeRenderable(bottomLabelEndAccessory); + const renderBottomAccessory = + !renderBottomLabelRow && isReactNodeRenderable(bottomAccessory); + + const titleRow = ( + + {title} + + ); + + const subtitleRow = ( + + {subtitle} + + ); + + const titleAvatarSlot = ( + + {titleAvatar} + + ); + + const identityRow = ( + + + {renderTitleRow ? titleRow : null} + {renderSubtitleRow ? subtitleRow : null} + + + ); + + const amountRow = ( + + {amount} + + ); + + const bottomLabelRow = ( + + {bottomLabel} + + ); + + return ( + + {identityRow} + {renderAmountRow ? amountRow : null} + {renderBottomLabelRow ? bottomLabelRow : null} + {renderBottomAccessory ? bottomAccessory : null} + + ); +}; + +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..add6a887e --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.types.ts @@ -0,0 +1,33 @@ +// Third party dependencies. +import type { TitleSubpagePropsShared } from '@metamask/design-system-shared'; +import type { ViewProps } from 'react-native'; + +// Internal dependencies. +import type { TextProps } from '../Text/Text.types'; + +/** + * TitleSubpage component props (React Native). + * Extends {@link TitleSubpagePropsShared} (requires `title` and `titleAvatar`) with platform `Text` passthroughs, `twClassName`, and `View` props. + */ +export type TitleSubpageProps = TitleSubpagePropsShared & { + /** + * Optional props merged into {@link BoxHorizontal} `textProps` when `amount` is a string. + */ + amountProps?: Partial; + /** + * Optional props merged into {@link BoxHorizontal} `textProps` when `title` is a string. + */ + titleProps?: Partial; + /** + * Optional props merged into {@link BoxHorizontal} `textProps` when `subtitle` is a string. + */ + subtitleProps?: Partial; + /** + * Optional props merged into {@link BoxHorizontal} `textProps` when `bottomLabel` is a string. + */ + bottomLabelProps?: Partial; + /** + * Optional Tailwind class name to apply to the container. + */ + twClassName?: string; +} & Omit; 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 e057985d2..bce3504cf 100644 --- a/packages/design-system-react-native/src/components/index.ts +++ b/packages/design-system-react-native/src/components/index.ts @@ -204,6 +204,12 @@ export type { TextFieldSearchProps } from './TextFieldSearch'; export { TextOrChildren } from './temp-components/TextOrChildren'; export type { TextOrChildrenProps } from './temp-components/TextOrChildren'; +export { TitleSubpage } from './TitleSubpage'; +export type { + TitleSubpageProps, + TitleSubpagePropsShared, +} from './TitleSubpage'; + export { Toast, ToastVariant, diff --git a/packages/design-system-shared/src/index.ts b/packages/design-system-shared/src/index.ts index c16cb2e07..daa746976 100644 --- a/packages/design-system-shared/src/index.ts +++ b/packages/design-system-shared/src/index.ts @@ -37,12 +37,15 @@ export { type BannerBasePropsShared } from './types/BannerBase'; // TextOrChildren types (ADR-0004) export { type TextOrChildrenPropsShared } from './types/TextOrChildren'; -// BoxRow types (ADR-0004) -export { type BoxRowPropsShared } from './types/BoxRow'; +// TitleSubpage types (ADR-0004) +export { type TitleSubpagePropsShared } from './types/TitleSubpage'; // BoxColumn types (ADR-0004) export { type BoxColumnPropsShared } from './types/BoxColumn'; +// BoxRow types (ADR-0004) +export { type BoxRowPropsShared } from './types/BoxRow'; + // HeaderSearch types (ADR-0003 + ADR-0004) export { HeaderSearchVariant, 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..35fe3174d --- /dev/null +++ b/packages/design-system-shared/src/types/TitleSubpage/TitleSubpage.types.ts @@ -0,0 +1,55 @@ +import type { ReactNode } from 'react'; + +/** + * TitleSubpage component shared props (ADR-0004). + * Platform-independent properties; platform packages extend with `ViewProps` / `className`, + * `twClassName`, and platform `Text` prop passthroughs. + */ +export type TitleSubpagePropsShared = { + /** + * Optional primary amount line below the title and optional subtitle (for example a fiat or token value). + * When a string, platforms typically wrap with large display styles via `textProps`. + * The amount row renders when `amount` or `amountEndAccessory` is renderable. + */ + amount?: ReactNode; + /** + * Optional accessory rendered inline to the right of the amount. + */ + amountEndAccessory?: ReactNode; + /** + * Title row above the optional amount (via platform `textProps` when a string). Required. + */ + title: ReactNode; + /** + * Leading visual for the identity row (for example an avatar). On React Native this is rendered + * in a 40×40 box, centered, as the `startAccessory` of the identity `BoxRow`. + */ + titleAvatar: ReactNode; + /** + * Optional accessory rendered inline to the right of `title` in the title row. + */ + titleEndAccessory?: ReactNode; + /** + * Optional subtitle row below the title and above the amount (via platform `textProps` when a string). + * The subtitle row renders when `subtitle` or `subtitleEndAccessory` is renderable. + */ + subtitle?: ReactNode; + /** + * Optional accessory rendered inline to the right of `subtitle` in the subtitle row. + */ + subtitleEndAccessory?: ReactNode; + /** + * Optional custom bottom row when neither `bottomLabel` nor `bottomLabelEndAccessory` is renderable. + * Mutually exclusive with the bottom label row: only one bottom row is shown. + */ + bottomAccessory?: ReactNode; + /** + * Optional bottom row with secondary label styling when a string (via platform `textProps`). + * If `bottomLabel` or `bottomLabelEndAccessory` is renderable, that row is shown instead of `bottomAccessory`. + */ + bottomLabel?: ReactNode; + /** + * Optional accessory rendered inline to the right of `bottomLabel` in the bottom label row. + */ + bottomLabelEndAccessory?: ReactNode; +}; 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';