Skip to content
Draft
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
71 changes: 51 additions & 20 deletions packages/react/src/Banner/Banner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {Banner} from '../Banner'
describe('Banner', () => {
it('should render as a region element', () => {
render(<Banner title="test" />)
expect(screen.getByRole('region', {name: 'Information'})).toBeInTheDocument()
expect(screen.getByRole('region', {name: 'test'})).toBeInTheDocument()
expect(screen.getByRole('heading', {name: 'test'})).toBeInTheDocument()
})

Expand All @@ -15,34 +15,41 @@ describe('Banner', () => {
expect(render(<Element />).container.firstChild).toHaveClass('test-class-name')
})

it('should label the landmark element with the corresponding variant label text', () => {
render(<Banner title="test" />)
expect(screen.getByRole('region')).toEqual(screen.getByLabelText('Information'))
it('should label the landmark element with the title by default', () => {
render(<Banner title="My Banner Title" />)
const region = screen.getByRole('region', {name: 'My Banner Title'})
expect(region).toHaveAttribute('aria-labelledby')
expect(region).not.toHaveAttribute('aria-label')
})

it('should label the landmark element with the label for the critical variant', () => {
render(<Banner title="test" variant="critical" />)
expect(screen.getByRole('region')).toEqual(screen.getByLabelText('Critical'))
it('should use aria-labelledby to reference the title for the critical variant', () => {
render(<Banner title="Critical Issue" variant="critical" />)
const region = screen.getByRole('region', {name: 'Critical Issue'})
expect(region).toHaveAttribute('aria-labelledby')
})

it('should label the landmark element with the label for the info variant', () => {
render(<Banner title="test" variant="info" />)
expect(screen.getByRole('region')).toEqual(screen.getByLabelText('Information'))
it('should use aria-labelledby to reference the title for the info variant', () => {
render(<Banner title="Information" variant="info" />)
const region = screen.getByRole('region', {name: 'Information'})
expect(region).toHaveAttribute('aria-labelledby')
})

it('should label the landmark element with the label for the success variant', () => {
render(<Banner title="test" variant="success" />)
expect(screen.getByRole('region')).toEqual(screen.getByLabelText('Success'))
it('should use aria-labelledby to reference the title for the success variant', () => {
render(<Banner title="Success Message" variant="success" />)
const region = screen.getByRole('region', {name: 'Success Message'})
expect(region).toHaveAttribute('aria-labelledby')
})

it('should label the landmark element with the label for the upsell variant', () => {
render(<Banner title="test" variant="upsell" />)
expect(screen.getByRole('region')).toEqual(screen.getByLabelText('Recommendation'))
it('should use aria-labelledby to reference the title for the upsell variant', () => {
render(<Banner title="Recommendation" variant="upsell" />)
const region = screen.getByRole('region', {name: 'Recommendation'})
expect(region).toHaveAttribute('aria-labelledby')
})

it('should label the landmark element with the label for the warning variant', () => {
render(<Banner title="test" variant="warning" />)
expect(screen.getByRole('region')).toEqual(screen.getByLabelText('Warning'))
it('should use aria-labelledby to reference the title for the warning variant', () => {
render(<Banner title="Warning" variant="warning" />)
const region = screen.getByRole('region', {name: 'Warning'})
expect(region).toHaveAttribute('aria-labelledby')
})

it('should support the `aria-label` prop to override the default label for the landmark', () => {
Expand All @@ -69,6 +76,30 @@ describe('Banner', () => {
expect(region).not.toHaveAttribute('aria-labelledby')
})

it('should use aria-labelledby to reference Banner.Title when provided as a child', () => {
render(
<Banner>
<Banner.Title>Custom Title Component</Banner.Title>
</Banner>,
)
const region = screen.getByRole('region', {name: 'Custom Title Component'})
const heading = screen.getByRole('heading', {name: 'Custom Title Component'})
expect(region).toHaveAttribute('aria-labelledby', heading.id)
expect(heading).toHaveAttribute('id')
expect(region).not.toHaveAttribute('aria-label')
})

it('should use aria-labelledby to reference Banner.Title with custom id', () => {
render(
<Banner aria-labelledby="custom-title-id">
<Banner.Title id="custom-title-id">Title with Custom ID</Banner.Title>
</Banner>,
)
const region = screen.getByRole('region', {name: 'Title with Custom ID'})
expect(region).toHaveAttribute('aria-labelledby', 'custom-title-id')
expect(screen.getByRole('heading')).toHaveAttribute('id', 'custom-title-id')
})

it('should default the title to a h2', () => {
render(<Banner title="test" />)
expect(screen.getByRole('heading', {level: 2})).toBeInTheDocument()
Expand All @@ -86,7 +117,7 @@ describe('Banner', () => {
it('should rendering a description with the `description` prop', () => {
render(<Banner title="test" description="test-description" />)
expect(screen.getByText('test-description')).toBeInTheDocument()
expect(screen.getByRole('region', {name: 'Information'})).toContainElement(screen.getByText('test-description'))
expect(screen.getByRole('region', {name: 'test'})).toContainElement(screen.getByText('test-description'))
})

it('should support a primary action', async () => {
Expand Down
101 changes: 53 additions & 48 deletions packages/react/src/Banner/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@ import {AlertIcon, InfoIcon, StopIcon, CheckCircleIcon, XIcon} from '@primer/oct
import {Button, IconButton, type ButtonProps} from '../Button'
import {VisuallyHidden} from '../VisuallyHidden'
import {useMergedRefs} from '../internal/hooks/useMergedRefs'
import {useId} from '../hooks/useId'
import classes from './Banner.module.css'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'

export type BannerVariant = 'critical' | 'info' | 'success' | 'upsell' | 'warning'

type BannerContextValue = {
titleId: string
}

const BannerContext = React.createContext<BannerContextValue | undefined>(undefined)

export type BannerProps = React.ComponentPropsWithoutRef<'section'> & {
/**
* Provide an optional label to override the default name for the Banner
Expand Down Expand Up @@ -84,14 +91,6 @@ const iconForVariant: Record<BannerVariant, React.ReactNode> = {
warning: <AlertIcon />,
}

const labels: Record<BannerVariant, string> = {
critical: 'Critical',
info: 'Information',
success: 'Success',
upsell: 'Recommendation',
warning: 'Warning',
}

export const Banner = React.forwardRef<HTMLElement, BannerProps>(function Banner(
{
'aria-label': label,
Expand All @@ -116,6 +115,7 @@ export const Banner = React.forwardRef<HTMLElement, BannerProps>(function Banner
const bannerRef = React.useRef<HTMLElement>(null)
const ref = useMergedRefs(forwardRef, bannerRef)
const supportsCustomIcon = variant === 'info' || variant === 'upsell'
const titleId = useId()

if (__DEV__) {
// This hook is called consistently depending on the environment
Expand All @@ -141,46 +141,48 @@ export const Banner = React.forwardRef<HTMLElement, BannerProps>(function Banner
}

return (
<section
{...rest}
aria-labelledby={labelledBy}
aria-label={labelledBy ? undefined : (label ?? labels[variant])}
className={clsx(className, classes.Banner)}
data-dismissible={onDismiss ? '' : undefined}
data-title-hidden={hideTitle ? '' : undefined}
data-variant={variant}
data-actions-layout={actionsLayout}
tabIndex={-1}
ref={ref}
data-layout={rest.layout || 'default'}
>
<div className={classes.BannerIcon}>{icon && supportsCustomIcon ? icon : iconForVariant[variant]}</div>
<div className={classes.BannerContainer}>
<div className={classes.BannerContent}>
{title ? (
hideTitle ? (
<VisuallyHidden>
<BannerContext.Provider value={{titleId}}>
<section
{...rest}
aria-labelledby={labelledBy ?? (label ? undefined : titleId)}
aria-label={labelledBy ? undefined : label}
className={clsx(className, classes.Banner)}
data-dismissible={onDismiss ? '' : undefined}
data-title-hidden={hideTitle ? '' : undefined}
data-variant={variant}
data-actions-layout={actionsLayout}
tabIndex={-1}
ref={ref}
data-layout={rest.layout || 'default'}
>
<div className={classes.BannerIcon}>{icon && supportsCustomIcon ? icon : iconForVariant[variant]}</div>
<div className={classes.BannerContainer}>
<div className={classes.BannerContent}>
{title ? (
hideTitle ? (
<VisuallyHidden>
<BannerTitle>{title}</BannerTitle>
</VisuallyHidden>
) : (
<BannerTitle>{title}</BannerTitle>
</VisuallyHidden>
) : (
<BannerTitle>{title}</BannerTitle>
)
) : null}
{description ? <BannerDescription>{description}</BannerDescription> : null}
{children}
)
) : null}
{description ? <BannerDescription>{description}</BannerDescription> : null}
{children}
</div>
{hasActions ? <BannerActions primaryAction={primaryAction} secondaryAction={secondaryAction} /> : null}
</div>
{hasActions ? <BannerActions primaryAction={primaryAction} secondaryAction={secondaryAction} /> : null}
</div>
{dismissible ? (
<IconButton
aria-label="Dismiss banner"
onClick={onDismiss}
className={classes.BannerDismiss}
icon={XIcon}
variant="invisible"
/>
) : null}
</section>
{dismissible ? (
<IconButton
aria-label="Dismiss banner"
onClick={onDismiss}
className={classes.BannerDismiss}
icon={XIcon}
variant="invisible"
/>
) : null}
</section>
</BannerContext.Provider>
)
})

Expand All @@ -192,9 +194,12 @@ export type BannerTitleProps<As extends HeadingElement> = {
} & React.ComponentPropsWithoutRef<As extends 'h2' ? 'h2' : As>

export function BannerTitle<As extends HeadingElement>(props: BannerTitleProps<As>) {
const {as: Heading = 'h2', className, children, ...rest} = props
const {as: Heading = 'h2', className, children, id, ...rest} = props
const context = React.useContext(BannerContext)
const titleId = id ?? context?.titleId

return (
<Heading {...rest} className={clsx(className, classes.BannerTitle)} data-banner-title="">
<Heading {...rest} id={titleId} className={clsx(className, classes.BannerTitle)} data-banner-title="">
{children}
</Heading>
)
Expand Down
Loading