-
Notifications
You must be signed in to change notification settings - Fork 638
feat(Banner): add banner component #4335
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6f53c42
e7ecf41
9afaad9
73542f6
017ba4e
8405bcf
6136b81
860eea1
8140698
ea248f8
49f0fb1
7fca41e
1fc43e9
107f0ee
c418441
3952918
e627264
beeaf2f
fd2d938
b4797a8
6f39e0b
b838530
b0cb281
f38a562
e7ff35a
d139a86
c82781e
8f547e3
a555ba2
17c0384
21b5854
a219f7e
17893b2
36f9cea
3dbbeb6
6c54463
f0a4d14
fe93241
b628857
2f753ba
59299d7
2f520a7
068b0da
b3c8e93
0e9f8e1
3f91ca0
42fe2bf
54a70f4
167c43b
fe5b3ed
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@primer/react': minor | ||
--- | ||
|
||
Add support for experimental Banner component |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,112 @@ | ||
import {test, expect} from '@playwright/test' | ||
import {visit} from '../test-helpers/storybook' | ||
import {themes} from '../test-helpers/themes' | ||
import {viewports} from '../test-helpers/viewports' | ||
|
||
const stories: Array<{title: string; id: string; viewports?: Array<keyof typeof viewports>}> = [ | ||
{ | ||
title: 'Default', | ||
id: 'drafts-components-banner--default', | ||
viewports: ['primer.breakpoint.xs', 'primer.breakpoint.sm'], | ||
}, | ||
{ | ||
title: 'Critical', | ||
id: 'drafts-components-banner-features--critical', | ||
}, | ||
{ | ||
title: 'Dismiss', | ||
id: 'drafts-components-banner-features--dismiss', | ||
}, | ||
{ | ||
title: 'Dismiss With Actions', | ||
id: 'drafts-components-banner-features--dismiss-with-actions', | ||
}, | ||
{ | ||
title: 'Info', | ||
id: 'drafts-components-banner-features--info', | ||
}, | ||
{ | ||
title: 'Success', | ||
id: 'drafts-components-banner-features--success', | ||
}, | ||
{ | ||
title: 'Upsell', | ||
id: 'drafts-components-banner-features--upsell', | ||
}, | ||
{ | ||
title: 'Warning', | ||
id: 'drafts-components-banner-features--warning', | ||
}, | ||
{ | ||
title: 'WithActions', | ||
id: 'drafts-components-banner-features--with-actions', | ||
viewports: ['primer.breakpoint.xs', 'primer.breakpoint.sm'], | ||
}, | ||
{ | ||
title: 'WithHiddenTitle', | ||
id: 'drafts-components-banner-features--with-hidden-title', | ||
}, | ||
{ | ||
title: 'WithHiddenTitleAndActions', | ||
id: 'drafts-components-banner-features--with-hidden-title-and-actions', | ||
viewports: ['primer.breakpoint.xs', 'primer.breakpoint.sm'], | ||
}, | ||
{ | ||
title: 'InSidebar', | ||
id: 'drafts-components-banner-examples--in-sidebar', | ||
}, | ||
{ | ||
title: 'Multiline', | ||
id: 'drafts-components-banner-examples--multiline', | ||
viewports: ['primer.breakpoint.xs', 'primer.breakpoint.sm'], | ||
}, | ||
] | ||
|
||
test.describe('Banner', () => { | ||
for (const story of stories) { | ||
test.describe(story.title, () => { | ||
for (const theme of themes) { | ||
test.describe(theme, () => { | ||
test('default @vrt', async ({page}) => { | ||
await visit(page, { | ||
id: story.id, | ||
globals: { | ||
colorScheme: theme, | ||
}, | ||
}) | ||
|
||
// Default state | ||
expect(await page.screenshot()).toMatchSnapshot(`Banner.${story.title}.${theme}.png`) | ||
}) | ||
|
||
test('axe @aat', async ({page}) => { | ||
await visit(page, { | ||
id: story.id, | ||
globals: { | ||
colorScheme: theme, | ||
}, | ||
}) | ||
await expect(page).toHaveNoViolations() | ||
}) | ||
}) | ||
} | ||
|
||
if (story.viewports) { | ||
for (const name of story.viewports) { | ||
test(`${name} @vrt`, async ({page}) => { | ||
await visit(page, { | ||
id: story.id, | ||
}) | ||
const width = viewports[name] | ||
|
||
await page.setViewportSize({ | ||
width, | ||
height: 667, | ||
}) | ||
expect(await page.screenshot()).toMatchSnapshot(`Banner.${story.title}.${name}.png`) | ||
}) | ||
} | ||
} | ||
}) | ||
} | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,8 +1,8 @@ | ||
// TODO: Import PrimerBreakpoints from src/utils/layout/breakpoints.ts and refactor the usage of this object | ||
export const viewports: {[key: string]: number} = { | ||
export const viewports = { | ||
'primer.breakpoint.xs': 544, | ||
'primer.breakpoint.sm': 768, | ||
'primer.breakpoint.md': 1012, | ||
'primer.breakpoint.lg': 1280, | ||
'primer.breakpoint.xl': 1400, | ||
} | ||
} as const |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
{ | ||
"id": "banner", | ||
"name": "Banner", | ||
"status": "alpha", | ||
"a11yReviewed": false, | ||
"importPath": "@primer/react/experimental", | ||
"stories": [], | ||
"props": [ | ||
{ | ||
"name": "description", | ||
"type": "React.ReactNode", | ||
"description": "Provide an optional description for the Banner. This should provide supplemental information about the Banner" | ||
}, | ||
{ | ||
"name": "icon", | ||
"type": "React.ReactNode", | ||
"description": "Provide an icon for the banner" | ||
}, | ||
{ | ||
"name": "onDismiss", | ||
"type": "() => void", | ||
"description": "Optionally provide a handler to be called when the banner is dismissed. Providing this prop will show a dismiss button" | ||
}, | ||
{ | ||
"name": "primaryAction", | ||
"type": "React.ReactNode", | ||
"description": "" | ||
}, | ||
{ | ||
"name": "secondaryAction", | ||
"type": "React.ReactNode", | ||
"description": "" | ||
}, | ||
{ | ||
"name": "title", | ||
"type": "React.ReactNode", | ||
"description": "The title for the Banner. This will be used as the accessible name and is required unless `Banner.Title` is used as a child" | ||
}, | ||
{ | ||
"name": "variant", | ||
"type": "'critical' | 'info' | 'success' | 'upsell' | 'warning'", | ||
"description": "" | ||
} | ||
], | ||
"subcomponents": [ | ||
{ | ||
"name": "Banner.Title", | ||
"props": [ | ||
{ | ||
"name": "as", | ||
"type": "'h2' | 'h3' | 'h4' | 'h5' | 'h6'" | ||
} | ||
] | ||
}, | ||
{ | ||
"name": "Banner.Description", | ||
"props": [] | ||
}, | ||
{ | ||
"name": "Banner.PrimaryAction", | ||
"props": [] | ||
}, | ||
{ | ||
"name": "Banner.SecondaryAction", | ||
"props": [] | ||
} | ||
] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,152 @@ | ||
import {Banner} from '../Banner' | ||
import {action} from '@storybook/addon-actions' | ||
import Link from '../Link' | ||
import type {Meta} from '@storybook/react' | ||
import {Status} from '../internal/components/Status' | ||
import {Alert} from '../internal/components/Alert' | ||
import FormControl from '../FormControl' | ||
import RadioGroup from '../RadioGroup' | ||
import Radio from '../Radio' | ||
import {Button} from '../Button' | ||
import React from 'react' | ||
import {useFocus} from '../internal/hooks/useFocus' | ||
import {PageLayout} from '../PageLayout' | ||
|
||
const meta = { | ||
title: 'Drafts/Components/Banner/Examples', | ||
component: Banner, | ||
} satisfies Meta<typeof Banner> | ||
|
||
export default meta | ||
|
||
export const WithUserAction = () => { | ||
const [hasError, setHasError] = React.useState(false) | ||
const bannerRef = React.useRef<React.ElementRef<typeof Banner>>(null) | ||
const focus = useFocus() | ||
|
||
return ( | ||
<> | ||
{hasError ? ( | ||
<Banner | ||
ref={bannerRef} | ||
title="Error" | ||
description={<Alert>Something went wrong. Please try again later.</Alert>} | ||
Comment on lines
+32
to
+33
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Though I understand that heading semantics won't be conveyed through a live region, I'm wondering whether it would make sense to encourage including it as part of the live region announcement since it may contain information that isn't part of the description. 🤔 What would that look like? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @khiga8 would something like this maybe work? description={
<Alert>
<VisuallyHidden>Error:</VisuallyHidden> Something went wrong. Please try again later.
</Alert>
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To reduce redundancy around the heading text appearing in the banner twice for screen reader users, would something like this be possible? <Banner>
<Status>
<Banner.Title>Subscription renewed</Banner.Title>
<Banner.Description>Your subscription has been successfully renewed until May 5, 2024. </Banner.description>
</Status>
</Banner> cc: @ichelsea if you have other thoughts! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @khiga8 I bet we could make it work, we could also announce the live region itself if that would be desirable (e.g. something like |
||
variant="critical" | ||
/> | ||
) : null} | ||
<Button | ||
type="button" | ||
onClick={() => { | ||
setHasError(true) | ||
focus(bannerRef) | ||
}} | ||
> | ||
Update profile | ||
</Button> | ||
</> | ||
) | ||
} | ||
|
||
export const WithDynamicContent = () => { | ||
type Choice = 'one' | 'two' | 'three' | ||
const messages: Map<Choice, string> = new Map([ | ||
['one', 'This is a message for choice one'], | ||
['two', 'This is a message for choice two'], | ||
['three', 'This is a message for choice three'], | ||
]) | ||
const [selected, setSelected] = React.useState<Choice>('one') | ||
|
||
return ( | ||
<> | ||
<Banner | ||
title="Info" | ||
description={<Status>{messages.get(selected)}</Status>} | ||
onDismiss={action('onDismiss')} | ||
primaryAction={<Banner.PrimaryAction>Button</Banner.PrimaryAction>} | ||
secondaryAction={<Banner.SecondaryAction>Button</Banner.SecondaryAction>} | ||
/> | ||
<RadioGroup | ||
sx={{marginTop: 4}} | ||
name="options" | ||
onChange={selected => { | ||
setSelected(selected as Choice) | ||
}} | ||
> | ||
<RadioGroup.Label>Choices</RadioGroup.Label> | ||
<FormControl> | ||
<Radio value="one" defaultChecked /> | ||
<FormControl.Label>Choice one</FormControl.Label> | ||
</FormControl> | ||
<FormControl> | ||
<Radio value="two" /> | ||
<FormControl.Label>Choice two</FormControl.Label> | ||
</FormControl> | ||
<FormControl> | ||
<Radio value="three" /> | ||
<FormControl.Label>Choice three</FormControl.Label> | ||
</FormControl> | ||
</RadioGroup> | ||
</> | ||
) | ||
} | ||
|
||
export const WithCustomHeading = () => { | ||
return ( | ||
<Banner | ||
onDismiss={action('onDismiss')} | ||
primaryAction={<Banner.PrimaryAction>Button</Banner.PrimaryAction>} | ||
secondaryAction={<Banner.SecondaryAction>Button</Banner.SecondaryAction>} | ||
> | ||
<Banner.Title as="h3">Info</Banner.Title> | ||
<Banner.Description> | ||
GitHub users are{' '} | ||
<Link inline underline href="#"> | ||
now required | ||
</Link>{' '} | ||
to enable two-factor authentication as an additional security measure. | ||
</Banner.Description> | ||
</Banner> | ||
) | ||
} | ||
|
||
export const InSidebar = () => { | ||
return ( | ||
<> | ||
<PageLayout> | ||
<PageLayout.Header divider="line">PageLayout Header</PageLayout.Header> | ||
<PageLayout.Pane position="start" divider="line"> | ||
<h2>PageLayout Pane</h2> | ||
<Banner | ||
title="Info" | ||
description={ | ||
<> | ||
GitHub users are{' '} | ||
<Link inline underline href="#"> | ||
now required | ||
</Link>{' '} | ||
to enable two-factor authentication as an additional security measure. | ||
</> | ||
} | ||
primaryAction={<Banner.PrimaryAction>Button</Banner.PrimaryAction>} | ||
secondaryAction={<Banner.SecondaryAction>Button</Banner.SecondaryAction>} | ||
/> | ||
</PageLayout.Pane> | ||
<PageLayout.Content> | ||
<h1>PageLayout Content</h1> | ||
</PageLayout.Content> | ||
</PageLayout> | ||
</> | ||
) | ||
} | ||
|
||
export const Multiline = () => { | ||
return ( | ||
<Banner | ||
onDismiss={action('onDismiss')} | ||
title="Info" | ||
description="GitHub users are now required to enable two-factor authentication as an additional security measure. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen bSed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?" | ||
primaryAction={<Banner.PrimaryAction>Button</Banner.PrimaryAction>} | ||
secondaryAction={<Banner.SecondaryAction>Button</Banner.SecondaryAction>} | ||
/> | ||
) | ||
} |
Uh oh!
There was an error while loading. Please reload this page.