Skip to content

Commit 2c6bf75

Browse files
feat: add asChild functionality to Box component (#834)
## **Description** This PR adds `asChild` functionality to the Box component, enabling it to merge its props onto its immediate child element instead of rendering a `div`. This enhancement allows developers to apply Box styling and layout properties to any HTML element while maintaining semantic meaning and accessibility. The implementation leverages Radix UI's Slot component to ensure proper prop merging and ref forwarding, following the same pattern used in the AvatarBase component. ## **Related issues** Fixes: <!-- Add issue links if any --> ## **Manual testing steps** 1. Pull and build the branch locally 2. Navigate to the Box component in Storybook 3. View the new "AsChild" story to see the functionality in action 4. Test that Box renders as a div by default (asChild=false) 5. Test that Box renders as its child element when asChild=true 6. Verify that all Box props (padding, margin, flexDirection, etc.) are properly applied to the child element 7. Test ref forwarding works correctly with asChild=true 8. Verify prop merging works correctly between Box and child element props 9. Run the test suite to ensure all 5 new asChild tests pass 10. Test accessibility by verifying semantic HTML elements maintain their native behavior ## **Screenshots/Recordings** Not applicable - this is a component API enhancement with comprehensive test coverage. ### **Before** Box component could only render as a div element. ### **After** Box component can now render as any HTML element or React component while preserving all Box styling capabilities. <img width="1159" height="336" alt="Screenshot 2025-09-17 at 12 27 57 PM" src="https://github.com/user-attachments/assets/ffb5b4ed-a880-4710-b381-ede2c6d93c8d" /> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) - [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-extension/blob/develop/.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.
1 parent 0870490 commit 2c6bf75

File tree

5 files changed

+146
-2
lines changed

5 files changed

+146
-2
lines changed

packages/design-system-react/src/components/Box/Box.stories.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1795,3 +1795,15 @@ export const BackgroundColor: Story = {
17951795
</Box>
17961796
),
17971797
};
1798+
1799+
export const AsChild: Story = {
1800+
render: (args) => (
1801+
<Box {...args} asChild backgroundColor={BoxBackgroundColor.PrimaryMuted}>
1802+
<button>
1803+
<Text asChild>
1804+
<span>Box rendered as button</span>
1805+
</Text>
1806+
</button>
1807+
</Box>
1808+
),
1809+
};

packages/design-system-react/src/components/Box/Box.test.tsx

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,4 +561,85 @@ describe('Box', () => {
561561
expect(box).toHaveAttribute('aria-label', 'Test box');
562562
expect(box.tagName).toBe('DIV');
563563
});
564+
565+
it('renders as div by default when asChild is false', () => {
566+
render(
567+
<Box data-testid="box" asChild={false}>
568+
<span>Content</span>
569+
</Box>,
570+
);
571+
const box = screen.getByTestId('box');
572+
expect(box.tagName).toBe('DIV');
573+
});
574+
575+
it('renders as child element when asChild is true', () => {
576+
render(
577+
<Box data-testid="box" asChild padding={4} className="bg-primary-muted">
578+
<button type="button">Click me</button>
579+
</Box>,
580+
);
581+
const button = screen.getByTestId('box');
582+
expect(button.tagName).toBe('BUTTON');
583+
expect(button).toHaveAttribute('type', 'button');
584+
expect(button).toHaveClass('bg-primary-muted');
585+
expect(button).toHaveClass(TWCLASSMAP_BOX_PADDING[4]);
586+
expect(button).toHaveTextContent('Click me');
587+
});
588+
589+
it('forwards ref to child element when asChild is true', () => {
590+
const ref = createRef<HTMLDivElement>();
591+
render(
592+
<Box asChild ref={ref}>
593+
<button data-testid="button" type="button">
594+
Click me
595+
</button>
596+
</Box>,
597+
);
598+
599+
const button = screen.getByTestId('button');
600+
expect(ref.current).toBe(button);
601+
expect(ref.current?.tagName).toBe('BUTTON');
602+
});
603+
604+
it('merges Box props with child element props when asChild is true', () => {
605+
render(
606+
<Box
607+
asChild
608+
data-testid="merged-element"
609+
padding={2}
610+
className="bg-primary-muted"
611+
style={{ color: 'red' }}
612+
>
613+
<div className="bg-error-muted" style={{ backgroundColor: 'blue' }}>
614+
Merged content
615+
</div>
616+
</Box>,
617+
);
618+
619+
const element = screen.getByTestId('merged-element');
620+
expect(element.tagName).toBe('DIV');
621+
expect(element).toHaveClass('bg-primary-muted');
622+
expect(element).toHaveClass('bg-error-muted');
623+
expect(element).toHaveClass(TWCLASSMAP_BOX_PADDING[2]);
624+
expect(element).toHaveStyle({ color: 'red', backgroundColor: 'blue' });
625+
});
626+
627+
it('applies flex classes to child element when asChild is true and flexDirection is provided', () => {
628+
render(
629+
<Box
630+
asChild
631+
data-testid="flex-child"
632+
flexDirection={BoxFlexDirection.Column}
633+
gap={3}
634+
>
635+
<section>Flex content</section>
636+
</Box>,
637+
);
638+
639+
const section = screen.getByTestId('flex-child');
640+
expect(section.tagName).toBe('SECTION');
641+
expect(section).toHaveClass('flex');
642+
expect(section).toHaveClass(BoxFlexDirection.Column);
643+
expect(section).toHaveClass(TWCLASSMAP_BOX_GAP[3]);
644+
});
564645
});

