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..033d2543a --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleSubpage/README.md @@ -0,0 +1,512 @@ +# 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 passed straight through as the identity `BoxRow` `startAccessory` (no fixed-size or `overflow: hidden` wrapper), so network badges and other overlays can extend past the token without being clipped. For the default layout, use `AvatarToken` at `AvatarTokenSize.Lg` (40×40) so the token aligns with the row; the component stays agnostic—you own composition when you need badges or other chrome. + +```tsx +import { + AvatarToken, + AvatarTokenSize, + TitleSubpage, +} from '@metamask/design-system-react-native'; + +} + title="Send" +/>; +``` + +## Props + +### `titleAvatar` + +Leading visual for the identity row (required). Passed as the `startAccessory` of the identity `BoxRow` with no inner layout wrapper. Prefer `AvatarToken` at `AvatarTokenSize.Lg` for the standard 40×40 footprint; wrap in `BadgeWrapper` (or similar) when you need a network badge or other element that should sit outside the token bounds. + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ------- | +| `ReactNode` | Yes | — | + +```tsx +import { + AvatarToken, + AvatarTokenSize, + TitleSubpage, +} from '@metamask/design-system-react-native'; + +} + title="Send" + amount="$4.42" +/>; +``` + +```tsx +import { + AvatarNetwork, + AvatarNetworkSize, + AvatarToken, + AvatarTokenSize, + BadgeWrapper, + BadgeWrapperPosition, + BadgeWrapperPositionAnchorShape, + TitleSubpage, +} from '@metamask/design-system-react-native'; + +// Supply `src` (or equivalent) for your token and network artwork. + + + } + position={BadgeWrapperPosition.BottomRight} + positionAnchorShape={BadgeWrapperPositionAnchorShape.Circular} + > + + + } + title="USD Coin" +/>; +``` + +### `title` + +Title row (required). The row renders when `title` is truthy. When `title` is a string, it uses `TextVariant.HeadingSm` and `TextColor.TextDefault` (merged with `titleProps`). Pass a `ReactNode` for custom layout. + +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 { + AvatarToken, + AvatarTokenSize, + TitleSubpage, +} 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`). Only renders when the title row is shown (truthy `title`). + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { + AvatarToken, + AvatarTokenSize, + Icon, + IconName, + IconSize, + TitleSubpage, +} from '@metamask/design-system-react-native'; + +} + title="Send" + titleEndAccessory={} + amount="$4.42" +/>; +``` + +### `subtitle` + +Optional subtitle row between the title and the amount. The row renders when `subtitle` is truthy. When `subtitle` is a string, it uses `TextVariant.BodySm`, medium weight, and `TextColor.TextAlternative` (merged with `subtitleProps`). Pass a `ReactNode` for custom layout. + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { + AvatarToken, + AvatarTokenSize, + TitleSubpage, +} 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`). Only renders when the subtitle row is shown (truthy `subtitle`). + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { + AvatarToken, + AvatarTokenSize, + Icon, + IconName, + IconSize, + TitleSubpage, +} 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` is truthy (for example a non-empty string or a `ReactNode`). Falsy values such as `false`, `null`, `undefined`, or `''` hide the row and do not render `amountEndAccessory`. 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 { + AvatarToken, + AvatarTokenSize, + TitleSubpage, +} 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). Only renders when the amount row is shown (truthy `amount`). + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { + AvatarToken, + AvatarTokenSize, + Icon, + IconName, + IconSize, + TitleSubpage, +} from '@metamask/design-system-react-native'; + +} + title="Send" + amount="$4.42" + amountEndAccessory={} +/>; +``` + +### `bottomLabel` + +Optional bottom label row. The row renders when `bottomLabel` is truthy; `bottomLabelEndAccessory` only appears on that row when `bottomLabel` is truthy. When `bottomLabel` is a string, it uses `TextVariant.BodySm`, medium weight, and `TextColor.TextAlternative` (merged with `bottomLabelProps`). When this row is shown, `bottomAccessory` is not rendered. + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { + AvatarToken, + AvatarTokenSize, + TitleSubpage, +} 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). The bottom label row only renders when `bottomLabel` is truthy, so this accessory does not appear on its own without `bottomLabel`. + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { + AvatarToken, + AvatarTokenSize, + FontWeight, + Text, + TextColor, + TextVariant, + TitleSubpage, +} 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 `bottomLabel` is omitted or not truthy (for example `false`, `null`, `undefined`, or `''`). Renders without default label typography; compose layout inside the node. + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { + AvatarToken, + AvatarTokenSize, + Box, + BoxAlignItems, + BoxFlexDirection, + Icon, + IconName, + IconSize, + Text, + TextVariant, + TitleSubpage, +} 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 | +| -------------------------------------- | -------- | ----------- | +| `Omit, 'children'>` | No | `undefined` | + +```tsx +import { + AvatarToken, + AvatarTokenSize, + TitleSubpage, +} 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 | +| -------------------------------------- | -------- | ----------- | +| `Omit, 'children'>` | No | `undefined` | + +```tsx +import { + AvatarToken, + AvatarTokenSize, + TitleSubpage, +} 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 | +| -------------------------------------- | -------- | ----------- | +| `Omit, 'children'>` | No | `undefined` | + +```tsx +import { + AvatarToken, + AvatarTokenSize, + TitleSubpage, +} 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 | +| -------------------------------------- | -------- | ----------- | +| `Omit, 'children'>` | No | `undefined` | + +```tsx +import { + AvatarToken, + AvatarTokenSize, + TitleSubpage, +} from '@metamask/design-system-react-native'; + +} + title="Send" + amount="$4.42" + bottomLabel="0.002 ETH" + bottomLabelProps={{ testID: 'title-subpage-bottom' }} +/>; +``` + +### `identityRowProps` + +Optional props spread onto the identity `BoxRow` after defaults. `children`, `startAccessory`, and `textProps` are reserved by the component. + +| TYPE | REQUIRED | DEFAULT | +| --------------------------------------------------------------------------- | -------- | ----------- | +| `Omit, 'children' \| 'startAccessory' \| 'textProps'>` | No | `undefined` | + +### `titleColumnProps` + +Optional props spread onto the title/subtitle column `Box`. `children` is reserved by the component. + +| TYPE | REQUIRED | DEFAULT | +| ------------------------------------- | -------- | ----------- | +| `Omit, 'children'>` | No | `undefined` | + +### `bottomLabelWrapperProps` + +Optional props spread onto the bottom label `BoxRow` after defaults. `children`, `endAccessory`, and `textProps` are reserved by the component. + +| TYPE | REQUIRED | DEFAULT | +| ------------------------------------------------------------------------- | -------- | ----------- | +| `Omit, 'children' \| 'endAccessory' \| 'textProps'>` | No | `undefined` | + +### `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 { + AvatarToken, + AvatarTokenSize, + TitleSubpage, +} 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 { + AvatarToken, + AvatarTokenSize, + TitleSubpage, +} 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..5a498f1c2 --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.stories.tsx @@ -0,0 +1,238 @@ +import { + FontWeight, + TextColor, + TextVariant, +} from '@metamask/design-system-shared'; +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 } 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 AmountEndAccessory: Story = { + render: () => ( + } + title={USDC_TITLE} + subtitle={USDC_SUBTITLE} + amount={USDC_AMOUNT} + amountEndAccessory={ + + } + /> + ), +}; + +export const Title: Story = { + render: () => ( + } title={USDC_TITLE} /> + ), +}; + +export const TitleEndAccessory: Story = { + render: () => ( + } + title={USDC_TITLE} + titleEndAccessory={} + /> + ), +}; + +export const Subtitle: Story = { + render: () => ( + } + title={USDC_TITLE} + subtitle={USDC_SUBTITLE} + /> + ), +}; + +export const SubtitleEndAccessory: Story = { + render: () => ( + } + title={USDC_TITLE} + subtitle={USDC_SUBTITLE} + subtitleEndAccessory={ + + } + /> + ), +}; + +export const BottomLabel: Story = { + render: (args) => ( + + ), +}; + +export const BottomLabelEndAccessory: 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..2cba6136a --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.test.tsx @@ -0,0 +1,541 @@ +// 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 TITLE_AVATAR_SLOT_TEST_ID = 'title-subpage-title-avatar-slot'; +const IDENTITY_ROW_TEST_ID = 'title-subpage-identity-row'; + +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('passes titleAvatar through as startAccessory without a fixed-size overflow wrapper', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(TITLE_AVATAR_TEST_ID)).toBeOnTheScreen(); + expect(queryByTestId(TITLE_AVATAR_SLOT_TEST_ID)).not.toBeOnTheScreen(); + }); + + it('forwards identityRowProps testID to identity BoxRow', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(IDENTITY_ROW_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(); + }); + + it('does not render title row or titleEndAccessory', () => { + const showTitle = false; + const { getByText, queryByTestId, queryByText } = render( + Top + ) : ( + false + ) + } + amount="$4.42" + titleEndAccessory={Only title accessory} + />, + ); + + expect(getByText('$4.42')).toBeOnTheScreen(); + expect(queryByText('Only title accessory')).not.toBeOnTheScreen(); + expect(queryByTestId('title-subpage-title-slot')).not.toBeOnTheScreen(); + }); + }); + + describe('when titleEndAccessory is false', () => { + it('does not render titleEndAccessory', () => { + const showTitleEndAccessory = false; + const { getByText, queryByTestId } = render( + Accessory + ) : ( + false + ) + } + />, + ); + + expect(getByText('Hi')).toBeOnTheScreen(); + expect(getByText('$4.42')).toBeOnTheScreen(); + expect( + queryByTestId('title-subpage-title-end-accessory'), + ).not.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 row or subtitleEndAccessory', () => { + const showSubtitle = false; + const { getByText, queryByTestId, queryByText } = render( + Sub + ) : ( + false + ) + } + amount="$4.42" + subtitleEndAccessory={Only sub accessory} + />, + ); + + expect(getByText('Send')).toBeOnTheScreen(); + expect(getByText('$4.42')).toBeOnTheScreen(); + expect(queryByText('Only sub accessory')).not.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 bottomLabel is omitted and bottomLabelEndAccessory is provided', () => { + it('does not render bottomLabelEndAccessory without bottomLabel', () => { + const { queryByText } = render( + Only accessory} + bottomAccessory={Full row} + />, + ); + + expect(queryByText('Only accessory')).not.toBeOnTheScreen(); + }); + + it('renders bottomAccessory', () => { + const { getByText } = render( + Only accessory} + bottomAccessory={Full row} + />, + ); + + expect(getByText('Full row')).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('does not render amount row when amount is an empty string', () => { + const { getByText, queryByText } = render( + Accessory only} + />, + ); + + expect(getByText('Send')).toBeOnTheScreen(); + expect(queryByText('Accessory only')).not.toBeOnTheScreen(); + }); + }); + + describe('when amountEndAccessory is false', () => { + it('does not render amountEndAccessory', () => { + const showAmountEndAccessory = false; + const { getByText, queryByTestId } = render( + Accessory + ) : ( + false + ) + } + />, + ); + + expect(getByText('Send')).toBeOnTheScreen(); + expect(getByText('$4.42')).toBeOnTheScreen(); + expect( + queryByTestId('title-subpage-amount-end-accessory'), + ).not.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..ed5a73a2e --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.tsx @@ -0,0 +1,134 @@ +// Third party dependencies. +import { + FontWeight, + TextColor, + TextVariant, +} from '@metamask/design-system-shared'; +import React from 'react'; + +// Internal dependencies. +import { Box } from '../Box'; +import { BoxRow } from '../BoxRow'; + +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); passed through as the identity `BoxRow` `startAccessory` so callers control layout and composition (for example badges that extend past the token) + * @param props.identityRowProps - Optional props spread onto the identity `BoxRow` after defaults (`children`, `startAccessory`, and `textProps` are reserved) + * @param props.titleColumnProps - Optional props spread onto the title/subtitle column `Box` (`children` is reserved) + * @param props.bottomLabelWrapperProps - Optional props spread onto the bottom label `BoxRow` after defaults (`children`, `endAccessory`, and `textProps` are reserved) + * @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, + identityRowProps, + titleColumnProps, + bottomLabelWrapperProps, + twClassName = '', + ...props +}) => { + return ( + + {/* Identity Row */} + + {/* Title and Subtitle Column */} + + {/* Title Row */} + {title && ( + + {title} + + )} + {/* Subtitle Row */} + {subtitle && ( + + {subtitle} + + )} + + + {/* Amount Row */} + {amount && ( + + {amount} + + )} + {/* Bottom Label Row */} + {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..73f264168 --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleSubpage/TitleSubpage.types.ts @@ -0,0 +1,53 @@ +// Third party dependencies. +import type { TitleSubpagePropsShared } from '@metamask/design-system-shared'; +import type { ViewProps } from 'react-native'; + +// Internal dependencies. +import type { BoxProps } from '../Box/Box.types'; +import type { BoxRowProps } from '../BoxRow/BoxRow.types'; +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 spread onto the identity {@link BoxRow} (excluding `children`, `startAccessory`, and `textProps`, which the component owns). + */ + identityRowProps?: Omit< + Partial, + 'children' | 'startAccessory' | 'textProps' + >; + /** + * Optional props spread onto the title/subtitle column {@link Box} (excluding `children`, which the component owns). + */ + titleColumnProps?: Omit, 'children'>; + /** + * Optional props spread onto the bottom label {@link BoxRow} (excluding `children`, `endAccessory`, and `textProps`, which the component owns). + */ + bottomLabelWrapperProps?: Omit< + Partial, + 'children' | 'endAccessory' | 'textProps' + >; + /** + * Optional props merged into {@link BoxRow} `textProps` when `amount` is a string. + */ + amountProps?: Omit, 'children'>; + /** + * Optional props merged into {@link BoxRow} `textProps` when `title` is a string. + */ + titleProps?: Omit, 'children'>; + /** + * Optional props merged into {@link BoxRow} `textProps` when `subtitle` is a string. + */ + subtitleProps?: Omit, 'children'>; + /** + * Optional props merged into {@link BoxRow} `textProps` when `bottomLabel` is a string. + */ + bottomLabelProps?: Omit, 'children'>; + /** + * 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..584779c9e --- /dev/null +++ b/packages/design-system-react-native/src/components/TitleSubpage/index.ts @@ -0,0 +1,2 @@ +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 ad2a17ed1..68f69acca 100644 --- a/packages/design-system-react-native/src/components/index.ts +++ b/packages/design-system-react-native/src/components/index.ts @@ -210,6 +210,9 @@ export type { TitleHubProps, TitleHubPropsShared } from './TitleHub'; export { TitleStandard } from './TitleStandard'; export type { TitleStandardProps } from './TitleStandard'; +export { TitleSubpage } from './TitleSubpage'; +export type { TitleSubpageProps } from './TitleSubpage'; + export { Toast, ToastVariant, diff --git a/packages/design-system-shared/src/index.ts b/packages/design-system-shared/src/index.ts index ac9df24b2..ff5627b30 100644 --- a/packages/design-system-shared/src/index.ts +++ b/packages/design-system-shared/src/index.ts @@ -41,6 +41,9 @@ export { type TitleHubPropsShared } from './types/TitleHub'; // TitleStandard types (ADR-0004) export { type TitleStandardPropsShared } from './types/TitleStandard'; +// TitleSubpage types (ADR-0004) +export { type TitleSubpagePropsShared } from './types/TitleSubpage'; + // BoxColumn types (ADR-0004) export { type BoxColumnPropsShared } from './types/BoxColumn'; 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..0215daf2b --- /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` is truthy; `amountEndAccessory` only appears on that row and does not show the row without `amount`. + */ + 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 passed as + * the `startAccessory` of the identity `BoxRow` (typically size the avatar to the design-spec 40×40 slot). + */ + 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` is renderable. + */ + subtitle?: ReactNode; + /** + * Optional accessory rendered inline to the right of `subtitle` in the subtitle row. + */ + subtitleEndAccessory?: ReactNode; + /** + * Optional custom bottom row when `bottomLabel` is not truthy. + * 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`). + * When `bottomLabel` is truthy, that row is shown instead of `bottomAccessory`; `bottomLabelEndAccessory` only appears with a truthy `bottomLabel`. + */ + 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';