Skip to content

Commit 4dfcae7

Browse files
tay1orjonesjoshblackkodiakhq[bot]
authored
feat: add actionable notification (#9494)
* feat: add actionable notification * style: add actionable notification scss * fix(notification): styling issues with tertiary action button * fix(actionablenotification): correct action button styling * chore: remove debugging comments * chore(notification): improve docs and test coverage for notifications * chore: remove unused code * fix(notification): pull component tokens from button correctly Co-authored-by: Josh Black <josh@josh.black> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
1 parent b510ad9 commit 4dfcae7

File tree

19 files changed

+941
-123
lines changed

19 files changed

+941
-123
lines changed

packages/carbon-react/.storybook/Welcome/Welcome.stories.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export default {
1616
docs: {
1717
page: mdx,
1818
},
19+
controls: {
20+
hideNoControlsWarning: true,
21+
},
1922
},
2023
};
2124

packages/carbon-react/__tests__/index-test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Array [
1818
"Accordion",
1919
"AccordionItem",
2020
"AccordionSkeleton",
21+
"ActionableNotification",
2122
"AspectRatio",
2223
"Breadcrumb",
2324
"BreadcrumbItem",

packages/carbon-react/src/components/Notification/Notification.stories.js

Lines changed: 63 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -6,84 +6,87 @@
66
*/
77

88
import {
9+
ActionableNotification,
910
ToastNotification,
1011
InlineNotification,
11-
NotificationActionButton,
12+
unstable_FeatureFlags as FeatureFlags,
1213
} from 'carbon-components-react';
1314
import React from 'react';
14-
15-
const notificationProps = () => ({
16-
kind: 'info',
17-
role: 'alert',
18-
title: 'Notification title',
19-
subtitle: 'Subtitle text goes here.',
20-
});
21-
22-
const toastNotificationProps = () => ({
23-
...notificationProps(),
24-
});
15+
import { action } from '@storybook/addon-actions';
2516

2617
export default {
2718
title: 'Components/Notifications',
28-
parameters: {
29-
controls: {
30-
hideNoControlsWarning: true,
19+
decorators: [
20+
(Story) => (
21+
<FeatureFlags flags={{ 'enable-v11-release': true }}>
22+
<Story />
23+
</FeatureFlags>
24+
),
25+
],
26+
argTypes: {
27+
kind: {
28+
options: [
29+
'error',
30+
'info',
31+
'info-square',
32+
'success',
33+
'warning',
34+
'warning-alt',
35+
],
36+
control: {
37+
type: 'select',
38+
},
3139
},
40+
className: {
41+
control: {
42+
type: 'text',
43+
},
44+
},
45+
},
46+
args: {
47+
kind: 'error',
48+
children: 'Notification content',
49+
lowContrast: false,
50+
closeOnEscape: false,
51+
hideCloseButton: false,
52+
iconDescription: 'closes notification',
53+
statusIconDescription: 'notification',
54+
onClose: action('onClose'),
55+
onCloseButtonClick: action('onCloseButtonClick'),
3256
},
3357
};
3458

35-
export const Toast = () => (
36-
<ToastNotification
37-
{...toastNotificationProps()}
38-
caption={('Caption (caption)', '00:00:00 AM')}
39-
style={{ marginBottom: '.5rem' }}
40-
/>
41-
);
42-
43-
export const ToastPlayground = ({
44-
kind = 'info',
45-
title = 'Notification title',
46-
subtitle = 'Notification subtitle',
47-
caption = '00:00:00 AM',
48-
lowContrast = false,
49-
}) => {
50-
return (
51-
<ToastNotification
52-
kind={kind}
53-
title={title}
54-
subtitle={subtitle}
55-
lowContrast={lowContrast}
56-
caption={caption}
57-
/>
58-
);
59-
};
60-
ToastPlayground.argTypes = {
61-
kind: {
62-
options: [
63-
'error',
64-
'info',
65-
'info-square',
66-
'success',
67-
'warning',
68-
'warning-alt',
69-
],
59+
export const Toast = (args) => <ToastNotification {...args} />;
60+
Toast.argTypes = {
61+
role: {
62+
options: ['alert', 'log', 'status'],
7063
control: {
7164
type: 'select',
7265
},
7366
},
74-
lowContrast: {
75-
value: false,
67+
};
68+
Toast.args = { role: 'status', timeout: 0 };
69+
70+
export const Inline = (args) => (
71+
<>
72+
<InlineNotification {...args} />
73+
<InlineNotification {...args} />
74+
<InlineNotification {...args} />
75+
</>
76+
);
77+
Inline.argTypes = {
78+
role: {
79+
options: ['alert', 'log', 'status'],
7680
control: {
77-
type: 'boolean',
81+
type: 'select',
7882
},
7983
},
8084
};
85+
Inline.args = { role: 'status' };
8186

82-
export const Inline = () => (
83-
<InlineNotification
84-
{...notificationProps()}
85-
actions={<NotificationActionButton>{'Action'}</NotificationActionButton>}
86-
/>
87-
);
87+
export const Actionable = (args) => <ActionableNotification {...args} />;
8888

89-
Inline.storyName = 'Inline';
89+
Actionable.args = {
90+
actionButtonLabel: 'Action',
91+
inline: false,
92+
};

packages/carbon-react/src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export {
7070
Loading,
7171
Modal,
7272
MultiSelect,
73+
ActionableNotification,
7374
ToastNotification,
7475
InlineNotification,
7576
NotificationActionButton,

packages/components/src/components/notification/_mixins.scss

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
background: $background-color;
3838

3939
.#{$prefix}--inline-notification__icon,
40-
.#{$prefix}--toast-notification__icon {
40+
.#{$prefix}--toast-notification__icon,
41+
.#{$prefix}--actionable-notification__icon {
4142
fill: $color;
4243
}
4344
}

packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4487,6 +4487,11 @@ Map {
44874487
},
44884488
"render": [Function],
44894489
},
4490+
"ActionableNotification" => Object {
4491+
"$$typeof": Symbol(react.forward_ref),
4492+
"displayName": "FeatureToggle(ActionableNotification)",
4493+
"render": [Function],
4494+
},
44904495
"ToastNotification" => Object {
44914496
"$$typeof": Symbol(react.forward_ref),
44924497
"displayName": "FeatureToggle(ToastNotification)",

packages/react/src/__tests__/index-test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Array [
1818
"Accordion",
1919
"AccordionItem",
2020
"AccordionSkeleton",
21+
"ActionableNotification",
2122
"AspectRatio",
2223
"Breadcrumb",
2324
"BreadcrumbItem",

packages/react/src/components/Notification/index.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
NotificationButton as NotificationButtonNext,
1111
ToastNotification as ToastNotificationNext,
1212
InlineNotification as InlineNotificationNext,
13+
ActionableNotification as ActionableNotificationNext,
1314
} from './next/Notification';
1415
import {
1516
NotificationActionButton as NotificationActionButtonClassic,
@@ -48,3 +49,9 @@ export const InlineNotification = createComponentToggle({
4849
next: InlineNotificationNext,
4950
classic: InlineNotificationClassic,
5051
});
52+
53+
export const ActionableNotification = createComponentToggle({
54+
name: 'ActionableNotification',
55+
next: ActionableNotificationNext,
56+
classic: null,
57+
});

packages/react/src/components/Notification/next/Notification-test.js

Lines changed: 69 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
NotificationButton,
1212
ToastNotification,
1313
InlineNotification,
14-
NotificationActionButton,
14+
ActionableNotification,
1515
} from '../next/Notification';
1616
import { render, screen, waitFor } from '@testing-library/react';
1717
import userEvent from '@testing-library/user-event';
@@ -101,7 +101,7 @@ describe('ToastNotification', () => {
101101
expect(() => {
102102
render(
103103
<ToastNotification>
104-
<button type="button">Sample text</button>
104+
<button type="button">Sample button text</button>
105105
</ToastNotification>
106106
);
107107
}).toThrow();
@@ -174,9 +174,17 @@ describe('ToastNotification', () => {
174174
/>
175175
);
176176

177+
// without focus being on/in the notification, it should not close via escape
178+
userEvent.keyboard('{Escape}');
179+
expect(onCloseButtonClick).toHaveBeenCalledTimes(0);
180+
expect(onClose).toHaveBeenCalledTimes(0);
181+
182+
// after focus is placed, the notification should close via escape
183+
userEvent.tab();
177184
userEvent.keyboard('{Escape}');
178185
expect(onCloseButtonClick).toHaveBeenCalledTimes(1);
179186
expect(onClose).toHaveBeenCalledTimes(1);
187+
180188
await waitFor(() => {
181189
expect(screen.queryByRole('status')).not.toBeInTheDocument();
182190
});
@@ -221,13 +229,19 @@ describe('InlineNotification', () => {
221229
expect(screen.queryByText(/Sample text/i)).toBeInTheDocument();
222230
});
223231

224-
it('allows interactive elements as children', () => {
225-
render(
226-
<InlineNotification>
227-
<button type="button">Sample text</button>
228-
</InlineNotification>
229-
);
230-
expect(screen.queryByText(/Sample text/i)).toBeInTheDocument();
232+
it('does not allow interactive elements as children', () => {
233+
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
234+
235+
expect(() => {
236+
render(
237+
<InlineNotification>
238+
<button type="button">Sample button text</button>
239+
</InlineNotification>
240+
);
241+
}).toThrow();
242+
243+
expect(spy).toHaveBeenCalled();
244+
spy.mockRestore();
231245
});
232246

233247
it('close button is rendered by default and includes aria-hidden=true', () => {
@@ -293,26 +307,64 @@ describe('InlineNotification', () => {
293307
/>
294308
);
295309

310+
// without focus being on/in the notification, it should not close via escape
311+
userEvent.keyboard('{Escape}');
312+
expect(onCloseButtonClick).toHaveBeenCalledTimes(0);
313+
expect(onClose).toHaveBeenCalledTimes(0);
314+
315+
// after focus is placed, the notification should close via escape
316+
userEvent.tab();
296317
userEvent.keyboard('{Escape}');
297318
expect(onCloseButtonClick).toHaveBeenCalledTimes(1);
298319
expect(onClose).toHaveBeenCalledTimes(1);
320+
299321
await waitFor(() => {
300322
expect(screen.queryByRole('status')).not.toBeInTheDocument();
301323
});
302324
});
325+
});
303326

304-
it('renders actions when provided, and overrides role to be alertdialog', () => {
327+
describe('ActionableNotification', () => {
328+
it('uses role=alertdialog', () => {
305329
const { container } = render(
306-
<InlineNotification
307-
actions={
308-
<NotificationActionButton>Button text</NotificationActionButton>
309-
}
310-
/>
330+
<ActionableNotification actionButtonLabel="My custom action" />
311331
);
332+
333+
expect(container.firstChild).toHaveAttribute('role', 'alertdialog');
334+
});
335+
336+
it('renders correct action label', () => {
337+
render(<ActionableNotification actionButtonLabel="My custom action" />);
312338
const actionButton = screen.queryByRole('button', {
313-
name: 'Button text',
339+
name: 'My custom action',
314340
});
315341
expect(actionButton).toBeInTheDocument();
316-
expect(container.firstChild).toHaveAttribute('role', 'alertdialog');
342+
});
343+
344+
it('closes notification via escape button when focus is placed on the notification', async () => {
345+
const onCloseButtonClick = jest.fn();
346+
const onClose = jest.fn();
347+
render(
348+
<ActionableNotification
349+
onClose={onClose}
350+
onCloseButtonClick={onCloseButtonClick}
351+
actionButtonLabel="My custom action"
352+
/>
353+
);
354+
355+
// without focus being on/in the notification, it should not close via escape
356+
userEvent.keyboard('{Escape}');
357+
expect(onCloseButtonClick).toHaveBeenCalledTimes(0);
358+
expect(onClose).toHaveBeenCalledTimes(0);
359+
360+
// after focus is placed, the notification should close via escape
361+
userEvent.tab();
362+
userEvent.keyboard('{Escape}');
363+
expect(onCloseButtonClick).toHaveBeenCalledTimes(1);
364+
expect(onClose).toHaveBeenCalledTimes(1);
365+
366+
await waitFor(() => {
367+
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
368+
});
317369
});
318370
});

0 commit comments

Comments
 (0)