Skip to content

Commit a058e48

Browse files
authored
feat: S2 notification badge (#7930)
* initialize notification badge * fix width in different locales * fix lint * update lock file * fix story * updates fromr review * fix lint * fix text color in light mode + forced colors * fix VO, rename isEmpty to isIndicatorOnly * remove extra space * fix VO * ensure value is integer * update stories * fix aria label * small change * update stories
1 parent 4b2c6e7 commit a058e48

File tree

8 files changed

+283
-3
lines changed

8 files changed

+283
-3
lines changed

packages/@react-spectrum/s2/intl/en-US.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
"label.(required)": "(required)",
1818
"label.(optional)": "(optional)",
1919
"menu.moreActions": "More actions",
20+
"notificationbadge.plus": "{notifications}+",
21+
"notificationbadge.indicatorOnly": "New activity",
2022
"picker.placeholder": "Select…",
2123
"slider.minimum": "Minimum",
2224
"slider.maximum": "Maximum",

packages/@react-spectrum/s2/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@
129129
"jest": "^29.5.0"
130130
},
131131
"dependencies": {
132+
"@internationalized/number": "^3.6.0",
132133
"@react-aria/collections": "3.0.0-beta.1",
133134
"@react-aria/focus": "^3.20.1",
134135
"@react-aria/i18n": "^3.12.7",

packages/@react-spectrum/s2/src/ActionButton.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {createContext, forwardRef, ReactNode, useContext} from 'react';
1919
import {FocusableRef, FocusableRefValue} from '@react-types/shared';
2020
import {getAllowedOverrides, staticColor, StyleProps} from './style-utils' with { type: 'macro' };
2121
import {IconContext} from './Icon';
22+
import {NotificationBadgeContext} from './NotificationBadge';
2223
import {pressScale} from './pressScale';
2324
import {SkeletonContext} from './Skeleton';
2425
import {Text, TextContext} from './Content';
@@ -59,6 +60,7 @@ export interface ActionButtonProps extends Omit<ButtonProps, 'className' | 'styl
5960

6061
// These styles handle both ActionButton and ToggleButton
6162
const iconOnly = ':has([slot=icon], [slot=avatar]):not(:has([data-rsp-slot=text]))';
63+
const textOnly = ':has([data-rsp-slot=text]):not(:has([slot=icon], [slot=avatar]))';
6264
export const btnStyles = style<ButtonRenderProps & ActionButtonStyleProps & ToggleButtonStyleProps & ActionGroupItemStyleProps & {isInGroup: boolean, isStaticColor: boolean}>({
6365
...focusRing(),
6466
...staticColor(),
@@ -216,7 +218,34 @@ export const btnStyles = style<ButtonRenderProps & ActionButtonStyleProps & Togg
216218
zIndex: {
217219
isFocusVisible: 2
218220
},
219-
disableTapHighlight: true
221+
disableTapHighlight: true,
222+
'--badgeTop': {
223+
type: 'top',
224+
value: {
225+
default: '[calc(self(height)/2 - var(--iconWidth)/2)]',
226+
[iconOnly]: '[calc(self(height)/2 - var(--iconWidth)/2)]',
227+
[textOnly]: 0
228+
}
229+
},
230+
'--buttonPaddingX': {
231+
type: 'paddingStart',
232+
value: {
233+
default: '[calc(self(height, self(minHeight)) * 3 / 8)]',
234+
[iconOnly]: 0
235+
}
236+
},
237+
'--iconWidth': {
238+
type: 'width',
239+
value: fontRelative(20)
240+
},
241+
'--badgePosition': {
242+
type: 'width',
243+
value: {
244+
default: '[calc(var(--buttonPaddingX) + var(--iconWidth))]',
245+
[iconOnly]: '[calc(self(minWidth)/2 + var(--iconWidth)/2)]',
246+
[textOnly]: 'full'
247+
}
248+
}
220249
}, getAllowedOverrides());
221250

222251
// Matching icon sizes. TBD.
@@ -232,7 +261,7 @@ export const ActionButtonContext = createContext<ContextValue<Partial<ActionButt
232261

233262
/**
234263
* ActionButtons allow users to perform an action.
235-
* Theyre used for similar, task-based options within a workflow, and are ideal for interfaces where buttons arent meant to draw a lot of attention.
264+
* They're used for similar, task-based options within a workflow, and are ideal for interfaces where buttons aren't meant to draw a lot of attention.
236265
*/
237266
export const ActionButton = forwardRef(function ActionButton(props: ActionButtonProps, ref: FocusableRef<HTMLButtonElement>) {
238267
[props, ref] = useSpectrumContextProps(props, ref, ActionButtonContext);
@@ -251,6 +280,7 @@ export const ActionButton = forwardRef(function ActionButton(props: ActionButton
251280
isDisabled = props.isDisabled
252281
} = ctx || {};
253282

283+
254284
return (
255285
<RACButton
256286
{...props}
@@ -281,6 +311,10 @@ export const ActionButton = forwardRef(function ActionButton(props: ActionButton
281311
[AvatarContext, {
282312
size: avatarSize[size],
283313
styles: style({marginStart: '--iconMargin', flexShrink: 0, order: 0})
314+
}],
315+
[NotificationBadgeContext, {
316+
size: props.size === 'XS' ? undefined : props.size,
317+
styles: style({position: 'absolute', top: '--badgeTop', insetStart: '[var(--badgePosition)]', marginTop: '[calc((self(height) * -1)/2)]', marginStart: '[calc((self(height) * -1)/2)]'})
284318
}]
285319
]}>
286320
{typeof props.children === 'string' ? <Text>{props.children}</Text> : props.children}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {AriaLabelingProps, DOMProps, DOMRef, DOMRefValue} from '@react-types/shared';
14+
import {ContextValue, SlotProps} from 'react-aria-components';
15+
import {filterDOMProps} from '@react-aria/utils';
16+
import {fontRelative, style} from '../style' with {type: 'macro'};
17+
import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'};
18+
// @ts-ignore
19+
import intlMessages from '../intl/*.json';
20+
import {NumberFormatter} from '@internationalized/number';
21+
import React, {createContext, forwardRef} from 'react';
22+
import {useDOMRef} from '@react-spectrum/utils';
23+
import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n';
24+
import {useSpectrumContextProps} from './useSpectrumContextProps';
25+
26+
export interface NotificationBadgeStyleProps {
27+
/**
28+
* The size of the notification badge.
29+
*
30+
* @default 'S'
31+
*/
32+
size?: 'S' | 'M' | 'L' | 'XL'
33+
}
34+
35+
export interface NotificationBadgeProps extends DOMProps, AriaLabelingProps, StyleProps, NotificationBadgeStyleProps, SlotProps {
36+
/**
37+
* The value to be displayed in the notification badge.
38+
*/
39+
value?: number | null
40+
}
41+
42+
export const NotificationBadgeContext = createContext<ContextValue<Partial<NotificationBadgeProps>, DOMRefValue<HTMLDivElement>>>(null);
43+
44+
const badge = style({
45+
display: 'flex',
46+
font: 'control',
47+
color: {
48+
default: 'white',
49+
forcedColors: 'ButtonText'
50+
},
51+
fontSize: {
52+
size: {
53+
S: 'ui-xs',
54+
M: 'ui-xs',
55+
L: 'ui-sm',
56+
XL: 'ui'
57+
}
58+
},
59+
borderStyle: {
60+
forcedColors: 'solid'
61+
},
62+
borderWidth: {
63+
forcedColors: '[1px]'
64+
},
65+
borderColor: {
66+
forcedColors: 'ButtonBorder'
67+
},
68+
justifyContent: 'center',
69+
alignItems: 'center',
70+
backgroundColor: {
71+
default: 'accent',
72+
forcedColors: 'ButtonFace'
73+
},
74+
height: {
75+
size: {
76+
S: {
77+
default: 12,
78+
isIndicatorOnly: 8
79+
},
80+
M: {
81+
default: fontRelative(18), // sort of arbitrary? tried to get as close to the figma designs as possible
82+
isIndicatorOnly: 8
83+
},
84+
L: {
85+
default: 16,
86+
isIndicatorOnly: fontRelative(12)
87+
},
88+
XL: {
89+
default: 18,
90+
isIndicatorOnly: fontRelative(12)
91+
}
92+
}
93+
},
94+
aspectRatio: {
95+
isIndicatorOnly: 'square',
96+
isSingleDigit: 'square'
97+
},
98+
width: 'fit',
99+
paddingX: {
100+
isDoubleDigit: 'edge-to-text'
101+
},
102+
borderRadius: 'pill'
103+
}, getAllowedOverrides());
104+
105+
/**
106+
* Notification badges are used to indicate new or pending activity .
107+
*/
108+
export const NotificationBadge = forwardRef(function Badge(props: NotificationBadgeProps, ref: DOMRef<HTMLDivElement>) {
109+
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2');
110+
[props, ref] = useSpectrumContextProps(props, ref, NotificationBadgeContext);
111+
let {
112+
size = 'S',
113+
value,
114+
...otherProps
115+
} = props;
116+
let domRef = useDOMRef(ref);
117+
let {locale} = useLocale();
118+
let formattedValue = '';
119+
120+
let isIndicatorOnly = false;
121+
let isSingleDigit = false;
122+
let isDoubleDigit = false;
123+
124+
if (value == null) {
125+
isIndicatorOnly = true;
126+
} else if (value <= 0) {
127+
throw new Error('Value cannot be negative or zero');
128+
} else if (!Number.isInteger(value)) {
129+
throw new Error('Value must be a positive integer');
130+
} else {
131+
formattedValue = new NumberFormatter(locale).format(Math.min(value, 99));
132+
let length = Math.log(value <= 99 ? value : 99) * Math.LOG10E + 1 | 0; // for positive integers (https://stackoverflow.com/questions/14879691/get-number-of-digits-with-javascript)
133+
if (length === 1) {
134+
isSingleDigit = true;
135+
} else if (length === 2) {
136+
isDoubleDigit = true;
137+
}
138+
139+
if (value > 99) {
140+
formattedValue = stringFormatter.format('notificationbadge.plus', {notifications: formattedValue});
141+
}
142+
}
143+
144+
let ariaLabel = props['aria-label'] || undefined;
145+
if (ariaLabel === undefined && isIndicatorOnly) {
146+
ariaLabel = stringFormatter.format('notificationbadge.indicatorOnly');
147+
}
148+
149+
return (
150+
<span
151+
{...filterDOMProps(otherProps, {labelable: true})}
152+
role={ariaLabel && 'img'}
153+
aria-label={ariaLabel}
154+
className={(props.UNSAFE_className || '') + badge({size, isIndicatorOnly, isSingleDigit, isDoubleDigit}, props.styles)}
155+
style={props.UNSAFE_style}
156+
ref={domRef}>
157+
{formattedValue}
158+
</span>
159+
);
160+
});

packages/@react-spectrum/s2/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export {InlineAlert, InlineAlertContext} from './InlineAlert';
5353
export {Link, LinkContext} from './Link';
5454
export {MenuItem, MenuTrigger, Menu, MenuSection, SubmenuTrigger, MenuContext} from './Menu';
5555
export {Meter, MeterContext} from './Meter';
56+
export {NotificationBadge, NotificationBadgeContext} from './NotificationBadge';
5657
export {NumberField, NumberFieldContext} from './NumberField';
5758
export {Picker, PickerItem, PickerSection, PickerContext} from './Picker';
5859
export {Popover} from './Popover';
@@ -123,6 +124,7 @@ export type {ImageCoordinatorProps} from './ImageCoordinator';
123124
export type {LinkProps} from './Link';
124125
export type {MenuTriggerProps, MenuProps, MenuItemProps, MenuSectionProps, SubmenuTriggerProps} from './Menu';
125126
export type {MeterProps} from './Meter';
127+
export type {NotificationBadgeProps} from './NotificationBadge';
126128
export type {PickerProps, PickerItemProps, PickerSectionProps} from './Picker';
127129
export type {PopoverProps} from './Popover';
128130
export type {ProgressBarProps} from './ProgressBar';

packages/@react-spectrum/s2/stories/ActionButton.stories.tsx

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {ActionButton, Avatar, Text} from '../src';
13+
import {ActionButton, Avatar, NotificationBadge, Text} from '../src';
14+
import BellIcon from '../s2wf-icons/S2_Icon_Bell_20_N.svg';
1415
import {categorizeArgTypes, StaticColorDecorator} from './utils';
16+
import CommentIcon from '../s2wf-icons/S2_Icon_Comment_20_N.svg';
1517
import type {Meta, StoryObj} from '@storybook/react';
1618
import NewIcon from '../s2wf-icons/S2_Icon_New_20_N.svg';
1719
import {style} from '../style' with { type: 'macro' };
1820
import './unsafe.css';
21+
import {useNumberFormatter} from 'react-aria';
1922

2023
const meta: Meta<typeof ActionButton> = {
2124
component: ActionButton,
@@ -198,3 +201,41 @@ export const Avatars: Story = {
198201
);
199202
}
200203
};
204+
205+
const NotificationBadgesExample = (args) => {
206+
let badgeValue = 10;
207+
let formattedValue = useNumberFormatter().format(badgeValue);
208+
209+
return (
210+
<div style={{display: 'flex', gap: 8, padding: 8, justifyContent: 'center'}}>
211+
<ActionButton aria-label="Messages has new activity" {...args}><CommentIcon /><NotificationBadge /></ActionButton>
212+
<ActionButton aria-label={`${formattedValue} notifications`} {...args}><BellIcon /><NotificationBadge value={badgeValue} /></ActionButton>
213+
<ActionButton {...args}><CommentIcon /><Text>Messages</Text><NotificationBadge value={5} /></ActionButton>
214+
<ActionButton {...args}><Text>Notifications</Text><NotificationBadge value={105} /></ActionButton>
215+
</div>
216+
);
217+
};
218+
219+
export const NotificationBadges: Story = {
220+
render: NotificationBadgesExample
221+
};
222+
223+
NotificationBadges.parameters = {
224+
docs: {
225+
source: {
226+
transform: () => {
227+
return `let badgeValue = 10;
228+
let formattedValue = useNumberFormatter().format(badgeValue);
229+
230+
return (
231+
<div style={{display: 'flex', gap: 8, padding: 8, justifyContent: 'center'}}>
232+
<ActionButton aria-label="Messages has new activity" {...props}><CommentIcon /><NotificationBadge /></ActionButton>
233+
<ActionButton aria-label={\`\${formattedValue} notifications\`} {...props}><BellIcon /><NotificationBadge value={badgeValue} /></ActionButton>
234+
<ActionButton {...props}><CommentIcon /><Text>Messages</Text><NotificationBadge value={5} /></ActionButton>
235+
<ActionButton {...props}><Text>Notifications</Text><NotificationBadge value={105} /></ActionButton>
236+
</div>
237+
)`;
238+
}
239+
}
240+
}
241+
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import type {Meta, StoryObj} from '@storybook/react';
14+
import {NotificationBadge} from '../src';
15+
16+
const meta: Meta<typeof NotificationBadge> = {
17+
component: NotificationBadge,
18+
parameters: {
19+
layout: 'centered'
20+
},
21+
tags: ['autodocs'],
22+
title: 'NotificationBadge'
23+
};
24+
25+
export default meta;
26+
27+
type Story = StoryObj<typeof NotificationBadge>;
28+
export const Example: Story = {
29+
render: (args) => {
30+
return (
31+
<div style={{display: 'flex', flexWrap: 'wrap', gap: 8, maxWidth: '600px'}}>
32+
<NotificationBadge {...args} />
33+
<NotificationBadge {...args} value={1} />
34+
<NotificationBadge {...args} value={24} />
35+
<NotificationBadge {...args} value={100} />
36+
</div>
37+
);
38+
}
39+
};

yarn.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7935,6 +7935,7 @@ __metadata:
79357935
resolution: "@react-spectrum/s2@workspace:packages/@react-spectrum/s2"
79367936
dependencies:
79377937
"@adobe/spectrum-tokens": "npm:^13.0.0-beta.56"
7938+
"@internationalized/number": "npm:^3.6.0"
79387939
"@parcel/macros": "npm:^2.14.0"
79397940
"@react-aria/collections": "npm:3.0.0-beta.1"
79407941
"@react-aria/focus": "npm:^3.20.1"

0 commit comments

Comments
 (0)