|
1 | 1 | import cx from 'clsx'
|
2 |
| -import React, {createContext, useContext, useEffect, useId, useMemo} from 'react' |
| 2 | +import React, {useEffect} from 'react' |
3 | 3 | import styled from 'styled-components'
|
4 | 4 | import {AlertIcon, InfoIcon, StopIcon, CheckCircleIcon, XIcon} from '@primer/octicons-react'
|
5 | 5 | import {Button, IconButton} from '../Button'
|
6 | 6 | import {get} from '../constants'
|
7 | 7 | import {VisuallyHidden} from '../internal/components/VisuallyHidden'
|
| 8 | +import {useMergedRefs} from '../internal/hooks/useMergedRefs' |
8 | 9 |
|
9 | 10 | type BannerVariant = 'critical' | 'info' | 'success' | 'upsell' | 'warning'
|
10 | 11 |
|
11 | 12 | export type BannerProps = React.ComponentPropsWithoutRef<'section'> & {
|
| 13 | + /** |
| 14 | + * Provide an optional label to override the default name for the Banner |
| 15 | + * landmark region |
| 16 | + */ |
| 17 | + 'aria-label'?: string |
| 18 | + |
12 | 19 | /**
|
13 | 20 | * Provide an optional description for the Banner. This should provide
|
14 | 21 | * supplemental information about the Banner
|
@@ -64,74 +71,96 @@ const iconForVariant: Record<BannerVariant, React.ReactNode> = {
|
64 | 71 | warning: <AlertIcon />,
|
65 | 72 | }
|
66 | 73 |
|
| 74 | +const labels: Record<BannerVariant, string> = { |
| 75 | + critical: 'Critical', |
| 76 | + info: 'Information', |
| 77 | + success: 'Success', |
| 78 | + upsell: 'Recommendation', |
| 79 | + warning: 'Warning', |
| 80 | +} |
| 81 | + |
67 | 82 | export const Banner = React.forwardRef<HTMLElement, BannerProps>(function Banner(
|
68 |
| - {children, description, hideTitle, icon, onDismiss, primaryAction, secondaryAction, title, variant = 'info', ...rest}, |
69 |
| - ref, |
| 83 | + { |
| 84 | + 'aria-label': label, |
| 85 | + children, |
| 86 | + description, |
| 87 | + hideTitle, |
| 88 | + icon, |
| 89 | + onDismiss, |
| 90 | + primaryAction, |
| 91 | + secondaryAction, |
| 92 | + title, |
| 93 | + variant = 'info', |
| 94 | + ...rest |
| 95 | + }, |
| 96 | + forwardRef, |
70 | 97 | ) {
|
71 |
| - const titleId = useId() |
72 |
| - const value = useMemo(() => { |
73 |
| - return { |
74 |
| - titleId, |
75 |
| - } |
76 |
| - }, [titleId]) |
77 | 98 | const dismissible = variant !== 'critical' && onDismiss
|
78 | 99 | const hasActions = primaryAction || secondaryAction
|
| 100 | + const bannerRef = React.useRef<HTMLElement>(null) |
| 101 | + const ref = useMergedRefs(forwardRef, bannerRef) |
79 | 102 |
|
80 | 103 | if (__DEV__) {
|
81 |
| - // Note: __DEV__ will make it so that this hook is consistently called, or |
82 |
| - // not called, depending on environment |
| 104 | + // This hook is called consistently depending on the environment |
83 | 105 | // eslint-disable-next-line react-hooks/rules-of-hooks
|
84 | 106 | useEffect(() => {
|
85 |
| - const title = document.getElementById(titleId) |
86 |
| - if (!title) { |
| 107 | + if (title) { |
| 108 | + return |
| 109 | + } |
| 110 | + |
| 111 | + const {current: banner} = bannerRef |
| 112 | + if (!banner) { |
| 113 | + return |
| 114 | + } |
| 115 | + |
| 116 | + const hasTitle = banner.querySelector('[data-banner-title]') |
| 117 | + if (!hasTitle) { |
87 | 118 | throw new Error(
|
88 |
| - 'The Banner component requires a title to be provided as the `title` prop or through `Banner.Title`', |
| 119 | + 'Expected a title to be provided to the <Banner> component with the `title` prop or through `<Banner.Title>` but no title was found', |
89 | 120 | )
|
90 | 121 | }
|
91 |
| - }, [titleId]) |
| 122 | + }, [title]) |
92 | 123 | }
|
93 | 124 |
|
94 | 125 | return (
|
95 |
| - <BannerContext.Provider value={value}> |
96 |
| - <StyledBanner |
97 |
| - {...rest} |
98 |
| - aria-labelledby={titleId} |
99 |
| - as="section" |
100 |
| - data-dismissible={onDismiss ? '' : undefined} |
101 |
| - data-title-hidden={hideTitle ? '' : undefined} |
102 |
| - data-variant={variant} |
103 |
| - tabIndex={-1} |
104 |
| - ref={ref} |
105 |
| - > |
106 |
| - <style>{BannerContainerQuery}</style> |
107 |
| - <div className="BannerIcon">{icon && variant === 'info' ? icon : iconForVariant[variant]}</div> |
108 |
| - <div className="BannerContainer"> |
109 |
| - <div className="BannerContent"> |
110 |
| - {title ? ( |
111 |
| - hideTitle ? ( |
112 |
| - <VisuallyHidden> |
113 |
| - <BannerTitle>{title}</BannerTitle> |
114 |
| - </VisuallyHidden> |
115 |
| - ) : ( |
| 126 | + <StyledBanner |
| 127 | + {...rest} |
| 128 | + aria-label={label ?? labels[variant]} |
| 129 | + as="section" |
| 130 | + data-dismissible={onDismiss ? '' : undefined} |
| 131 | + data-title-hidden={hideTitle ? '' : undefined} |
| 132 | + data-variant={variant} |
| 133 | + tabIndex={-1} |
| 134 | + ref={ref} |
| 135 | + > |
| 136 | + <style>{BannerContainerQuery}</style> |
| 137 | + <div className="BannerIcon">{icon && variant === 'info' ? icon : iconForVariant[variant]}</div> |
| 138 | + <div className="BannerContainer"> |
| 139 | + <div className="BannerContent"> |
| 140 | + {title ? ( |
| 141 | + hideTitle ? ( |
| 142 | + <VisuallyHidden> |
116 | 143 | <BannerTitle>{title}</BannerTitle>
|
117 |
| - ) |
118 |
| - ) : null} |
119 |
| - {description ? <BannerDescription>{description}</BannerDescription> : null} |
120 |
| - {children} |
121 |
| - </div> |
122 |
| - {hasActions ? <BannerActions primaryAction={primaryAction} secondaryAction={secondaryAction} /> : null} |
| 144 | + </VisuallyHidden> |
| 145 | + ) : ( |
| 146 | + <BannerTitle>{title}</BannerTitle> |
| 147 | + ) |
| 148 | + ) : null} |
| 149 | + {description ? <BannerDescription>{description}</BannerDescription> : null} |
| 150 | + {children} |
123 | 151 | </div>
|
124 |
| - {dismissible ? ( |
125 |
| - <IconButton |
126 |
| - aria-label="Dismiss banner" |
127 |
| - onClick={onDismiss} |
128 |
| - className="BannerDismiss" |
129 |
| - icon={XIcon} |
130 |
| - variant="invisible" |
131 |
| - /> |
132 |
| - ) : null} |
133 |
| - </StyledBanner> |
134 |
| - </BannerContext.Provider> |
| 152 | + {hasActions ? <BannerActions primaryAction={primaryAction} secondaryAction={secondaryAction} /> : null} |
| 153 | + </div> |
| 154 | + {dismissible ? ( |
| 155 | + <IconButton |
| 156 | + aria-label="Dismiss banner" |
| 157 | + onClick={onDismiss} |
| 158 | + className="BannerDismiss" |
| 159 | + icon={XIcon} |
| 160 | + variant="invisible" |
| 161 | + /> |
| 162 | + ) : null} |
| 163 | + </StyledBanner> |
135 | 164 | )
|
136 | 165 | })
|
137 | 166 |
|
@@ -342,9 +371,8 @@ export type BannerTitleProps<As extends HeadingElement> = {
|
342 | 371 |
|
343 | 372 | export function BannerTitle<As extends HeadingElement>(props: BannerTitleProps<As>) {
|
344 | 373 | const {as: Heading = 'h2', className, children, ...rest} = props
|
345 |
| - const banner = useBanner() |
346 | 374 | return (
|
347 |
| - <Heading {...rest} id={banner.titleId} className={cx('BannerTitle', className)}> |
| 375 | + <Heading {...rest} className={cx('BannerTitle', className)} data-banner-title=""> |
348 | 376 | {children}
|
349 | 377 | </Heading>
|
350 | 378 | )
|
@@ -399,14 +427,3 @@ export function BannerSecondaryAction({children, className, ...rest}: BannerSeco
|
399 | 427 | </Button>
|
400 | 428 | )
|
401 | 429 | }
|
402 |
| - |
403 |
| -type BannerContextValue = {titleId: string} |
404 |
| -const BannerContext = createContext<BannerContextValue | null>(null) |
405 |
| - |
406 |
| -function useBanner(): BannerContextValue { |
407 |
| - const value = useContext(BannerContext) |
408 |
| - if (value) { |
409 |
| - return value |
410 |
| - } |
411 |
| - throw new Error('Component must be used within a <Banner> component') |
412 |
| -} |
|
0 commit comments