Skip to content

Commit

Permalink
feat: add actionable notification (#9494)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Sep 28, 2021
1 parent b510ad9 commit 4dfcae7
Show file tree
Hide file tree
Showing 19 changed files with 941 additions and 123 deletions.
3 changes: 3 additions & 0 deletions packages/carbon-react/.storybook/Welcome/Welcome.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export default {
docs: {
page: mdx,
},
controls: {
hideNoControlsWarning: true,
},
},
};

Expand Down
1 change: 1 addition & 0 deletions packages/carbon-react/__tests__/index-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Array [
"Accordion",
"AccordionItem",
"AccordionSkeleton",
"ActionableNotification",
"AspectRatio",
"Breadcrumb",
"BreadcrumbItem",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,84 +6,87 @@
*/

import {
ActionableNotification,
ToastNotification,
InlineNotification,
NotificationActionButton,
unstable_FeatureFlags as FeatureFlags,
} from 'carbon-components-react';
import React from 'react';

const notificationProps = () => ({
kind: 'info',
role: 'alert',
title: 'Notification title',
subtitle: 'Subtitle text goes here.',
});

const toastNotificationProps = () => ({
...notificationProps(),
});
import { action } from '@storybook/addon-actions';

export default {
title: 'Components/Notifications',
parameters: {
controls: {
hideNoControlsWarning: true,
decorators: [
(Story) => (
<FeatureFlags flags={{ 'enable-v11-release': true }}>
<Story />
</FeatureFlags>
),
],
argTypes: {
kind: {
options: [
'error',
'info',
'info-square',
'success',
'warning',
'warning-alt',
],
control: {
type: 'select',
},
},
className: {
control: {
type: 'text',
},
},
},
args: {
kind: 'error',
children: 'Notification content',
lowContrast: false,
closeOnEscape: false,
hideCloseButton: false,
iconDescription: 'closes notification',
statusIconDescription: 'notification',
onClose: action('onClose'),
onCloseButtonClick: action('onCloseButtonClick'),
},
};

export const Toast = () => (
<ToastNotification
{...toastNotificationProps()}
caption={('Caption (caption)', '00:00:00 AM')}
style={{ marginBottom: '.5rem' }}
/>
);

export const ToastPlayground = ({
kind = 'info',
title = 'Notification title',
subtitle = 'Notification subtitle',
caption = '00:00:00 AM',
lowContrast = false,
}) => {
return (
<ToastNotification
kind={kind}
title={title}
subtitle={subtitle}
lowContrast={lowContrast}
caption={caption}
/>
);
};
ToastPlayground.argTypes = {
kind: {
options: [
'error',
'info',
'info-square',
'success',
'warning',
'warning-alt',
],
export const Toast = (args) => <ToastNotification {...args} />;
Toast.argTypes = {
role: {
options: ['alert', 'log', 'status'],
control: {
type: 'select',
},
},
lowContrast: {
value: false,
};
Toast.args = { role: 'status', timeout: 0 };

export const Inline = (args) => (
<>
<InlineNotification {...args} />
<InlineNotification {...args} />
<InlineNotification {...args} />
</>
);
Inline.argTypes = {
role: {
options: ['alert', 'log', 'status'],
control: {
type: 'boolean',
type: 'select',
},
},
};
Inline.args = { role: 'status' };

export const Inline = () => (
<InlineNotification
{...notificationProps()}
actions={<NotificationActionButton>{'Action'}</NotificationActionButton>}
/>
);
export const Actionable = (args) => <ActionableNotification {...args} />;

Inline.storyName = 'Inline';
Actionable.args = {
actionButtonLabel: 'Action',
inline: false,
};
1 change: 1 addition & 0 deletions packages/carbon-react/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export {
Loading,
Modal,
MultiSelect,
ActionableNotification,
ToastNotification,
InlineNotification,
NotificationActionButton,
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/components/notification/_mixins.scss
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
background: $background-color;

.#{$prefix}--inline-notification__icon,
.#{$prefix}--toast-notification__icon {
.#{$prefix}--toast-notification__icon,
.#{$prefix}--actionable-notification__icon {
fill: $color;
}
}
5 changes: 5 additions & 0 deletions packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -4487,6 +4487,11 @@ Map {
},
"render": [Function],
},
"ActionableNotification" => Object {
"$$typeof": Symbol(react.forward_ref),
"displayName": "FeatureToggle(ActionableNotification)",
"render": [Function],
},
"ToastNotification" => Object {
"$$typeof": Symbol(react.forward_ref),
"displayName": "FeatureToggle(ToastNotification)",
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/__tests__/index-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Array [
"Accordion",
"AccordionItem",
"AccordionSkeleton",
"ActionableNotification",
"AspectRatio",
"Breadcrumb",
"BreadcrumbItem",
Expand Down
7 changes: 7 additions & 0 deletions packages/react/src/components/Notification/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
NotificationButton as NotificationButtonNext,
ToastNotification as ToastNotificationNext,
InlineNotification as InlineNotificationNext,
ActionableNotification as ActionableNotificationNext,
} from './next/Notification';
import {
NotificationActionButton as NotificationActionButtonClassic,
Expand Down Expand Up @@ -48,3 +49,9 @@ export const InlineNotification = createComponentToggle({
next: InlineNotificationNext,
classic: InlineNotificationClassic,
});

export const ActionableNotification = createComponentToggle({
name: 'ActionableNotification',
next: ActionableNotificationNext,
classic: null,
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
NotificationButton,
ToastNotification,
InlineNotification,
NotificationActionButton,
ActionableNotification,
} from '../next/Notification';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
Expand Down Expand Up @@ -101,7 +101,7 @@ describe('ToastNotification', () => {
expect(() => {
render(
<ToastNotification>
<button type="button">Sample text</button>
<button type="button">Sample button text</button>
</ToastNotification>
);
}).toThrow();
Expand Down Expand Up @@ -174,9 +174,17 @@ describe('ToastNotification', () => {
/>
);

// without focus being on/in the notification, it should not close via escape
userEvent.keyboard('{Escape}');
expect(onCloseButtonClick).toHaveBeenCalledTimes(0);
expect(onClose).toHaveBeenCalledTimes(0);

// after focus is placed, the notification should close via escape
userEvent.tab();
userEvent.keyboard('{Escape}');
expect(onCloseButtonClick).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);

await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
Expand Down Expand Up @@ -221,13 +229,19 @@ describe('InlineNotification', () => {
expect(screen.queryByText(/Sample text/i)).toBeInTheDocument();
});

it('allows interactive elements as children', () => {
render(
<InlineNotification>
<button type="button">Sample text</button>
</InlineNotification>
);
expect(screen.queryByText(/Sample text/i)).toBeInTheDocument();
it('does not allow interactive elements as children', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});

expect(() => {
render(
<InlineNotification>
<button type="button">Sample button text</button>
</InlineNotification>
);
}).toThrow();

expect(spy).toHaveBeenCalled();
spy.mockRestore();
});

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

// without focus being on/in the notification, it should not close via escape
userEvent.keyboard('{Escape}');
expect(onCloseButtonClick).toHaveBeenCalledTimes(0);
expect(onClose).toHaveBeenCalledTimes(0);

// after focus is placed, the notification should close via escape
userEvent.tab();
userEvent.keyboard('{Escape}');
expect(onCloseButtonClick).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);

await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
});
});

it('renders actions when provided, and overrides role to be alertdialog', () => {
describe('ActionableNotification', () => {
it('uses role=alertdialog', () => {
const { container } = render(
<InlineNotification
actions={
<NotificationActionButton>Button text</NotificationActionButton>
}
/>
<ActionableNotification actionButtonLabel="My custom action" />
);

expect(container.firstChild).toHaveAttribute('role', 'alertdialog');
});

it('renders correct action label', () => {
render(<ActionableNotification actionButtonLabel="My custom action" />);
const actionButton = screen.queryByRole('button', {
name: 'Button text',
name: 'My custom action',
});
expect(actionButton).toBeInTheDocument();
expect(container.firstChild).toHaveAttribute('role', 'alertdialog');
});

it('closes notification via escape button when focus is placed on the notification', async () => {
const onCloseButtonClick = jest.fn();
const onClose = jest.fn();
render(
<ActionableNotification
onClose={onClose}
onCloseButtonClick={onCloseButtonClick}
actionButtonLabel="My custom action"
/>
);

// without focus being on/in the notification, it should not close via escape
userEvent.keyboard('{Escape}');
expect(onCloseButtonClick).toHaveBeenCalledTimes(0);
expect(onClose).toHaveBeenCalledTimes(0);

// after focus is placed, the notification should close via escape
userEvent.tab();
userEvent.keyboard('{Escape}');
expect(onCloseButtonClick).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);

await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
});
});
});
Loading

0 comments on commit 4dfcae7

Please sign in to comment.