Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/bright-dots-notify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Button: Add configurable notification indicators to Button and IconButton
43 changes: 41 additions & 2 deletions packages/react/src/Button/Button.features.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
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} from '.'
import {Button, IconButton} from '.'
import {Stack} from '../Stack/Stack'
import {announce} from '@primer/live-region-element'
import {Tooltip} from '../TooltipV2/Tooltip'
Expand All @@ -23,6 +32,36 @@ export const LeadingVisual = () => <Button leadingVisual={HeartIcon}>Leading vis

export const TrailingVisual = () => <Button trailingVisual={EyeIcon}>Trailing visual</Button>

export const NotificationIndicator = () => (
<Stack gap="spacious">
<Stack gap="condensed">
<div>
<strong>Indicator on button boundary</strong>
<div>Use when the notification applies to the whole control.</div>
</div>
<Stack direction="horizontal" gap="normal">
<IconButton icon={InboxIcon} aria-label="Inbox (new activity)" notificationIndicator="button" />
</Stack>
</Stack>
<Stack gap="condensed">
<div>
<strong>Indicator on icon or leading visual</strong>
<div>Use the leading visual for Button and the icon for IconButton.</div>
</div>
<Stack direction="horizontal" gap="normal">
<Button
leadingVisual={GearIcon}
aria-label="Saved view menu (update available)"
notificationIndicator="leadingVisual"
>
View
</Button>
<IconButton icon={KebabHorizontalIcon} aria-label="More options (new activity)" notificationIndicator="icon" />
</Stack>
</Stack>
</Stack>
)

const AccessibilityNote = () => {
{
return (
Expand Down
38 changes: 38 additions & 0 deletions packages/react/src/Button/ButtonBase.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 18 additions & 3 deletions packages/react/src/Button/ButtonBase.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -21,12 +21,20 @@ const renderModuleVisual = (
) => (
<span
data-component={visualName}
className={clsx(!counterLabel && classes.Visual, loading ? classes.LoadingSpinner : classes.VisualWrap)}
className={clsx(
!counterLabel && classes.Visual,
visualName === 'leadingVisual' && classes.LeadingVisual,
loading ? classes.LoadingSpinner : classes.VisualWrap,
)}
>
{loading ? <Spinner size="small" /> : isElement(Visual) ? Visual : <Visual />}
</span>
)

type ButtonBaseComponentProps = Omit<ButtonProps, 'notificationIndicator'> & {
notificationIndicator?: NotificationIndicatorPlacement
}

const ButtonBase = forwardRef(({children, as: Component = 'button', ...props}, forwardedRef): JSX.Element => {
const {
leadingVisual: LeadingVisual,
Expand All @@ -46,6 +54,7 @@ const ButtonBase = forwardRef(({children, as: Component = 'button', ...props}, f
inactive,
onClick,
labelWrap,
notificationIndicator,
className,
...rest
} = props
Expand All @@ -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.
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -184,6 +199,6 @@ const ButtonBase = forwardRef(({children, as: Component = 'button', ...props}, f
)}
</ConditionalWrapper>
)
}) as PolymorphicForwardRefComponent<'button' | 'a', ButtonProps>
}) as PolymorphicForwardRefComponent<'button' | 'a', ButtonBaseComponentProps>

export {ButtonBase}
32 changes: 32 additions & 0 deletions packages/react/src/Button/__tests__/Button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,38 @@ describe('Button', () => {
expect(button).toMatchSnapshot()
})

it('displays a notification indicator on the button boundary', () => {
const {getByRole} = render(<Button notificationIndicator="button">Notifications</Button>)
expect(getByRole('button')).toHaveAttribute('data-notification-indicator', 'button')
})

it('displays a notification indicator on the leading visual', () => {
const {getByRole} = render(
<Button leadingVisual={HeartIcon} notificationIndicator="leadingVisual">
Favorite
</Button>,
)
expect(getByRole('button')).toHaveAttribute('data-notification-indicator', 'leadingVisual')
})

it('displays a notification indicator on an icon button icon', () => {
const {getByRole} = render(
<IconButton icon={HeartIcon} aria-label="Favorite (new activity)" notificationIndicator="icon" />,
)
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(<Button notificationIndicator="leadingVisual">Favorite</Button>)

expect(consoleSpy).toHaveBeenCalledWith(
'Button: `notificationIndicator="leadingVisual"` requires a `leadingVisual` prop.',
)
consoleSpy.mockRestore()
})

it('respects the "disabled" prop', () => {
const onClick = vi.fn()
const container = render(
Expand Down
17 changes: 17 additions & 0 deletions packages/react/src/Button/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ export type Size = 'small' | 'medium' | 'large'

export type AlignContent = 'start' | 'center'

export type NotificationIndicatorPlacement = 'button' | 'leadingVisual' | 'icon'

type ButtonNotificationIndicatorPlacement = Exclude<NotificationIndicatorPlacement, 'icon'>
type IconButtonNotificationIndicatorPlacement = Exclude<NotificationIndicatorPlacement, 'leadingVisual'>

type ButtonA11yProps =
| {'aria-label': string; 'aria-labelledby'?: undefined}
| {'aria-label'?: undefined; 'aria-labelledby': string}
Expand Down Expand Up @@ -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
Expand Down
Loading