Skip to content

Commit 55500a9

Browse files
chore: Enhance Headers API (MetaMask#24242)
## **Description** This PR enhances the header components API in `component-library/components-temp` to provide more flexibility and consistency: 1. **HeaderCenter**: Added `startButtonIconProps` support by removing the `Omit` constraint, allowing direct control over the start button. 2. **HeaderWithTitleLeft & HeaderWithTitleLeftScrollable**: Added close button support via `onClose` and `closeButtonProps` props, along with `endButtonIconProps` for additional end accessories. 3. **HeaderWithTitleLeftScrollable**: Added `isInsideSafeAreaView` prop to properly position the header when used inside a SafeAreaView, respecting safe area insets. 4. **TitleLeft**: Added `twClassName` prop for custom Tailwind styling. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Header Components API Scenario: HeaderCenter with custom startButtonIconProps Given the app renders a HeaderCenter component When user provides startButtonIconProps Then the custom start button should render instead of the default back button Scenario: HeaderWithTitleLeft with close button Given the app renders a HeaderWithTitleLeft component When user provides onClose callback Then a close button should appear on the right side of the header Scenario: HeaderWithTitleLeftScrollable inside SafeAreaView Given the app renders HeaderWithTitleLeftScrollable inside a SafeAreaView When isInsideSafeAreaView is set to true Then the header should position at the safe area boundary instead of top-0 ``` ## **Screenshots/Recordings** ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **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] > Introduces more flexible header controls and positioning while aligning APIs across components. > > - **Close button support**: `HeaderWithTitleLeft` and `HeaderWithTitleLeftScrollable` accept `onClose`/`closeButtonProps`; merged into `endButtonIconProps` alongside any provided end icons > - **Custom start button**: `HeaderCenter` now accepts `startButtonIconProps` (takes priority over `onBack`); `HeaderCenterProps` now extends `HeaderBaseProps` > - **Safe area handling**: `HeaderWithTitleLeftScrollable` adds `isInsideSafeAreaView` to position container using `useSafeAreaInsets()` > - **Styling**: `TitleLeft` adds `twClassName` to customize container classes > - Tests updated/added to cover new behaviors and prop precedence > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 65cdf6c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 1b329d2 commit 55500a9

11 files changed

Lines changed: 337 additions & 11 deletions

File tree

app/component-library/components-temp/HeaderCenter/HeaderCenter.test.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,42 @@ describe('HeaderCenter', () => {
150150

151151
expect(queryByTestId(START_ACCESSORY_TEST_ID)).not.toBeOnTheScreen();
152152
});
153+
154+
it('renders startButtonIconProps when provided', () => {
155+
const onPress = jest.fn();
156+
const { getByTestId } = render(
157+
<HeaderCenter
158+
title="Title"
159+
startButtonIconProps={{
160+
iconName: IconName.Menu,
161+
onPress,
162+
testID: 'custom-start-button',
163+
}}
164+
/>,
165+
);
166+
167+
expect(getByTestId('custom-start-button')).toBeOnTheScreen();
168+
});
169+
170+
it('startButtonIconProps takes priority over onBack', () => {
171+
const onBack = jest.fn();
172+
const onPress = jest.fn();
173+
const { getByTestId, queryByTestId } = render(
174+
<HeaderCenter
175+
title="Title"
176+
onBack={onBack}
177+
backButtonProps={{ testID: BACK_BUTTON_TEST_ID }}
178+
startButtonIconProps={{
179+
iconName: IconName.Menu,
180+
onPress,
181+
testID: 'custom-start-button',
182+
}}
183+
/>,
184+
);
185+
186+
expect(getByTestId('custom-start-button')).toBeOnTheScreen();
187+
expect(queryByTestId(BACK_BUTTON_TEST_ID)).not.toBeOnTheScreen();
188+
});
153189
});
154190

