Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(Banner): update region to use a dedicated aria-label #4539

Merged
merged 8 commits into from
May 3, 2024
5 changes: 5 additions & 0 deletions .changeset/cold-starfishes-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Update Banner to use an explicit aria-label instead of being labelled by Banner title
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["docs", "example-*"]
"ignore": ["docs", "example-*", "codesandbox"]
}
34 changes: 30 additions & 4 deletions packages/react/src/Banner/Banner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,38 @@ describe('Banner', () => {

it('should render as a region element', () => {
render(<Banner title="test" />)
expect(screen.getByRole('region', {name: 'test'})).toBeInTheDocument()
expect(screen.getByRole('region', {name: 'Information'})).toBeInTheDocument()
expect(screen.getByRole('heading', {name: 'test'})).toBeInTheDocument()
})

it('should label the landmark element with the title prop', () => {
it('should label the landmark element with the corresponding variant label text', () => {
joshblack marked this conversation as resolved.
Show resolved Hide resolved
render(<Banner title="test" />)
expect(screen.getByRole('region')).toEqual(screen.getByLabelText('test'))
expect(screen.getByRole('region')).toEqual(screen.getByLabelText('Information'))
})

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 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 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 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 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 default the title to a h2', () => {
Expand All @@ -50,7 +76,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: 'test'})).toContainElement(screen.getByText('test-description'))
expect(screen.getByRole('region', {name: 'Information'})).toContainElement(screen.getByText('test-description'))
})

it('should support a primary action', async () => {
Expand Down
129 changes: 64 additions & 65 deletions packages/react/src/Banner/Banner.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import cx from 'clsx'
import React, {createContext, useContext, useEffect, useId, useMemo} from 'react'
import React, {useEffect} from 'react'
import styled from 'styled-components'
import {AlertIcon, InfoIcon, StopIcon, CheckCircleIcon, XIcon} from '@primer/octicons-react'
import {Button, IconButton} from '../Button'
import {get} from '../constants'
import {VisuallyHidden} from '../internal/components/VisuallyHidden'
import {useMergedRefs} from '../internal/hooks/useMergedRefs'

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

Expand Down Expand Up @@ -64,74 +65,84 @@ 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(
{children, description, hideTitle, icon, onDismiss, primaryAction, secondaryAction, title, variant = 'info', ...rest},
ref,
forwardRef,
) {
const titleId = useId()
const value = useMemo(() => {
return {
titleId,
}
}, [titleId])
const dismissible = variant !== 'critical' && onDismiss
const hasActions = primaryAction || secondaryAction
const bannerRef = React.useRef<HTMLElement>(null)
const ref = useMergedRefs(forwardRef, bannerRef)

if (__DEV__) {
// Note: __DEV__ will make it so that this hook is consistently called, or
// not called, depending on environment
// This hook is called consistently depending on the environment
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
const title = document.getElementById(titleId)
if (!title) {
if (title) {
return
}

const {current: banner} = bannerRef
if (!banner) {
return
}

const hasTitle = banner.querySelector('[data-banner-title]')
if (!hasTitle) {
throw new Error(
'The Banner component requires a title to be provided as the `title` prop or through `Banner.Title`',
'Expected a title to be provided to the <Banner> component with the `title` prop or through `<Banner.Title>` but no title was found',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is a good way to differentiate Error vs console.error (or invariant) in these type of scenarios like when to use one over the other one? 👀

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@broccolinisoup great question honestly, they kind of each have a niche and I'm not sure when it's best to use which 🤔 Would this be a good topic for a working session?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is a great idea 🔥 I had related questions in mind as well. I'll add it to the agenda!

)
}
}, [titleId])
}, [title])
}

return (
<BannerContext.Provider value={value}>
<StyledBanner
{...rest}
aria-labelledby={titleId}
as="section"
data-dismissible={onDismiss ? '' : undefined}
data-title-hidden={hideTitle ? '' : undefined}
data-variant={variant}
tabIndex={-1}
ref={ref}
>
<style>{BannerContainerQuery}</style>
<div className="BannerIcon">{icon && variant === 'info' ? icon : iconForVariant[variant]}</div>
<div className="BannerContainer">
<div className="BannerContent">
{title ? (
hideTitle ? (
<VisuallyHidden>
<BannerTitle>{title}</BannerTitle>
</VisuallyHidden>
) : (
<StyledBanner
{...rest}
aria-label={labels[variant]}
as="section"
data-dismissible={onDismiss ? '' : undefined}
data-title-hidden={hideTitle ? '' : undefined}
data-variant={variant}
tabIndex={-1}
ref={ref}
>
<style>{BannerContainerQuery}</style>
<div className="BannerIcon">{icon && variant === 'info' ? icon : iconForVariant[variant]}</div>
<div className="BannerContainer">
<div className="BannerContent">
{title ? (
hideTitle ? (
<VisuallyHidden>
<BannerTitle>{title}</BannerTitle>
)
) : null}
{description ? <BannerDescription>{description}</BannerDescription> : null}
{children}
</div>
{hasActions ? <BannerActions primaryAction={primaryAction} secondaryAction={secondaryAction} /> : null}
</VisuallyHidden>
) : (
<BannerTitle>{title}</BannerTitle>
)
) : null}
{description ? <BannerDescription>{description}</BannerDescription> : null}
{children}
</div>
{dismissible ? (
<IconButton
aria-label="Dismiss banner"
onClick={onDismiss}
className="BannerDismiss"
icon={XIcon}
variant="invisible"
/>
) : null}
</StyledBanner>
</BannerContext.Provider>
{hasActions ? <BannerActions primaryAction={primaryAction} secondaryAction={secondaryAction} /> : null}
</div>
{dismissible ? (
<IconButton
aria-label="Dismiss banner"
onClick={onDismiss}
className="BannerDismiss"
icon={XIcon}
variant="invisible"
/>
) : null}
</StyledBanner>
)
})

Expand Down Expand Up @@ -342,9 +353,8 @@ export type BannerTitleProps<As extends HeadingElement> = {

export function BannerTitle<As extends HeadingElement>(props: BannerTitleProps<As>) {
const {as: Heading = 'h2', className, children, ...rest} = props
const banner = useBanner()
return (
<Heading {...rest} id={banner.titleId} className={cx('BannerTitle', className)}>
<Heading {...rest} className={cx('BannerTitle', className)} data-banner-title="">
{children}
</Heading>
)
Expand Down Expand Up @@ -399,14 +409,3 @@ export function BannerSecondaryAction({children, className, ...rest}: BannerSeco
</Button>
)
}

type BannerContextValue = {titleId: string}
const BannerContext = createContext<BannerContextValue | null>(null)

function useBanner(): BannerContextValue {
const value = useContext(BannerContext)
if (value) {
return value
}
throw new Error('Component must be used within a <Banner> component')
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Banner should throw an error if no title is provided 1`] = `"The Banner component requires a title to be provided as the \`title\` prop or through \`Banner.Title\`"`;
exports[`Banner should throw an error if no title is provided 1`] = `"Expected a title to be provided to the <Banner> component with the \`title\` prop or through \`<Banner.Title>\` but no title was found"`;
16 changes: 16 additions & 0 deletions packages/react/src/internal/hooks/useMergedRefs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {useCallback} from 'react'

export function useMergedRefs<T>(
...refs: Array<React.MutableRefObject<T> | React.ForwardedRef<T> | React.RefCallback<T>>
): React.RefCallback<T> {
return useCallback((instance: T) => {
for (const ref of refs) {
if (typeof ref === 'function') {
ref(instance)
} else if (ref) {
ref.current = instance
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, refs)
}
Loading