From d674eb190bb035dc1e5df521cafa5999a0db9235 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Wed, 5 Apr 2023 13:43:03 +0100 Subject: [PATCH] Feature Branch: Updated Shopper Notices (#8659) * Notice banner component * Snackbar support * Switch to new components * Finish snackbar implementation * Summary notice * Styling issues * Fix text wrap in shipping calculator * Storybook entries * Docs and tests for NoticeBanner * Framer motion to avoid components dependency * Snackbar list stories * Docs for snackbar list * Update assets/js/base/components/notice-banner/README.md Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com> * Update assets/js/base/components/notice-banner/README.md Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com> * Update assets/js/base/components/notice-banner/README.md Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com> * Update assets/js/base/components/notice-banner/README.md Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com> * Update assets/js/base/components/notice-banner/README.md Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com> * Types/docblocks * Docs * Update notice type * Use NoticeBannerProps for type of noticeProps * Raw html to fix notice encoding * getClassNameFromStatus is unused * Update position text * Clarify notice text * Fix hover style in whisper TT3 theme * remove div styles * Add new templates for legacy buyer notices in WooCommerce core (#8732) * Add templates for legacy core notices * Update src/Domain/Services/Notices.php Co-authored-by: Paulo Arromba <17236129+wavvves@users.noreply.github.com> * Remove debugging code * DRY get_notices_template * Simplify error template * Fix padding * Only include new notices if using block cart/checkout --------- Co-authored-by: Paulo Arromba <17236129+wavvves@users.noreply.github.com> * Fix view box tag * Hover and focus styles * Styling when notices added via ajax * Remove margin change * Implement react-transition-group instead of framer (#8920) * Add screenshots to docs --------- Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com> Co-authored-by: Paulo Arromba <17236129+wavvves@users.noreply.github.com> --- .eslintrc.js | 1 + .../shipping-rates-control/index.tsx | 10 +- .../shipping/shipping-rate-selector.tsx | 18 ++- assets/js/base/components/index.ts | 40 +++++ .../base/components/notice-banner/README.md | 138 ++++++++++++++++ .../base/components/notice-banner/index.tsx | 99 ++++++++++++ .../notice-banner/screenshots/default.png | Bin 0 -> 9342 bytes .../notice-banner/screenshots/error.png | Bin 0 -> 9593 bytes .../notice-banner/screenshots/info.png | Bin 0 -> 9418 bytes .../notice-banner/screenshots/success.png | Bin 0 -> 9356 bytes .../notice-banner/screenshots/warning.png | Bin 0 -> 9866 bytes .../notice-banner/stories/index.tsx | 107 +++++++++++++ .../base/components/notice-banner/style.scss | 149 ++++++++++++++++++ .../components/notice-banner/test/index.tsx | 99 ++++++++++++ .../js/base/components/notice-banner/utils.ts | 37 +++++ .../base/components/snackbar-list/README.md | 62 ++++++++ .../components/snackbar-list/constants.ts | 1 + .../base/components/snackbar-list/index.tsx | 88 +++++++++++ .../components/snackbar-list/snackbar.tsx | 46 ++++++ .../snackbar-list/stories/index.tsx | 82 ++++++++++ .../base/components/snackbar-list/style.scss | 54 +++++++ assets/js/base/hooks/index.js | 1 + assets/js/base/hooks/use-spoken-message.ts | 25 +++ .../no-payment-methods/index.js | 15 +- .../checkout-shipping-methods-block/block.tsx | 26 +-- assets/js/types/type-defs/index.ts | 15 +- assets/js/types/type-defs/notices.ts | 12 ++ package-lock.json | 64 ++++++++ package.json | 2 + .../components/store-notice/index.tsx | 25 +-- .../components/store-notice/style.scss | 51 ------ .../store-notices-container/index.tsx | 13 +- .../snackbar-notices.tsx | 21 +-- .../store-notices-container/store-notices.tsx | 127 ++++++++------- .../store-notices-container/types.ts | 9 +- .../store-notices-container/utils.ts | 12 -- src/Domain/Bootstrap.php | 8 + src/Domain/Services/Notices.php | 111 +++++++++++++ templates/notices/error.php | 50 ++++++ templates/notices/notice.php | 37 +++++ templates/notices/success.php | 29 ++++ 41 files changed, 1485 insertions(+), 199 deletions(-) create mode 100644 assets/js/base/components/index.ts create mode 100644 assets/js/base/components/notice-banner/README.md create mode 100644 assets/js/base/components/notice-banner/index.tsx create mode 100644 assets/js/base/components/notice-banner/screenshots/default.png create mode 100644 assets/js/base/components/notice-banner/screenshots/error.png create mode 100644 assets/js/base/components/notice-banner/screenshots/info.png create mode 100644 assets/js/base/components/notice-banner/screenshots/success.png create mode 100644 assets/js/base/components/notice-banner/screenshots/warning.png create mode 100644 assets/js/base/components/notice-banner/stories/index.tsx create mode 100644 assets/js/base/components/notice-banner/style.scss create mode 100644 assets/js/base/components/notice-banner/test/index.tsx create mode 100644 assets/js/base/components/notice-banner/utils.ts create mode 100644 assets/js/base/components/snackbar-list/README.md create mode 100644 assets/js/base/components/snackbar-list/constants.ts create mode 100644 assets/js/base/components/snackbar-list/index.tsx create mode 100644 assets/js/base/components/snackbar-list/snackbar.tsx create mode 100644 assets/js/base/components/snackbar-list/stories/index.tsx create mode 100644 assets/js/base/components/snackbar-list/style.scss create mode 100644 assets/js/base/hooks/use-spoken-message.ts create mode 100644 assets/js/types/type-defs/notices.ts delete mode 100644 packages/checkout/components/store-notice/style.scss delete mode 100644 packages/checkout/components/store-notices-container/utils.ts create mode 100644 src/Domain/Services/Notices.php create mode 100644 templates/notices/error.php create mode 100644 templates/notices/notice.php create mode 100644 templates/notices/success.php 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 0000000000000000000000000000000000000000..b332186648e95164e6ba225874934eca051147ba GIT binary patch literal 9342 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 G;Qs-b4Gs-MNTVo%fYgAL#Lyi> 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8fcf176155b350239b146e86788fed7fe0cac045 GIT binary patch literal 9866 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 eB3IBm#NyDDSGAaL; + +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"> + +
    + +
    +
    +