diff --git a/.changeset/bright-dots-notify.md b/.changeset/bright-dots-notify.md new file mode 100644 index 00000000000..20d6f3a2ad9 --- /dev/null +++ b/.changeset/bright-dots-notify.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +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 0b7d3da149b..161029a4a88 100644 --- a/packages/react/src/Button/Button.features.stories.tsx +++ b/packages/react/src/Button/Button.features.stories.tsx @@ -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' @@ -23,6 +32,36 @@ 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.
+
+ + + + +
+
+) + const AccessibilityNote = () => { { return ( 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