diff --git a/packages/carbon-react/.storybook/Welcome/Welcome.stories.js b/packages/carbon-react/.storybook/Welcome/Welcome.stories.js index 3a02ac039371..ab7c1de5933b 100644 --- a/packages/carbon-react/.storybook/Welcome/Welcome.stories.js +++ b/packages/carbon-react/.storybook/Welcome/Welcome.stories.js @@ -16,6 +16,9 @@ export default { docs: { page: mdx, }, + controls: { + hideNoControlsWarning: true, + }, }, }; diff --git a/packages/carbon-react/__tests__/index-test.js b/packages/carbon-react/__tests__/index-test.js index 238c2bdae2b1..431378548312 100644 --- a/packages/carbon-react/__tests__/index-test.js +++ b/packages/carbon-react/__tests__/index-test.js @@ -18,6 +18,7 @@ Array [ "Accordion", "AccordionItem", "AccordionSkeleton", + "ActionableNotification", "AspectRatio", "Breadcrumb", "BreadcrumbItem", diff --git a/packages/carbon-react/src/components/Notification/Notification.stories.js b/packages/carbon-react/src/components/Notification/Notification.stories.js index 443866f0117c..4b4f61f3b74b 100644 --- a/packages/carbon-react/src/components/Notification/Notification.stories.js +++ b/packages/carbon-react/src/components/Notification/Notification.stories.js @@ -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) => ( + + + + ), + ], + 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 = () => ( - -); - -export const ToastPlayground = ({ - kind = 'info', - title = 'Notification title', - subtitle = 'Notification subtitle', - caption = '00:00:00 AM', - lowContrast = false, -}) => { - return ( - - ); -}; -ToastPlayground.argTypes = { - kind: { - options: [ - 'error', - 'info', - 'info-square', - 'success', - 'warning', - 'warning-alt', - ], +export const Toast = (args) => ; +Toast.argTypes = { + role: { + options: ['alert', 'log', 'status'], control: { type: 'select', }, }, - lowContrast: { - value: false, +}; +Toast.args = { role: 'status', timeout: 0 }; + +export const Inline = (args) => ( + <> + + + + +); +Inline.argTypes = { + role: { + options: ['alert', 'log', 'status'], control: { - type: 'boolean', + type: 'select', }, }, }; +Inline.args = { role: 'status' }; -export const Inline = () => ( - {'Action'}} - /> -); +export const Actionable = (args) => ; -Inline.storyName = 'Inline'; +Actionable.args = { + actionButtonLabel: 'Action', + inline: false, +}; diff --git a/packages/carbon-react/src/index.js b/packages/carbon-react/src/index.js index f86f2c9987de..4d19bb1c448e 100644 --- a/packages/carbon-react/src/index.js +++ b/packages/carbon-react/src/index.js @@ -70,6 +70,7 @@ export { Loading, Modal, MultiSelect, + ActionableNotification, ToastNotification, InlineNotification, NotificationActionButton, diff --git a/packages/components/src/components/notification/_mixins.scss b/packages/components/src/components/notification/_mixins.scss index 669847b93f28..bb0803dd6d5c 100644 --- a/packages/components/src/components/notification/_mixins.scss +++ b/packages/components/src/components/notification/_mixins.scss @@ -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; } } diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 39f8f6b54ee3..41704553c0ba 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -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)", diff --git a/packages/react/src/__tests__/index-test.js b/packages/react/src/__tests__/index-test.js index 83249b451aac..b78efd31b253 100644 --- a/packages/react/src/__tests__/index-test.js +++ b/packages/react/src/__tests__/index-test.js @@ -18,6 +18,7 @@ Array [ "Accordion", "AccordionItem", "AccordionSkeleton", + "ActionableNotification", "AspectRatio", "Breadcrumb", "BreadcrumbItem", diff --git a/packages/react/src/components/Notification/index.js b/packages/react/src/components/Notification/index.js index 06e6329daddf..7d060a62edc0 100644 --- a/packages/react/src/components/Notification/index.js +++ b/packages/react/src/components/Notification/index.js @@ -10,6 +10,7 @@ import { NotificationButton as NotificationButtonNext, ToastNotification as ToastNotificationNext, InlineNotification as InlineNotificationNext, + ActionableNotification as ActionableNotificationNext, } from './next/Notification'; import { NotificationActionButton as NotificationActionButtonClassic, @@ -48,3 +49,9 @@ export const InlineNotification = createComponentToggle({ next: InlineNotificationNext, classic: InlineNotificationClassic, }); + +export const ActionableNotification = createComponentToggle({ + name: 'ActionableNotification', + next: ActionableNotificationNext, + classic: null, +}); diff --git a/packages/react/src/components/Notification/next/Notification-test.js b/packages/react/src/components/Notification/next/Notification-test.js index 5ecbecdd59dd..3c7eccc114bd 100644 --- a/packages/react/src/components/Notification/next/Notification-test.js +++ b/packages/react/src/components/Notification/next/Notification-test.js @@ -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'; @@ -101,7 +101,7 @@ describe('ToastNotification', () => { expect(() => { render( - + ); }).toThrow(); @@ -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(); }); @@ -221,13 +229,19 @@ describe('InlineNotification', () => { expect(screen.queryByText(/Sample text/i)).toBeInTheDocument(); }); - it('allows interactive elements as children', () => { - render( - - - - ); - expect(screen.queryByText(/Sample text/i)).toBeInTheDocument(); + it('does not allow interactive elements as children', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + render( + + + + ); + }).toThrow(); + + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); }); it('close button is rendered by default and includes aria-hidden=true', () => { @@ -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( - Button text - } - /> + ); + + expect(container.firstChild).toHaveAttribute('role', 'alertdialog'); + }); + + it('renders correct action label', () => { + render(); 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( + + ); + + // 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(); + }); }); }); diff --git a/packages/react/src/components/Notification/next/Notification.js b/packages/react/src/components/Notification/next/Notification.js index 84b498b29d50..b02308226ad5 100644 --- a/packages/react/src/components/Notification/next/Notification.js +++ b/packages/react/src/components/Notification/next/Notification.js @@ -28,12 +28,18 @@ const { prefix } = settings; /** * Conditionally call a callback when the escape key is pressed + * @param {node} ref - ref of the container element to scope the functionality to * @param {func} callback - function to be called * @param {bool} override - escape hatch to conditionally call the callback */ -function useEscapeToClose(callback, override = true) { +function useEscapeToClose(ref, callback, override = true) { const handleKeyDown = (event) => { - if (matches(event, [keys.Escape]) && override) { + // The callback should only be called when focus is on or within the container + const elementContainsFocus = + (ref.current && document.activeElement === ref.current) || + ref.current.contains(document.activeElement); + + if (matches(event, [keys.Escape]) && override && elementContainsFocus) { callback(event); } }; @@ -48,19 +54,19 @@ export function NotificationActionButton({ children, className: customClassName, onClick, + inline, ...rest }) { - const className = cx( - customClassName, - `${prefix}--inline-notification__action-button` - ); + const className = cx(customClassName, { + [`${prefix}--actionable-notification__action-button`]: true, + }); return ( @@ -78,6 +84,11 @@ NotificationActionButton.propTypes = { */ className: PropTypes.string, + /** + * Specify if the visual treatment of the button should be for an inline notification + */ + inline: PropTypes.bool, + /** * Optionally specify a click handler for the notification action button. */ @@ -141,7 +152,7 @@ NotificationButton.propTypes = { /** * Specify the notification type */ - notificationType: PropTypes.oneOf(['toast', 'inline']), + notificationType: PropTypes.oneOf(['toast', 'inline', 'actionable']), /** * Optional prop to allow overriding the icon rendering. @@ -220,15 +231,16 @@ export function ToastNotification({ [`${prefix}--toast-notification--${kind}`]: kind, }); - const ref = useRef(null); - useNoInteractiveChildren(ref); + const contentRef = useRef(null); + useNoInteractiveChildren(contentRef); const handleClose = (evt) => { if (!onClose || onClose(evt) !== false) { setIsOpen(false); } }; - useEscapeToClose(handleCloseButtonClick, closeOnEscape); + const ref = useRef(null); + useEscapeToClose(ref, handleCloseButtonClick, closeOnEscape); function handleCloseButtonClick(event) { onCloseButtonClick(event); @@ -262,15 +274,18 @@ export function ToastNotification({ } return ( -
+
-
+
{children}
+ {!hideCloseButton && ( { + if (!onClose || onClose(evt) !== false) { + setIsOpen(false); + } + }; + const ref = useRef(null); + useEscapeToClose(ref, handleCloseButtonClick, closeOnEscape); + + function handleCloseButtonClick(event) { + onCloseButtonClick(event); + handleClose(event); + } + + if (!isOpen) { + return null; + } + + return ( +
+
+ +
+
+ {children} +
+
+
+ {!hideCloseButton && ( + + )} +
+ ); +} + +InlineNotification.propTypes = { + /** + * Specify the content + */ + children: PropTypes.node, + + /** + * Specify an optional className to be applied to the notification box + */ + className: PropTypes.string, + + /** + * Specify if pressing the escape key should close notifications + */ + closeOnEscape: PropTypes.bool, + + /** + * Specify the close button should be disabled, or not + */ + hideCloseButton: PropTypes.bool, + + /** + * Provide a description for "close" icon that can be read by screen readers + */ + iconDescription: PropTypes.string, + + /** + * Specify what state the notification represents + */ + kind: PropTypes.oneOf([ + 'error', + 'info', + 'info-square', + 'success', + 'warning', + 'warning-alt', + ]).isRequired, + + /** + * Specify whether you are using the low contrast variant of the InlineNotification. + */ + lowContrast: PropTypes.bool, + + /** + * Provide a function that is called when menu is closed + */ + onClose: PropTypes.func, + + /** + * Provide a function that is called when the close button is clicked + */ + onCloseButtonClick: PropTypes.func, + + /** + * By default, this value is "alert". You can also provide an alternate + * role if it makes sense from the accessibility-side. If the `actions` prop is + * configured, this will be overridden to "alertdialog". + */ + role: PropTypes.oneOf(['alert', 'log', 'status']).isRequired, + + /** + * Provide a description for "status" icon that can be read by screen readers + */ + statusIconDescription: PropTypes.string, +}; + +InlineNotification.defaultProps = { + kind: 'error', + children: 'provide content', + role: 'status', + iconDescription: 'closes notification', + onCloseButtonClick: () => {}, + hideCloseButton: false, + closeOnEscape: true, +}; + +export function ActionableNotification({ + actionButtonLabel, + children, + role, + onActionButtonClick, + onClose, + onCloseButtonClick, + iconDescription, + statusIconDescription, + className, + inline, + kind, + lowContrast, + hideCloseButton, + hasFocus, + closeOnEscape, + ...rest +}) { + const [isOpen, setIsOpen] = useState(true); + const containerClassName = cx(className, { + [`${prefix}--actionable-notification`]: true, + [`${prefix}--actionable-notification--toast`]: !inline, + [`${prefix}--actionable-notification--low-contrast`]: lowContrast, + [`${prefix}--actionable-notification--${kind}`]: kind, + [`${prefix}--actionable-notification--hide-close-button`]: hideCloseButton, + }); + const ref = useRef(null); useIsomorphicEffect(() => { - if (ref.current && role == 'alertdialog' && hasFocus) { + if (ref.current && hasFocus) { ref.current.focus(); } }); @@ -403,7 +564,7 @@ export function InlineNotification({ setIsOpen(false); } }; - useEscapeToClose(handleCloseButtonClick, closeOnEscape); + useEscapeToClose(ref, handleCloseButtonClick, closeOnEscape); function handleCloseButtonClick(event) { onCloseButtonClick(event); @@ -416,37 +577,40 @@ export function InlineNotification({ return (
-
+
-
-
+
+
{children}
- {actions} + + + {actionButtonLabel} + + {!hideCloseButton && ( )}
); } -InlineNotification.propTypes = { +ActionableNotification.propTypes = { /** - * Pass in the action nodes that will be rendered within the InlineNotification. - * If this prop is configured, the aria role will be changed to "alertdialog" + * Pass in the action button label that will be rendered within the ActionableNotification. */ - actions: PropTypes.node, + actionButtonLabel: PropTypes.string.isRequired, /** * Specify the content @@ -478,6 +642,11 @@ InlineNotification.propTypes = { */ iconDescription: PropTypes.string, + /* + * Specify if the notification should have inline styling applied instead of toast + */ + inline: PropTypes.bool, + /** * Specify what state the notification represents */ @@ -491,10 +660,15 @@ InlineNotification.propTypes = { ]).isRequired, /** - * Specify whether you are using the low contrast variant of the InlineNotification. + * Specify whether you are using the low contrast variant of the ActionableNotification. */ lowContrast: PropTypes.bool, + /** + * Provide a function that is called when the action is clicked + */ + onActionButtonClick: PropTypes.func, + /** * Provide a function that is called when menu is closed */ @@ -506,11 +680,10 @@ InlineNotification.propTypes = { onCloseButtonClick: PropTypes.func, /** - * By default, this value is "alert". You can also provide an alternate - * role if it makes sense from the accessibility-side. If the `actions` prop is - * configured, this will be overridden to "alertdialog". + * By default, this value is "alertdialog". You can also provide an alternate + * role if it makes sense from the accessibility-side. */ - role: PropTypes.string.isRequired, + role: PropTypes.string, /** * Provide a description for "status" icon that can be read by screen readers @@ -518,13 +691,14 @@ InlineNotification.propTypes = { statusIconDescription: PropTypes.string, }; -InlineNotification.defaultProps = { +ActionableNotification.defaultProps = { kind: 'error', - content: 'provide content', - role: 'status', + children: 'provide content', + role: 'alertdialog', iconDescription: 'closes notification', onCloseButtonClick: () => {}, hideCloseButton: false, hasFocus: true, closeOnEscape: true, + inline: false, }; diff --git a/packages/react/src/index.js b/packages/react/src/index.js index efff07203742..4b81f0fd6e85 100644 --- a/packages/react/src/index.js +++ b/packages/react/src/index.js @@ -74,6 +74,7 @@ export Modal from './components/Modal'; export ModalWrapper from './components/ModalWrapper'; export MultiSelect from './components/MultiSelect'; export { + ActionableNotification, ToastNotification, InlineNotification, NotificationActionButton, diff --git a/packages/styles/scss/components/__tests__/notification-test.js b/packages/styles/scss/components/__tests__/notification-test.js index 0205796c3155..5f4210505c1f 100644 --- a/packages/styles/scss/components/__tests__/notification-test.js +++ b/packages/styles/scss/components/__tests__/notification-test.js @@ -38,6 +38,11 @@ describe('scss/components/notification', () => { 'notification-background-info', 'notification-background-warning', 'notification-action-hover', + 'notification-action-tertiary-inverse', + 'notification-action-tertiary-inverse-active', + 'notification-action-tertiary-inverse-hover', + 'notification-action-tertiary-inverse-text', + 'notification-action-tertiary-inverse-text-on-color-disabled', 'notification-tokens', ]); }); diff --git a/packages/styles/scss/components/notification/_actionable-notification.scss b/packages/styles/scss/components/notification/_actionable-notification.scss new file mode 100644 index 000000000000..ea84cd2f0c74 --- /dev/null +++ b/packages/styles/scss/components/notification/_actionable-notification.scss @@ -0,0 +1,407 @@ +// +// Copyright IBM Corp. 2016, 2018 +// +// This source code is licensed under the Apache-2.0 license found in the +// LICENSE file in the root directory of this source tree. +// +@use 'mixins' as *; +@use '../../breakpoint' as *; +@use '../../colors' as *; +@use '../../config' as *; +@use '../../feature-flags' as *; +@use '../../motion' as *; +@use '../../spacing' as *; +@use '../../theme' as *; +@use '../../themes' as *; +@use '../../type' as *; +@use '../../utilities/convert' as *; +@use '../../utilities/high-contrast-mode' as *; +@use '../../utilities/focus-outline' as *; +@use './tokens' as *; +@use '../button/mixins' as button-mixins; +@use '../button/vars' as button-vars; + +/// Actionable notification styles +/// @access public +/// @group notification +@mixin actionable-notification { + .#{$prefix}--actionable-notification { + @include reset; + + position: relative; + display: flex; + width: 100%; + min-width: rem(288px); + max-width: rem(288px); + height: auto; + min-height: rem(48px); + flex-wrap: wrap; + margin-top: $spacing-05; + margin-bottom: $spacing-05; + color: $text-inverse; + + @include breakpoint(md) { + max-width: rem(608px); + flex-wrap: nowrap; + } + + @include breakpoint(lg) { + max-width: rem(736px); + } + + @include breakpoint(max) { + max-width: rem(832px); + } + } + + .#{$prefix}--actionable-notification--toast { + min-width: rem(288px); + max-width: rem(288px); + flex-wrap: wrap; + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.2); + } + + .#{$prefix}--actionable-notification:not(.#{$prefix}--actionable-notification--low-contrast) + a { + color: $link-inverse; + } + + .#{$prefix}--actionable-notification a { + text-decoration: none; + } + + .#{$prefix}--actionable-notification a:hover { + text-decoration: underline; + } + + .#{$prefix}--actionable-notification a:focus { + outline: 1px solid $link-inverse; + } + + .#{$prefix}--actionable-notification.#{$prefix}--actionable-notification--low-contrast + a:focus { + @include focus-outline; + } + + .#{$prefix}--actionable-notification--low-contrast { + color: $text-primary; + + &::before { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + border-width: 1px 1px 1px 0; + border-style: solid; + content: ''; + filter: opacity(0.4); + pointer-events: none; + } + } + + .#{$prefix}--actionable-notification--error { + @include notification--experimental( + $support-error-inverse, + $background-inverse + ); + } + + .#{$prefix}--actionable-notification--low-contrast.#{$prefix}--actionable-notification--error { + @include notification--experimental( + $support-error, + $notification-background-error + ); + + &::before { + border-color: $support-error; + } + } + + .#{$prefix}--actionable-notification--success { + @include notification--experimental( + $support-success-inverse, + $background-inverse + ); + } + + .#{$prefix}--actionable-notification--low-contrast.#{$prefix}--actionable-notification--success { + @include notification--experimental( + $support-success, + $notification-background-success + ); + + &::before { + border-color: $support-success; + } + } + + .#{$prefix}--actionable-notification--info, + .#{$prefix}--actionable-notification--info-square { + @include notification--experimental( + $support-info-inverse, + $background-inverse + ); + } + + .#{$prefix}--actionable-notification--low-contrast.#{$prefix}--actionable-notification--info, + .#{$prefix}--actionable-notification--low-contrast.#{$prefix}--actionable-notification--info-square { + @include notification--experimental( + $support-info, + $notification-background-info + ); + + &::before { + border-color: $support-info; + } + } + + .#{$prefix}--actionable-notification--warning, + .#{$prefix}--actionable-notification--warning-alt { + @include notification--experimental( + $support-warning-inverse, + $background-inverse + ); + } + + .#{$prefix}--actionable-notification--low-contrast.#{$prefix}--actionable-notification--warning, + .#{$prefix}--actionable-notification--low-contrast.#{$prefix}--actionable-notification--warning-alt { + @include notification--experimental( + $support-warning, + $notification-background-warning + ); + + &::before { + border-color: $support-warning; + } + } + + .#{$prefix}--actionable-notification--warning + .#{$prefix}--inline-notification__icon, + .#{$prefix}--actionable-notification--warning + .#{$prefix}--toast-notification__icon + path[opacity='0'] { + fill: $black-100; + opacity: 1; + } + + .#{$prefix}--actionable-notification__details { + display: flex; + flex-grow: 1; + margin: 0 $spacing-09 0 $spacing-05; + + @include breakpoint(md) { + margin: 0 $spacing-05; + } + } + + .#{$prefix}--actionable-notification .#{$prefix}--inline-notification__icon { + flex-shrink: 0; + margin-top: rem(14px); + margin-right: $spacing-05; + } + + .#{$prefix}--actionable-notification .#{$prefix}--toast-notification__icon { + flex-shrink: 0; + margin-top: $spacing-05; + margin-right: $spacing-05; + } + + .#{$prefix}--actionable-notification__text-wrapper { + display: flex; + flex-wrap: wrap; + padding: rem(15px) 0; + } + + .#{$prefix}--actionable-notification__content { + @include type-style('body-short-01'); + + word-break: break-word; + } + + /* Ghost action button when inline */ + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--ghost { + height: rem(32px); + margin-bottom: $spacing-03; + margin-left: $spacing-08; + + @include breakpoint(md) { + margin: $spacing-03 0; + } + } + + .#{$prefix}--actionable-notification:not(.#{$prefix}--actionable-notification--low-contrast) + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--ghost { + color: $link-inverse; + } + + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--ghost:active, + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--ghost:hover { + background-color: $background-inverse-hover; + } + + .#{$prefix}--actionable-notification--low-contrast + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--ghost:active, + .#{$prefix}--actionable-notification--low-contrast + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--ghost:hover { + background-color: $notification-action-hover; + } + + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--ghost:focus { + border-color: transparent; + box-shadow: none; + outline: 2px solid $focus-inverse; + outline-offset: -2px; + } + + .#{$prefix}--actionable-notification--low-contrast + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--ghost:focus { + outline-color: $focus; + } + + .#{$prefix}--actionable-notification--hide-close-button + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--ghost { + margin-right: $spacing-03; + } + + /* Tertiary action button when not inline (toast) */ + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--tertiary { + margin-bottom: $spacing-03; + + // Button should be left aligned with text. 20px is the width of the notification icon + margin-left: calc(#{$spacing-07} + #{rem(20px)}); + } + + // Tertiary button styles by default use mostly "inverse" tokens. Since the non-low-contrast notification + // background is dark, the button styles have to be inversed again. + // So essentially the: + // btn-tertiary white theme button styles should become g100 button styles + // btn-tertiary g10 theme button styles should become g90 button styles + // btn-tertiary g90 theme button styles should become g10 button styles + // btn-tertiary g100 theme button styles should become white button styles + .#{$prefix}--actionable-notification:not(.#{$prefix}--actionable-notification--low-contrast) + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--tertiary { + @include button-mixins.button-theme( + transparent, + $notification-action-tertiary-inverse, + $notification-action-tertiary-inverse, + $notification-action-tertiary-inverse-hover, + currentColor, + $notification-action-tertiary-inverse-active + ); + } + + .#{$prefix}--actionable-notification:not(.#{$prefix}--actionable-notification--low-contrast) + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--tertiary:hover { + color: $notification-action-tertiary-inverse-text; + } + + .#{$prefix}--actionable-notification:not(.#{$prefix}--actionable-notification--low-contrast) + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--tertiary:focus { + border-color: $focus-inverse; + background-color: $notification-action-tertiary-inverse; + box-shadow: inset 0 0 0 button-vars.$button-outline-width $focus-inverse, + inset 0 0 0 button-vars.$button-border-width $background-inverse; + color: $notification-action-tertiary-inverse-text; + } + + .#{$prefix}--actionable-notification:not(.#{$prefix}--actionable-notification--low-contrast) + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--tertiary:active { + border-color: transparent; + background-color: $notification-action-tertiary-inverse-active; + color: $notification-action-tertiary-inverse-text; + } + + .#{$prefix}--actionable-notification:not(.#{$prefix}--actionable-notification--low-contrast) + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--tertiary:disabled, + .#{$prefix}--actionable-notification:not(.#{$prefix}--actionable-notification--low-contrast) + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--tertiary:hover:disabled, + .#{$prefix}--actionable-notification:not(.#{$prefix}--actionable-notification--low-contrast) + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--tertiary:focus:disabled, + .#{$prefix}--actionable-notification:not(.#{$prefix}--actionable-notification--low-contrast) + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--tertiary.#{$prefix}--btn--disabled, + .#{$prefix}--actionable-notification:not(.#{$prefix}--actionable-notification--low-contrast) + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--tertiary.#{$prefix}--btn--disabled:hover, + .#{$prefix}--actionable-notification:not(.#{$prefix}--actionable-notification--low-contrast) + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--tertiary.#{$prefix}--btn--disabled:focus { + background: transparent; + color: $notification-action-tertiary-inverse-text-on-color-disabled; + outline: none; + } + + .#{$prefix}--actionable-notification--hide-close-button + .#{$prefix}--actionable-notification__action-button.#{$prefix}--btn--tertiary { + margin-right: $spacing-03; + } + + .#{$prefix}--actionable-notification__close-button { + @include focus-outline('reset'); + + position: absolute; + top: 0; + right: 0; + display: flex; + width: rem(48px); + min-width: rem(48px); + max-width: rem(48px); + height: rem(48px); + flex-direction: column; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + transition: outline $duration-fast-02 motion(standard, productive), + background-color $duration-fast-02 motion(standard, productive); + + &:focus { + outline: 2px solid $focus-inverse; + outline-offset: -2px; + } + + .#{$prefix}--actionable-notification__close-icon { + fill: $icon-inverse; + } + + @include breakpoint(md) { + position: static; + } + } + + .#{$prefix}--actionable-notification--toast + .#{$prefix}--actionable-notification__close-button { + @include breakpoint(md) { + position: absolute; + } + } + + .#{$prefix}--actionable-notification--low-contrast + .#{$prefix}--actionable-notification__close-button:focus { + @include focus-outline('outline'); + } + + .#{$prefix}--actionable-notification--low-contrast + .#{$prefix}--actionable-notification__close-button + .#{$prefix}--actionable-notification__close-icon { + fill: $icon-primary; + } + + // Windows HCM fix + /* stylelint-disable */ + .#{$prefix}--actionable-notification { + @include high-contrast-mode('outline'); + } + + .#{$prefix}--actionable-notification__close-button:focus, + .#{$prefix}--btn.#{$prefix}--btn--ghost.#{$prefix}--actionable-notification__action-button:focus { + @include high-contrast-mode('focus'); + } + + .#{$prefix}--actionable-notification .#{$prefix}--inline-notification__icon, + .#{$prefix}--actionable-notification .#{$prefix}--toast-notification__icon { + @include high-contrast-mode('icon-fill'); + } + /* stylelint-enable */ +} diff --git a/packages/styles/scss/components/notification/_index.scss b/packages/styles/scss/components/notification/_index.scss index 83f68110c448..aa9e74716b5a 100644 --- a/packages/styles/scss/components/notification/_index.scss +++ b/packages/styles/scss/components/notification/_index.scss @@ -8,12 +8,15 @@ @forward './tokens'; @forward 'inline-notification'; @forward 'toast-notification'; +@forward 'actionable-notification'; @use '../../theme'; @use './tokens'; @use './inline-notification'; @use './toast-notification'; +@use './actionable-notification'; @include theme.add-component-tokens(tokens.$notification-tokens); @include inline-notification.inline-notification; @include toast-notification.toast-notification; +@include actionable-notification.actionable-notification; diff --git a/packages/styles/scss/components/notification/_inline-notification.scss b/packages/styles/scss/components/notification/_inline-notification.scss index 33cc79cebdb5..5f281a2cf556 100644 --- a/packages/styles/scss/components/notification/_inline-notification.scss +++ b/packages/styles/scss/components/notification/_inline-notification.scss @@ -197,7 +197,7 @@ padding: rem(15px) 0; } - @if enabled('enable-v11-release') { + @if feature-flag-enabled('enable-v11-release') { .#{$prefix}--inline-notification__content { @include type-style('body-short-01'); diff --git a/packages/styles/scss/components/notification/_mixins.scss b/packages/styles/scss/components/notification/_mixins.scss index 3e6930e063e8..e4e5c5f24c8d 100644 --- a/packages/styles/scss/components/notification/_mixins.scss +++ b/packages/styles/scss/components/notification/_mixins.scss @@ -35,7 +35,8 @@ background: $background-color; .#{$prefix}--inline-notification__icon, - .#{$prefix}--toast-notification__icon { + .#{$prefix}--toast-notification__icon, + .#{$prefix}--actionable-notification__icon { fill: $color; } } diff --git a/packages/styles/scss/components/notification/_toast-notification.scss b/packages/styles/scss/components/notification/_toast-notification.scss index 6a187e55dbc5..80765bcc3a93 100644 --- a/packages/styles/scss/components/notification/_toast-notification.scss +++ b/packages/styles/scss/components/notification/_toast-notification.scss @@ -146,7 +146,7 @@ margin-right: $spacing-05; } - @if enabled('enable-v11-release') { + @if feature-flag-enabled('enable-v11-release') { .#{$prefix}--toast-notification__content { @include type-style('body-short-01'); @@ -205,7 +205,7 @@ fill: $icon-primary; } - @if not enabled('enable-v11-release') { + @if not feature-flag-enabled('enable-v11-release') { .#{$prefix}--toast-notification__title { @include type-style('productive-heading-01'); diff --git a/packages/styles/scss/components/notification/_tokens.scss b/packages/styles/scss/components/notification/_tokens.scss index b9985e3a2c0d..18dfc14c5065 100644 --- a/packages/styles/scss/components/notification/_tokens.scss +++ b/packages/styles/scss/components/notification/_tokens.scss @@ -6,10 +6,13 @@ // @use 'sass:color'; +@use "sass:map"; +@use "sass:list"; @use '../../colors'; @use '../../themes'; @use '../../theme'; @use '../../utilities/component-tokens'; +@use '../button/tokens' as button-tokens; $notification-background-error: ( fallback: colors.$red-10, @@ -121,12 +124,91 @@ $notification-action-hover: ( ), ) !default; +$notification-action-tertiary-inverse: ( + fallback: map.get(button-tokens.$button-tokens, button-tertiary, fallback), + values: + component-tokens.get-inverse-theme-values( + map.get(button-tokens.$button-tokens, button-tertiary) + ), +) !default; + +$notification-action-tertiary-inverse-active: ( + fallback: + map.get(button-tokens.$button-tokens, button-tertiary-active, fallback), + values: + component-tokens.get-inverse-theme-values( + map.get(button-tokens.$button-tokens, button-tertiary-active) + ), +) !default; + +$notification-action-tertiary-inverse-hover: ( + fallback: + map.get(button-tokens.$button-tokens, button-tertiary-hover, fallback), + values: + component-tokens.get-inverse-theme-values( + map.get(button-tokens.$button-tokens, button-tertiary-hover) + ), +) !default; + +$notification-action-tertiary-inverse-text: ( + fallback: map.get(themes.$white, text-inverse), + values: ( + ( + theme: themes.$white, + value: map.get(themes.$g100, text-inverse), + ), + ( + theme: themes.$g10, + value: map.get(themes.$g90, text-inverse), + ), + ( + theme: themes.$g90, + value: map.get(themes.$g10, text-inverse), + ), + ( + theme: themes.$g100, + value: map.get(themes.$white, text-inverse), + ), + ), +) !default; + +$notification-action-tertiary-inverse-text-on-color-disabled: ( + fallback: map.get(themes.$white, text-on-color-disabled), + values: ( + ( + theme: themes.$white, + value: map.get(themes.$g100, text-on-color-disabled), + ), + ( + theme: themes.$g10, + value: map.get(themes.$g90, text-on-color-disabled), + ), + ( + theme: themes.$g90, + value: map.get(themes.$g10, text-on-color-disabled), + ), + ( + theme: themes.$g100, + value: map.get(themes.$white, text-on-color-disabled), + ), + ), +) !default; + $notification-tokens: ( notification-background-error: $notification-background-error, notification-background-success: $notification-background-success, notification-background-info: $notification-background-info, notification-background-warning: $notification-background-warning, notification-action-hover: $notification-action-hover, + notification-action-tertiary-inverse: $notification-action-tertiary-inverse, + notification-action-tertiary-inverse-active: + $notification-action-tertiary-inverse-active, + notification-action-tertiary-inverse-hover: + $notification-action-tertiary-inverse-hover, + notification-action-tertiary-inverse-text: + $notification-action-tertiary-inverse-text, + notification-action-tertiary-inverse-text-on-color-disabled: + $notification-action-tertiary-inverse-text-on-color-disabled, ); $notification-background-error: component-tokens.get-var( @@ -153,3 +235,28 @@ $notification-action-hover: component-tokens.get-var( $notification-action-hover, 'notification-action-hover' ); + +$notification-action-tertiary-inverse: component-tokens.get-var( + $notification-action-tertiary-inverse, + 'notification-action-tertiary-inverse' +); + +$notification-action-tertiary-inverse-active: component-tokens.get-var( + $notification-action-tertiary-inverse-active, + 'notification-action-tertiary-inverse-active' +); + +$notification-action-tertiary-inverse-hover: component-tokens.get-var( + $notification-action-tertiary-inverse-hover, + 'notification-action-tertiary-inverse-hover' +); + +$notification-action-tertiary-inverse-text: component-tokens.get-var( + $notification-action-tertiary-inverse-text, + 'notification-action-tertiary-inverse-text' +); + +$notification-action-tertiary-inverse-text-on-color-disabled: component-tokens.get-var( + $notification-action-tertiary-inverse-text-on-color-disabled, + 'notification-action-tertiary-inverse-text-on-color-disabled' +); diff --git a/packages/styles/scss/utilities/_component-tokens.scss b/packages/styles/scss/utilities/_component-tokens.scss index bc6f50e2a198..261169b658d9 100644 --- a/packages/styles/scss/utilities/_component-tokens.scss +++ b/packages/styles/scss/utilities/_component-tokens.scss @@ -7,6 +7,7 @@ @use 'sass:map'; @use 'sass:meta'; +@use "sass:list"; @use '../themes'; @use '../theme'; @use './custom-property'; @@ -53,3 +54,48 @@ @return custom-property.get-var($name, $token-map); } + +/// Get inverse theme values for a given component token. +/// @param {any} $token-map The possible values for the token, this value can +/// be a plain value used as a CSS value or a Sass Map +@function get-inverse-theme-values($token-map) { + $inverse-theme-values: (); + $inverse-value: (); + + @each $theme-value in map.get($token-map, values) { + $theme: map.get($theme-value, theme); + $value: map.get($theme-value, value); + + @if $theme == themes.$g100 { + $inverse-value: ( + theme: themes.$white, + value: $value, + ); + } + + @if $theme == themes.$g90 { + $inverse-value: ( + theme: themes.$g10, + value: $value, + ); + } + + @if $theme == themes.$g10 { + $inverse-value: ( + theme: themes.$g90, + value: $value, + ); + } + + @if $theme == themes.$white { + $inverse-value: ( + theme: themes.$g100, + value: $value, + ); + } + + $inverse-theme-values: list.append($inverse-theme-values, $inverse-value); + } + + @return $inverse-theme-values; +}