packages/design-system-react/src/components/Box/Box.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Slot } from '@radix-ui/react-slot';
12
import React, { forwardRef } from 'react';
23

34
import { twMerge } from '../../utils/tw-merge';
@@ -49,11 +50,14 @@ export const Box = forwardRef<HTMLDivElement, BoxProps>(
4950
backgroundColor,
5051
className = '',
5152
style,
53+
asChild,
5254
children,
5355
...props
5456
}: BoxProps,
5557
ref,
5658
) => {
59+
const Component = asChild ? Slot : 'div';
60+
5761
const mergedClassName = twMerge(
5862
flexDirection ? 'flex' : '',
5963
flexDirection,
@@ -96,9 +100,9 @@ export const Box = forwardRef<HTMLDivElement, BoxProps>(
96100
);
97101

98102
return (
99-
<div ref={ref} className={mergedClassName} style={style} {...props}>
103+
<Component ref={ref} className={mergedClassName} style={style} {...props}>
100104
{children}
101-
</div>
105+
</Component>
102106
);
103107
},
104108
);

packages/design-system-react/src/components/Box/Box.types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,11 @@ export type BoxProps = ComponentProps<'div'> & {
120120
* Optional prop for additional CSS classes to be applied to the Box component.
121121
*/
122122
className?: string;
123+
/**
124+
* Optional boolean that determines if the component should merge its props onto its immediate child
125+
* instead of rendering a div element
126+
*
127+
* @default false
128+
*/
129+
asChild?: boolean;
123130
};

packages/design-system-react/src/components/Box/README.mdx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,46 @@ The border width of the component. Uses only valid Tailwind CSS border width val
334334

335335
<Canvas of={BoxStories.BorderWidth} />
336336

337+
### `asChild`
338+
339+
When `true`, the Box component merges its props onto its immediate child element instead of rendering a `div`. This allows you to apply Box styling and layout properties to any HTML element or React component.
340+
341+
The `asChild` prop leverages Radix UI's Slot component to ensure proper prop merging and ref forwarding.
342+
343+
<table>
344+
<thead>
345+
<tr>
346+
<th align="left">TYPE</th>
347+
<th align="left">REQUIRED</th>
348+
<th align="left">DEFAULT</th>
349+
</tr>
350+
</thead>
351+
<tbody>
352+
<tr>
353+
<td align="left">
354+
<code>boolean</code>
355+
</td>
356+
<td align="left">No</td>
357+
<td align="left">
358+
<code>false</code>
359+
</td>
360+
</tr>
361+
</tbody>
362+
</table>
363+
364+
```tsx
365+
// Box as button (renders as button with Box styling)
366+
<Box asChild backgroundColor={BoxBackgroundColor.PrimaryMuted}>
367+
<button>
368+
<Text asChild>
369+
<span>Box rendered as button</span>
370+
</Text>
371+
</button>
372+
</Box>
373+
```
374+
375+
<Canvas of={BoxStories.AsChild} />
376+
337377
### `className`
338378

339379
Use the `className` prop to add custom CSS classes to the component. These classes will be merged with the component's default classes using `twMerge`, allowing you to:

0 commit comments

Comments
 (0)