diff --git a/.eslintrc.js b/.eslintrc.js index 7b5ebef5325..8121a04e34b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -52,6 +52,7 @@ module.exports = { 'prop-types', 'react', 'requireindex', + 'react-transition-group', ], 'import/resolver': { node: {}, diff --git a/assets/js/base/components/cart-checkout/shipping-rates-control/index.tsx b/assets/js/base/components/cart-checkout/shipping-rates-control/index.tsx index bd4970e16f5..b735a1724c8 100644 --- a/assets/js/base/components/cart-checkout/shipping-rates-control/index.tsx +++ b/assets/js/base/components/cart-checkout/shipping-rates-control/index.tsx @@ -4,10 +4,7 @@ import { __ } from '@wordpress/i18n'; import { useEffect } from '@wordpress/element'; import LoadingMask from '@woocommerce/base-components/loading-mask'; -import { - ExperimentalOrderShippingPackages, - StoreNotice, -} from '@woocommerce/blocks-checkout'; +import { ExperimentalOrderShippingPackages } from '@woocommerce/blocks-checkout'; import { getShippingRatesPackageCount, getShippingRatesRateCount, @@ -17,6 +14,7 @@ import { useEditorContext, useShippingData, } from '@woocommerce/base-context'; +import NoticeBanner from '@woocommerce/base-components/notice-banner'; /** * Internal dependencies @@ -111,7 +109,7 @@ const ShippingRatesControl = ( { { hasSelectedLocalPickup && shippingRates.length > 1 && ! isEditor && ( - + ) } - { isAddressComplete && - __( - 'There are no shipping options available. Please check your shipping address.', - 'woo-gutenberg-products-block' - ) } + { isAddressComplete && ( + + { __( + 'There are no shipping options available. Please check your shipping address.', + 'woo-gutenberg-products-block' + ) } + + ) } } shippingRates={ shippingRates } diff --git a/assets/js/base/components/index.ts b/assets/js/base/components/index.ts new file mode 100644 index 00000000000..aa445113079 --- /dev/null +++ b/assets/js/base/components/index.ts @@ -0,0 +1,40 @@ +export * from './block-error-boundary'; +export * from './button'; +export * from './cart-checkout'; +export * from './checkbox-list'; +export * from './chip'; +export * from './combobox'; +export * from './country-input'; +export * from './drawer'; +export * from './filter-element-label'; +export * from './filter-placeholder'; +export * from './filter-reset-button'; +export * from './filter-submit-button'; +export * from './form'; +export * from './form-token-field'; +export * from './formatted-monetary-amount'; +export * from './label'; +export * from './load-more-button'; +export * from './loading-mask'; +export * from './noninteractive'; +export * from './notice-banner'; +export * from './pagination'; +export * from './price-slider'; +export * from './product-list'; +export * from './product-name'; +export * from './product-price'; +export * from './product-rating'; +export * from './quantity-selector'; +export * from './radio-control'; +export * from './radio-control-accordion'; +export * from './read-more'; +export * from './reviews'; +export * from './sidebar-layout'; +export * from './snackbar-list'; +export * from './sort-select'; +export * from './spinner'; +export * from './state-input'; +export * from './summary'; +export * from './tabs'; +export * from './textarea'; +export * from './title'; diff --git a/assets/js/base/components/notice-banner/README.md b/assets/js/base/components/notice-banner/README.md new file mode 100644 index 00000000000..9345d194672 --- /dev/null +++ b/assets/js/base/components/notice-banner/README.md @@ -0,0 +1,138 @@ +# NoticeBanner Component + +An informational UI displayed near the top of the store pages. + +## Table of contents + +- [Design Guidelines](#design-guidelines) +- [Development Guidelines](#development-guidelines) + - [Usage](#usage) + - [Props](#props) + - [`children`: `React.ReactNode`](#children-reactreactnode) + - [`className`: `string`](#classname-string) + - [`isDismissible`: `boolean`](#isdismissible-boolean) + - [`onRemove`: `() => void`](#onremove---void) + - [`politeness`: `'polite' | 'assertive'`](#politeness-polite--assertive) + - [`spokenMessage`: `string`](#spokenmessage-string) + - [`status`: `'success' | 'error' | 'info' | 'warning' | 'default'`](#status-success--error--info--warning--default) + - [`summary`: `string`](#summary-string) + - [Example](#example) + +## Design Guidelines + +Notices are informational UI displayed near the top of store pages. Notices are used to indicate the result of an action, or to draw the user’s attention to necessary information. + +Notices are color-coded to indicate the type of message being communicated, and also show an icon to reinforce the meaning of the message. The color and icon used for a notice are determined by the `status` prop. + +### Informational + +Blue notices used for general information for buyers that are not blocking and do not require action. + +![Informational notice](./screenshots/info.png) + +### Error + +Red notices to show that an error has occurred and that the user needs to take action. + +![Error notice](./screenshots/error.png) + +### Success + +Green notices that show an action was successful. + +![Success notice](./screenshots/success.png) + +### Warning + +Yellow notices that show that the user may need to take action, or needs to be aware of something important. + +![Warning notice](./screenshots/warning.png) + +### Default + +Gray notice, similar to info, but used for less important messaging. + +![Default notice](./screenshots/default.png) + +## Development Guidelines + +### Usage + +To display a plain notice, pass the notice message as a string: + +```jsx +import { NoticeBanner } from '@woocommerce/base-components'; + +Your message here; +``` + +For more complex markup, you can pass any JSX element: + +```jsx +import { NoticeBanner } from '@woocommerce/base-components'; + + +

+ An error occurred: { errorDetails }. +

+
; +``` + +### Props + +#### `children`: `React.ReactNode` + +The displayed message of a notice. Also used as the spoken message for assistive technology, unless `spokenMessage` is provided as an alternative message. + +#### `className`: `string` + +Additional class name to give to the notice. + +#### `isDismissible`: `boolean` + +Determines whether the notice can be dismissed by the user. When set to true, a close icon will be displayed on the banner. + +#### `onRemove`: `() => void` + +Function called when dismissing the notice. When the close icon is clicked or the Escape key is pressed, this function will be called. + +#### `politeness`: `'polite' | 'assertive'` + +Determines the level of politeness for the notice for assistive technology. Acceptable values are 'polite' and 'assertive'. Default is 'polite'. + +#### `spokenMessage`: `string` + +Optionally provided to change the spoken message for assistive technology. If not provided, the `children` prop will be used as the spoken message. + +#### `status`: `'success' | 'error' | 'info' | 'warning' | 'default'` + +Status determines the color of the notice and the icon. Acceptable values are `success`, `error`, `info`, `warning`, and `default`. + +#### `summary`: `string` + +Optional summary text shown above notice content, used when several notices are listed together. + +##### Example + +```tsx +import { NoticeBanner } from '@woocommerce/base-components'; + +const errorMessages = [ + 'First error message', + 'Second error message', + 'Third error message', +]; + + +
    + { errorMessages.map( ( message ) => ( +
  • { message }
  • + ) ) } +
+
; +``` + +In this example, the summary prop is used to indicate to the user that there are errors in the form submission. The list of error messages is rendered within the NoticeBanner component using an unordered list (`
    `) and list items (`
  • `). The `status` prop is set to `error` to indicate that the notice represents an error message. diff --git a/assets/js/base/components/notice-banner/index.tsx b/assets/js/base/components/notice-banner/index.tsx new file mode 100644 index 00000000000..50dcbac641b --- /dev/null +++ b/assets/js/base/components/notice-banner/index.tsx @@ -0,0 +1,99 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { __ } from '@wordpress/i18n'; +import { Icon, close } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import './style.scss'; +import { getDefaultPoliteness, getStatusIcon } from './utils'; +import Button from '../button'; +import { useSpokenMessage } from '../../hooks'; + +export interface NoticeBannerProps { + // The displayed message of a notice. Also used as the spoken message for assistive technology, unless `spokenMessage` is provided as an alternative message. + children: React.ReactNode; + // Additional class name to give to the notice. + className?: string | undefined; + // Determines whether the notice can be dismissed by the user. + isDismissible?: boolean | undefined; + // Function called when dismissing the notice. + onRemove?: ( () => void ) | undefined; + // Determines the level of politeness for the notice for assistive technology. + politeness?: 'polite' | 'assertive' | undefined; + // Optionally provided to change the spoken message for assistive technology. + spokenMessage?: string | React.ReactNode | undefined; + // Status determines the color of the notice and the icon. + status: 'success' | 'error' | 'info' | 'warning' | 'default'; + // Optional summary text shown above notice content, used when several notices are listed together. + summary?: string | undefined; +} + +/** + * NoticeBanner: An informational UI displayed near the top of the store pages. + * + * Notices are informational UI displayed near the top of store pages. WooCommerce blocks, themes, and plugins all use + * notices to indicate the result of an action, or to draw the user’s attention to necessary information. + */ +const NoticeBanner = ( { + className, + status = 'default', + children, + spokenMessage = children, + onRemove = () => void 0, + isDismissible = true, + politeness = getDefaultPoliteness( status ), + summary, +}: NoticeBannerProps ) => { + useSpokenMessage( spokenMessage, politeness ); + + const dismiss = ( event: React.SyntheticEvent ) => { + if ( + typeof event?.preventDefault === 'function' && + event.preventDefault + ) { + event.preventDefault(); + } + onRemove(); + }; + + return ( +
    + +
    + { summary && ( +

    + { summary } +

    + ) } + { children } +
    + { !! isDismissible && ( +
    + ); +}; + +export default NoticeBanner; diff --git a/assets/js/base/components/notice-banner/screenshots/default.png b/assets/js/base/components/notice-banner/screenshots/default.png new file mode 100644 index 00000000000..b332186648e Binary files /dev/null and b/assets/js/base/components/notice-banner/screenshots/default.png differ diff --git a/assets/js/base/components/notice-banner/screenshots/error.png b/assets/js/base/components/notice-banner/screenshots/error.png new file mode 100644 index 00000000000..4de65910933 Binary files /dev/null and b/assets/js/base/components/notice-banner/screenshots/error.png differ diff --git a/assets/js/base/components/notice-banner/screenshots/info.png b/assets/js/base/components/notice-banner/screenshots/info.png new file mode 100644 index 00000000000..de4ebc5124b Binary files /dev/null and b/assets/js/base/components/notice-banner/screenshots/info.png differ diff --git a/assets/js/base/components/notice-banner/screenshots/success.png b/assets/js/base/components/notice-banner/screenshots/success.png new file mode 100644 index 00000000000..dcb4536ee19 Binary files /dev/null and b/assets/js/base/components/notice-banner/screenshots/success.png differ diff --git a/assets/js/base/components/notice-banner/screenshots/warning.png b/assets/js/base/components/notice-banner/screenshots/warning.png new file mode 100644 index 00000000000..8fcf176155b Binary files /dev/null and b/assets/js/base/components/notice-banner/screenshots/warning.png differ diff --git a/assets/js/base/components/notice-banner/stories/index.tsx b/assets/js/base/components/notice-banner/stories/index.tsx new file mode 100644 index 00000000000..202a0088e5e --- /dev/null +++ b/assets/js/base/components/notice-banner/stories/index.tsx @@ -0,0 +1,107 @@ +/** + * External dependencies + */ +import type { Story, Meta } from '@storybook/react'; + +/** + * Internal dependencies + */ +import NoticeBanner, { NoticeBannerProps } from '../'; +const availableStatus = [ 'default', 'success', 'error', 'warning', 'info' ]; + +export default { + title: 'WooCommerce Blocks/@base-components/NoticeBanner', + argTypes: { + status: { + control: 'radio', + options: availableStatus, + description: + 'Status determines the color of the notice and the icon.', + }, + isDismissible: { + control: 'boolean', + description: 'Determines whether the notice can be dismissed.', + }, + summary: { + description: + 'Optional summary text shown above notice content, used when several notices are listed together.', + control: 'text', + }, + className: { + description: 'Additional class name to give to the notice.', + control: 'text', + }, + spokenMessage: { + description: + 'Optionally provided to change the spoken message for assistive technology.', + control: 'text', + }, + politeness: { + control: 'radio', + options: [ 'polite', 'assertive' ], + description: + 'Determines the level of politeness for the notice for assistive technology.', + }, + children: { + description: + 'The content of the notice; either text or a React node such as a list of errors.', + disable: true, + }, + onRemove: { + description: 'Function called when dismissing the notice.', + disable: true, + }, + }, + component: NoticeBanner, +} as Meta< NoticeBannerProps >; + +const Template: Story< NoticeBannerProps > = ( args ) => { + return ; +}; + +export const Default = Template.bind( {} ); +Default.args = { + children: 'This is a default notice', + status: 'default', + isDismissible: true, + summary: undefined, + className: undefined, + spokenMessage: undefined, + politeness: undefined, +}; + +export const Error = Template.bind( {} ); +Error.args = { + children: 'This is an error notice', + status: 'error', +}; + +export const Warning = Template.bind( {} ); +Warning.args = { + children: 'This is a warning notice', + status: 'warning', +}; + +export const Info = Template.bind( {} ); +Info.args = { + children: 'This is an info notice', + status: 'info', +}; + +export const Success = Template.bind( {} ); +Success.args = { + children: 'This is a success notice', + status: 'success', +}; + +export const ErrorSummary = Template.bind( {} ); +ErrorSummary.args = { + summary: 'Please fix the following errors', + children: ( +
      +
    • This is an error notice
    • +
    • This is another error notice
    • +
    + ), + status: 'error', +}; diff --git a/assets/js/base/components/notice-banner/style.scss b/assets/js/base/components/notice-banner/style.scss new file mode 100644 index 00000000000..395de052cb4 --- /dev/null +++ b/assets/js/base/components/notice-banner/style.scss @@ -0,0 +1,149 @@ +%notice-banner { + display: flex; + align-items: stretch; + align-content: flex-start; + color: $gray-800; + padding: $gap !important; + gap: $gap-small; + margin: $gap 0; + border-radius: 4px; + border-color: $gray-800; + font-weight: 400; + line-height: 1.5; + border: 1px solid; + @include font-size(small); + background-color: #fff; + box-sizing: border-box; + + > .wc-block-components-notice-banner__content { + padding-right: $gap; + align-self: center; + white-space: normal; + flex-basis: 100%; + + &:last-child { + padding-right: 0; + } + + .wc-block-components-notice-banner__summary { + margin: 0 0 $gap-smaller; + font-weight: 600; + } + + ul, + ol { + margin: 0 0 0 $gap-large; + padding: 0; + + li::after { + content: ""; + clear: both; + display: block; + } + } + + // Legacy notice compatibility. + .wc-forward.wp-element-button { + float: right; + color: $gray-800 !important; + background: transparent; + padding: 0 !important; + margin: 0; + border: 0; + appearance: none; + opacity: 0.6; + + &:hover, + &:focus, + &:active { + opacity: 1; + } + } + } + + > svg { + fill: #fff; + border-radius: 50%; + padding: 2px; + background-color: $gray-800; + flex-shrink: 0; + flex-grow: 0; + } + + > .wc-block-components-button { + margin: 6px 0 0 auto !important; + background: transparent none !important; + box-shadow: none !important; + outline: none !important; + border: 0 !important; + padding: 0 !important; + height: 16px !important; + width: 16px !important; + min-height: auto !important; + color: $gray-800 !important; + min-width: 0 !important; + flex: 0 0 16px; + opacity: 0.6; + + > svg { + margin: 0 !important; + } + + &:hover, + &:focus, + &:active { + opacity: 1; + } + } +} + +%error { + border-color: $alert-red; + background-color: #fff0f0; + + > svg { + background-color: $alert-red; + transform: rotate(180deg); + } +} +%warning { + border-color: $alert-yellow; + background-color: #fffbf4; + + > svg { + background-color: $alert-yellow; + transform: rotate(180deg); + } +} +%success { + border-color: $alert-green; + background-color: #f4fff7; + + > svg { + background-color: $alert-green; + } +} +%info { + border-color: #007cba; + background-color: #f4f8ff; + + > svg { + background-color: #007cba; + } +} + +.wc-block-components-notice-banner { + @extend %notice-banner; + &.is-error { + @extend %error; + } + &.is-warning { + @extend %warning; + } + &.is-success { + @extend %success; + } + &.is-info { + @extend %info; + } +} diff --git a/assets/js/base/components/notice-banner/test/index.tsx b/assets/js/base/components/notice-banner/test/index.tsx new file mode 100644 index 00000000000..ce9f2722cf6 --- /dev/null +++ b/assets/js/base/components/notice-banner/test/index.tsx @@ -0,0 +1,99 @@ +/** + * External dependencies + */ +import { render, fireEvent, findByText } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import NoticeBanner from '../index'; + +describe( 'NoticeBanner', () => { + test( 'renders without errors when all required props are provided', async () => { + const { container } = render( + This is an error message + ); + expect( + await findByText( container, 'This is an error message' ) + ).toBeInTheDocument(); + } ); + + test( 'displays the notice message correctly', () => { + const message = 'This is a test message'; + const { getByText } = render( + + { message } + + ); + const messageElement = getByText( message ); + expect( messageElement ).toBeInTheDocument(); + } ); + + test( 'displays the correct status for the notice', () => { + const { container } = render( + + This is a warning message + + ); + expect( container.querySelector( '.is-warning' ) ).toBeInTheDocument(); + } ); + + test( 'displays the summary correctly when provided', () => { + const summaryText = '4 new messages'; + const { getByText } = render( + + This is a test message + + ); + const summaryElement = getByText( summaryText ); + expect( summaryElement ).toBeInTheDocument(); + } ); + + test( 'can be dismissed when isDismissible prop is true', () => { + const onRemoveMock = jest.fn(); + const { getByRole } = render( + + This is a success message + + ); + const closeButton = getByRole( 'button' ); + fireEvent.click( closeButton ); + expect( onRemoveMock ).toHaveBeenCalled(); + } ); + + test( 'calls onRemove function when the notice is dismissed', () => { + const onRemoveMock = jest.fn(); + const { getByRole } = render( + + This is an informative message + + ); + const closeButton = getByRole( 'button' ); + fireEvent.click( closeButton ); + expect( onRemoveMock ).toHaveBeenCalled(); + } ); + + test( 'applies the className prop to the notice', () => { + const customClassName = 'my-custom-class'; + const { container } = render( + + This is a success message + + ); + const noticeElement = container.firstChild; + expect( noticeElement ).toHaveClass( customClassName ); + } ); + + test( 'does not throw any errors when all props are provided correctly', () => { + const spyError = jest.spyOn( console, 'error' ); + render( + This is a test message + ); + expect( spyError ).not.toHaveBeenCalled(); // Should not print any error/warning messages + spyError.mockRestore(); // Restore the original mock + } ); +} ); diff --git a/assets/js/base/components/notice-banner/utils.ts b/assets/js/base/components/notice-banner/utils.ts new file mode 100644 index 00000000000..eb061664480 --- /dev/null +++ b/assets/js/base/components/notice-banner/utils.ts @@ -0,0 +1,37 @@ +/** + * External dependencies + */ +import { info, megaphone, check } from '@wordpress/icons'; + +/** + * Get the default politeness level for a given status. This is based on how severe the status is. + */ +export const getDefaultPoliteness = ( status: string ) => { + switch ( status ) { + case 'success': + case 'warning': + case 'info': + case 'default': + return 'polite'; + + case 'error': + default: + return 'assertive'; + } +}; + +/** + * Gets the icon for the notice from the status. Note; we spin the warning status 180 degrees to make it look like an exclamation mark. + */ +export const getStatusIcon = ( status: string ): JSX.Element => { + switch ( status ) { + case 'success': + return check; + case 'warning': + case 'info': + case 'error': + return info; + default: + return megaphone; + } +}; diff --git a/assets/js/base/components/snackbar-list/README.md b/assets/js/base/components/snackbar-list/README.md new file mode 100644 index 00000000000..b3bc398b3ea --- /dev/null +++ b/assets/js/base/components/snackbar-list/README.md @@ -0,0 +1,62 @@ +# SnackbarList Component + +A temporary informational UI displayed at the bottom of store pages. + +## Table of contents + +- [Design Guidelines](#design-guidelines) +- [Development Guidelines](#development-guidelines) + - [Usage](#usage) + - [Props](#props) + - [`className`: `string`](#classname-string) + - [`onRemove`: `( noticeId ) => void`](#onremove--noticeid---void) + - [`notices`: `NoticeType[]`](#notices-noticetype) + +## Design Guidelines + +The buyer notice snackbar is temporary informational UI displayed at the bottom of store pages. WooCommerce blocks, themes, and plugins all use snackbar notices to indicate the result of a successful action. + +Snackbar notices work in the same way as the NoticeBanner component, and support the same statuses and styles. + +## Development Guidelines + +### Usage + +To display snackbar notices, pass an array of `notices` to the `SnackbarList` component: + +```jsx +import { SnackbarList } from '@woocommerce/base-components'; + +const notices = [ + { + id: '1', + content: 'This is a snackbar notice.', + status: 'default', + isDismissible: true, + } +]; + +; +``` + +The component consuming `SnackbarList` is responsible for managing the notices state. The `SnackbarList` component will automatically remove notices from the list when they are dismissed by the user using the provided `onRemove` callback, and also when the notice times out after 10000ms. + +### Props + +#### `className`: `string` + +Additional class name to give to the notice. + +#### `onRemove`: `( noticeId ) => void` + +Function called when dismissing the notice. When the close icon is clicked or the Escape key is pressed, this function will be called. This is also called when the notice times out after 10000ms. + +#### `notices`: `NoticeType[]` + +A list of notices to display as snackbars. Each notice must have an `id` and `content` prop. + +- The `id` prop is used to identify the notice and should be unique. +- The `content` prop is the content to display in the notice. +- The `status` prop is used to determine the color of the notice and the icon. Acceptable values are 'success', 'error', 'info', 'warning', and 'default'. +- The `isDismissible` prop determines whether the notice can be dismissed by the user. +- The `spokenMessage` prop is used to change the spoken message for assistive technology. If not provided, the `content` prop will be used as the spoken message. diff --git a/assets/js/base/components/snackbar-list/constants.ts b/assets/js/base/components/snackbar-list/constants.ts new file mode 100644 index 00000000000..bdc1149a5d4 --- /dev/null +++ b/assets/js/base/components/snackbar-list/constants.ts @@ -0,0 +1 @@ +export const SNACKBAR_TIMEOUT = 10000; diff --git a/assets/js/base/components/snackbar-list/index.tsx b/assets/js/base/components/snackbar-list/index.tsx new file mode 100644 index 00000000000..af7e92e4f5f --- /dev/null +++ b/assets/js/base/components/snackbar-list/index.tsx @@ -0,0 +1,88 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import type { NoticeType } from '@woocommerce/types'; +import { useReducedMotion } from '@wordpress/compose'; +import { useRef } from '@wordpress/element'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; + +/** + * Internal dependencies + */ +import './style.scss'; +import Snackbar from './snackbar'; + +export type SnackbarListProps = { + // Class name to be added to the container. + className?: string | undefined; + // List of notices to be rendered. + notices: NoticeType[]; + // Callback to be called when a notice is dismissed. + onRemove: ( id: string ) => void; +}; + +/** + * A temporary informational UI displayed at the bottom of store pages. + */ +const SnackbarList = ( { + notices, + className, + onRemove = () => void 0, +}: SnackbarListProps ): JSX.Element => { + const listRef = useRef< HTMLDivElement | null >( null ); + const isReducedMotion = useReducedMotion(); + + const removeNotice = ( notice: NoticeType ) => () => + onRemove( notice?.id || '' ); + + return ( +
    + { isReducedMotion ? ( + notices.map( ( notice ) => { + const { content, ...restNotice } = notice; + return ( + + { notice.content } + + ); + } ) + ) : ( + + { notices.map( ( notice ) => { + const { content, ...restNotice } = notice; + return ( + + + { content } + + + ); + } ) } + + ) } +
    + ); +}; + +export default SnackbarList; diff --git a/assets/js/base/components/snackbar-list/snackbar.tsx b/assets/js/base/components/snackbar-list/snackbar.tsx new file mode 100644 index 00000000000..41718d819cf --- /dev/null +++ b/assets/js/base/components/snackbar-list/snackbar.tsx @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import { useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import NoticeBanner, { NoticeBannerProps } from '../notice-banner'; +import { SNACKBAR_TIMEOUT } from './constants'; + +const Snackbar = ( { + onRemove = () => void 0, + children, + listRef, + ...notice +}: { + // A ref to the list that contains the snackbar. + listRef?: React.MutableRefObject< HTMLDivElement | null >; +} & NoticeBannerProps ) => { + // Only set up the timeout dismiss if we're not explicitly dismissing. + useEffect( () => { + const timeoutHandle = setTimeout( () => { + onRemove(); + }, SNACKBAR_TIMEOUT ); + + return () => clearTimeout( timeoutHandle ); + }, [ onRemove ] ); + + return ( + { + // Prevent focus loss by moving it to the list element. + if ( listRef && listRef.current ) { + listRef.current.focus(); + } + onRemove(); + } } + > + { children } + + ); +}; + +export default Snackbar; diff --git a/assets/js/base/components/snackbar-list/stories/index.tsx b/assets/js/base/components/snackbar-list/stories/index.tsx new file mode 100644 index 00000000000..9c8df331e5d --- /dev/null +++ b/assets/js/base/components/snackbar-list/stories/index.tsx @@ -0,0 +1,82 @@ +/** + * External dependencies + */ +import type { Story, Meta } from '@storybook/react'; + +/** + * Internal dependencies + */ +import SnackbarList, { SnackbarListProps } from '../'; + +export default { + title: 'WooCommerce Blocks/@base-components/SnackbarList', + args: { + notices: [ + { + id: '1', + content: 'This is a snackbar notice.', + status: 'success', + isDismissible: true, + }, + ], + className: undefined, + onRemove: () => void 0, + }, + argTypes: { + className: { + description: 'Additional class name to give to the notice.', + control: 'text', + }, + notices: { + description: 'List of notice objects to show as snackbar notices.', + disable: true, + }, + onRemove: { + description: 'Function called when dismissing the notice(s).', + disable: true, + }, + }, + component: SnackbarList, +} as Meta< SnackbarListProps >; + +const Template: Story< SnackbarListProps > = ( args ) => { + return ; +}; + +export const Default = Template.bind( {} ); +Default.args = { + notices: [ + { + id: '1', + content: 'This is a snackbar notice.', + status: 'default', + isDismissible: true, + }, + { + id: '2', + content: 'This is an informational snackbar notice.', + status: 'info', + isDismissible: true, + }, + { + id: '3', + content: 'This is a snackbar error notice.', + status: 'error', + isDismissible: true, + }, + { + id: '4', + content: 'This is a snackbar warning notice.', + status: 'warning', + isDismissible: true, + }, + { + id: '5', + content: 'This is a snackbar success notice.', + status: 'success', + isDismissible: true, + }, + ], + className: undefined, + onRemove: () => void 0, +}; diff --git a/assets/js/base/components/snackbar-list/style.scss b/assets/js/base/components/snackbar-list/style.scss new file mode 100644 index 00000000000..cd3f934e1e4 --- /dev/null +++ b/assets/js/base/components/snackbar-list/style.scss @@ -0,0 +1,54 @@ +.wc-block-components-notice-snackbar-list { + z-index: 100000; + box-sizing: border-box; + // Disable pointer events, so that clicking this area + // outside of an individual notice still allows the UI + // underneath to be clicked. + pointer-events: none; + position: fixed; + bottom: $gap-large; + left: $gap-large; + right: $gap-large; + + .wc-block-components-notice-banner { + display: inline-flex; + width: auto; + max-width: 600px; + margin: 0; + pointer-events: all; + border: 1px solid transparent; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + position: relative; + margin: $gap-large 0 0; + + &.is-default { + border-color: $gray-800; + } + + @include breakpoint("<782px") { + width: 100%; + max-width: none; + } + } +} + +.notice-transition-enter { + max-height: 0; +} +.notice-transition-enter.notice-transition-enter-active { + max-height: 99em; + transition: max-height 0.5s ease-in; +} +.notice-transition-enter-done { + max-height: auto; +} +.notice-transition-exit { + opacity: 1; +} +.notice-transition-exit.notice-transition-exit-active { + opacity: 0; + transition: opacity 0.5s ease-in; +} +.notice-transition-exit-done { + opacity: 0; +} diff --git a/assets/js/base/hooks/index.js b/assets/js/base/hooks/index.js index 8722ffb4f46..96e2f8d97b0 100644 --- a/assets/js/base/hooks/index.js +++ b/assets/js/base/hooks/index.js @@ -9,3 +9,4 @@ export * from './use-typography-props'; export * from './use-color-props'; export * from './use-border-props'; export * from './use-is-mounted'; +export * from './use-spoken-message'; diff --git a/assets/js/base/hooks/use-spoken-message.ts b/assets/js/base/hooks/use-spoken-message.ts new file mode 100644 index 00000000000..a7f5f4aa32a --- /dev/null +++ b/assets/js/base/hooks/use-spoken-message.ts @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import { useEffect, renderToString } from '@wordpress/element'; +import { speak } from '@wordpress/a11y'; + +/** + * Custom hook which announces the message with the given politeness, if a + * valid message is provided. + */ +export const useSpokenMessage = ( + message: string | React.ReactNode | undefined, + politeness: 'polite' | 'assertive' | undefined +) => { + const spokenMessage = + typeof message === 'string' ? message : renderToString( message ); + + useEffect( () => { + if ( spokenMessage ) { + speak( spokenMessage, politeness ); + } + }, [ spokenMessage, politeness ] ); +}; + +export default useSpokenMessage; diff --git a/assets/js/blocks/cart-checkout-shared/payment-methods/no-payment-methods/index.js b/assets/js/blocks/cart-checkout-shared/payment-methods/no-payment-methods/index.js index f6677acaaf4..78244ce8e98 100644 --- a/assets/js/blocks/cart-checkout-shared/payment-methods/no-payment-methods/index.js +++ b/assets/js/blocks/cart-checkout-shared/payment-methods/no-payment-methods/index.js @@ -2,11 +2,11 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { Placeholder, Button, Notice } from 'wordpress-components'; +import { Placeholder, Button } from 'wordpress-components'; import { Icon, payment } from '@wordpress/icons'; import { ADMIN_URL } from '@woocommerce/settings'; import { useEditorContext } from '@woocommerce/base-context'; -import classnames from 'classnames'; +import NoticeBanner from '@woocommerce/base-components/notice-banner'; /** * Internal dependencies @@ -62,19 +62,16 @@ const NoPaymentMethodsPlaceholder = () => { */ const NoPaymentMethodsNotice = () => { return ( - { __( 'There are no payment methods available. This may be an error on our side. Please contact us if you need any help placing your order.', 'woo-gutenberg-products-block' ) } - +
    ); }; diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.tsx index 86f26557eed..80da1f52a2c 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-methods-block/block.tsx @@ -21,7 +21,7 @@ import type { } from '@woocommerce/types'; import { CART_STORE_KEY } from '@woocommerce/block-data'; import { useSelect } from '@wordpress/data'; -import type { ReactElement } from 'react'; +import NoticeBanner from '@woocommerce/base-components/notice-banner'; /** * Internal dependencies @@ -57,7 +57,7 @@ const renderShippingRatesControlOption = ( const Block = ( { noShippingPlaceholder = null, shippingCostRequiresAddress = false, -} ): ReactElement | null => { +} ): React.ReactElement | null => { const { isEditor } = useEditorContext(); const { @@ -126,15 +126,23 @@ const Block = ( { - { addressComplete - ? __( + { addressComplete ? ( + + { __( 'There are no shipping options available. Please check your shipping address.', 'woo-gutenberg-products-block' - ) - : __( - 'Add a shipping address to view shipping options.', - 'woo-gutenberg-products-block' - ) } + ) } + + ) : ( + __( + 'Add a shipping address to view shipping options.', + 'woo-gutenberg-products-block' + ) + ) } } renderOption={ renderShippingRatesControlOption } diff --git a/assets/js/types/type-defs/index.ts b/assets/js/types/type-defs/index.ts index 3afb7f73155..e6517f5c273 100644 --- a/assets/js/types/type-defs/index.ts +++ b/assets/js/types/type-defs/index.ts @@ -1,20 +1,21 @@ -export * from './api-response'; export * from './api-error-response'; +export * from './api-response'; +export * from './attributes'; export * from './blocks'; -export * from './cart'; export * from './cart-response'; +export * from './cart'; export * from './checkout'; -export * from './currency'; export * from './contexts'; +export * from './currency'; export * from './events'; export * from './hooks'; +export * from './notices'; export * from './objects'; -export * from './payments'; export * from './payment-method-interface'; +export * from './payments'; export * from './product-response'; export * from './shipping'; -export * from './utils'; -export * from './taxes'; -export * from './attributes'; export * from './stock-status'; +export * from './taxes'; +export * from './utils'; export * from './validation'; diff --git a/assets/js/types/type-defs/notices.ts b/assets/js/types/type-defs/notices.ts new file mode 100644 index 00000000000..921da712db6 --- /dev/null +++ b/assets/js/types/type-defs/notices.ts @@ -0,0 +1,12 @@ +/** + * External dependencies + */ +import type { Notice } from '@wordpress/notices'; + +export interface NoticeType extends Partial< Omit< Notice, 'status' > > { + id: string; + content: string; + status: 'success' | 'error' | 'info' | 'warning' | 'default'; + isDismissible: boolean; + context?: string | undefined; +} diff --git a/package-lock.json b/package-lock.json index 6f3111fdb53..216cc89054c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "postcode-validator": "3.7.0", "preact": "^10.11.3", "react-number-format": "4.9.3", + "react-transition-group": "^4.4.5", "reakit": "1.3.11", "snakecase-keys": "5.4.2", "trim-html": "0.1.9", @@ -79,6 +80,7 @@ "@types/puppeteer": "5.4.6", "@types/react": "18.0.29", "@types/react-dom": "18.0.10", + "@types/react-transition-group": "^4.4.5", "@types/wordpress__block-editor": "6.0.6", "@types/wordpress__blocks": "11.0.9", "@types/wordpress__components": "^23.0.0", @@ -11507,6 +11509,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/responselike": { "version": "1.0.0", "dev": true, @@ -25021,6 +25032,15 @@ "utila": "~0.4" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dom-scroll-into-view": { "version": "1.2.1", "license": "MIT" @@ -43073,6 +43093,21 @@ "react": "17.0.2" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/react-use-gesture": { "version": "9.1.3", "license": "MIT", @@ -58731,6 +58766,15 @@ "@types/react": "*" } }, + "@types/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/responselike": { "version": "1.0.0", "dev": true, @@ -68178,6 +68222,15 @@ "utila": "~0.4" } }, + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "dom-scroll-into-view": { "version": "1.2.1" }, @@ -80523,6 +80576,17 @@ "scheduler": "^0.20.2" } }, + "react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, "react-use-gesture": { "version": "9.1.3", "requires": {} diff --git a/package.json b/package.json index 3f872de9159..4de8b5eb733 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "@types/puppeteer": "5.4.6", "@types/react": "18.0.29", "@types/react-dom": "18.0.10", + "@types/react-transition-group": "^4.4.5", "@types/wordpress__block-editor": "6.0.6", "@types/wordpress__blocks": "11.0.9", "@types/wordpress__components": "^23.0.0", @@ -255,6 +256,7 @@ "postcode-validator": "3.7.0", "preact": "^10.11.3", "react-number-format": "4.9.3", + "react-transition-group": "^4.4.5", "reakit": "1.3.11", "snakecase-keys": "5.4.2", "trim-html": "0.1.9", diff --git a/packages/checkout/components/store-notice/index.tsx b/packages/checkout/components/store-notice/index.tsx index b80c42dbcf0..7236125a447 100644 --- a/packages/checkout/components/store-notice/index.tsx +++ b/packages/checkout/components/store-notice/index.tsx @@ -2,24 +2,27 @@ * External dependencies */ import classnames from 'classnames'; -import { Notice } from 'wordpress-components'; -import { info, warning, Icon } from '@wordpress/icons'; +import NoticeBanner, { + NoticeBannerProps, +} from '@woocommerce/base-components/notice-banner'; /** - * Internal dependencies + * Wrapper for NoticeBanner component. */ -import './style.scss'; - -const StoreNotice = ( { className, children, status, ...props } ) => { +const StoreNotice = ( { + className, + children, + status, + ...props +}: NoticeBannerProps ) => { return ( - - -
    { children }
    -
    + { children } + ); }; diff --git a/packages/checkout/components/store-notice/style.scss b/packages/checkout/components/store-notice/style.scss deleted file mode 100644 index 9816a3a878a..00000000000 --- a/packages/checkout/components/store-notice/style.scss +++ /dev/null @@ -1,51 +0,0 @@ -.wc-block-store-notice { - margin: $gap 0; - @include font-size(small); - padding: 0.5em; - border-radius: 4px; - border: 2px solid; - - .components-notice__content { - position: relative; - - > div { - padding-left: 2.5em; - } - svg { - vertical-align: middle; - width: 2em; - height: 2em; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - } - } - - &.is-warning { - background-color: #fffdf9; - border-color: #fec; - - .components-notice__content svg { - fill: #f9b51f; - } - } - - &.is-info { - background-color: #e7f6f9; - border-color: #c8f6ff; - - .components-notice__content svg { - fill: #419ece; - } - } - - &.is-error { - background-color: #f8ebea; - border-color: #ffd4cd; - - .components-notice__content svg { - fill: #cd433b; - } - } -} diff --git a/packages/checkout/components/store-notices-container/index.tsx b/packages/checkout/components/store-notices-container/index.tsx index d1271174931..e84b6ffdecf 100644 --- a/packages/checkout/components/store-notices-container/index.tsx +++ b/packages/checkout/components/store-notices-container/index.tsx @@ -9,6 +9,7 @@ import { import { getNoticeContexts } from '@woocommerce/base-utils'; import type { Notice } from '@wordpress/notices'; import { useMemo, useEffect } from '@wordpress/element'; +import type { NoticeType } from '@woocommerce/types'; /** * Internal dependencies @@ -16,13 +17,13 @@ import { useMemo, useEffect } from '@wordpress/element'; import './style.scss'; import StoreNotices from './store-notices'; import SnackbarNotices from './snackbar-notices'; -import type { StoreNoticesContainerProps, StoreNotice } from './types'; +import type { StoreNoticesContainerProps } from './types'; -const formatNotices = ( notices: Notice[], context: string ): StoreNotice[] => { +const formatNotices = ( notices: Notice[], context: string ): NoticeType[] => { return notices.map( ( notice ) => ( { ...notice, context, - } ) ) as StoreNotice[]; + } ) ) as NoticeType[]; }; const StoreNoticesContainer = ( { @@ -57,7 +58,7 @@ const StoreNoticesContainer = ( { // Get notices from the current context and any sub-contexts and append the name of the context to the notice // objects for later reference. - const notices = useSelect< StoreNotice[] >( ( select ) => { + const notices = useSelect< NoticeType[] >( ( select ) => { const { getNotices } = select( 'core/notices' ); return [ @@ -70,7 +71,7 @@ const StoreNoticesContainer = ( { subContext ) ), - ].filter( Boolean ) as StoreNotice[]; + ].filter( Boolean ) as NoticeType[]; } ); // Register the container context with the parent. @@ -81,7 +82,7 @@ const StoreNoticesContainer = ( { }; }, [ contexts, registerContainer, unregisterContainer ] ); - if ( suppressNotices || ! notices.length ) { + if ( suppressNotices ) { return null; } diff --git a/packages/checkout/components/store-notices-container/snackbar-notices.tsx b/packages/checkout/components/store-notices-container/snackbar-notices.tsx index f5b591cbb75..448ca6441fa 100644 --- a/packages/checkout/components/store-notices-container/snackbar-notices.tsx +++ b/packages/checkout/components/store-notices-container/snackbar-notices.tsx @@ -2,39 +2,26 @@ * External dependencies */ import classnames from 'classnames'; -import { SnackbarList } from 'wordpress-components'; +import SnackbarList from '@woocommerce/base-components/snackbar-list'; import { useDispatch } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import type { StoreNotice } from './types'; +import type { NoticeType } from '@woocommerce/types'; const SnackbarNotices = ( { className, notices, }: { className: string; - notices: StoreNotice[]; + notices: NoticeType[]; } ): JSX.Element | null => { const { removeNotice } = useDispatch( 'core/notices' ); - if ( ! notices.length ) { - return null; - } - return ( { - return { - ...notice, - className: 'components-snackbar--status-' + notice.status, - }; - } ) } + notices={ notices } onRemove={ ( noticeId: string ) => { notices.forEach( ( notice ) => { if ( notice.explicitDismiss && notice.id === noticeId ) { diff --git a/packages/checkout/components/store-notices-container/store-notices.tsx b/packages/checkout/components/store-notices-container/store-notices.tsx index 73a6e29ad8a..8680fd7ae95 100644 --- a/packages/checkout/components/store-notices-container/store-notices.tsx +++ b/packages/checkout/components/store-notices-container/store-notices.tsx @@ -1,26 +1,27 @@ /** * External dependencies */ +import { __ } from '@wordpress/i18n'; import classnames from 'classnames'; -import { useRef, useEffect } from '@wordpress/element'; -import { Notice } from 'wordpress-components'; +import { useRef, useEffect, RawHTML } from '@wordpress/element'; import { sanitizeHTML } from '@woocommerce/utils'; import { useDispatch } from '@wordpress/data'; import { usePrevious } from '@woocommerce/base-hooks'; import { decodeEntities } from '@wordpress/html-entities'; +import type { NoticeType } from '@woocommerce/types'; +import type { NoticeBannerProps } from '@woocommerce/base-components/notice-banner'; /** * Internal dependencies */ -import { getClassNameFromStatus } from './utils'; -import type { StoreNotice } from './types'; +import StoreNotice from '../store-notice'; const StoreNotices = ( { className, notices, }: { className: string; - notices: StoreNotice[]; + notices: NoticeType[]; } ): JSX.Element => { const ref = useRef< HTMLDivElement >( null ); const { removeNotice } = useDispatch( 'core/notices' ); @@ -79,6 +80,9 @@ const StoreNotices = ( { ( { status } ) => status === 'warning' ), info: dismissibleNotices.filter( ( { status } ) => status === 'info' ), + default: dismissibleNotices.filter( + ( { status } ) => status === 'default' + ), }; return ( @@ -87,70 +91,75 @@ const StoreNotices = ( { className={ classnames( className, 'wc-block-components-notices' ) } > { nonDismissibleNotices.map( ( notice ) => ( - - { sanitizeHTML( decodeEntities( notice.content ) ) } - + + { sanitizeHTML( decodeEntities( notice.content ) ) } + + ) ) } { Object.entries( dismissibleNoticeGroups ).map( ( [ status, noticeGroup ] ) => { if ( ! noticeGroup.length ) { return null; } - const uniqueNotices = noticeGroup.filter( - ( - notice: Notice, - noticeIndex: number, - noticesArray: Notice[] - ) => - noticesArray.findIndex( - ( _notice: Notice ) => - _notice.content === notice.content - ) === noticeIndex - ); - return ( - { - noticeGroup.forEach( ( notice ) => { - removeNotice( notice.id, notice.context ); - } ); - } } + const uniqueNotices = noticeGroup + .filter( + ( + notice: NoticeType, + noticeIndex: number, + noticesArray: NoticeType[] + ) => + noticesArray.findIndex( + ( _notice: NoticeType ) => + _notice.content === notice.content + ) === noticeIndex + ) + .map( ( notice ) => ( { + ...notice, + content: sanitizeHTML( + decodeEntities( notice.content ) + ), + } ) ); + const noticeProps: Omit< NoticeBannerProps, 'children' > & { + key: string; + } = { + key: `store-notice-${ status }`, + status: 'error', + onRemove: () => { + noticeGroup.forEach( ( notice ) => { + removeNotice( notice.id, notice.context ); + } ); + }, + }; + return uniqueNotices.length === 1 ? ( + + { noticeGroup[ 0 ].content } + + ) : ( + - { uniqueNotices.length === 1 ? ( - <> - { sanitizeHTML( - decodeEntities( - noticeGroup[ 0 ].content - ) - ) } - - ) : ( -
      - { uniqueNotices.map( ( notice ) => ( -
    • - { sanitizeHTML( - decodeEntities( notice.content ) - ) } -
    • - ) ) } -
    - ) } -
    +
      + { uniqueNotices.map( ( notice ) => ( +
    • + { notice.content } +
    • + ) ) } +
    + ); } ) } diff --git a/packages/checkout/components/store-notices-container/types.ts b/packages/checkout/components/store-notices-container/types.ts index 999c1ac291d..e5970be7491 100644 --- a/packages/checkout/components/store-notices-container/types.ts +++ b/packages/checkout/components/store-notices-container/types.ts @@ -1,16 +1,11 @@ /** * External dependencies */ -import type { - Notice as NoticeType, - Options as NoticeOptions, -} from '@wordpress/notices'; +import type { NoticeType } from '@woocommerce/types'; export interface StoreNoticesContainerProps { className?: string | undefined; context?: string | string[]; // List of additional notices that were added inline and not stored in the `core/notices` store. - additionalNotices?: ( NoticeType & NoticeOptions )[]; + additionalNotices?: NoticeType[]; } - -export type StoreNotice = NoticeType & NoticeOptions; diff --git a/packages/checkout/components/store-notices-container/utils.ts b/packages/checkout/components/store-notices-container/utils.ts deleted file mode 100644 index 8565c4bb626..00000000000 --- a/packages/checkout/components/store-notices-container/utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const getClassNameFromStatus = ( status = 'default' ): string => { - switch ( status ) { - case 'error': - return 'woocommerce-error'; - case 'success': - return 'woocommerce-message'; - case 'info': - case 'warning': - return 'woocommerce-info'; - } - return ''; -}; diff --git a/src/Domain/Bootstrap.php b/src/Domain/Bootstrap.php index 8ef3f15dc46..f50b6597cf8 100644 --- a/src/Domain/Bootstrap.php +++ b/src/Domain/Bootstrap.php @@ -8,6 +8,7 @@ use Automattic\WooCommerce\Blocks\BlockTemplatesController; use Automattic\WooCommerce\Blocks\BlockTypesController; use Automattic\WooCommerce\Blocks\Domain\Services\CreateAccount; +use Automattic\WooCommerce\Blocks\Domain\Services\Notices; use Automattic\WooCommerce\Blocks\Domain\Services\DraftOrders; use Automattic\WooCommerce\Blocks\Domain\Services\FeatureGating; use Automattic\WooCommerce\Blocks\Domain\Services\GoogleAnalytics; @@ -124,6 +125,7 @@ function() { } $this->container->get( DraftOrders::class )->init(); $this->container->get( CreateAccount::class )->init(); + $this->container->get( Notices::class )->init(); $this->container->get( StoreApi::class )->init(); $this->container->get( GoogleAnalytics::class ); $this->container->get( BlockTypesController::class ); @@ -314,6 +316,12 @@ function( Container $container ) { return new GoogleAnalytics( $asset_api ); } ); + $this->container->register( + Notices::class, + function( Container $container ) { + return new Notices( $container->get( Package::class ) ); + } + ); $this->container->register( PaymentsApi::class, function ( Container $container ) { diff --git a/src/Domain/Services/Notices.php b/src/Domain/Services/Notices.php new file mode 100644 index 00000000000..990681489b0 --- /dev/null +++ b/src/Domain/Services/Notices.php @@ -0,0 +1,111 @@ +package = $package; + } + + /** + * Set all hooks related to adding Checkout Draft order functionality to Woo Core. This is only enabled if the user + * is using the new block based cart/checkout. + */ + public function init() { + // Core page IDs. + $cart_page_id = wc_get_page_id( 'cart' ); + $checkout_page_id = wc_get_page_id( 'checkout' ); + + // Checks a specific page (by ID) to see if it contains the named block. + $has_block_cart = $cart_page_id && has_block( 'woocommerce/cart', $cart_page_id ); + $has_block_checkout = $checkout_page_id && has_block( 'woocommerce/checkout', $checkout_page_id ); + + if ( $has_block_cart || $has_block_checkout ) { + add_filter( 'woocommerce_kses_notice_allowed_tags', [ $this, 'add_kses_notice_allowed_tags' ] ); + add_filter( 'wc_get_template', [ $this, 'get_notices_template' ], 10, 5 ); + add_action( + 'wp_head', + function() { + // These pages may return notices in ajax responses, so we need the styles to be ready. + if ( is_cart() || is_checkout() ) { + wp_enqueue_style( 'wc-blocks-style' ); + } + } + ); + } + } + + /** + * Allow SVG icon in notices. + * + * @param array $allowed_tags Allowed tags. + * @return array + */ + public function add_kses_notice_allowed_tags( $allowed_tags ) { + $svg_args = array( + 'svg' => array( + 'aria-hidden' => true, + 'xmlns' => true, + 'width' => true, + 'height' => true, + 'viewbox' => true, + 'focusable' => true, + ), + 'path' => array( + 'd' => true, + ), + ); + return array_merge( $allowed_tags, $svg_args ); + } + + /** + * Replaces core notice templates with those from blocks. + * + * The new notice templates match block components with matching icons and styling. The only difference is that core + * only has notices for info, success, and error notices, whereas blocks has notices for info, success, error, + * warning, and a default notice type. + * + * @param string $template Located template path. + * @param string $template_name Template name. + * @param array $args Template arguments. + * @param string $template_path Template path. + * @param string $default_path Default path. + * @return string + */ + public function get_notices_template( $template, $template_name, $args, $template_path, $default_path ) { + if ( in_array( $template_name, $this->notice_templates, true ) ) { + $template = $this->package->get_path( 'templates/' . $template_name ); + wp_enqueue_style( 'wc-blocks-style' ); + } + return $template; + } +} diff --git a/templates/notices/error.php b/templates/notices/error.php new file mode 100644 index 00000000000..436430d3c65 --- /dev/null +++ b/templates/notices/error.php @@ -0,0 +1,50 @@ + 1; + +?> + + + + +
    role="alert"> + +
    + +
    +
    + diff --git a/templates/notices/success.php b/templates/notices/success.php new file mode 100644 index 00000000000..a6d459663a3 --- /dev/null +++ b/templates/notices/success.php @@ -0,0 +1,29 @@ + + + +
    role="alert"> + +
    + +
    +
    +