|
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