-
Notifications
You must be signed in to change notification settings - Fork 638
refactor(Banner): update region to use a dedicated aria-label #4539
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
Changes from all commits
9568dc6
6e1c409
e89bd6a
ff471fa
7e13350
c008782
663af56
1366cdc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,14 +1,21 @@ | ||
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' | ||
|
||
export type BannerProps = React.ComponentPropsWithoutRef<'section'> & { | ||
/** | ||
* Provide an optional label to override the default name for the Banner | ||
* landmark region | ||
*/ | ||
'aria-label'?: string | ||
|
||
/** | ||
* Provide an optional description for the Banner. This should provide | ||
* supplemental information about the Banner | ||
|
@@ -64,74 +71,96 @@ 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, | ||
{ | ||
'aria-label': label, | ||
children, | ||
description, | ||
hideTitle, | ||
icon, | ||
onDismiss, | ||
primaryAction, | ||
secondaryAction, | ||
title, | ||
variant = 'info', | ||
...rest | ||
}, | ||
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', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is a good way to differentiate There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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={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> | ||
) | ||
}) | ||
|
||
|
@@ -342,9 +371,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> | ||
) | ||
|
@@ -399,14 +427,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"`; |
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) | ||
} |
Uh oh!
There was an error while loading. Please reload this page.