Skip to content

Commit 5b18690

Browse files
chore: Added HeaderWithTitleLeft component (MetaMask#24085)
## **Description** Adds a new `HeaderWithTitleLeft` component to the component library (`components-temp`). This component combines `HeaderBase` with a `TitleLeft` section, providing a reusable header pattern for screens that need a back button with a left-aligned title section below. **Key features:** - Combines `HeaderBase` (with back button) on top and `TitleLeft` section below - Supports `onBack` callback or `backButtonProps` for back button customization - Supports `titleLeftProps` for TitleLeft configuration or custom `titleLeft` node for full flexibility - Extends all `HeaderBase` props (`endButtonIconProps`, `includesTopInset`, etc.) - No default testIDs stored in the component - testIDs are passed via props for testing flexibility - Includes comprehensive unit tests and Storybook stories ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/jira/software/c/projects/MDP/boards/2972?assignee=62afb43d33a882e2be47c36f&selectedIssue=MDP-642 ## **Manual testing steps** ```gherkin Feature: HeaderWithTitleLeft component Scenario: user views HeaderWithTitleLeft in Storybook Given the user has Storybook running When user navigates to Components Temp / HeaderWithTitleLeft Then user sees the component with back button and title section ``` ## **Screenshots/Recordings** ### **Before** N/A - New component ### **After** https://github.com/user-attachments/assets/5e38b9c7-b281-4da9-af3b-1f7c3f850165 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds a new `HeaderWithTitleLeft` component (with stories and tests) that composes `HeaderBase` and `TitleLeft` with flexible back button and title configuration. > > - **Components**: > - New `components-temp/HeaderWithTitleLeft` component combining `HeaderBase` with a below `TitleLeft` section. > - API: supports `onBack`/`backButtonProps` (with priority), custom `startButtonIconProps`, forwards `endButtonIconProps`, accepts `titleLeft` node or `titleLeftProps`, plus `twClassName`, `testID`, `titleSectionTestID`. > - **Tests**: > - Added unit tests covering rendering, back button behavior/priority, prop forwarding, and testID passthrough in `HeaderWithTitleLeft.test.tsx`. > - **Storybook**: > - Added `HeaderWithTitleLeft.stories.tsx` and registered it in `.storybook/storybook.requires.js`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit cb362c8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent d176e83 commit 5b18690

6 files changed

Lines changed: 481 additions & 0 deletions

File tree

.storybook/storybook.requires.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/* eslint-disable no-console */
2+
import React from 'react';
3+
4+
import {
5+
Box,
6+
Text,
7+
TextVariant,
8+
IconName,
9+
} from '@metamask/design-system-react-native';
10+
11+
import HeaderWithTitleLeft from './HeaderWithTitleLeft';
12+
13+
const HeaderWithTitleLeftMeta = {
14+
title: 'Components Temp / HeaderWithTitleLeft',
15+
component: HeaderWithTitleLeft,
16+
argTypes: {
17+
twClassName: {
18+
control: 'text',
19+
},
20+
},
21+
};
22+
23+
export default HeaderWithTitleLeftMeta;
24+
25+
const SampleNFTImage = () => (
26+
<Box twClassName="w-12 h-12 rounded-lg bg-success-muted items-center justify-center">
27+
<Text variant={TextVariant.BodySm}>NFT</Text>
28+
</Box>
29+
);
30+
31+
export const Default = {
32+
args: {
33+
titleLeftProps: {
34+
topLabel: 'Send',
35+
title: '$4.42',
36+
},
37+
},
38+
};
39+
40+
export const OnBack = {
41+
render: () => (
42+
<HeaderWithTitleLeft
43+
onBack={() => console.log('Back pressed')}
44+
titleLeftProps={{
45+
topLabel: 'Send',
46+
title: '$4.42',
47+
endAccessory: <SampleNFTImage />,
48+
}}
49+
/>
50+
),
51+
};
52+
53+
export const WithBottomLabel = {
54+
render: () => (
55+
<HeaderWithTitleLeft
56+
onBack={() => console.log('Back pressed')}
57+
titleLeftProps={{
58+
topLabel: 'Send',
59+
title: '$4.42',
60+
bottomLabel: '0.002 ETH',
61+
endAccessory: <SampleNFTImage />,
62+
}}
63+
/>
64+
),
65+
};
66+
67+
export const EndButtonIconProps = {
68+
render: () => (
69+
<HeaderWithTitleLeft
70+
onBack={() => console.log('Back pressed')}
71+
endButtonIconProps={[
72+
{
73+
iconName: IconName.Close,
74+
onPress: () => console.log('Close pressed'),
75+
},
76+
]}
77+
titleLeftProps={{
78+
topLabel: 'Send',
79+
title: '$4.42',
80+
endAccessory: <SampleNFTImage />,
81+
}}
82+
/>
83+
),
84+
};
85+
86+
export const BackButtonProps = {
87+
render: () => (
88+
<HeaderWithTitleLeft
89+
backButtonProps={{
90+
onPress: () => console.log('Custom back pressed'),
91+
}}
92+
titleLeftProps={{
93+
topLabel: 'Receive',
94+
title: '$1,234.56',
95+
}}
96+
/>
97+
),
98+
};
99+
100+
export const TitleLeft = {
101+
render: () => (
102+
<HeaderWithTitleLeft
103+
onBack={() => console.log('Back pressed')}
104+
titleLeft={
105+
<Box twClassName="px-4 py-2">
106+
<Text variant={TextVariant.HeadingMd}>Custom Title Section</Text>
107+
<Text variant={TextVariant.BodySm}>
108+
This is a completely custom title section
109+
</Text>
110+
</Box>
111+
}
112+
/>
113+
),
114+
};
115+
116+
export const NoBackButton = {
117+
render: () => (
118+
<HeaderWithTitleLeft
119+
titleLeftProps={{
120+
topLabel: 'Account Balance',
121+
title: '$12,345.67',
122+
bottomLabel: '+$123.45 (1.2%)',
123+
}}
124+
/>
125+
),
126+
};
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Third party dependencies.
2+
import React from 'react';
3+
import { render, fireEvent } from '@testing-library/react-native';
4+
import { Text, IconName } from '@metamask/design-system-react-native';
5+
6+
// Internal dependencies.
7+
import HeaderWithTitleLeft from './HeaderWithTitleLeft';
8+
9+
const TEST_IDS = {
10+
CONTAINER: 'header-with-title-left-container',
11+
TITLE_SECTION: 'header-with-title-left-title-section',
12+
BACK_BUTTON: 'header-with-title-left-back-button',
13+
TITLE_LEFT: 'title-left',
14+
HEADER_BASE_END_ACCESSORY: 'header-base-end-accessory',
15+
};
16+
17+
describe('HeaderWithTitleLeft', () => {
18+
beforeEach(() => {
19+
jest.clearAllMocks();
20+
});
21+
22+
describe('rendering', () => {
23+
it('renders container with correct testID', () => {
24+
const { getByTestId } = render(
25+
<HeaderWithTitleLeft
26+
testID={TEST_IDS.CONTAINER}
27+
titleLeftProps={{ title: 'Test' }}
28+
/>,
29+
);
30+
31+
expect(getByTestId(TEST_IDS.CONTAINER)).toBeOnTheScreen();
32+
});
33+
34+
it('renders title section when titleLeftProps provided', () => {
35+
const { getByTestId } = render(
36+
<HeaderWithTitleLeft
37+
titleSectionTestID={TEST_IDS.TITLE_SECTION}
38+
titleLeftProps={{ title: '$4.42' }}
39+
/>,
40+
);
41+
42+
expect(getByTestId(TEST_IDS.TITLE_SECTION)).toBeOnTheScreen();
43+
});
44+
45+
it('renders TitleLeft with props when titleLeftProps provided', () => {
46+
const { getByText, getByTestId } = render(
47+
<HeaderWithTitleLeft
48+
titleLeftProps={{
49+
topLabel: 'Send',
50+
title: '$4.42',
51+
testID: TEST_IDS.TITLE_LEFT,
52+
}}
53+
/>,
54+
);
55+
56+
expect(getByText('Send')).toBeOnTheScreen();
57+
expect(getByText('$4.42')).toBeOnTheScreen();
58+
expect(getByTestId(TEST_IDS.TITLE_LEFT)).toBeOnTheScreen();
59+
});
60+
61+
it('renders custom titleLeft node when provided', () => {
62+
const { getByText } = render(
63+
<HeaderWithTitleLeft titleLeft={<Text>Custom Title Section</Text>} />,
64+
);
65+
66+
expect(getByText('Custom Title Section')).toBeOnTheScreen();
67+
});
68+
69+
it('titleLeft takes priority over titleLeftProps', () => {
70+
const { getByText, queryByText } = render(
71+
<HeaderWithTitleLeft
72+
titleLeft={<Text>Custom Node</Text>}
73+
titleLeftProps={{ title: 'Props Title' }}
74+
/>,
75+
);
76+
77+
expect(getByText('Custom Node')).toBeOnTheScreen();
78+
expect(queryByText('Props Title')).toBeNull();
79+
});
80+
81+
it('does not render title section when neither titleLeft nor titleLeftProps provided', () => {
82+
const { queryByTestId } = render(
83+
<HeaderWithTitleLeft
84+
onBack={jest.fn()}
85+
titleSectionTestID={TEST_IDS.TITLE_SECTION}
86+
/>,
87+
);
88+
89+
expect(queryByTestId(TEST_IDS.TITLE_SECTION)).toBeNull();
90+
});
91+
});
92+
93+
describe('back button', () => {
94+
it('renders back button when onBack provided', () => {
95+
const { getByTestId } = render(
96+
<HeaderWithTitleLeft
97+
onBack={jest.fn()}
98+
backButtonProps={{ testID: TEST_IDS.BACK_BUTTON }}
99+
titleLeftProps={{ title: 'Test' }}
100+
/>,
101+
);
102+
103+
expect(getByTestId(TEST_IDS.BACK_BUTTON)).toBeOnTheScreen();
104+
});
105+
106+
it('renders back button when backButtonProps provided', () => {
107+
const { getByTestId } = render(
108+
<HeaderWithTitleLeft
109+
backButtonProps={{ onPress: jest.fn(), testID: TEST_IDS.BACK_BUTTON }}
110+
titleLeftProps={{ title: 'Test' }}
111+
/>,
112+
);
113+
114+
expect(getByTestId(TEST_IDS.BACK_BUTTON)).toBeOnTheScreen();
115+
});
116+
117+
it('calls onBack when back button pressed', () => {
118+
const onBack = jest.fn();
119+
const { getByTestId } = render(
120+
<HeaderWithTitleLeft
121+
onBack={onBack}
122+
backButtonProps={{ testID: TEST_IDS.BACK_BUTTON }}
123+
titleLeftProps={{ title: 'Test' }}
124+
/>,
125+
);
126+
127+
fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON));
128+
129+
expect(onBack).toHaveBeenCalledTimes(1);
130+
});
131+
132+
it('calls backButtonProps.onPress when back button pressed', () => {
133+
const onPress = jest.fn();
134+
const { getByTestId } = render(
135+
<HeaderWithTitleLeft
136+
backButtonProps={{ onPress, testID: TEST_IDS.BACK_BUTTON }}
137+
titleLeftProps={{ title: 'Test' }}
138+
/>,
139+
);
140+
141+
fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON));
142+
143+
expect(onPress).toHaveBeenCalledTimes(1);
144+
});
145+
146+
it('backButtonProps.onPress takes priority over onBack', () => {
147+
const onBack = jest.fn();
148+
const onPress = jest.fn();
149+
const { getByTestId } = render(
150+
<HeaderWithTitleLeft
151+
onBack={onBack}
152+
backButtonProps={{ onPress, testID: TEST_IDS.BACK_BUTTON }}
153+
titleLeftProps={{ title: 'Test' }}
154+
/>,
155+
);
156+
157+
fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON));
158+
159+
expect(onPress).toHaveBeenCalledTimes(1);
160+
expect(onBack).not.toHaveBeenCalled();
161+
});
162+
163+
it('does not render back button when neither onBack nor backButtonProps provided', () => {
164+
const { queryByLabelText } = render(
165+
<HeaderWithTitleLeft titleLeftProps={{ title: 'Test' }} />,
166+
);
167+
168+
expect(queryByLabelText('Arrow Left')).toBeNull();
169+
});
170+
});
171+
172+
describe('props forwarding', () => {
173+
it('forwards endButtonIconProps to HeaderBase', () => {
174+
const { getByTestId } = render(
175+
<HeaderWithTitleLeft
176+
onBack={jest.fn()}
177+
endButtonIconProps={[
178+
{
179+
iconName: IconName.Close,
180+
onPress: jest.fn(),
181+
testID: TEST_IDS.HEADER_BASE_END_ACCESSORY,
182+
},
183+
]}
184+
titleLeftProps={{ title: 'Test' }}
185+
/>,
186+
);
187+
188+
expect(getByTestId(TEST_IDS.HEADER_BASE_END_ACCESSORY)).toBeOnTheScreen();
189+
});
190+
191+
it('accepts custom testID', () => {
192+
const { getByTestId } = render(
193+
<HeaderWithTitleLeft
194+
testID="custom-header"
195+
titleLeftProps={{ title: 'Test' }}
196+
/>,
197+
);
198+
199+
expect(getByTestId('custom-header')).toBeOnTheScreen();
200+
});
201+
202+
it('forwards startButtonIconProps directly when provided', () => {
203+
const onPress = jest.fn();
204+
const { getByTestId } = render(
205+
<HeaderWithTitleLeft
206+
startButtonIconProps={{
207+
iconName: IconName.Menu,
208+
onPress,
209+
testID: 'custom-start-button',
210+
}}
211+
titleLeftProps={{ title: 'Test' }}
212+
/>,
213+
);
214+
215+
expect(getByTestId('custom-start-button')).toBeOnTheScreen();
216+
});
217+
});
218+
});

0 commit comments

Comments
 (0)