Skip to content
5 changes: 5 additions & 0 deletions .changeset/banner-leading-visual-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': major
---

Banner: Restrict custom leading visuals to the info and upsell variants
Comment on lines +1 to +5
28 changes: 16 additions & 12 deletions packages/react/src/Banner/Banner.figma.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,20 @@ const componentProps = {

figma.connect(Banner, 'https://www.figma.com/design/GCvY3Qv8czRgZgvl1dG6lp/Primer-Web?node-id=34303-2712&m=dev', {
props: componentProps,
example: ({dismissible, variant, icon, secondaryAction, primaryAction, description, title, hideTitle}) => (
<Banner
hideTitle={hideTitle}
title={title}
description={description}
icon={icon}
variant={variant}
onDismiss={dismissible}
primaryAction={primaryAction}
secondaryAction={secondaryAction}
/>
),
example: ({dismissible, variant, icon, secondaryAction, primaryAction, description, title, hideTitle}) => {
// icon is only supported for the info and upsell variants
const variantAndVisualProps = variant === 'info' || variant === 'upsell' ? {variant, icon} : {variant}

return (
<Banner
hideTitle={hideTitle}
title={title}
description={description}
onDismiss={dismissible}
primaryAction={primaryAction}
secondaryAction={secondaryAction}
{...variantAndVisualProps}
/>
)
},
})
20 changes: 15 additions & 5 deletions packages/react/src/Banner/Banner.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,19 @@ const iconMap = {
}

export const Playground: StoryObj<typeof Banner> = {
render: ({onDismiss, primaryAction, secondaryAction, leadingVisual, ...rest}) => {
// Map the string selection to the actual icon component
const leadingVisualElement = leadingVisual && iconMap[leadingVisual as keyof typeof iconMap]
render: ({
onDismiss,
primaryAction,
secondaryAction,
leadingVisual,
variant,
icon: _deprecatedIconIgnored,
...rest
}) => {
const variantAndVisualProps =
variant === 'info' || variant === 'upsell'
? {variant, leadingVisual: iconMap[leadingVisual as keyof typeof iconMap]}
: {variant}

return (
<PageLayout>
Expand All @@ -52,7 +62,7 @@ export const Playground: StoryObj<typeof Banner> = {
secondaryAction={
secondaryAction ? <Banner.SecondaryAction>{secondaryAction}</Banner.SecondaryAction> : null
}
leadingVisual={leadingVisualElement}
{...variantAndVisualProps}
{...rest}
/>
</PageLayout.Pane>
Expand All @@ -65,7 +75,7 @@ export const Playground: StoryObj<typeof Banner> = {
secondaryAction={
secondaryAction ? <Banner.SecondaryAction>{secondaryAction}</Banner.SecondaryAction> : null
}
leadingVisual={leadingVisualElement}
{...variantAndVisualProps}
{...rest}
/>
</PageLayout.Content>
Expand Down
6 changes: 6 additions & 0 deletions packages/react/src/Banner/Banner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,12 +238,15 @@ describe('Banner', () => {
rerender(<Banner title="test" description="test-description" variant="upsell" icon={<CustomIcon />} />)
expect(screen.getByTestId('icon')).toBeInTheDocument()

// @ts-expect-error unsupported variant used to verify runtime fallback behavior
rerender(<Banner title="test" description="test-description" variant="critical" icon={<CustomIcon />} />)
expect(screen.queryByTestId('icon')).toBe(null)

// @ts-expect-error unsupported variant used to verify runtime fallback behavior
rerender(<Banner title="test" description="test-description" variant="success" icon={<CustomIcon />} />)
expect(screen.queryByTestId('icon')).toBe(null)

// @ts-expect-error unsupported variant used to verify runtime fallback behavior
rerender(<Banner title="test" description="test-description" variant="warning" icon={<CustomIcon />} />)
expect(screen.queryByTestId('icon')).toBe(null)
})
Expand All @@ -258,12 +261,15 @@ describe('Banner', () => {
rerender(<Banner title="test" description="test-description" variant="upsell" leadingVisual={<CustomIcon />} />)
expect(screen.getByTestId('leading-visual')).toBeInTheDocument()

// @ts-expect-error unsupported variant used to verify runtime fallback behavior
rerender(<Banner title="test" description="test-description" variant="critical" leadingVisual={<CustomIcon />} />)
expect(screen.queryByTestId('leading-visual')).toBe(null)

// @ts-expect-error unsupported variant used to verify runtime fallback behavior
rerender(<Banner title="test" description="test-description" variant="success" leadingVisual={<CustomIcon />} />)
expect(screen.queryByTestId('leading-visual')).toBe(null)

// @ts-expect-error unsupported variant used to verify runtime fallback behavior
rerender(<Banner title="test" description="test-description" variant="warning" leadingVisual={<CustomIcon />} />)
expect(screen.queryByTestId('leading-visual')).toBe(null)
})
Expand Down
49 changes: 30 additions & 19 deletions packages/react/src/Banner/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ import classes from './Banner.module.css'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'

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

type BannerContextValue = {
titleId: string
}

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

export type BannerProps = React.ComponentPropsWithoutRef<'section'> & {
type BannerBaseProps = React.ComponentPropsWithoutRef<'section'> & {
/**
* Provide an optional label to override the default name for the Banner
* landmark region
Expand All @@ -41,17 +42,6 @@ export type BannerProps = React.ComponentPropsWithoutRef<'section'> & {
*/
hideTitle?: boolean

/**
* Provide a custom icon for the Banner. This is only available when `variant` is `info` or `upsell`
* @deprecated Use `leadingVisual` instead
*/
icon?: React.ReactNode

/**
* Provide a custom leading visual for the Banner. This is only available when `variant` is `info` or `upsell`
*/
leadingVisual?: React.ReactNode

/**
* Optionally provide a handler to be called when the banner is dismissed.
* Providing this prop will show a dismiss button.
Expand All @@ -74,11 +64,6 @@ export type BannerProps = React.ComponentPropsWithoutRef<'section'> & {
*/
title?: React.ReactNode

/**
* Specify the type of the Banner
*/
variant?: BannerVariant

/**
* Specify the layout of the Banner. Compact layout will reduce the padding.
*/
Expand All @@ -95,7 +80,33 @@ export type BannerProps = React.ComponentPropsWithoutRef<'section'> & {
flush?: boolean
}

const iconForVariant: Record<BannerVariant, React.ReactNode> = {
type VariantAndLeadingVisualProps =
| {
/**
* Specify the type of the Banner, default = info
*/
variant?: BannerVariantsWithCustomVisual

/**
* Provide a custom leading visual for the Banner. This is only available when `variant` is `info` or `upsell`
*/
leadingVisual?: React.ReactNode

/**
* Provide a custom icon for the Banner. This is only available when `variant` is `info` or `upsell`
* @deprecated Use `leadingVisual` instead
*/
icon?: React.ReactNode
}
| {
variant: Exclude<BannerVariant, BannerVariantsWithCustomVisual>
icon?: never
leadingVisual?: never
}

export type BannerProps = BannerBaseProps & VariantAndLeadingVisualProps

const defaultIconForVariant: Record<BannerVariant, React.ReactNode> = {
critical: <StopIcon />,
info: <InfoIcon />,
success: <CheckCircleIcon />,
Expand Down Expand Up @@ -170,7 +181,7 @@ export const Banner = React.forwardRef<HTMLElement, BannerProps>(function Banner
data-flush={flush ? '' : undefined}
>
<div data-component="Banner.Icon" className={classes.BannerIcon}>
{visual && supportsCustomIcon ? visual : iconForVariant[variant]}
{visual && supportsCustomIcon ? visual : defaultIconForVariant[variant]}
</div>
<div className={classes.BannerContainer}>
<div data-component="Banner.Content" className={classes.BannerContent}>
Expand Down
Loading