Skip to content

Commit 641d956

Browse files
MrMuzyksatya164
andauthored
refactor: modernize badge component to latest MD3 spec (#5013)
BREAKING CHANGE: The `size` prop has been removed. Passing no children renders a 6dp dot. Passing children renders a 16dp pill. Co-authored-by: Satyajit Sahoo <satyajit.happy@gmail.com>
1 parent 466d9c6 commit 641d956

8 files changed

Lines changed: 1147 additions & 812 deletions

File tree

example/src/DrawerItems.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,7 @@ const DrawerItemsData = [
3232
icon: 'star',
3333
key: 1,
3434
right: ({ color }: { color: ColorValue }) => (
35-
<Badge
36-
visible
37-
size={8}
38-
style={[styles.badge, { backgroundColor: color }]}
39-
/>
35+
<Badge visible style={[styles.badge, { backgroundColor: color }]} />
4036
),
4137
},
4238
{ label: 'Sent mail', icon: 'send', key: 2 },
@@ -45,7 +41,7 @@ const DrawerItemsData = [
4541
label: 'A very long title that will be truncated',
4642
icon: 'delete',
4743
key: 4,
48-
right: () => <Badge visible size={8} style={styles.badge} />,
44+
right: () => <Badge visible style={styles.badge} />,
4945
},
5046
];
5147

