Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions example/src/DrawerItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,7 @@ const DrawerItemsData = [
icon: 'star',
key: 1,
right: ({ color }: { color: ColorValue }) => (
<Badge
visible
size={8}
style={[styles.badge, { backgroundColor: color }]}
/>
<Badge visible style={[styles.badge, { backgroundColor: color }]} />
),
},
{ label: 'Sent mail', icon: 'send', key: 2 },
Expand All @@ -45,7 +41,7 @@ const DrawerItemsData = [
label: 'A very long title that will be truncated',
icon: 'delete',
key: 4,
right: () => <Badge visible size={8} style={styles.badge} />,
right: () => <Badge visible style={styles.badge} />,
},
];

Expand Down
19 changes: 12 additions & 7 deletions example/src/Examples/BadgeExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const BadgeExample = () => {
<View style={styles.row}>
<View style={styles.item}>
<IconButton icon="palette-swatch" size={36} style={styles.button} />
<Badge visible={visible} style={styles.badge}>
<Badge visible={visible} style={styles.textBadge}>
12
</Badge>
</View>
Expand All @@ -39,7 +39,7 @@ const BadgeExample = () => {
<Badge
visible={visible}
style={[
styles.badge,
styles.textBadge,
{
backgroundColor: Palette.primary80,
},
Expand All @@ -54,11 +54,11 @@ const BadgeExample = () => {
<View style={styles.row}>
<View style={styles.item}>
<IconButton icon="book-open" size={36} style={styles.button} />
<Badge visible={visible} style={styles.badge} size={6} />
<Badge visible={visible} style={styles.dotBadge} />
</View>
<View style={styles.item}>
<IconButton icon="receipt" size={36} style={styles.button} />
<Badge visible={visible} style={styles.badge} size={6} />
<Badge visible={visible} style={styles.dotBadge} />
</View>
</View>
</List.Section>
Expand All @@ -79,10 +79,15 @@ const styles = StyleSheet.create({
button: {
opacity: 0.6,
},
badge: {
textBadge: {
position: 'absolute',
top: 4,
right: 0,
top: 12,
left: 38,
},
dotBadge: {
position: 'absolute',
top: 14,
right: 14,
},
label: {
flex: 1,
Expand Down
84 changes: 38 additions & 46 deletions src/components/Badge.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import * as React from 'react';
import { Animated, StyleSheet, useWindowDimensions } from 'react-native';
import type { StyleProp, TextStyle } from 'react-native';
import type { StyleProp, TextProps, TextStyle } from 'react-native';
import { StyleSheet } from 'react-native';

import Animated from 'react-native-reanimated';

import { useInternalTheme } from '../core/theming';
import { cornerFull } from '../theme/tokens/sys/shape';
import type { ThemeProp } from '../types';

const defaultSize = 20;
const SMALL_SIZE = 6;
const LARGE_SIZE = 16;
const MAX_LARGE_WIDTH = 36;
const LARGE_PADDING = 4;

export type Props = React.ComponentProps<typeof Animated.Text> & {
export type Props = TextProps & {
/**
* Whether the badge is visible
*/
Expand All @@ -16,12 +21,7 @@ export type Props = React.ComponentProps<typeof Animated.Text> & {
* Content of the `Badge`.
*/
children?: string | number;
/**
* Size of the `Badge`.
*/
size?: number;
style?: StyleProp<TextStyle>;
ref?: React.RefObject<typeof Animated.Text>;
/**
* @optional
*/
Expand All @@ -32,6 +32,10 @@ export type Props = React.ComponentProps<typeof Animated.Text> & {
* Badges are small status descriptors for UI elements.
* A badge consists of a small circle, typically containing a number or other short set of characters, that appears in proximity to another object.
*
* The badge is styled differently based on whether `children` is passed:
* - Small dot when it doesn't have `children`
* - Larger pill when it has `children`
*
* ## Usage
* ```js
* import * as React from 'react';
Expand All @@ -46,64 +50,52 @@ export type Props = React.ComponentProps<typeof Animated.Text> & {
*/
const Badge = ({
children,
size = defaultSize,
style,
theme: themeOverrides,
visible = true,
...rest
}: Props) => {
const theme = useInternalTheme(themeOverrides);
const { current: opacity } = React.useRef<Animated.Value>(
new Animated.Value(visible ? 1 : 0)
);
const { fontScale } = useWindowDimensions();

const isFirstRendering = React.useRef<boolean>(true);

const {
animation: { scale },
} = theme;

React.useEffect(() => {
// Do not run animation on very first rendering
if (isFirstRendering.current) {
isFirstRendering.current = false;
return;
}

Animated.timing(opacity, {
toValue: visible ? 1 : 0,
duration: 150 * scale,
useNativeDriver: true,
}).start();
}, [visible, opacity, scale]);

const { backgroundColor = theme.colors.error, ...restStyle } =
(StyleSheet.flatten(style) || {}) as TextStyle;

const textColor = theme.colors.onError;

const borderRadius = size / 2;
const isLarge = children != null;
const badgeSize = isLarge ? LARGE_SIZE : SMALL_SIZE;
const labelFont = theme.fonts.labelSmall;

const paddingHorizontal = 3;
const transitionStyle = {
opacity: visible ? 1 : 0,
transitionDuration: 150 * scale,
transitionProperty: 'opacity',
};

return (
<Animated.Text
numberOfLines={1}
allowFontScaling={false}
style={[
styles.container,
transitionStyle,
{
opacity,
backgroundColor,
backgroundColor: theme.colors.error,
color: textColor,
fontSize: size * 0.5,
lineHeight: size / fontScale,
height: size,
minWidth: size,
borderRadius,
paddingHorizontal,
borderRadius: cornerFull,
height: badgeSize,
minWidth: badgeSize,
},
styles.container,
restStyle,
isLarge && [
labelFont,
{
lineHeight: LARGE_SIZE,
maxWidth: MAX_LARGE_WIDTH,
paddingHorizontal: LARGE_PADDING,
},
],
style,
]}
{...rest}
>
Expand Down
6 changes: 2 additions & 4 deletions src/components/BottomNavigation/BottomNavigationBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -681,11 +681,9 @@ const BottomNavigationBar = <Route extends BaseRoute>({
</Animated.View>
<View style={[styles.badgeContainer, badgeStyle]}>
{typeof badge === 'boolean' ? (
<Badge visible={badge} size={6} />
<Badge visible={badge} />
) : (
<Badge visible={badge != null} size={16}>
{badge}
</Badge>
<Badge visible={badge != null}>{badge}</Badge>
)}
</View>
</Animated.View>
Expand Down
7 changes: 2 additions & 5 deletions src/components/Drawer/DrawerCollapsedItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ export type Props = ViewProps & {
testID?: string;
};

const badgeSize = 8;
const iconSize = 24;
const itemSize = 56;
const outlineHeight = 32;
Expand Down Expand Up @@ -200,11 +199,9 @@ const DrawerCollapsedItem = ({
{badge !== false && (
<View style={styles.badgeContainer}>
{typeof badge === 'boolean' ? (
<Badge visible={badge} size={badgeSize} />
<Badge visible={badge} />
) : (
<Badge visible={badge != null} size={2 * badgeSize}>
{badge}
</Badge>
<Badge visible={badge != null}>{badge}</Badge>
)}
</View>
)}
Expand Down
52 changes: 38 additions & 14 deletions src/components/__tests__/Badge.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, it } from '@jest/globals';

import { render } from '../../test-utils';
import { render, screen } from '../../test-utils';
import { red500 } from '../../theme/colors';
import Badge from '../Badge';

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

it('renders badge in different size', async () => {
const tree = (await render(<Badge size={12}>3</Badge>)).toJSON();

expect(tree).toMatchSnapshot();
});

it('renders badge as hidden', async () => {
const tree = (
await render(
<Badge visible={false} size={12}>
3
</Badge>
)
).toJSON();
const tree = (await render(<Badge visible={false}>3</Badge>)).toJSON();

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

expect(tree).toMatchSnapshot();
});

it('applies small dot dimensions when no children', async () => {
await render(<Badge testID="badge" />);

expect(screen.getByTestId('badge')).toHaveStyle({
height: 6,
minWidth: 6,
borderRadius: 9999,
});
});

it('applies large pill dimensions when children are present', async () => {
await render(<Badge testID="badge">3</Badge>);

expect(screen.getByTestId('badge')).toHaveStyle({
height: 16,
minWidth: 16,
paddingHorizontal: 4,
fontSize: 11,
lineHeight: 16,
borderRadius: 9999,
});
});

it('clips oversized label via maxWidth', async () => {
await render(<Badge testID="badge">9999999</Badge>);

expect(screen.getByTestId('badge')).toHaveStyle({ maxWidth: 36 });
});

it('does not apply typography or padding to dot badge', async () => {
await render(<Badge testID="badge" />);

expect(screen.getByTestId('badge')).not.toHaveStyle({ paddingHorizontal: 4 });
expect(screen.getByTestId('badge')).not.toHaveStyle({ fontSize: 11 });
});
Loading