diff --git a/src/alert/alert.module.css b/src/alert/alert.module.css
deleted file mode 100644
index 926df0f82..000000000
--- a/src/alert/alert.module.css
+++ /dev/null
@@ -1,47 +0,0 @@
-.container {
- border-style: solid;
- border-width: 1px;
- color: var(--reactist-content-primary);
- padding: var(--reactist-spacing-small);
-}
-
-.content {
- /* this is to make sure it always has the same minimum height, whether it has a close button or not */
- min-height: var(--reactist-button-small-height);
-}
-
-.icon {
- display: block;
-}
-
-.tone-info {
- background-color: var(--reactist-alert-tone-info-background);
- border-color: var(--reactist-alert-tone-info-border);
-}
-.tone-info .icon {
- color: var(--reactist-alert-tone-info-icon);
-}
-
-.tone-positive {
- background-color: var(--reactist-alert-tone-positive-background);
- border-color: var(--reactist-alert-tone-positive-border);
-}
-.tone-positive .icon {
- color: var(--reactist-alert-tone-positive-icon);
-}
-
-.tone-caution {
- background-color: var(--reactist-alert-tone-caution-background);
- border-color: var(--reactist-alert-tone-caution-border);
-}
-.tone-caution .icon {
- color: var(--reactist-alert-tone-caution-icon);
-}
-
-.tone-critical {
- background-color: var(--reactist-alert-tone-critical-background);
- border-color: var(--reactist-alert-tone-critical-border);
-}
-.tone-critical .icon {
- color: var(--reactist-alert-tone-critical-icon);
-}
diff --git a/src/alert/alert.stories.mdx b/src/alert/alert.stories.mdx
deleted file mode 100644
index 76f020e0e..000000000
--- a/src/alert/alert.stories.mdx
+++ /dev/null
@@ -1,109 +0,0 @@
-import { Meta, Story, Canvas, ArgsTable, Description } from '@storybook/addon-docs'
-import { Box } from '../box'
-import { Text } from '../text'
-import { Stack } from '../stack'
-import { Alert } from './alert'
-
-
-
-export function getContent(content) {
- return content === 'long' ? (
- 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi non gravida lacus. Sed sit amet congue diam, ac ultrices elit.'
- ) : content === 'short' ? (
- 'Lorem ipsum dolor sit amet.'
- ) : (
-
-
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi non gravida lacus.
- Sed sit amet congue diam, ac ultrices elit.
-
-
- Suspendisse at neque leo. Duis facilisis nulla non lectus malesuada, vitae
- scelerisque massa hendrerit. Nulla lacinia luctus risus, dapibus semper turpis
- vestibulum eu.
-
-
- )
-}
-
-# Alert
-
-export function AlertWrapper({ tone, content }) {
- return (
- undefined}>
- {getContent(content)}
-
- )
-}
-
-A simple Alert component.
-
-
-
-
-
- {['info', 'positive', 'caution', 'critical'].map((tone) => (
-
- ))}
-
-
-
-
-
-## Playground
-
-export function Template({ tone, content, closeLabel }) {
- const text = getContent(content)
- return (
-
- {text}
- undefined}>
- {text}
-
-
- )
-}
-
-
-
- {Template.bind({})}
-
-
-
----
-
-
-
diff --git a/src/alert/alert.test.tsx b/src/alert/alert.test.tsx
deleted file mode 100644
index de48fd0e2..000000000
--- a/src/alert/alert.test.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import * as React from 'react'
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { axe } from 'jest-axe'
-import { Alert, AlertProps } from './alert'
-
-describe('Alert', () => {
- it('allows to be dismissed', () => {
- function Example(props: Omit) {
- const [show, setShow] = React.useState(true)
- return show ? (
- setShow(false)} />
- ) : null
- }
- render(Info message )
- expect(screen.getByRole('alert')).toHaveTextContent('Info message')
- userEvent.click(screen.getByRole('button', { name: 'Close alert' }))
- expect(screen.queryByRole('alert')).not.toBeInTheDocument()
- })
-
- it('renders with no a11y violations', async () => {
- const { container } = render(
- <>
- Info message
- undefined}>
- Another info message
-
- >,
- )
- const results = await axe(container)
- expect(results).toHaveNoViolations()
- })
-})
diff --git a/src/alert/alert.tsx b/src/alert/alert.tsx
deleted file mode 100644
index bce2f8fc3..000000000
--- a/src/alert/alert.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import * as React from 'react'
-import { getClassNames } from '../utils/responsive-props'
-import { Box } from '../box'
-import { IconButton } from '../button'
-import { Columns, Column } from '../columns'
-
-import { AlertIcon } from '../icons/alert-icon'
-import { CloseIcon } from '../icons/close-icon'
-
-import styles from './alert.module.css'
-
-import type { AlertTone } from '../utils/common-types'
-
-type AllOrNone = T | { [K in keyof T]?: never }
-
-type AlertCloseProps = AllOrNone<{
- closeLabel: string
- onClose: () => void
-}>
-
-type AlertProps = {
- id?: string
- children: React.ReactNode
- tone: AlertTone
-} & AlertCloseProps
-
-function Alert({ id, children, tone, closeLabel, onClose }: AlertProps) {
- return (
-
-
-
-
-
-
-
- {children}
-
-
- {onClose != null && closeLabel != null ? (
-
- }
- style={{
- // @ts-expect-error not sure how to make TypeScript understand custom CSS properties
- '--reactist-btn-hover-fill': 'transparent',
- }}
- />
-
- ) : null}
-
-
- )
-}
-
-export { Alert }
-export type { AlertProps }
diff --git a/src/alert/index.ts b/src/alert/index.ts
deleted file mode 100644
index 17dc897db..000000000
--- a/src/alert/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './alert'
diff --git a/src/banner/banner.module.css b/src/banner/banner.module.css
index dd410c364..e2a7f19cb 100644
--- a/src/banner/banner.module.css
+++ b/src/banner/banner.module.css
@@ -1,70 +1,96 @@
:root {
- --reactist-banner-title-font-size: 14px;
- --reactist-banner-title-line-height: 21px;
+ --reactist-banner-background-color: #fcfaf8;
+ --reactist-banner-border-color: #e6e6e6;
+ --reactist-divider-color: var(--reactist-divider-secondary);
- --reactist-banner-description-font-size: 12px;
- --reactist-banner-description-line-height: 20px;
+ --reactist-banner-main-copy-font-size: 14px;
+ --reactist-banner-main-copy-line-height: 21px;
+ --reactist-banner-main-copy-spacing: -0.15px;
+ --reactist-banner-main-copy-color: #202020;
- --reactist-banner-info-border: #eeeeee;
- --reactist-banner-info-background: #fafafa;
- --reactist-banner-info-title: #202020;
- --reactist-banner-info-description: #666666;
-
- --reactist-banner-promotion-border: #fae8d6;
- --reactist-banner-promotion-background: #fffaf4;
- --reactist-banner-promotion-title: #202020;
- --reactist-banner-promotion-description: #666666;
+ --reactist-banner-secondary-copy-font-size: 12px;
+ --reactist-banner-secondary-copy-line-height: 20px;
+ --reactist-banner-secondary-copy-color: #666666;
}
.banner {
- border-style: solid;
- border-width: 1px;
- padding-top: var(--reactist-spacing-small);
- padding-bottom: var(--reactist-spacing-small);
- padding-left: var(--reactist-spacing-large);
- padding-right: var(--reactist-spacing-large);
- letter-spacing: -0.15px;
+ container-name: banner;
+ container-type: inline-size;
+ background-color: var(--reactist-banner-background-color);
font-family: var(--reactist-font-family);
+ border: 1px solid var(--reactist-banner-border-color);
+ overflow: hidden;
+ min-height: 64px;
+}
+.banner:has(.image) {
+ width: min-content;
+ container-type: normal;
}
-.banner-info {
- background-color: var(--reactist-banner-info-background);
- border-color: var(--reactist-banner-info-border);
+.content {
+ padding: var(--reactist-spacing-large);
}
-.banner-promotion {
- background-color: var(--reactist-banner-promotion-background);
- border-color: var(--reactist-banner-promotion-border);
+.title,
+.description {
+ font-size: var(--reactist-banner-main-copy-font-size);
+ line-height: var(--reactist-banner-main-copy-line-height);
+ letter-spacing: var(--reactist-banner-main-copy-spacing);
+ color: var(--reactist-banner-main-copy-color);
}
.title {
- font-size: var(--reactist-banner-title-font-size);
- line-height: var(--reactist-banner-title-line-height);
font-weight: var(--reactist-font-weight-strong);
}
-.title-without-description {
- font-weight: var(--reactist-font-weight-regular);
+.description.secondary {
+ font-size: var(--reactist-banner-secondary-copy-font-size);
+ line-height: var(--reactist-banner-secondary-copy-line-height);
+ color: var(--reactist-banner-secondary-copy-color);
+ letter-spacing: initial;
}
-.title-info {
- color: var(--reactist-banner-info-title);
+.image {
+ border-bottom: 1px solid var(--reactist-banner-divider-color);
}
-.title-promotion {
- color: var(--reactist-banner-promotion-title);
+.image img,
+.icon svg {
+ display: block;
}
-.description {
- font-size: var(--reactist-banner-description-font-size);
- line-height: var(--reactist-banner-description-line-height);
- font-weight: var(--reactist-font-weight-regular);
+.icon .closeButton {
+ display: none;
}
-.description-info {
- color: var(--reactist-banner-info-description);
+.copy {
+ padding: calc(var(--reactist-spacing-xsmall) / 2) 0;
}
+.copy .inlineLink:first-of-type {
+ margin-left: var(--reactist-spacing-xsmall);
+}
+
+@container banner (width < 235px) {
+ .content,
+ .staticContent {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .icon {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ justify-content: space-between;
+ }
+ .icon .closeButton {
+ display: flex;
+ }
+ .icon .closeButton:only-child {
+ margin-left: auto;
+ }
-.description-promotion {
- color: var(--reactist-banner-promotion-description);
+ .actions .closeButton {
+ display: none;
+ }
}
diff --git a/src/banner/banner.stories.mdx b/src/banner/banner.stories.mdx
index a1bac33ef..c1bd6721c 100644
--- a/src/banner/banner.stories.mdx
+++ b/src/banner/banner.stories.mdx
@@ -4,6 +4,7 @@ import { Text } from '../text'
import { Stack } from '../stack'
import { Banner } from './banner'
import { Button } from '../button'
+import { PromoImage } from './story-promo-image'
-export function Icon(theme) {
+export function ArchiveIcon() {
return (
-
+
)
}
-export function StarIcon(theme) {
+export function getButton(buttonText) {
return (
-
-
-
+
+ {buttonText}
+
)
}
-export function ArchiveIcon(theme) {
- if (theme === 'dark')
- return (
-
-
-
- )
+export function PlaygroundTemplate({ type, title, description, action }) {
return (
-
-
+ }
+ title={title}
+ description={description}
+ action={action}
/>
-
+
)
}
-export function EyeIcon(theme) {
- if (theme === 'dark')
- return (
-
-
-
- )
+export function BannerIconExamples({ theme }) {
return (
-
-
-
+
+
+ }
+ description="This is a neutral message"
+ />
+
+
+
+
+
+
+
+
+
)
}
-export function getButton(buttonText) {
+export function BannerActionExamples({ theme }) {
return (
-
- {buttonText}
-
+
+
+ }
+ description="A read-only banner without any action"
+ />
+ }
+ description="A banner with a dismiss action"
+ onClose={() => ({})}
+ />
+ }
+ description="A banner with a primary CTA"
+ action={{
+ type: 'button',
+ label: 'Action',
+ variant: 'primary',
+ }}
+ />
+ }
+ description="A banner with a tertiary CTA"
+ action={{
+ type: 'button',
+ label: 'Action',
+ variant: 'tertiary',
+ }}
+ />
+ }
+ description="A banner with a primary CTA and a dismiss option"
+ action={{
+ type: 'button',
+ label: 'Action',
+ variant: 'primary',
+ }}
+ onClose={() => ({})}
+ />
+ }
+ description="A banner with a tertiary CTA and a dismiss option"
+ action={{
+ type: 'button',
+ label: 'Action',
+ variant: 'tertiary',
+ }}
+ onClose={() => ({})}
+ />
+ }
+ description="A banner with a inline link."
+ inlineLinks={[{ label: 'Learn more', href: '#' }]}
+ />
+ }
+ title="This is a sample title"
+ description="A banner with a inline link in secondary copy."
+ inlineLinks={[{ label: 'Learn more', href: '#' }]}
+ />
+ }
+ title="This is a sample title"
+ description="A banner with multiple inline links."
+ inlineLinks={[
+ { label: 'Learn more', href: '#' },
+ { label: 'Send feedback', href: '#' },
+ ]}
+ />
+ }
+ title="This is a sample title"
+ description="Here’s the message below the title."
+ />
+ }
+ title="This is a sample title"
+ description="Here’s the message below the title."
+ action={{
+ type: 'button',
+ label: 'Action',
+ variant: 'primary',
+ }}
+ onClose={() => ({})}
+ />
+
+
)
}
-export function PlaygroundTemplate({ tone, title, description, action }) {
+export function BannerCopyExamples({ theme }) {
return (
-
-
+
+
+ }
+ description="This is a some body text. It can span over two lines or more."
+ />
+ }
+ title="This is a sample title"
+ description="Here’s the message below the title. The copy can span over more than one line."
+ />
+
)
}
-export function BannerExamples({ theme }) {
+export function BannerImageExamples({ theme }) {
return (
- This workspace has used 5 of its 5 project limit. Upgrade to Pro for
- more.
- >
- }
- action={getButton('Upgrade')}
+ type="neutral"
+ title="This is a sample title"
+ description="Here’s the message below the title, sometimes the copy spans over two lines."
+ image={ }
+ inlineLinks={[{ label: 'Learn more', href: '#' }]}
/>
- Members can view but not edit.
- >
- }
- action={getButton('Unarchive project')}
+ type="neutral"
+ description="Here’s the message below the title, sometimes the copy spans over two lines."
+ image={ }
+ inlineLinks={[{ label: 'Learn more', href: '#' }]}
/>
- Members can view but not edit.
- >
- }
- />
- }
+ inlineLinks={[{ label: 'Learn more', href: '#' }]}
+ onClose={() => ({})}
/>
@@ -187,20 +225,27 @@ export function BannerExamples({ theme }) {
# Banner
-A simple banner component meant to be used to _inform_ the user of promotional content or disclaimers.
-
-If you're intending to _alert_ the user of a certain error or warning condition, consider using the `Alert` component instead.
+A simple banner component meant to be used to _inform_ the user of promotional content, disclaimers, warnings, as well as success and error states.
+
+
+
+
+
+### Actions
+
+Banners can have an optional dismiss action, or a primary or tertiary CTA, but never both. Additionally, banners can have an optional inline links near the description.
+
+These actions can be combined, such as a primary CTA with a dismiss option, or a primary CTA with a dismiss option and an inline link.
+
-
+
+
+
+
+### Copy
+
+Banners have two content options: a regular text that can span over multiple lines, or a bold header and a description with a secondary text color.
+
+
+
+
+
+
+
+### Image
+
+Banners can include images placed at the top, separated from the copy by a divider. The banner adapts to the image, ensuring it fills the full width and height of the banner.
+
+Image banners do not feature icons but can include body text or a combination of a header and description. They also support optional inline links and a dismiss action.
+
+
+
+
@@ -242,17 +343,17 @@ The following CSS custom properties are available to customize the banner compon
```css
:root {
- // tone="info"
- --reactist-banner-info-background: #eeeeee;
- --reactist-banner-info-border: #fafafa;
- --reactist-banner-info-title: #202020;
- --reactist-banner-info-description: #666666;
-
- // tone="promotion"
- --reactist-banner-promotion-border: #fae8d6;
- --reactist-banner-promotion-background: #fffaf4;
- --reactist-banner-promotion-title: #202020;
- --reactist-banner-promotion-description: #666666;
+ --reactist-banner-background-color: #fcfaf8;
+ --reactist-banner-border-color: #e6e6e6;
+
+ --reactist-banner-main-copy-font-size: 14px;
+ --reactist-banner-main-copy-line-height: 21px;
+ --reactist-banner-main-copy-spacing: -0.15px;
+ --reactist-banner-main-copy-color: #202020;
+
+ --reactist-banner-secondary-copy-font-size: 12px;
+ --reactist-banner-secondary-copy-line-height: 20px;
+ --reactist-banner-secondary-copy-color: #666666;
}
```
@@ -261,22 +362,32 @@ The following CSS custom properties are available to customize the banner compon
export function DarkModeTemplate() {
return (
-
+ } description="This is a neutral message" />
+
+
+
+
+
+
+ }
+ description="This is a neutral message"
+ onClose={() => ({})}
+ />
)
}
diff --git a/src/banner/banner.test.tsx b/src/banner/banner.test.tsx
index 49ef7efcd..b86daaf5b 100644
--- a/src/banner/banner.test.tsx
+++ b/src/banner/banner.test.tsx
@@ -1,127 +1,244 @@
import * as React from 'react'
-import { render, screen } from '@testing-library/react'
+import { render, screen, within } from '@testing-library/react'
import { axe } from 'jest-axe'
-import { Banner, BannerTone } from './banner'
+import { Banner, SystemBannerType } from './banner'
+import userEvent from '@testing-library/user-event'
describe('Banner', () => {
it('renders as a
element', () => {
- render( )
- expect(screen.getByTestId('test-banner').tagName).toBe('DIV')
+ render( )
+ expect(screen.getByRole('status').tagName).toBe('DIV')
})
- it('renders the title inside the badge element', () => {
- render( )
- expect(screen.getByTestId('test-banner')).toHaveTextContent('New')
+ it('renders the title inside the banner element', () => {
+ render( )
+ expect(screen.getByRole('status')).toHaveTextContent('Info title')
+ })
+
+ it('by default does not render an icon for neutral type', () => {
+ expect(screen.queryByTestId('banner-icon-neutral')).not.toBeInTheDocument()
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
+ })
+
+ it('renders a custom icon for neutral type', () => {
+ render(Custom Icon} description="Info" />)
+ expect(screen.getByText('Custom Icon')).toBeInTheDocument()
+ })
+
+ it('renders an image for neutral type', () => {
+ render( } description="Info" />)
+ expect(screen.getByAltText('Custom Image')).toBeInTheDocument()
})
test.each([
- ['info' as BannerTone, 'title-info'],
- ['promotion' as BannerTone, 'title-promotion'],
- ])('renders a different CSS class according to the tone', (tone, expectedCSSClass) => {
- render(
- <>
-
- >,
- )
- expect(screen.getByText('Info')).toHaveClass(expectedCSSClass)
+ ['info' as SystemBannerType, 'info'],
+ ['upgrade' as SystemBannerType, 'upgrade'],
+ ['experiment' as SystemBannerType, 'experiment'],
+ ['warning' as SystemBannerType, 'warning'],
+ ['error' as SystemBannerType, 'error'],
+ ['success' as SystemBannerType, 'success'],
+ ])('renders a different icon according to the type', (type, expectedtype) => {
+ render( )
+ expect(
+ within(screen.getByRole('status')).getByTestId(`banner-icon-${expectedtype}`),
+ ).toBeInTheDocument()
})
it('passes through aria-related attributes', () => {
- render(
- ,
- )
- expect(screen.getByTestId('test-banner')).toHaveAttribute('aria-hidden', 'true')
+ render( )
+ expect(screen.queryByRole('status')).not.toBeInTheDocument()
+ expect(screen.getByRole('status', { hidden: true })).toBeInTheDocument()
})
it('passes through data-* attributes', () => {
- // Even though the use of data-testid already proves that the test passes, it may be important
- // to assert that any data-* attribute is forwarded as well.
render(
,
)
- expect(screen.getByTestId('test-banner')).toHaveAttribute('data-gtm-id', 'track-id')
+ expect(screen.getByRole('status')).toHaveAttribute('data-gtm-id', 'track-id')
})
- it('does not passes through other attributes such as className, or exceptionallySetClassName', () => {
+ it('does not pass through other attributes such as className, or exceptionallySetClassName', () => {
render(
,
)
- expect(screen.getByTestId('test-banner')).not.toHaveClass('test-one')
- expect(screen.getByTestId('test-banner')).not.toHaveClass('test-two')
+ expect(screen.getByRole('status')).not.toHaveClass('test-one')
+ expect(screen.getByRole('status')).not.toHaveClass('test-two')
})
- it('renders more than one banner with no a11y violations', async () => {
- const { container } = render(
- <>
-
-
- >,
+ it('honors rich text in the title', () => {
+ render(
+
+ This is really important
+ >
+ }
+ description="Description"
+ />,
+ )
+ expect(screen.getByRole('status').innerHTML).toContain(
+ 'This is really important ',
)
- const results = await axe(container)
- expect(results).toHaveNoViolations()
})
- it('honors rich text', () => {
+ it('honors rich text in the description', () => {
render(
This is really important
>
}
- data-testid="test-banner"
/>,
)
- expect(screen.getByTestId('test-banner').innerHTML).toContain(
+ expect(screen.getByRole('status').innerHTML).toContain(
'This is really important ',
)
})
it('uses the title as the accessible name', () => {
- render( )
+ render( )
expect(screen.getByRole('status', { name: 'Hello World' })).toBeInTheDocument()
})
- it('uses the description as the accessible description', () => {
+ it("uses the description as the accessible name if there isn't a title", () => {
+ render( )
+ expect(screen.getByRole('status', { name: 'Hello World' })).toBeInTheDocument()
+ })
+
+ it('renders action button', () => {
+ const onClickSpy = jest.fn()
render(
,
)
- expect(screen.getByRole('status', { name: 'Hello World' })).toHaveAccessibleDescription(
- 'Welcome to the world, Linus!',
+ userEvent.click(screen.getByRole('button', { name: 'Click Me' }))
+ expect(onClickSpy).toHaveBeenCalled()
+ })
+
+ it('renders action link', () => {
+ render(
+ ,
+ )
+ expect(screen.getByRole('link', { name: 'Click Me' })).toHaveAttribute(
+ 'href',
+ 'http://localhost',
)
})
- it('does not have an accessible description if description is missing', () => {
- render( )
- expect(
- screen.getByRole('status', { name: 'Hello World' }),
- ).not.toHaveAccessibleDescription()
+ it('renders inline link', () => {
+ render(
+ ,
+ )
+ expect(screen.getByRole('link', { name: 'Learn more' })).toBeInTheDocument()
+ })
+
+ it('renders multiple inline links', () => {
+ render(
+ Info
}
+ inlineLinks={[
+ { label: 'Learn more', href: '#' },
+ { label: 'Send feedback', href: '#' },
+ ]}
+ />,
+ )
+ expect(screen.getByRole('link', { name: 'Learn more' })).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'Send feedback' })).toBeInTheDocument()
+ expect(screen.getByRole('status')).toHaveTextContent('Learn more · Send feedback')
+ })
+
+ it('renders close button', () => {
+ const onClose = jest.fn()
+ const { rerender } = render( )
+ expect(screen.queryByRole('button', { name: 'Close banner' })).not.toBeInTheDocument()
+
+ rerender( )
+ // close button is rendered twice because depending on banner size it can be in two places,
+ // but only one is visible at a time (the other is set to display: none with CSS)
+ expect(screen.getAllByRole('button', { name: 'Close banner' })).toHaveLength(2)
+
+ rerender(
+ ,
+ )
+ // close button is rendered twice because depending on banner size it can be in two places,
+ // but only one is visible at a time (the other is set to display: none with CSS)
+ expect(screen.getAllByRole('button', { name: 'Custom close label' })).toHaveLength(2)
+ })
+
+ it('calls onClose when close button is clicked', () => {
+ const onClose = jest.fn()
+ render(
+ ,
+ )
+ // close button is rendered twice because depending on banner size it can be in two places,
+ // but only one is visible at a time (the other is set to display: none with CSS)
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ userEvent.click(screen.getAllByRole('button', { name: 'Custom close label' })[0]!)
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('renders more than one banner with no a11y violations', async () => {
+ const { container } = render(
+ <>
+
+ Custom Icon}
+ onClose={jest.fn()}
+ inlineLinks={[{ label: 'Link 1', href: '#' }]}
+ />
+
+ >,
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
})
})
diff --git a/src/banner/banner.tsx b/src/banner/banner.tsx
index dd67ce052..021f20c53 100644
--- a/src/banner/banner.tsx
+++ b/src/banner/banner.tsx
@@ -1,106 +1,204 @@
import * as React from 'react'
import { Box } from '../box'
-import { Columns, Column } from '../columns'
import { useId } from '../utils/common-helpers'
import styles from './banner.module.css'
+import { Button, ButtonProps, IconButton } from '../button'
+import { CloseIcon } from '../icons/close-icon'
+import { BannerIcon } from '../icons/banner-icon'
+import { TextLink } from '../text-link'
-export type BannerTone = 'info' | 'promotion'
+/**
+ * Represents the type of a banner.
+ * 'neutral' accepts a custom icon, the rest do not.
+ * @default 'neutral'
+ */
+export type BannerType = 'neutral' | SystemBannerType
-type BannerProps = {
+/**
+ * Predefined system types for banners.
+ * Each type has its own preset icon.
+ */
+export type SystemBannerType = 'info' | 'upgrade' | 'experiment' | 'warning' | 'error' | 'success'
+
+type BaseAction = {
+ variant: 'primary' | 'tertiary'
+ label: string
+} & Pick
+type ActionButton = BaseAction & { type: 'button' } & Omit<
+ React.ButtonHTMLAttributes,
+ 'className'
+ >
+type ActionLink = BaseAction & { type: 'link' } & Omit<
+ React.AnchorHTMLAttributes,
+ 'className'
+ >
+/**
+ * Represents an action that can be taken from the banner.
+ * Can be either a button or a link, sharing common properties from BaseAction.
+ */
+type Action = ActionButton | ActionLink
+
+/**
+ * Configuration for inline links within the banner description.
+ * Extends TextLink component props with a required label.
+ */
+type InlineLink = { label: string } & React.ComponentProps
+
+type WithCloseButton = {
+ closeLabel?: string
+ onClose: () => void
+}
+type WithoutCloseButton = {
+ closeLabel?: never
+ onClose?: never
+}
+/**
+ * Controls the close button behavior.
+ * If none is provided, the banner will not have a close button.
+ */
+type CloseButton = WithCloseButton | WithoutCloseButton
+
+type BaseBanner = {
id?: string
+ title?: React.ReactNode
+ description: Exclude
+ action?: Action
+ inlineLinks?: InlineLink[]
+} & CloseButton
- /**
- * The tone of the Banner. Affects the background color and the outline.
- */
- tone: BannerTone
-
- /**
- * The icon that should be added inside the Banner.
- */
- icon: React.ReactElement | string | number
-
- /**
- * The title to be displayed at the top of the Banner.
- */
- title: React.ReactNode
-
- /**
- * An optional description to be displayed inside the Banner.
- */
- description?: React.ReactNode
-
- /**
- * An optional action to displayed inside the Banner.
- */
- action?: React.ReactElement | string | number
+/**
+ * Configuration for neutral banners.
+ * Can include either an image, an icon, or neither, but never both.
+ */
+type NeutralBanner = BaseBanner & {
+ type: Extract
+} & (
+ | { image: React.ReactElement; icon?: never }
+ | { icon: React.ReactElement; image?: never }
+ | { image?: never; icon?: never }
+ )
+
+/**
+ * Configuration for system banners.
+ * Cannot include custom images or icons as they use preset ones.
+ */
+type SystemBanner = BaseBanner & {
+ type: SystemBannerType
+ image?: never
+ icon?: never
}
+type BannerProps = NeutralBanner | SystemBanner
+
const Banner = React.forwardRef(function Banner(
- { id, tone, icon, title, description, action, ...props }: BannerProps,
+ {
+ id,
+ type,
+ title,
+ description,
+ action,
+ icon,
+ image,
+ inlineLinks,
+ closeLabel,
+ onClose,
+ ...props
+ }: BannerProps,
ref,
) {
const titleId = useId()
const descriptionId = useId()
+
+ const closeButton = onClose ? (
+ }
+ aria-label={closeLabel ?? 'Close banner'}
+ />
+ ) : null
+
return (
-
-
- {icon}
-
-
-
- {description ? (
-
- {title}
-
- ) : (
-
+ {image ? {image} : null}
+
+
+
+
+ {type === 'neutral' ? icon : }
+ {closeButton}
+
+
+
+ {title ? (
+
{title}
- )}
- {description ? (
-
- {description}
-
) : null}
+
+ {description}
+ {inlineLinks?.map(({ label, ...props }, index) => {
+ return (
+
+
+ {label}
+
+ {index < inlineLinks.length - 1 ? · : ''}
+
+ )
+ })}
+
-
- {action ? {action} : null}
-
+
+
+ {action || closeButton ? (
+
+ {action?.type === 'button' ? : null}
+ {action?.type === 'link' ? : null}
+ {closeButton}
+
+ ) : null}
+
)
})
+function ActionButton({ type, label, ...props }: ActionButton) {
+ return {label}
+}
+
+function ActionLink({ type, label, variant, ...props }: ActionLink) {
+ return (
+ }
+ >
+ {label}
+
+ )
+}
+
export { Banner }
export type { BannerProps }
diff --git a/src/banner/story-promo-image.tsx b/src/banner/story-promo-image.tsx
new file mode 100644
index 000000000..0c74b768d
--- /dev/null
+++ b/src/banner/story-promo-image.tsx
@@ -0,0 +1,13 @@
+import * as React from 'react'
+
+export function PromoImage({ theme = 'light' }: { theme?: 'light' | 'dark' }) {
+ if (theme === 'dark') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/icons/banner-icon.tsx b/src/icons/banner-icon.tsx
new file mode 100644
index 000000000..b0f6ed566
--- /dev/null
+++ b/src/icons/banner-icon.tsx
@@ -0,0 +1,100 @@
+import * as React from 'react'
+import type { SystemBannerType } from '../banner/banner'
+
+const bannerIconForType: Record = {
+ info: BannerInfoIcon,
+ upgrade: BannerUpgradeIcon,
+ experiment: BannerExperimentIcon,
+ warning: BannerWarningIcon,
+ error: BannerErrorIcon,
+ success: BannerSuccessIcon,
+}
+
+function BannerIcon({ type, ...props }: JSX.IntrinsicElements['svg'] & { type: SystemBannerType }) {
+ const Icon = bannerIconForType[type]
+ return Icon ? : null
+}
+
+function BannerInfoIcon(props: JSX.IntrinsicElements['svg']) {
+ return (
+
+
+
+ )
+}
+
+function BannerUpgradeIcon(props: JSX.IntrinsicElements['svg']) {
+ return (
+
+
+
+
+ )
+}
+
+function BannerExperimentIcon(props: JSX.IntrinsicElements['svg']) {
+ return (
+
+
+
+ )
+}
+
+function BannerWarningIcon(props: JSX.IntrinsicElements['svg']) {
+ return (
+
+
+
+ )
+}
+
+function BannerErrorIcon(props: JSX.IntrinsicElements['svg']) {
+ return (
+
+
+
+ )
+}
+
+function BannerSuccessIcon(props: JSX.IntrinsicElements['svg']) {
+ return (
+
+
+
+ )
+}
+
+export { BannerIcon }
diff --git a/src/index.ts b/src/index.ts
index f6b8fe9b6..00234a048 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -10,7 +10,6 @@ export * from './hidden'
export * from './hidden-visually'
// alerts, notifications, etc.
-export * from './alert'
export * from './banner'
export * from './loading'
export * from './notice'
diff --git a/stories/components/Dropdown.stories.tsx b/stories/components/Dropdown.stories.tsx
index 03345c21c..70040de42 100644
--- a/stories/components/Dropdown.stories.tsx
+++ b/stories/components/Dropdown.stories.tsx
@@ -2,7 +2,7 @@ import * as React from 'react'
import Button from '../../src/components/deprecated-button'
import Dropdown from '../../src/components/deprecated-dropdown'
-import { Alert } from '../../src/alert'
+import { Banner } from '../../src/banner'
import { Stack } from '../../src/stack'
import LinkTo from '@storybook/addon-links/react'
@@ -18,10 +18,15 @@ export default {
export const DropdownStory = () => (
-
- Deprecated: While not a 1:1 replacement, consider using{' '}
- Menu as an alternative
-
+
+ Deprecated: While not a 1:1 replacement, consider using{' '}
+ Menu as an alternative
+ >
+ }
+ />
diff --git a/stories/components/Input.stories.tsx b/stories/components/Input.stories.tsx
index 0e8f32db3..2964450df 100644
--- a/stories/components/Input.stories.tsx
+++ b/stories/components/Input.stories.tsx
@@ -1,7 +1,7 @@
import * as React from 'react'
import Input from '../../src/components/deprecated-input'
-import { Alert } from '../../src/alert'
+import { Banner } from '../../src/banner'
import './styles/input_story.less'
import LinkTo from '@storybook/addon-links/react'
@@ -20,10 +20,15 @@ export default {
export const InputStory = () => (
-
- Deprecated: Please use{' '}
- TextField instead
-
+
+ Deprecated: Please use{' '}
+ TextField instead
+ >
+ }
+ />
This component is a dumb wrapper around the
<input />
element which justs add a class name to give it is
diff --git a/stories/components/Select.stories.tsx b/stories/components/Select.stories.tsx
index e35217a72..b552fc4f0 100644
--- a/stories/components/Select.stories.tsx
+++ b/stories/components/Select.stories.tsx
@@ -1,7 +1,7 @@
import * as React from 'react'
import Select from '../../src/components/deprecated-select'
-import { Alert } from '../../src/alert'
+import { Banner } from '../../src/banner'
import { Stack } from '../../src/stack'
import LinkTo from '@storybook/addon-links/react'
@@ -31,10 +31,15 @@ export function SelectStory() {
return (
-
- Deprecated: Please use{' '}
- SelectField instead
-
+
+ Deprecated: Please use{' '}
+ SelectField instead
+ >
+ }
+ />