155191
describe('close button', () => {

app/component-library/components-temp/HeaderCenter/HeaderCenter.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,16 @@ const HeaderCenter: React.FC<HeaderCenterProps> = ({
4343
onClose,
4444
closeButtonProps,
4545
endButtonIconProps,
46+
startButtonIconProps,
4647
twClassName,
4748
testID,
4849
...headerBaseProps
4950
}) => {
5051
// Build the startButtonIconProps with back button if needed
5152
const resolvedStartButtonIconProps = useMemo(() => {
53+
if (startButtonIconProps) {
54+
return startButtonIconProps;
55+
}
5256
if (onBack || backButtonProps) {
5357
return {
5458
iconName: IconName.ArrowLeft,
@@ -57,7 +61,7 @@ const HeaderCenter: React.FC<HeaderCenterProps> = ({
5761
} as ButtonIconProps;
5862
}
5963
return undefined;
60-
}, [onBack, backButtonProps]);
64+
}, [onBack, backButtonProps, startButtonIconProps]);
6165

6266
// Build the endButtonIconProps array with close button if needed
6367
const resolvedEndButtonIconProps = useMemo(() => {

app/component-library/components-temp/HeaderCenter/HeaderCenter.types.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ import { HeaderBaseProps } from '../../components/HeaderBase';
1010
/**
1111
* HeaderCenter component props.
1212
*/
13-
export interface HeaderCenterProps
14-
extends Omit<HeaderBaseProps, 'startButtonIconProps'> {
13+
export interface HeaderCenterProps extends HeaderBaseProps {
1514
/**
1615
* Title text to display in the header.
1716
* Used as children if children prop is not provided.

app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.test.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const TEST_IDS = {
1010
CONTAINER: 'header-with-title-left-container',
1111
TITLE_SECTION: 'header-with-title-left-title-section',
1212
BACK_BUTTON: 'header-with-title-left-back-button',
13+
CLOSE_BUTTON: 'header-with-title-left-close-button',
1314
TITLE_LEFT: 'title-left',
1415
HEADER_BASE_END_ACCESSORY: 'header-base-end-accessory',
1516
};
@@ -169,6 +170,88 @@ describe('HeaderWithTitleLeft', () => {
169170
});
170171
});
171172

173+
describe('close button', () => {
174+
it('renders close button when onClose provided', () => {
175+
const { getByTestId } = render(
176+
<HeaderWithTitleLeft
177+
onClose={jest.fn()}
178+
closeButtonProps={{ testID: TEST_IDS.CLOSE_BUTTON }}
179+
titleLeftProps={{ title: 'Test' }}
180+
/>,
181+
);
182+
183+
expect(getByTestId(TEST_IDS.CLOSE_BUTTON)).toBeOnTheScreen();
184+
});
185+
186+
it('renders close button when closeButtonProps provided', () => {
187+
const { getByTestId } = render(
188+
<HeaderWithTitleLeft
189+
closeButtonProps={{
190+
onPress: jest.fn(),
191+
testID: TEST_IDS.CLOSE_BUTTON,
192+
}}
193+
titleLeftProps={{ title: 'Test' }}
194+
/>,
195+
);
196+
197+
expect(getByTestId(TEST_IDS.CLOSE_BUTTON)).toBeOnTheScreen();
198+
});
199+
200+
it('calls onClose when close button pressed', () => {
201+
const onClose = jest.fn();
202+
const { getByTestId } = render(
203+
<HeaderWithTitleLeft
204+
onClose={onClose}
205+
closeButtonProps={{ testID: TEST_IDS.CLOSE_BUTTON }}
206+
titleLeftProps={{ title: 'Test' }}
207+
/>,
208+
);
209+
210+
fireEvent.press(getByTestId(TEST_IDS.CLOSE_BUTTON));
211+
212+
expect(onClose).toHaveBeenCalledTimes(1);
213+
});
214+
215+
it('calls closeButtonProps.onPress when close button pressed', () => {
216+
const onPress = jest.fn();
217+
const { getByTestId } = render(
218+
<HeaderWithTitleLeft
219+
closeButtonProps={{ onPress, testID: TEST_IDS.CLOSE_BUTTON }}
220+
titleLeftProps={{ title: 'Test' }}
221+
/>,
222+
);
223+
224+
fireEvent.press(getByTestId(TEST_IDS.CLOSE_BUTTON));
225+
226+
expect(onPress).toHaveBeenCalledTimes(1);
227+
});
228+
229+
it('closeButtonProps.onPress takes priority over onClose', () => {
230+
const onClose = jest.fn();
231+
const onPress = jest.fn();
232+
const { getByTestId } = render(
233+
<HeaderWithTitleLeft
234+
onClose={onClose}
235+
closeButtonProps={{ onPress, testID: TEST_IDS.CLOSE_BUTTON }}
236+
titleLeftProps={{ title: 'Test' }}
237+
/>,
238+
);
239+
240+
fireEvent.press(getByTestId(TEST_IDS.CLOSE_BUTTON));
241+
242+
expect(onPress).toHaveBeenCalledTimes(1);
243+
expect(onClose).not.toHaveBeenCalled();
244+
});
245+
246+
it('does not render close button when neither onClose nor closeButtonProps provided', () => {
247+
const { queryByLabelText } = render(
248+
<HeaderWithTitleLeft titleLeftProps={{ title: 'Test' }} />,
249+
);
250+
251+
expect(queryByLabelText('Close')).toBeNull();
252+
});
253+
});
254+
172255
describe('props forwarding', () => {
173256
it('forwards endButtonIconProps to HeaderBase', () => {
174257
const { getByTestId } = render(

app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,12 @@ import { HeaderWithTitleLeftProps } from './HeaderWithTitleLeft.types';
3232
const HeaderWithTitleLeft: React.FC<HeaderWithTitleLeftProps> = ({
3333
onBack,
3434
backButtonProps,
35+
onClose,
36+
closeButtonProps,
3537
titleLeft,
3638
titleLeftProps,
3739
startButtonIconProps,
40+
endButtonIconProps,
3841
twClassName,
3942
testID,
4043
titleSectionTestID,
@@ -59,6 +62,26 @@ const HeaderWithTitleLeft: React.FC<HeaderWithTitleLeftProps> = ({
5962
return undefined;
6063
}, [startButtonIconProps, onBack, backButtonProps]);
6164

65+
// Build endButtonIconProps with close button if onClose or closeButtonProps is provided
66+
const resolvedEndButtonIconProps = useMemo(() => {
67+
const props: ButtonIconProps[] = [];
68+
69+
if (onClose || closeButtonProps) {
70+
const closeProps: ButtonIconProps = {
71+
iconName: IconName.Close,
72+
...(closeButtonProps || {}),
73+
onPress: closeButtonProps?.onPress ?? onClose,
74+
};
75+
props.push(closeProps);
76+
}
77+
78+
if (endButtonIconProps) {
79+
props.push(...endButtonIconProps);
80+
}
81+
82+
return props.length > 0 ? props : undefined;
83+
}, [endButtonIconProps, onClose, closeButtonProps]);
84+
6285
// Render title section content
6386
const renderTitleSection = () => {
6487
if (titleLeft) {
@@ -79,6 +102,7 @@ const HeaderWithTitleLeft: React.FC<HeaderWithTitleLeftProps> = ({
79102
{/* HeaderBase section */}
80103
<HeaderBase
81104
startButtonIconProps={resolvedStartButtonIconProps}
105+
endButtonIconProps={resolvedEndButtonIconProps}
82106
twClassName={resolvedTwClassName}
83107
{...headerBaseProps}
84108
/>

app/component-library/components-temp/HeaderWithTitleLeft/HeaderWithTitleLeft.types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ export interface HeaderWithTitleLeftProps
2323
* If provided, a back button will be rendered with these props spread.
2424
*/
2525
backButtonProps?: Omit<ButtonIconProps, 'iconName'>;
26+
/**
27+
* Callback when the close button is pressed.
28+
* If provided, a close button will be added to endButtonIconProps.
29+
*/
30+
onClose?: () => void;
31+
/**
32+
* Additional props to pass to the close ButtonIcon.
33+
* If provided, a close button will be added to endButtonIconProps with these props spread.
34+
*/
35+
closeButtonProps?: Omit<ButtonIconProps, 'iconName'>;
2636
/**
2737
* Custom node to render in the title section.
2838
* If provided, takes priority over titleLeftProps.

app/component-library/components-temp/HeaderWithTitleLeftScrollable/HeaderWithTitleLeftScrollable.test.tsx

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { render, renderHook } from '@testing-library/react-native';
44
import { useSharedValue, SharedValue } from 'react-native-reanimated';
55
import { Text } from 'react-native';
66

7+
// External dependencies.
8+
import { IconName } from '@metamask/design-system-react-native';
9+
710
// Internal dependencies.
811
import HeaderWithTitleLeftScrollable from './HeaderWithTitleLeftScrollable';
912
import useHeaderWithTitleLeftScrollable from './useHeaderWithTitleLeftScrollable';
@@ -22,6 +25,11 @@ jest.mock('react-native-reanimated', () => {
2225
return Reanimated;
2326
});
2427

28+
// Mock react-native-safe-area-context
29+
jest.mock('react-native-safe-area-context', () => ({
30+
useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }),
31+
}));
32+
2533
// Test wrapper component that provides scrollY
2634
const TestWrapper: React.FC<{
2735
children: (scrollYValue: SharedValue<number>) => React.ReactNode;
@@ -106,6 +114,114 @@ describe('HeaderWithTitleLeftScrollable', () => {
106114
});
107115
});
108116

117+
describe('close button', () => {
118+
it('renders close button when onClose provided', () => {
119+
const { getByTestId } = render(
120+
<TestWrapper>
121+
{(scrollYValue) => (
122+
<HeaderWithTitleLeftScrollable
123+
title="Test"
124+
scrollY={scrollYValue}
125+
onClose={jest.fn()}
126+
closeButtonProps={{ testID: 'test-close-button' }}
127+
/>
128+
)}
129+
</TestWrapper>,
130+
);
131+
132+
expect(getByTestId('test-close-button')).toBeOnTheScreen();
133+
});
134+
135+
it('renders close button when closeButtonProps provided', () => {
136+
const { getByTestId } = render(
137+
<TestWrapper>
138+
{(scrollYValue) => (
139+
<HeaderWithTitleLeftScrollable
140+
title="Test"
141+
scrollY={scrollYValue}
142+
closeButtonProps={{
143+
onPress: jest.fn(),
144+
testID: 'test-close-button',
145+
}}
146+
/>
147+
)}
148+
</TestWrapper>,
149+
);
150+
151+
expect(getByTestId('test-close-button')).toBeOnTheScreen();
152+
});
153+
});
154+
155+
describe('endButtonIconProps', () => {
156+
it('renders endButtonIconProps', () => {
157+
const { getByTestId } = render(
158+
<TestWrapper>
159+
{(scrollYValue) => (
160+
<HeaderWithTitleLeftScrollable
161+
title="Test"
162+
scrollY={scrollYValue}
163+
endButtonIconProps={[
164+
{
165+
iconName: IconName.Close,
166+
onPress: jest.fn(),
167+
testID: 'end-button',
168+
},
169+
]}
170+
/>
171+
)}
172+
</TestWrapper>,
173+
);
174+
175+
expect(getByTestId('end-button')).toBeOnTheScreen();
176+
});
177+
});
178+
179+
describe('isInsideSafeAreaView', () => {
180+
it('positions header at top 0 when isInsideSafeAreaView is false', () => {
181+
const { getByTestId } = render(
182+
<TestWrapper>
183+
{(scrollYValue) => (
184+
<HeaderWithTitleLeftScrollable
185+
title="Test"
186+
scrollY={scrollYValue}
187+
isInsideSafeAreaView={false}
188+
testID="test-container"
189+
/>
190+
)}
191+
</TestWrapper>,
192+
);
193+
194+
const container = getByTestId('test-container');
195+
const flattenedStyle = Array.isArray(container.props.style)
196+
? Object.assign({}, ...container.props.style)
197+
: container.props.style;
198+
199+
expect(flattenedStyle.top).toBe(0);
200+
});
201+
202+
it('positions header at insets.top when isInsideSafeAreaView is true', () => {
203+
const { getByTestId } = render(
204+
<TestWrapper>
205+
{(scrollYValue) => (
206+
<HeaderWithTitleLeftScrollable
207+
title="Test"
208+
scrollY={scrollYValue}
209+
isInsideSafeAreaView
210+
testID="test-container"
211+
/>
212+
)}
213+
</TestWrapper>,
214+
);
215+
216+
const container = getByTestId('test-container');
217+
const flattenedStyle = Array.isArray(container.props.style)
218+
? Object.assign({}, ...container.props.style)
219+
: container.props.style;
220+
221+
expect(flattenedStyle.top).toBe(44);
222+
});
223+
});
224+
109225
describe('titleLeft and titleLeftProps', () => {
110226
it('renders custom titleLeft node', () => {
111227
const { getByText } = render(

0 commit comments

Comments
 (0)