From 19bb72ea26a18750aabba99815101d9d0aa4cee6 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Mon, 17 Aug 2026 13:15:57 -0700 Subject: [PATCH 1/3] notification indicator --- .changeset/bright-dots-notify.md | 5 +++++ .../src/Button/Button.features.stories.tsx | 12 +++++++++++- .../NotificationIndicator.module.css | 18 ++++++++++++++++++ .../react/src/NotificationIndicator/index.ts | 3 +++ packages/react/src/index.ts | 1 + 5 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 .changeset/bright-dots-notify.md create mode 100644 packages/react/src/NotificationIndicator/NotificationIndicator.module.css create mode 100644 packages/react/src/NotificationIndicator/index.ts diff --git a/.changeset/bright-dots-notify.md b/.changeset/bright-dots-notify.md new file mode 100644 index 00000000000..e7dfb138c43 --- /dev/null +++ b/.changeset/bright-dots-notify.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Utilities: Add a reusable notification indicator for interactive controls \ No newline at end of file diff --git a/packages/react/src/Button/Button.features.stories.tsx b/packages/react/src/Button/Button.features.stories.tsx index 0b7d3da149b..4d82d817486 100644 --- a/packages/react/src/Button/Button.features.stories.tsx +++ b/packages/react/src/Button/Button.features.stories.tsx @@ -1,10 +1,11 @@ import {EyeIcon, TriangleDownIcon, HeartIcon, DownloadIcon, CommentIcon} from '@primer/octicons-react' import {useState} from 'react' -import {Button} from '.' +import {Button, IconButton} from '.' import {Stack} from '../Stack/Stack' import {announce} from '@primer/live-region-element' import {Tooltip} from '../TooltipV2/Tooltip' import {KeybindingHint} from '../KeybindingHint' +import {notificationIndicator} from '../NotificationIndicator' import VisuallyHidden from '../_VisuallyHidden' export default { @@ -23,6 +24,15 @@ export const LeadingVisual = () => +export const NotificationIndicator = () => ( + + + + +) + const AccessibilityNote = () => { { return ( diff --git a/packages/react/src/NotificationIndicator/NotificationIndicator.module.css b/packages/react/src/NotificationIndicator/NotificationIndicator.module.css new file mode 100644 index 00000000000..b2609781098 --- /dev/null +++ b/packages/react/src/NotificationIndicator/NotificationIndicator.module.css @@ -0,0 +1,18 @@ +.NotificationIndicator { + position: relative; + + &::before { + position: absolute; + top: calc(var(--base-size-4) / -2); + right: calc(var(--base-size-4) / -2); + display: block; + width: var(--base-size-8); + height: var(--base-size-8); + content: ''; + /* stylelint-disable-next-line primer/colors */ + background-color: var(--fgColor-accent); + border-radius: var(--borderRadius-full); + /* stylelint-disable-next-line primer/box-shadow */ + box-shadow: 0 0 0 calc(var(--base-size-4) / 2) var(--bgColor-inset); + } +} diff --git a/packages/react/src/NotificationIndicator/index.ts b/packages/react/src/NotificationIndicator/index.ts new file mode 100644 index 00000000000..da562c0664a --- /dev/null +++ b/packages/react/src/NotificationIndicator/index.ts @@ -0,0 +1,3 @@ +import classes from './NotificationIndicator.module.css' + +export const notificationIndicator = classes.NotificationIndicator diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 0c4ebf8767d..2972816f941 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -64,6 +64,7 @@ export type {SlotMarker, WithSlotMarker, FCWithSlotMarker} from './utils/types' export {asSlot} from './utils/as-slot' export {isSlot} from './utils/is-slot' export {useSlots} from './hooks/useSlots' +export {notificationIndicator} from './NotificationIndicator' // Components export {default as Radio} from './Radio' From 179835b31623512b8d0a1228382c2edb8408d9c5 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Mon, 17 Aug 2026 13:42:20 -0700 Subject: [PATCH 2/3] update test snap --- packages/react/src/__tests__/__snapshots__/exports.test.ts.snap | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index 068bacd8275..ccf616e36c9 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -114,6 +114,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "type NavListProps", "type NavListSubNavProps", "type NavListTrailingVisualProps", + "notificationIndicator", "Overlay", "type OverlayProps", "PageHeader", From b4ca76ef3f602d6f8219f0f46217cff1c68d60c6 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Mon, 17 Aug 2026 15:52:38 -0700 Subject: [PATCH 3/3] classname->prop, add variants for indicator placement --- .changeset/bright-dots-notify.md | 2 +- .../src/Button/Button.features.stories.tsx | 43 ++++++++++++++++--- .../react/src/Button/ButtonBase.module.css | 38 ++++++++++++++++ packages/react/src/Button/ButtonBase.tsx | 21 +++++++-- .../src/Button/__tests__/Button.test.tsx | 32 ++++++++++++++ packages/react/src/Button/types.ts | 17 ++++++++ .../NotificationIndicator.module.css | 18 -------- .../react/src/NotificationIndicator/index.ts | 3 -- .../__snapshots__/exports.test.ts.snap | 1 - packages/react/src/index.ts | 1 - 10 files changed, 142 insertions(+), 34 deletions(-) delete mode 100644 packages/react/src/NotificationIndicator/NotificationIndicator.module.css delete mode 100644 packages/react/src/NotificationIndicator/index.ts diff --git a/.changeset/bright-dots-notify.md b/.changeset/bright-dots-notify.md index e7dfb138c43..20d6f3a2ad9 100644 --- a/.changeset/bright-dots-notify.md +++ b/.changeset/bright-dots-notify.md @@ -2,4 +2,4 @@ '@primer/react': minor --- -Utilities: Add a reusable notification indicator for interactive controls \ No newline at end of file +Button: Add configurable notification indicators to Button and IconButton \ No newline at end of file diff --git a/packages/react/src/Button/Button.features.stories.tsx b/packages/react/src/Button/Button.features.stories.tsx index 4d82d817486..161029a4a88 100644 --- a/packages/react/src/Button/Button.features.stories.tsx +++ b/packages/react/src/Button/Button.features.stories.tsx @@ -1,11 +1,19 @@ -import {EyeIcon, TriangleDownIcon, HeartIcon, DownloadIcon, CommentIcon} from '@primer/octicons-react' +import { + EyeIcon, + TriangleDownIcon, + HeartIcon, + DownloadIcon, + CommentIcon, + GearIcon, + InboxIcon, + KebabHorizontalIcon, +} from '@primer/octicons-react' import {useState} from 'react' import {Button, IconButton} from '.' import {Stack} from '../Stack/Stack' import {announce} from '@primer/live-region-element' import {Tooltip} from '../TooltipV2/Tooltip' import {KeybindingHint} from '../KeybindingHint' -import {notificationIndicator} from '../NotificationIndicator' import VisuallyHidden from '../_VisuallyHidden' export default { @@ -25,11 +33,32 @@ export const LeadingVisual = () => export const NotificationIndicator = () => ( - - - + + +
+ Indicator on button boundary +
Use when the notification applies to the whole control.
+
+ + + +
+ +
+ Indicator on icon or leading visual +
Use the leading visual for Button and the icon for IconButton.
+
+ + + + +
) diff --git a/packages/react/src/Button/ButtonBase.module.css b/packages/react/src/Button/ButtonBase.module.css index b9c26348a66..dc9705170cb 100644 --- a/packages/react/src/Button/ButtonBase.module.css +++ b/packages/react/src/Button/ButtonBase.module.css @@ -41,6 +41,44 @@ transition: none; } + &:where([data-notification-indicator='button'], [data-notification-indicator='icon'].IconButton) { + position: relative; + } + + &:where([data-notification-indicator='leadingVisual']) .LeadingVisual { + position: relative; + } + + &:where([data-notification-indicator='button'])::before, + &:where([data-notification-indicator='icon'].IconButton)::before, + &:where([data-notification-indicator='leadingVisual']) .LeadingVisual::before { + position: absolute; + display: block; + width: var(--base-size-8); + height: var(--base-size-8); + content: ''; + /* stylelint-disable-next-line primer/colors */ + background-color: var(--fgColor-accent); + border-radius: var(--borderRadius-full); + /* stylelint-disable-next-line primer/box-shadow */ + box-shadow: 0 0 0 calc(var(--base-size-4) / 2) var(--bgColor-inset); + } + + &:where([data-notification-indicator='button'])::before { + top: calc(var(--base-size-4) / -2); + right: calc(var(--base-size-4) / -2); + } + + &:where([data-notification-indicator='icon'].IconButton)::before { + top: calc(50% - var(--base-size-12)); + right: calc(50% - var(--base-size-12)); + } + + &:where([data-notification-indicator='leadingVisual']) .LeadingVisual::before { + top: calc(var(--base-size-4) * -1); + right: calc(var(--base-size-4) * -1); + } + &:disabled, &[aria-disabled='true']:not([data-loading='true']) { cursor: not-allowed; diff --git a/packages/react/src/Button/ButtonBase.tsx b/packages/react/src/Button/ButtonBase.tsx index 19357e9ae43..055d5771f56 100644 --- a/packages/react/src/Button/ButtonBase.tsx +++ b/packages/react/src/Button/ButtonBase.tsx @@ -1,6 +1,6 @@ import React, {forwardRef, type JSX} from 'react' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' -import type {ButtonProps} from './types' +import type {ButtonProps, NotificationIndicatorPlacement} from './types' import {useMergedRefs} from '../hooks/useMergedRefs' import {VisuallyHidden} from '../VisuallyHidden' import Spinner from '../Spinner' @@ -21,12 +21,20 @@ const renderModuleVisual = ( ) => ( {loading ? : isElement(Visual) ? Visual : } ) +type ButtonBaseComponentProps = Omit & { + notificationIndicator?: NotificationIndicatorPlacement +} + const ButtonBase = forwardRef(({children, as: Component = 'button', ...props}, forwardedRef): JSX.Element => { const { leadingVisual: LeadingVisual, @@ -46,6 +54,7 @@ const ButtonBase = forwardRef(({children, as: Component = 'button', ...props}, f inactive, onClick, labelWrap, + notificationIndicator, className, ...rest } = props @@ -60,6 +69,11 @@ const ButtonBase = forwardRef(({children, as: Component = 'button', ...props}, f const ariaDescribedByIds = loading ? [loadingAnnouncementID, ariaDescribedBy] : [ariaDescribedBy] if (__DEV__) { + if (notificationIndicator === 'leadingVisual' && !LeadingVisual) { + // eslint-disable-next-line no-console + console.warn('Button: `notificationIndicator="leadingVisual"` requires a `leadingVisual` prop.') + } + // Validate that the element is a semantic button/anchor. // This runs during render (not in an effect) to avoid a conditional hook call // that prevents React Compiler from optimizing this component. @@ -98,6 +112,7 @@ const ButtonBase = forwardRef(({children, as: Component = 'button', ...props}, f data-size={size} data-variant={variant} data-label-wrap={labelWrap} + data-notification-indicator={notificationIndicator} data-has-count={count !== undefined ? true : undefined} data-icon-only-counter={count !== undefined && LeadingVisual && !children ? true : undefined} aria-describedby={ariaDescribedByIds.filter(descriptionID => Boolean(descriptionID)).join(' ') || undefined} @@ -184,6 +199,6 @@ const ButtonBase = forwardRef(({children, as: Component = 'button', ...props}, f )} ) -}) as PolymorphicForwardRefComponent<'button' | 'a', ButtonProps> +}) as PolymorphicForwardRefComponent<'button' | 'a', ButtonBaseComponentProps> export {ButtonBase} diff --git a/packages/react/src/Button/__tests__/Button.test.tsx b/packages/react/src/Button/__tests__/Button.test.tsx index f2c11962df8..0233900bc05 100644 --- a/packages/react/src/Button/__tests__/Button.test.tsx +++ b/packages/react/src/Button/__tests__/Button.test.tsx @@ -57,6 +57,38 @@ describe('Button', () => { expect(button).toMatchSnapshot() }) + it('displays a notification indicator on the button boundary', () => { + const {getByRole} = render() + expect(getByRole('button')).toHaveAttribute('data-notification-indicator', 'button') + }) + + it('displays a notification indicator on the leading visual', () => { + const {getByRole} = render( + , + ) + expect(getByRole('button')).toHaveAttribute('data-notification-indicator', 'leadingVisual') + }) + + it('displays a notification indicator on an icon button icon', () => { + const {getByRole} = render( + , + ) + expect(getByRole('button')).toHaveAttribute('data-notification-indicator', 'icon') + }) + + it('warns when a leading visual notification indicator has no leading visual', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + render() + + expect(consoleSpy).toHaveBeenCalledWith( + 'Button: `notificationIndicator="leadingVisual"` requires a `leadingVisual` prop.', + ) + consoleSpy.mockRestore() + }) + it('respects the "disabled" prop', () => { const onClick = vi.fn() const container = render( diff --git a/packages/react/src/Button/types.ts b/packages/react/src/Button/types.ts index acdd7190527..660fa3a5352 100644 --- a/packages/react/src/Button/types.ts +++ b/packages/react/src/Button/types.ts @@ -8,6 +8,11 @@ export type Size = 'small' | 'medium' | 'large' export type AlignContent = 'start' | 'center' +export type NotificationIndicatorPlacement = 'button' | 'leadingVisual' | 'icon' + +type ButtonNotificationIndicatorPlacement = Exclude +type IconButtonNotificationIndicatorPlacement = Exclude + type ButtonA11yProps = | {'aria-label': string; 'aria-labelledby'?: undefined} | {'aria-label'?: undefined; 'aria-labelledby': string} @@ -82,10 +87,22 @@ export type ButtonProps = { children?: React.ReactNode count?: number | string + + /** + * Displays a visual indicator for new activity on the button boundary or leading visual. + * The `leadingVisual` placement requires the `leadingVisual` prop. Consumers are responsible + * for communicating the indicator's meaning through an accessible label or description. + */ + notificationIndicator?: ButtonNotificationIndicatorPlacement } & ButtonBaseProps export type IconButtonProps = ButtonA11yProps & { icon: React.ElementType + /** + * Displays a visual indicator for new activity on the button boundary or icon. Consumers are + * responsible for communicating the indicator's meaning through an accessible label or description. + */ + notificationIndicator?: IconButtonNotificationIndicatorPlacement unsafeDisableTooltip?: boolean description?: string tooltipDirection?: TooltipDirection diff --git a/packages/react/src/NotificationIndicator/NotificationIndicator.module.css b/packages/react/src/NotificationIndicator/NotificationIndicator.module.css deleted file mode 100644 index b2609781098..00000000000 --- a/packages/react/src/NotificationIndicator/NotificationIndicator.module.css +++ /dev/null @@ -1,18 +0,0 @@ -.NotificationIndicator { - position: relative; - - &::before { - position: absolute; - top: calc(var(--base-size-4) / -2); - right: calc(var(--base-size-4) / -2); - display: block; - width: var(--base-size-8); - height: var(--base-size-8); - content: ''; - /* stylelint-disable-next-line primer/colors */ - background-color: var(--fgColor-accent); - border-radius: var(--borderRadius-full); - /* stylelint-disable-next-line primer/box-shadow */ - box-shadow: 0 0 0 calc(var(--base-size-4) / 2) var(--bgColor-inset); - } -} diff --git a/packages/react/src/NotificationIndicator/index.ts b/packages/react/src/NotificationIndicator/index.ts deleted file mode 100644 index da562c0664a..00000000000 --- a/packages/react/src/NotificationIndicator/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import classes from './NotificationIndicator.module.css' - -export const notificationIndicator = classes.NotificationIndicator diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index ccf616e36c9..068bacd8275 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -114,7 +114,6 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "type NavListProps", "type NavListSubNavProps", "type NavListTrailingVisualProps", - "notificationIndicator", "Overlay", "type OverlayProps", "PageHeader", diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 2972816f941..0c4ebf8767d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -64,7 +64,6 @@ export type {SlotMarker, WithSlotMarker, FCWithSlotMarker} from './utils/types' export {asSlot} from './utils/as-slot' export {isSlot} from './utils/is-slot' export {useSlots} from './hooks/useSlots' -export {notificationIndicator} from './NotificationIndicator' // Components export {default as Radio} from './Radio'