From 6aa1a7d56e999ec4c22ef899640227bf8e45fef2 Mon Sep 17 00:00:00 2001 From: Pedro Alves Date: Thu, 26 Dec 2024 16:16:52 -0100 Subject: [PATCH 1/7] feat: Unify Alert and Banner under a single Banner component with new designs --- src/banner/banner.module.css | 110 +++++---- src/banner/banner.stories.mdx | 406 +++++++++++++++++++++------------- src/banner/banner.test.tsx | 241 ++++++++++++++------ src/banner/banner.tsx | 240 ++++++++++++++------ src/banner/promo-image.tsx | 13 ++ src/icons/banner-icon.tsx | 100 +++++++++ 6 files changed, 780 insertions(+), 330 deletions(-) create mode 100644 src/banner/promo-image.tsx create mode 100644 src/icons/banner-icon.tsx 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..89f325286 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 './promo-image' -export function Icon(theme) { +export function ArchiveIcon() { return ( - + - - ) -} - -export function StarIcon(theme) { - return ( - - - - ) -} - -export function ArchiveIcon(theme) { - if (theme === 'dark') - return ( - - - - ) - return ( - - - - ) -} - -export function EyeIcon(theme) { - if (theme === 'dark') - return ( - - - - ) - return ( - - ) @@ -128,7 +40,7 @@ export function PlaygroundTemplate({ tone, title, description, action }) { } title={title} description={description} action={action} @@ -137,59 +49,183 @@ export function PlaygroundTemplate({ tone, title, description, action }) { ) } -export function BannerExamples({ theme }) { +export function BannerIconExamples({ theme }) { return ( - This workspace has used 5 of its 5 project limit. Upgrade to Pro for - more. - - } - action={getButton('Upgrade')} + tone="neutral" + icon={} + description="This is a neutral message" + /> + + + + + + + + + + ) +} + +export function BannerActionExamples({ theme }) { + return ( + + + } + 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={() => ({})} /> - Members can view but not edit. - - } - action={getButton('Unarchive project')} + tone="neutral" + icon={} + description="A banner with a inline link." + inlineLinks={[{ label: 'Learn more', href: '#' }]} /> - Members can view but not edit. - - } + tone="neutral" + icon={} + 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={() => ({})} /> ) } -# Banner +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 BannerImageExamples({ theme }) { + return ( + + + } + inlineLinks={[{ label: 'Learn more', href: '#' }]} + /> + } + inlineLinks={[{ label: 'Learn more', href: '#' }]} + /> + } + inlineLinks={[{ label: 'Learn more', href: '#' }]} + onClose={() => ({})} + /> + + + ) +} -A simple banner component meant to be used to _inform_ the user of promotional content or disclaimers. +# Banner -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 +334,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 +353,26 @@ 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..2c72e4ff5 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, SystemBannerTone } 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 tone', () => { + expect(screen.queryByTestId('banner-icon-neutral')).not.toBeInTheDocument() + expect(screen.queryByRole('img')).not.toBeInTheDocument() + }) + + it('renders a custom icon for neutral tone', () => { + render(Custom Icon} description="Info" />) + expect(screen.getByText('Custom Icon')).toBeInTheDocument() + }) + + it('renders an image for neutral tone', () => { + 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 SystemBannerTone, 'info'], + ['upgrade' as SystemBannerTone, 'upgrade'], + ['experiment' as SystemBannerTone, 'experiment'], + ['warning' as SystemBannerTone, 'warning'], + ['error' as SystemBannerTone, 'error'], + ['success' as SystemBannerTone, 'success'], + ])('renders a different icon according to the tone', (tone, expectedTone) => { + render() + expect( + within(screen.getByRole('status')).getByTestId(`banner-icon-${expectedTone}`), + ).toBeInTheDocument() }) it('passes through aria-related attributes', () => { - render( -