Skip to content

Commit e965f82

Browse files
[DSRN] Added BadgeCount (#472)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** This PR adds the `BadgeCount` component to the `@metamask/design-system-react-native` package <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Related issues** Fixes: #400 ## **Manual testing steps** 1. Run `yarn storybook:ios` from root 2. Go to Components > BadgeCount 3. ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** https://github.com/user-attachments/assets/9153da12-d96a-451f-853f-e170bda1a90f <!-- [screenshots/recordings] --> ## **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. --------- Co-authored-by: George Marshall <[email protected]>
1 parent 46195b0 commit e965f82

File tree

9 files changed

+559
-0
lines changed

9 files changed

+559
-0
lines changed

apps/storybook-react-native/.storybook/storybook.requires.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const getStories = () => {
5353
"./../../packages/design-system-react-native/src/components/AvatarIcon/AvatarIcon.stories.tsx": require("../../../packages/design-system-react-native/src/components/AvatarIcon/AvatarIcon.stories.tsx"),
5454
"./../../packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.stories.tsx": require("../../../packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.stories.tsx"),
5555
"./../../packages/design-system-react-native/src/components/AvatarToken/AvatarToken.stories.tsx": require("../../../packages/design-system-react-native/src/components/AvatarToken/AvatarToken.stories.tsx"),
56+
"./../../packages/design-system-react-native/src/components/BadgeCount/BadgeCount.stories.tsx": require("../../../packages/design-system-react-native/src/components/BadgeCount/BadgeCount.stories.tsx"),
5657
"./../../packages/design-system-react-native/src/components/BadgeNetwork/BadgeNetwork.stories.tsx": require("../../../packages/design-system-react-native/src/components/BadgeNetwork/BadgeNetwork.stories.tsx"),
5758
"./../../packages/design-system-react-native/src/components/BadgeStatus/BadgeStatus.stories.tsx": require("../../../packages/design-system-react-native/src/components/BadgeStatus/BadgeStatus.stories.tsx"),
5859
"./../../packages/design-system-react-native/src/components/Button/Button.stories.tsx": require("../../../packages/design-system-react-native/src/components/Button/Button.stories.tsx"),
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { TextVariant } from '../Text';
2+
import { BadgeCountSize } from './BadgeCount.types';
3+
4+
// Mappings
5+
export const MAP_BADGECOUNT_SIZE_TEXTVARIANT: Record<
6+
BadgeCountSize,
7+
TextVariant
8+
> = {
9+
[BadgeCountSize.Md]: TextVariant.BodyXs,
10+
[BadgeCountSize.Lg]: TextVariant.BodySm,
11+
};
12+
export const MAP_BADGECOUNT_SIZE_LINEHEIGHT: Record<BadgeCountSize, string> = {
13+
[BadgeCountSize.Md]: 'leading-[14px]', // line-height 14px
14+
[BadgeCountSize.Lg]: 'leading-4', // line-height 16px
15+
};
16+
17+
export const TWCLASSMAP_BADGECOUNT_SIZE_CONTAINER: Record<
18+
BadgeCountSize,
19+
string
20+
> = {
21+
[BadgeCountSize.Md]: 'min-w-4 h-3.5 py-0 px-1', // min-width 16px, height 14px, padding-vertical 0, padding-horizontal 4
22+
[BadgeCountSize.Lg]: 'min-w-6 h-5 py-0.5 px-1.5', // min-width 24px, height 20px, padding-vertical 2, padding-horizontal 6
23+
};
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { Meta, StoryObj } from '@storybook/react-native';
2+
import { View } from 'react-native';
3+
4+
import BadgeCount from './BadgeCount';
5+
import { BadgeCountSize } from './BadgeCount.types';
6+
import type { BadgeCountProps } from './BadgeCount.types';
7+
8+
const meta: Meta<BadgeCountProps> = {
9+
title: 'Components/BadgeCount',
10+
component: BadgeCount,
11+
argTypes: {
12+
size: {
13+
control: 'select',
14+
options: BadgeCountSize,
15+
},
16+
count: {
17+
control: 'number',
18+
},
19+
max: {
20+
control: 'number',
21+
},
22+
twClassName: {
23+
control: 'text',
24+
},
25+
},
26+
};
27+
28+
export default meta;
29+
30+
type Story = StoryObj<BadgeCountProps>;
31+
32+
export const Default: Story = {
33+
args: {
34+
size: BadgeCountSize.Md,
35+
count: 8,
36+
max: 99,
37+
twClassName: '',
38+
},
39+
};
40+
41+
export const Sizes: Story = {
42+
render: () => (
43+
<View style={{ gap: 8 }}>
44+
{Object.values(BadgeCountSize).map((size) => (
45+
<BadgeCount key={size} size={size} count={100} />
46+
))}
47+
</View>
48+
),
49+
};
50+
51+
export const Max: Story = {
52+
render: () => (
53+
<View style={{ gap: 16 }}>
54+
<BadgeCount count={10} />
55+
<BadgeCount count={100} />
56+
</View>
57+
),
58+
};
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import React from 'react';
2+
import { render } from '@testing-library/react-native';
3+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
4+
5+
import Text, { TextColor, FontWeight } from '../Text';
6+
import BadgeCount from './BadgeCount';
7+
import { BadgeCountSize } from './BadgeCount.types';
8+
import {
9+
MAP_BADGECOUNT_SIZE_TEXTVARIANT,
10+
TWCLASSMAP_BADGECOUNT_SIZE_CONTAINER,
11+
MAP_BADGECOUNT_SIZE_LINEHEIGHT,
12+
} from './BadgeCount.constants';
13+
14+
describe('BadgeCount', () => {
15+
it('renders with default props and count less than max', () => {
16+
const TestComponent = () => {
17+
const tw = useTailwind();
18+
const sizeVal = BadgeCountSize.Md;
19+
const computedExpectedOuter = tw`
20+
bg-error-default
21+
rounded-lg
22+
items-center
23+
justify-center
24+
self-start
25+
${TWCLASSMAP_BADGECOUNT_SIZE_CONTAINER[sizeVal]}
26+
`;
27+
const expectedTextProps = {
28+
variant: MAP_BADGECOUNT_SIZE_TEXTVARIANT[sizeVal],
29+
color: TextColor.ErrorInverse,
30+
fontWeight: FontWeight.Medium,
31+
// Note: trailing space comes from template literal if textProps.twClassName is undefined.
32+
twClassName: `${MAP_BADGECOUNT_SIZE_LINEHEIGHT[sizeVal]} `,
33+
};
34+
return (
35+
<>
36+
<BadgeCount count={50} testID="badge-count" />
37+
<Text testID="expectedOuter">
38+
{JSON.stringify(computedExpectedOuter)}
39+
</Text>
40+
<Text testID="expectedTextProps">
41+
{JSON.stringify(expectedTextProps)}
42+
</Text>
43+
</>
44+
);
45+
};
46+
47+
const { getByTestId } = render(<TestComponent />);
48+
const expectedOuter = JSON.parse(
49+
getByTestId('expectedOuter').props.children,
50+
);
51+
const expectedTextProps = JSON.parse(
52+
getByTestId('expectedTextProps').props.children,
53+
);
54+
const container = getByTestId('badge-count');
55+
// Outer container style (first element of style array) should match expected
56+
expect(container.props.style[0]).toStrictEqual(expectedOuter);
57+
// Inner Text should render the count as a string
58+
const textElement = container.props.children;
59+
expect(textElement.props.children).toStrictEqual('50');
60+
// Verify that Text props are set correctly
61+
expect(textElement.props.variant).toStrictEqual(expectedTextProps.variant);
62+
expect(textElement.props.color).toStrictEqual(expectedTextProps.color);
63+
expect(textElement.props.fontWeight).toStrictEqual(
64+
expectedTextProps.fontWeight,
65+
);
66+
expect(textElement.props.twClassName).toContain(
67+
MAP_BADGECOUNT_SIZE_LINEHEIGHT[BadgeCountSize.Md],
68+
);
69+
});
70+
71+
it('renders with count greater than max (overflow)', () => {
72+
const TestComponent = () => {
73+
const tw = useTailwind();
74+
const sizeVal = BadgeCountSize.Md;
75+
const computedExpectedOuter = tw`
76+
bg-error-default
77+
rounded-lg
78+
items-center
79+
justify-center
80+
self-start
81+
${TWCLASSMAP_BADGECOUNT_SIZE_CONTAINER[sizeVal]}
82+
`;
83+
return (
84+
<>
85+
<BadgeCount count={150} max={99} testID="badge-count" />
86+
<Text testID="expectedOuter">
87+
{JSON.stringify(computedExpectedOuter)}
88+
</Text>
89+
</>
90+
);
91+
};
92+
93+
const { getByTestId } = render(<TestComponent />);
94+
const expectedOuter = JSON.parse(
95+
getByTestId('expectedOuter').props.children,
96+
);
97+
const container = getByTestId('badge-count');
98+
expect(container.props.style[0]).toStrictEqual(expectedOuter);
99+
const textElement = container.props.children;
100+
// When count > max, text should be "99+"
101+
expect(textElement.props.children).toStrictEqual('99+');
102+
});
103+
104+
it('applies custom textProps overrides', () => {
105+
const customTextProps = {
106+
color: TextColor.PrimaryDefault,
107+
fontWeight: FontWeight.Bold,
108+
twClassName: 'custom',
109+
};
110+
const TestComponent = () => {
111+
const tw = useTailwind();
112+
const sizeVal = BadgeCountSize.Md;
113+
const computedExpectedOuter = tw`
114+
bg-error-default
115+
rounded-lg
116+
items-center
117+
justify-center
118+
self-start
119+
${TWCLASSMAP_BADGECOUNT_SIZE_CONTAINER[sizeVal]}
120+
`;
121+
const expectedTextProps = {
122+
variant: MAP_BADGECOUNT_SIZE_TEXTVARIANT[sizeVal],
123+
color: customTextProps.color, // overridden
124+
fontWeight: customTextProps.fontWeight, // overridden
125+
twClassName: `${MAP_BADGECOUNT_SIZE_LINEHEIGHT[sizeVal]} ${customTextProps.twClassName}`,
126+
};
127+
return (
128+
<>
129+
<BadgeCount
130+
count={25}
131+
textProps={customTextProps}
132+
testID="badge-count"
133+
/>
134+
<Text testID="expectedOuter">
135+
{JSON.stringify(computedExpectedOuter)}
136+
</Text>
137+
<Text testID="expectedTextProps">
138+
{JSON.stringify(expectedTextProps)}
139+
</Text>
140+
</>
141+
);
142+
};
143+
144+
const { getByTestId } = render(<TestComponent />);
145+
const expectedOuter = JSON.parse(
146+
getByTestId('expectedOuter').props.children,
147+
);
148+
const expectedTextProps = JSON.parse(
149+
getByTestId('expectedTextProps').props.children,
150+
);
151+
const container = getByTestId('badge-count');
152+
expect(container.props.style[0]).toStrictEqual(expectedOuter);
153+
const textElement = container.props.children;
154+
expect(textElement.props.children).toStrictEqual('25');
155+
expect(textElement.props.variant).toStrictEqual(expectedTextProps.variant);
156+
expect(textElement.props.color).toStrictEqual(expectedTextProps.color);
157+
expect(textElement.props.fontWeight).toStrictEqual(
158+
expectedTextProps.fontWeight,
159+
);
160+
expect(textElement.props.twClassName).toContain(
161+
MAP_BADGECOUNT_SIZE_LINEHEIGHT[BadgeCountSize.Md],
162+
);
163+
expect(textElement.props.twClassName).toContain('custom');
164+
});
165+
166+
it('applies additional container style and forwards extra props', () => {
167+
const customStyle = { margin: 10 };
168+
const extraProp = { accessibilityLabel: 'badge' };
169+
const TestComponent = () => (
170+
<BadgeCount
171+
count={10}
172+
style={customStyle}
173+
testID="badge-count"
174+
{...extraProp}
175+
/>
176+
);
177+
const { getByTestId } = render(<TestComponent />);
178+
const container = getByTestId('badge-count');
179+
// The container style is an array; customStyle should be included.
180+
expect(container.props.style).toEqual(
181+
expect.arrayContaining([customStyle]),
182+
);
183+
expect(container.props.accessibilityLabel).toStrictEqual('badge');
184+
});
185+
186+
it('renders with custom size Lg', () => {
187+
const customSize = BadgeCountSize.Lg;
188+
const TestComponent = () => {
189+
const tw = useTailwind();
190+
const computedExpectedOuter = tw`
191+
bg-error-default
192+
rounded-lg
193+
items-center
194+
justify-center
195+
self-start
196+
${TWCLASSMAP_BADGECOUNT_SIZE_CONTAINER[customSize]}
197+
`;
198+
const expectedTextProps = {
199+
variant: MAP_BADGECOUNT_SIZE_TEXTVARIANT[customSize],
200+
color: TextColor.ErrorInverse,
201+
fontWeight: FontWeight.Medium,
202+
twClassName: `${MAP_BADGECOUNT_SIZE_LINEHEIGHT[customSize]} `,
203+
};
204+
return (
205+
<>
206+
<BadgeCount count={5} size={customSize} testID="badge-count" />
207+
<Text testID="expectedOuter">
208+
{JSON.stringify(computedExpectedOuter)}
209+
</Text>
210+
<Text testID="expectedTextProps">
211+
{JSON.stringify(expectedTextProps)}
212+
</Text>
213+
</>
214+
);
215+
};
216+
217+
const { getByTestId } = render(<TestComponent />);
218+
const expectedOuter = JSON.parse(
219+
getByTestId('expectedOuter').props.children,
220+
);
221+
const expectedTextProps = JSON.parse(
222+
getByTestId('expectedTextProps').props.children,
223+
);
224+
const container = getByTestId('badge-count');
225+
expect(container.props.style[0]).toStrictEqual(expectedOuter);
226+
const textElement = container.props.children;
227+
expect(textElement.props.variant).toStrictEqual(expectedTextProps.variant);
228+
expect(textElement.props.twClassName).toContain(
229+
MAP_BADGECOUNT_SIZE_LINEHEIGHT[customSize],
230+
);
231+
});
232+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
2+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
3+
import React from 'react';
4+
import { View } from 'react-native';
5+
6+
import Text, { TextColor, FontWeight } from '../Text';
7+
import {
8+
MAP_BADGECOUNT_SIZE_TEXTVARIANT,
9+
TWCLASSMAP_BADGECOUNT_SIZE_CONTAINER,
10+
MAP_BADGECOUNT_SIZE_LINEHEIGHT,
11+
} from './BadgeCount.constants';
12+
import type { BadgeCountProps } from './BadgeCount.types';
13+
import { BadgeCountSize } from './BadgeCount.types';
14+
15+
const BadgeCount = ({
16+
size = BadgeCountSize.Md,
17+
count,
18+
max = 99,
19+
textProps,
20+
twClassName = '',
21+
style,
22+
...props
23+
}: BadgeCountProps) => {
24+
const tw = useTailwind();
25+
const twContainerClassNames = `
26+
bg-error-default
27+
rounded-lg
28+
items-center
29+
justify-center
30+
self-start
31+
${TWCLASSMAP_BADGECOUNT_SIZE_CONTAINER[size]}
32+
${twClassName}`;
33+
34+
return (
35+
<View style={[tw`${twContainerClassNames}`, style]} {...props}>
36+
<Text
37+
variant={MAP_BADGECOUNT_SIZE_TEXTVARIANT[size as BadgeCountSize]}
38+
color={TextColor.ErrorInverse}
39+
fontWeight={FontWeight.Medium}
40+
{...textProps}
41+
twClassName={`${MAP_BADGECOUNT_SIZE_LINEHEIGHT[size]} ${textProps?.twClassName || ''}`}
42+
>
43+
{count > max ? `${max}+` : `${count}`}
44+
</Text>
45+
</View>
46+
);
47+
};
48+
49+
export default BadgeCount;

0 commit comments

Comments
 (0)