example/src/Examples/BadgeExample.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const BadgeExample = () => {
3030
<View style={styles.row}>
3131
<View style={styles.item}>
3232
<IconButton icon="palette-swatch" size={36} style={styles.button} />
33-
<Badge visible={visible} style={styles.badge}>
33+
<Badge visible={visible} style={styles.textBadge}>
3434
12
3535
</Badge>
3636
</View>
@@ -39,7 +39,7 @@ const BadgeExample = () => {
3939
<Badge
4040
visible={visible}
4141
style={[
42-
styles.badge,
42+
styles.textBadge,
4343
{
4444
backgroundColor: Palette.primary80,
4545
},
@@ -54,11 +54,11 @@ const BadgeExample = () => {
5454
<View style={styles.row}>
5555
<View style={styles.item}>
5656
<IconButton icon="book-open" size={36} style={styles.button} />
57-
<Badge visible={visible} style={styles.badge} size={6} />
57+
<Badge visible={visible} style={styles.dotBadge} />
5858
</View>
5959
<View style={styles.item}>
6060
<IconButton icon="receipt" size={36} style={styles.button} />
61-
<Badge visible={visible} style={styles.badge} size={6} />
61+
<Badge visible={visible} style={styles.dotBadge} />
6262
</View>
6363
</View>
6464
</List.Section>
@@ -79,10 +79,15 @@ const styles = StyleSheet.create({
7979
button: {
8080
opacity: 0.6,
8181
},
82-
badge: {
82+
textBadge: {
8383
position: 'absolute',
84-
top: 4,
85-
right: 0,
84+
top: 12,
85+
left: 38,
86+
},
87+
dotBadge: {
88+
position: 'absolute',
89+
top: 14,
90+
right: 14,
8691
},
8792
label: {
8893
flex: 1,

src/components/Badge.tsx

Lines changed: 38 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1-
import * as React from 'react';
2-
import { Animated, StyleSheet, useWindowDimensions } from 'react-native';
3-
import type { StyleProp, TextStyle } from 'react-native';
1+
import type { StyleProp, TextProps, TextStyle } from 'react-native';
2+
import { StyleSheet } from 'react-native';
3+
4+
import Animated from 'react-native-reanimated';
45

56
import { useInternalTheme } from '../core/theming';
7+
import { cornerFull } from '../theme/tokens/sys/shape';
68
import type { ThemeProp } from '../types';
79

8-
const defaultSize = 20;
10+
const SMALL_SIZE = 6;
11+
const LARGE_SIZE = 16;
12+
const MAX_LARGE_WIDTH = 36;
13+
const LARGE_PADDING = 4;
914

10-
export type Props = React.ComponentProps<typeof Animated.Text> & {
15+
export type Props = TextProps & {
1116
/**
1217
* Whether the badge is visible
1318
*/
@@ -16,12 +21,7 @@ export type Props = React.ComponentProps<typeof Animated.Text> & {
1621
* Content of the `Badge`.
1722
*/
1823
children?: string | number;
19-
/**
20-
* Size of the `Badge`.
21-
*/
22-
size?: number;
2324
style?: StyleProp<TextStyle>;
24-
ref?: React.RefObject<typeof Animated.Text>;
2525
/**
2626
* @optional
2727
*/
@@ -32,6 +32,10 @@ export type Props = React.ComponentProps<typeof Animated.Text> & {
3232
* Badges are small status descriptors for UI elements.
3333
* A badge consists of a small circle, typically containing a number or other short set of characters, that appears in proximity to another object.
3434
*
35+
* The badge is styled differently based on whether `children` is passed:
36+
* - Small dot when it doesn't have `children`
37+
* - Larger pill when it has `children`
38+
*
3539
* ## Usage
3640
* ```js
3741
* import * as React from 'react';
@@ -46,64 +50,52 @@ export type Props = React.ComponentProps<typeof Animated.Text> & {
4650
*/
4751
const Badge = ({
4852
children,
49-
size = defaultSize,
5053
style,
5154
theme: themeOverrides,
5255
visible = true,
5356
...rest
5457
}: Props) => {
5558
const theme = useInternalTheme(themeOverrides);
56-
const { current: opacity } = React.useRef<Animated.Value>(
57-
new Animated.Value(visible ? 1 : 0)
58-
);
59-
const { fontScale } = useWindowDimensions();
60-
61-
const isFirstRendering = React.useRef<boolean>(true);
6259

6360
const {
6461
animation: { scale },
6562
} = theme;
6663

67-
React.useEffect(() => {
68-
// Do not run animation on very first rendering
69-
if (isFirstRendering.current) {
70-
isFirstRendering.current = false;
71-
return;
72-
}
73-
74-
Animated.timing(opacity, {
75-
toValue: visible ? 1 : 0,
76-
duration: 150 * scale,
77-
useNativeDriver: true,
78-
}).start();
79-
}, [visible, opacity, scale]);
80-
81-
const { backgroundColor = theme.colors.error, ...restStyle } =
82-
(StyleSheet.flatten(style) || {}) as TextStyle;
83-
8464
const textColor = theme.colors.onError;
8565

86-
const borderRadius = size / 2;
66+
const isLarge = children != null;
67+
const badgeSize = isLarge ? LARGE_SIZE : SMALL_SIZE;
68+
const labelFont = theme.fonts.labelSmall;
8769

88-
const paddingHorizontal = 3;
70+
const transitionStyle = {
71+
opacity: visible ? 1 : 0,
72+
transitionDuration: 150 * scale,
73+
transitionProperty: 'opacity',
74+
};
8975

9076
return (
9177
<Animated.Text
9278
numberOfLines={1}
79+
allowFontScaling={false}
9380
style={[
81+
styles.container,
82+
transitionStyle,
9483
{
95-
opacity,
96-
backgroundColor,
84+
backgroundColor: theme.colors.error,
9785
color: textColor,
98-
fontSize: size * 0.5,
99-
lineHeight: size / fontScale,
100-
height: size,
101-
minWidth: size,
102-
borderRadius,
103-
paddingHorizontal,
86+
borderRadius: cornerFull,
87+
height: badgeSize,
88+
minWidth: badgeSize,
10489
},
105-
styles.container,
106-
restStyle,
90+
isLarge && [
91+
labelFont,
92+
{
93+
lineHeight: LARGE_SIZE,
94+
maxWidth: MAX_LARGE_WIDTH,
95+
paddingHorizontal: LARGE_PADDING,
96+
},
97+
],
98+
style,
10799
]}
108100
{...rest}
109101
>

src/components/BottomNavigation/BottomNavigationBar.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -681,11 +681,9 @@ const BottomNavigationBar = <Route extends BaseRoute>({
681681
</Animated.View>
682682
<View style={[styles.badgeContainer, badgeStyle]}>
683683
{typeof badge === 'boolean' ? (
684-
<Badge visible={badge} size={6} />
684+
<Badge visible={badge} />
685685
) : (
686-
<Badge visible={badge != null} size={16}>
687-
{badge}
688-
</Badge>
686+
<Badge visible={badge != null}>{badge}</Badge>
689687
)}
690688
</View>
691689
</Animated.View>

src/components/Drawer/DrawerCollapsedItem.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ export type Props = ViewProps & {
6767
testID?: string;
6868
};
6969

70-
const badgeSize = 8;
7170
const iconSize = 24;
7271
const itemSize = 56;
7372
const outlineHeight = 32;
@@ -200,11 +199,9 @@ const DrawerCollapsedItem = ({
200199
{badge !== false && (
201200
<View style={styles.badgeContainer}>
202201
{typeof badge === 'boolean' ? (
203-
<Badge visible={badge} size={badgeSize} />
202+
<Badge visible={badge} />
204203
) : (
205-
<Badge visible={badge != null} size={2 * badgeSize}>
206-
{badge}
207-
</Badge>
204+
<Badge visible={badge != null}>{badge}</Badge>
208205
)}
209206
</View>
210207
)}
Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect, it } from '@jest/globals';
22

3-
import { render } from '../../test-utils';
3+
import { render, screen } from '../../test-utils';
44
import { red500 } from '../../theme/colors';
55
import Badge from '../Badge';
66

@@ -16,20 +16,8 @@ it('renders badge with content', async () => {
1616
expect(tree).toMatchSnapshot();
1717
});
1818

19-
it('renders badge in different size', async () => {
20-
const tree = (await render(<Badge size={12}>3</Badge>)).toJSON();
21-
22-
expect(tree).toMatchSnapshot();
23-
});
24-
2519
it('renders badge as hidden', async () => {
26-
const tree = (
27-
await render(
28-
<Badge visible={false} size={12}>
29-
3
30-
</Badge>
31-
)
32-
).toJSON();
20+
const tree = (await render(<Badge visible={false}>3</Badge>)).toJSON();
3321

3422
expect(tree).toMatchSnapshot();
3523
});
@@ -41,3 +29,39 @@ it('renders badge in different color', async () => {
4129

4230
expect(tree).toMatchSnapshot();
4331
});
32+
33+
it('applies small dot dimensions when no children', async () => {
34+
await render(<Badge testID="badge" />);
35+
36+
expect(screen.getByTestId('badge')).toHaveStyle({
37+
height: 6,
38+
minWidth: 6,
39+
borderRadius: 9999,
40+
});
41+
});
42+
43+
it('applies large pill dimensions when children are present', async () => {
44+
await render(<Badge testID="badge">3</Badge>);
45+
46+
expect(screen.getByTestId('badge')).toHaveStyle({
47+
height: 16,
48+
minWidth: 16,
49+
paddingHorizontal: 4,
50+
fontSize: 11,
51+
lineHeight: 16,
52+
borderRadius: 9999,
53+
});
54+
});
55+
56+
it('clips oversized label via maxWidth', async () => {
57+
await render(<Badge testID="badge">9999999</Badge>);
58+
59+
expect(screen.getByTestId('badge')).toHaveStyle({ maxWidth: 36 });
60+
});
61+
62+
it('does not apply typography or padding to dot badge', async () => {
63+
await render(<Badge testID="badge" />);
64+
65+
expect(screen.getByTestId('badge')).not.toHaveStyle({ paddingHorizontal: 4 });
66+
expect(screen.getByTestId('badge')).not.toHaveStyle({ fontSize: 11 });
67+
});

0 commit comments

Comments
 (0)