Skip to content

Commit 7514f25

Browse files
[DSRN] Added BadgeStatus (#471)
<!-- 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 `BadgeStatus` 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: #397 ## **Manual testing steps** 1. Run `yarn storybook:ios` from root 2. Go to Components > BadgeStatus 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/9dd91081-d9a7-4e87-a16c-1cdc5c6ceb47 <!-- [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 0671d38 commit 7514f25

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
@@ -54,6 +54,7 @@ const getStories = () => {
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"),
5656
"./../../packages/design-system-react-native/src/components/BadgeNetwork/BadgeNetwork.stories.tsx": require("../../../packages/design-system-react-native/src/components/BadgeNetwork/BadgeNetwork.stories.tsx"),
57+
"./../../packages/design-system-react-native/src/components/BadgeStatus/BadgeStatus.stories.tsx": require("../../../packages/design-system-react-native/src/components/BadgeStatus/BadgeStatus.stories.tsx"),
5758
"./../../packages/design-system-react-native/src/components/Button/Button.stories.tsx": require("../../../packages/design-system-react-native/src/components/Button/Button.stories.tsx"),
5859
"./../../packages/design-system-react-native/src/components/Button/variants/ButtonPrimary/ButtonPrimary.stories.tsx": require("../../../packages/design-system-react-native/src/components/Button/variants/ButtonPrimary/ButtonPrimary.stories.tsx"),
5960
"./../../packages/design-system-react-native/src/components/Button/variants/ButtonSecondary/ButtonSecondary.stories.tsx": require("../../../packages/design-system-react-native/src/components/Button/variants/ButtonSecondary/ButtonSecondary.stories.tsx"),
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { BadgeStatusStatus, BadgeStatusSize } from './BadgeStatus.types';
2+
3+
// Mappings
4+
export const TWCLASSMAP_BADGESTATUS_STATUS_CIRCLE: Record<
5+
BadgeStatusStatus,
6+
string
7+
> = {
8+
[BadgeStatusStatus.Active]: 'bg-success-default border-success-default',
9+
[BadgeStatusStatus.PartiallyActive]:
10+
'bg-background-default border-success-default',
11+
[BadgeStatusStatus.Inactive]: 'bg-icon-muted border-icon-muted',
12+
[BadgeStatusStatus.New]: 'bg-primary-default border-primary-default',
13+
[BadgeStatusStatus.Attention]: 'bg-error-default border-error-default',
14+
};
15+
16+
export const TWCLASSMAP_BADGESTATUS_SIZE: Record<BadgeStatusSize, string> = {
17+
[BadgeStatusSize.Md]: 'h-2 w-2', // 8px width and height
18+
[BadgeStatusSize.Lg]: 'h-2.5 w-2.5', // 10px width and height
19+
};
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type { Meta, StoryObj } from '@storybook/react-native';
2+
import { View, ViewProps } from 'react-native';
3+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
4+
5+
import BadgeStatus from './BadgeStatus';
6+
import type { BadgeStatusProps } from './BadgeStatus.types';
7+
import { BadgeStatusStatus, BadgeStatusSize } from './BadgeStatus.types';
8+
9+
const meta: Meta<BadgeStatusProps> = {
10+
title: 'Components/BadgeStatus',
11+
component: BadgeStatus,
12+
argTypes: {
13+
size: {
14+
control: 'select',
15+
options: BadgeStatusSize,
16+
},
17+
status: {
18+
control: 'select',
19+
options: BadgeStatusStatus,
20+
},
21+
hasBorder: {
22+
control: 'boolean',
23+
},
24+
twClassName: {
25+
control: 'text',
26+
},
27+
},
28+
};
29+
30+
export default meta;
31+
32+
const BadgeStatusStoryWrapper: React.FC<ViewProps> = ({
33+
children,
34+
...props
35+
}) => {
36+
const tw = useTailwind();
37+
return (
38+
<View {...props} style={[tw`bg-warning-muted`, props.style]}>
39+
{children}
40+
</View>
41+
);
42+
};
43+
44+
type Story = StoryObj<BadgeStatusProps>;
45+
46+
export const Default: Story = {
47+
args: {
48+
size: BadgeStatusSize.Md,
49+
status: BadgeStatusStatus.Active,
50+
hasBorder: true,
51+
twClassName: '',
52+
},
53+
render: (args) => (
54+
<BadgeStatusStoryWrapper>
55+
<BadgeStatus {...args} />
56+
</BadgeStatusStoryWrapper>
57+
),
58+
};
59+
60+
export const Sizes: Story = {
61+
render: () => (
62+
<BadgeStatusStoryWrapper style={{ gap: 16 }}>
63+
{Object.keys(BadgeStatusSize).map((sizeKey) => (
64+
<BadgeStatus
65+
key={sizeKey}
66+
size={BadgeStatusSize[sizeKey as keyof typeof BadgeStatusSize]}
67+
status={BadgeStatusStatus.Active}
68+
/>
69+
))}
70+
</BadgeStatusStoryWrapper>
71+
),
72+
};
73+
74+
export const Statuses: Story = {
75+
render: () => (
76+
<BadgeStatusStoryWrapper style={{ gap: 16 }}>
77+
{Object.keys(BadgeStatusStatus).map((statusKey) => (
78+
<BadgeStatus
79+
key={statusKey}
80+
status={
81+
BadgeStatusStatus[statusKey as keyof typeof BadgeStatusStatus]
82+
}
83+
/>
84+
))}
85+
</BadgeStatusStoryWrapper>
86+
),
87+
};
88+
89+
export const HasBorder: Story = {
90+
render: () => (
91+
<BadgeStatusStoryWrapper style={{ gap: 16 }}>
92+
{Object.keys(BadgeStatusStatus).map((statusKey) => (
93+
<View key={statusKey} style={{ gap: 4 }}>
94+
<BadgeStatus
95+
status={
96+
BadgeStatusStatus[statusKey as keyof typeof BadgeStatusStatus]
97+
}
98+
/>
99+
<BadgeStatus
100+
status={
101+
BadgeStatusStatus[statusKey as keyof typeof BadgeStatusStatus]
102+
}
103+
hasBorder={false}
104+
/>
105+
</View>
106+
))}
107+
</BadgeStatusStoryWrapper>
108+
),
109+
};
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import React from 'react';
2+
import { render } from '@testing-library/react-native';
3+
import BadgeStatus from './BadgeStatus';
4+
import { BadgeStatusStatus, BadgeStatusSize } from './BadgeStatus.types';
5+
import {
6+
TWCLASSMAP_BADGESTATUS_STATUS_CIRCLE,
7+
TWCLASSMAP_BADGESTATUS_SIZE,
8+
} from './BadgeStatus.constants';
9+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
10+
11+
describe('BadgeStatus', () => {
12+
it('renders with default props and status Active', () => {
13+
let expectedOuter;
14+
let expectedInner;
15+
const TestComponent = () => {
16+
const tw = useTailwind();
17+
const finalSize = BadgeStatusSize.Md;
18+
expectedOuter = tw`
19+
self-start
20+
rounded-full
21+
border-2 border-background-default
22+
`;
23+
expectedInner = tw`
24+
rounded-full
25+
border-2
26+
${TWCLASSMAP_BADGESTATUS_SIZE[finalSize]}
27+
${TWCLASSMAP_BADGESTATUS_STATUS_CIRCLE[BadgeStatusStatus.Active]}
28+
`;
29+
return <BadgeStatus status={BadgeStatusStatus.Active} testID="badge" />;
30+
};
31+
32+
const { getByTestId } = render(<TestComponent />);
33+
const badge = getByTestId('badge');
34+
expect(badge.props.style[0]).toStrictEqual(expectedOuter);
35+
// The inner view is rendered as the child of the outer View.
36+
const inner = badge.props.children[1];
37+
expect(inner.props.style[0]).toStrictEqual(expectedInner);
38+
});
39+
40+
it('renders without border when hasBorder is false', () => {
41+
let expectedOuter;
42+
const TestComponent = () => {
43+
const tw = useTailwind();
44+
expectedOuter = tw`
45+
self-start
46+
rounded-full
47+
`;
48+
return (
49+
<BadgeStatus
50+
status={BadgeStatusStatus.New}
51+
hasBorder={false}
52+
testID="badge"
53+
/>
54+
);
55+
};
56+
57+
const { getByTestId } = render(<TestComponent />);
58+
const badge = getByTestId('badge');
59+
expect(badge.props.style[0]).toStrictEqual(expectedOuter);
60+
});
61+
62+
it('applies custom style to the outer container', () => {
63+
const customStyle = { margin: 10 };
64+
const TestComponent = () => {
65+
return (
66+
<BadgeStatus
67+
status={BadgeStatusStatus.Inactive}
68+
style={customStyle}
69+
testID="badge"
70+
/>
71+
);
72+
};
73+
74+
const { getByTestId } = render(<TestComponent />);
75+
const badge = getByTestId('badge');
76+
// The outer container style is an array; the second element should equal customStyle.
77+
expect(badge.props.style[1]).toStrictEqual(customStyle);
78+
});
79+
80+
it('forwards additional props to the outer container', () => {
81+
const extraProp = { accessibilityLabel: 'status-badge' };
82+
const TestComponent = () => {
83+
return (
84+
<BadgeStatus
85+
status={BadgeStatusStatus.Attention}
86+
testID="badge"
87+
{...extraProp}
88+
/>
89+
);
90+
};
91+
92+
const { getByTestId } = render(<TestComponent />);
93+
const badge = getByTestId('badge');
94+
expect(badge.props.accessibilityLabel).toStrictEqual('status-badge');
95+
});
96+
97+
it('renders with custom size and status PartiallyActive', () => {
98+
let expectedInner;
99+
const customSize = BadgeStatusSize.Lg; // For example, '10'
100+
const TestComponent = () => {
101+
const tw = useTailwind();
102+
expectedInner = tw`
103+
rounded-full
104+
border-2
105+
${TWCLASSMAP_BADGESTATUS_SIZE[customSize]}
106+
${TWCLASSMAP_BADGESTATUS_STATUS_CIRCLE[BadgeStatusStatus.PartiallyActive]}
107+
`;
108+
return (
109+
<BadgeStatus
110+
status={BadgeStatusStatus.PartiallyActive}
111+
size={customSize}
112+
testID="badge"
113+
/>
114+
);
115+
};
116+
117+
const { getByTestId } = render(<TestComponent />);
118+
const badge = getByTestId('badge');
119+
const inner = badge.props.children[1];
120+
expect(inner.props.style[0]).toStrictEqual(expectedInner);
121+
});
122+
123+
it('uses default size and hasBorder when not provided', () => {
124+
let expectedOuter;
125+
let expectedInner;
126+
const TestComponent = () => {
127+
const tw = useTailwind();
128+
const defaultSize = BadgeStatusSize.Md;
129+
expectedOuter = tw`
130+
self-start
131+
rounded-full
132+
border-2 border-background-default
133+
`;
134+
expectedInner = tw`
135+
rounded-full
136+
border-2
137+
${TWCLASSMAP_BADGESTATUS_SIZE[defaultSize]}
138+
${TWCLASSMAP_BADGESTATUS_STATUS_CIRCLE[BadgeStatusStatus.Active]}
139+
`;
140+
return <BadgeStatus status={BadgeStatusStatus.Active} testID="badge" />;
141+
};
142+
143+
const { getByTestId } = render(<TestComponent />);
144+
const badge = getByTestId('badge');
145+
expect(badge.props.style[0]).toStrictEqual(expectedOuter);
146+
const inner = badge.props.children[1];
147+
expect(inner.props.style[0]).toStrictEqual(expectedInner);
148+
});
149+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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 {
7+
TWCLASSMAP_BADGESTATUS_STATUS_CIRCLE,
8+
TWCLASSMAP_BADGESTATUS_SIZE,
9+
} from './BadgeStatus.constants';
10+
import type { BadgeStatusProps } from './BadgeStatus.types';
11+
import { BadgeStatusSize } from './BadgeStatus.types';
12+
13+
const BadgeStatus = ({
14+
status,
15+
size = BadgeStatusSize.Md,
16+
hasBorder = true,
17+
twClassName = '',
18+
style,
19+
...props
20+
}: BadgeStatusProps) => {
21+
const tw = useTailwind();
22+
23+
return (
24+
<View
25+
style={[
26+
tw`
27+
self-start
28+
rounded-full
29+
${hasBorder ? 'border-2 border-background-default' : ''}
30+
${twClassName}`,
31+
style,
32+
]}
33+
{...props}
34+
>
35+
<View
36+
style={tw`bg-background-default absolute top-0 left-0 bottom-0 right-0 rounded-full`}
37+
/>
38+
<View
39+
style={[
40+
tw`
41+
rounded-full
42+
border-2
43+
${TWCLASSMAP_BADGESTATUS_SIZE[size]}
44+
${TWCLASSMAP_BADGESTATUS_STATUS_CIRCLE[status]}
45+
`,
46+
]}
47+
/>
48+
</View>
49+
);
50+
};
51+
52+
export default BadgeStatus;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { ViewProps } from 'react-native';
2+
3+
/**
4+
* The status of BadgeStatus
5+
*/
6+
export enum BadgeStatusStatus {
7+
Active = 'active',
8+
PartiallyActive = 'partiallyactive',
9+
Inactive = 'inactive',
10+
New = 'new',
11+
Attention = 'attention',
12+
}
13+
/**
14+
* The size of BadgeStatus
15+
*/
16+
export enum BadgeStatusSize {
17+
/**
18+
* Represents a medium badge status size (8px).
19+
*/
20+
Md = 'Md',
21+
/**
22+
* Represents a large avatar size (10px).
23+
*/
24+
Lg = 'Lg',
25+
}
26+
/**
27+
* BadgeStatus component props.
28+
*/
29+
export type BadgeStatusProps = {
30+
/**
31+
* Optional prop to control the status of the badge
32+
* Possible values:
33+
* - BadgeStatusStatus.Active.
34+
* - BadgeStatusStatus.PartiallyActive.
35+
* - BadgeStatusStatus.Inactive.
36+
* - BadgeStatusStatus.New.
37+
* - BadgeStatusStatus.Attention.
38+
*/
39+
status: BadgeStatusStatus;
40+
/**
41+
* Optional prop to determine whether the badge should display a border
42+
* @default true
43+
*/
44+
hasBorder?: boolean;
45+
/**
46+
* Optional prop to control the size of the BadgeStatus
47+
* Possible values:
48+
* - BadgeStatusSize.Md (8px),
49+
* - BadgeStatusSize.Lg (10px),
50+
* @default AvatarBaseSize.Md
51+
*/
52+
size?: BadgeStatusSize;
53+
/**
54+
* Optional prop to add twrnc overriding classNames.
55+
*/
56+
twClassName?: string;
57+
} & Omit<ViewProps, 'children'>;

0 commit comments

Comments
 (0)