initial commit

This commit is contained in:
2021-12-10 12:03:04 +00:00
commit c46c7ddbf0
3643 changed files with 582794 additions and 0 deletions

View File

@ -0,0 +1,88 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import PropTypes from 'prop-types';
import { WC_BLOCKS_IMAGE_URL } from '@woocommerce/block-settings';
const BlockError = ( {
imageUrl = `${ WC_BLOCKS_IMAGE_URL }/block-error.svg`,
header = __( 'Oops!', 'woocommerce' ),
text = __(
'There was an error loading the content.',
'woocommerce'
),
errorMessage,
errorMessagePrefix = __( 'Error:', 'woocommerce' ),
button,
} ) => {
return (
<div className="wc-block-error wc-block-components-error">
{ imageUrl && (
<img
className="wc-block-error__image wc-block-components-error__image"
src={ imageUrl }
alt=""
/>
) }
<div className="wc-block-error__content wc-block-components-error__content">
{ header && (
<p className="wc-block-error__header wc-block-components-error__header">
{ header }
</p>
) }
{ text && (
<p className="wc-block-error__text wc-block-components-error__text">
{ text }
</p>
) }
{ errorMessage && (
<p className="wc-block-error__message wc-block-components-error__message">
{ errorMessagePrefix ? errorMessagePrefix + ' ' : '' }
{ errorMessage }
</p>
) }
{ button && (
<p className="wc-block-error__button wc-block-components-error__button">
{ button }
</p>
) }
</div>
</div>
);
};
BlockError.propTypes = {
/**
* Error message to display below the content.
*/
errorMessage: PropTypes.node,
/**
* Text to display as the heading of the error block.
* If it's `null` or an empty string, no header will be displayed.
* If it's not defined, the default header will be used.
*/
header: PropTypes.string,
/**
* URL of the image to display.
* If it's `null` or an empty string, no image will be displayed.
* If it's not defined, the default image will be used.
*/
imageUrl: PropTypes.string,
/**
* Text to display in the error block below the header.
* If it's `null` or an empty string, nothing will be displayed.
* If it's not defined, the default text will be used.
*/
text: PropTypes.node,
/**
* Text preceeding the error message.
*/
errorMessagePrefix: PropTypes.string,
/**
* Button cta.
*/
button: PropTypes.node,
};
export default BlockError;

View File

@ -0,0 +1,104 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import { Component } from 'react';
/**
* Internal dependencies
*/
import BlockError from './block-error';
import './style.scss';
class BlockErrorBoundary extends Component {
state = { errorMessage: '', hasError: false };
static getDerivedStateFromError( error ) {
if (
typeof error.statusText !== 'undefined' &&
typeof error.status !== 'undefined'
) {
return {
errorMessage: (
<>
<strong>{ error.status }</strong>:&nbsp;
{ error.statusText }
</>
),
hasError: true,
};
}
return { errorMessage: error.message, hasError: true };
}
render() {
const {
header,
imageUrl,
showErrorMessage,
text,
errorMessagePrefix,
renderError,
button,
} = this.props;
const { errorMessage, hasError } = this.state;
if ( hasError ) {
if ( typeof renderError === 'function' ) {
return renderError( { errorMessage } );
}
return (
<BlockError
errorMessage={ showErrorMessage ? errorMessage : null }
header={ header }
imageUrl={ imageUrl }
text={ text }
errorMessagePrefix={ errorMessagePrefix }
button={ button }
/>
);
}
return this.props.children;
}
}
BlockErrorBoundary.propTypes = {
/**
* Text to display as the heading of the error block.
* If it's `null` or an empty string, no header will be displayed.
* If it's not defined, the default header will be used.
*/
header: PropTypes.string,
/**
* URL of the image to display.
* If it's `null` or an empty string, no image will be displayed.
* If it's not defined, the default image will be used.
*/
imageUrl: PropTypes.string,
/**
* Whether to display the JS error message.
*/
showErrorMessage: PropTypes.bool,
/**
* Text to display in the error block below the header.
* If it's `null` or an empty string, nothing will be displayed.
* If it's not defined, the default text will be used.
*/
text: PropTypes.node,
/**
* Text preceeding the error message.
*/
errorMessagePrefix: PropTypes.string,
/**
* Render function to show a custom error component.
*/
renderError: PropTypes.func,
};
BlockErrorBoundary.defaultProps = {
showErrorMessage: true,
};
export default BlockErrorBoundary;

View File

@ -0,0 +1,34 @@
.wc-block-components-error {
display: flex;
padding: $gap-largest 0;
margin: $gap-largest 0;
align-items: center;
justify-content: center;
flex-direction: column;
color: $gray-700;
text-align: center;
}
.wc-block-components-error__header {
@include font-size(larger);
margin: 0;
color: $studio-gray-50;
}
.wc-block-components-error__image {
width: 25%;
margin: 0 0 $gap-large 0;
}
.wc-block-components-error__text {
margin: 1em 0 0;
color: $studio-gray-30;
@include font-size(large);
max-width: 60ch;
}
.wc-block-components-error__message {
margin: 1em auto 0;
font-style: italic;
color: $studio-gray-30;
max-width: 60ch;
}
.wc-block-error__button {
margin: $gap-largest 0 0 0;
}

View File

@ -0,0 +1,48 @@
/**
* External dependencies
*/
import { Button as WPButton } from 'wordpress-components';
import type { ReactNode } from 'react';
import classNames from 'classnames';
import Spinner from '@woocommerce/base-components/spinner';
/**
* Internal dependencies
*/
import './style.scss';
interface ButtonProps extends WPButton.ButtonProps {
className?: string;
showSpinner?: boolean;
children?: ReactNode;
}
/**
* Component that visually renders a button but semantically might be `<button>` or `<a>` depending
* on the props.
*/
const Button = ( {
className,
showSpinner = false,
children,
...props
}: ButtonProps ): JSX.Element => {
const buttonClassName = classNames(
'wc-block-components-button',
className,
{
'wc-block-components-button--loading': showSpinner,
}
);
return (
<WPButton className={ buttonClassName } { ...props }>
{ showSpinner && <Spinner /> }
<span className="wc-block-components-button__text">
{ children }
</span>
</WPButton>
);
};
export default Button;

View File

@ -0,0 +1,11 @@
/**
* Internal dependencies
*/
import Button from '../';
export default {
title: 'WooCommerce Blocks/@base-components/Button',
component: Button,
};
export const Default = () => <Button>Buy now</Button>;

View File

@ -0,0 +1,35 @@
.wc-block-components-button:not(.is-link) {
@include reset-typography();
align-items: center;
background-color: $gray-900;
color: $white;
display: inline-flex;
font-weight: bold;
min-height: 3em;
justify-content: center;
line-height: 1;
padding: 0 em($gap);
text-align: center;
text-decoration: none;
text-transform: none;
position: relative;
&:disabled,
&:hover,
&:focus,
&:active {
background-color: $gray-900;
color: $white;
}
.wc-block-components-button__text {
display: block;
> svg {
fill: currentColor;
}
}
.wc-block-components-spinner + .wc-block-components-button__text {
visibility: hidden;
}
}

View File

@ -0,0 +1,254 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import { ValidatedTextInput } from '@woocommerce/base-components/text-input';
import {
BillingCountryInput,
ShippingCountryInput,
} from '@woocommerce/base-components/country-input';
import {
BillingStateInput,
ShippingStateInput,
} from '@woocommerce/base-components/state-input';
import { useValidationContext } from '@woocommerce/base-context';
import { useEffect, useMemo } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { withInstanceId } from '@wordpress/compose';
import { useShallowEqual } from '@woocommerce/base-hooks';
import {
AddressField,
AddressFields,
AddressType,
defaultAddressFields,
EnteredAddress,
} from '@woocommerce/settings';
/**
* Internal dependencies
*/
import prepareAddressFields from './prepare-address-fields';
// If it's the shipping address form and the user starts entering address
// values without having set the country first, show an error.
const validateShippingCountry = (
values: EnteredAddress,
setValidationErrors: ( errors: Record< string, unknown > ) => void,
clearValidationError: ( error: string ) => void,
hasValidationError: boolean
): void => {
if (
! hasValidationError &&
! values.country &&
( values.city || values.state || values.postcode )
) {
setValidationErrors( {
'shipping-missing-country': {
message: __(
'Please select a country to calculate rates.',
'woo-gutenberg-products-block'
),
hidden: false,
},
} );
}
if ( hasValidationError && values.country ) {
clearValidationError( 'shipping-missing-country' );
}
};
interface AddressFormProps {
id: string;
instanceId: string;
fields: ( keyof AddressFields )[];
fieldConfig: Record< keyof AddressFields, Partial< AddressField > >;
onChange: ( newValue: EnteredAddress ) => void;
type: AddressType;
values: EnteredAddress;
}
/**
* Checkout address form.
*
* @param {Object} props Incoming props for component.
* @param {string} props.id Id for component.
* @param {Array} props.fields Array of fields in form.
* @param {Object} props.fieldConfig Field configuration for fields in form.
* @param {string} props.instanceId Unique id for form.
* @param {function(any):any} props.onChange Function to all for an form onChange event.
* @param {string} props.type Type of form.
* @param {Object} props.values Values for fields.
*/
const AddressForm = ( {
id,
fields = ( Object.keys(
defaultAddressFields
) as unknown ) as ( keyof AddressFields )[],
fieldConfig = {} as Record< keyof AddressFields, Partial< AddressField > >,
instanceId,
onChange,
type = 'shipping',
values,
}: AddressFormProps ): JSX.Element => {
const {
getValidationError,
setValidationErrors,
clearValidationError,
} = useValidationContext();
const currentFields = useShallowEqual( fields );
const countryValidationError = ( getValidationError(
'shipping-missing-country'
) || {} ) as {
message: string;
hidden: boolean;
};
const addressFormFields = useMemo( () => {
return prepareAddressFields(
currentFields,
fieldConfig,
values.country
);
}, [ currentFields, fieldConfig, values.country ] );
// Clear values for hidden fields.
useEffect( () => {
addressFormFields.forEach( ( field ) => {
if ( field.hidden && values[ field.key ] ) {
onChange( {
...values,
[ field.key ]: '',
} );
}
} );
}, [ addressFormFields, onChange, values ] );
useEffect( () => {
if ( type === 'shipping' ) {
validateShippingCountry(
values,
setValidationErrors,
clearValidationError,
!! countryValidationError.message &&
! countryValidationError.hidden
);
}
}, [
values,
countryValidationError.message,
countryValidationError.hidden,
setValidationErrors,
clearValidationError,
type,
] );
id = id || instanceId;
return (
<div id={ id } className="wc-block-components-address-form">
{ addressFormFields.map( ( field ) => {
if ( field.hidden ) {
return null;
}
if ( field.key === 'country' ) {
const Tag =
type === 'shipping'
? ShippingCountryInput
: BillingCountryInput;
return (
<Tag
key={ field.key }
id={ `${ id }-${ field.key }` }
label={
field.required
? field.label
: field.optionalLabel
}
value={ values.country }
autoComplete={ field.autocomplete }
onChange={ ( newValue ) =>
onChange( {
...values,
country: newValue,
state: '',
} )
}
errorId={
type === 'shipping'
? 'shipping-missing-country'
: null
}
errorMessage={ field.errorMessage }
required={ field.required }
/>
);
}
if ( field.key === 'state' ) {
const Tag =
type === 'shipping'
? ShippingStateInput
: BillingStateInput;
return (
<Tag
key={ field.key }
id={ `${ id }-${ field.key }` }
country={ values.country }
label={
field.required
? field.label
: field.optionalLabel
}
value={ values.state }
autoComplete={ field.autocomplete }
onChange={ ( newValue ) =>
onChange( {
...values,
state: newValue,
} )
}
errorMessage={ field.errorMessage }
required={ field.required }
/>
);
}
return (
<ValidatedTextInput
key={ field.key }
id={ `${ id }-${ field.key }` }
className={ `wc-block-components-address-form__${ field.key }` }
label={
field.required ? field.label : field.optionalLabel
}
value={ values[ field.key ] }
autoCapitalize={ field.autocapitalize }
autoComplete={ field.autocomplete }
onChange={ ( newValue: string ) =>
onChange( {
...values,
[ field.key ]: newValue,
} )
}
errorMessage={ field.errorMessage }
required={ field.required }
/>
);
} ) }
</div>
);
};
AddressForm.propTypes = {
onChange: PropTypes.func.isRequired,
values: PropTypes.object.isRequired,
fields: PropTypes.arrayOf(
PropTypes.oneOf( Object.keys( defaultAddressFields ) )
),
fieldConfig: PropTypes.object,
type: PropTypes.oneOf( [ 'billing', 'shipping' ] ),
};
export default withInstanceId( AddressForm );

View File

@ -0,0 +1 @@
export { default as AddressForm } from './address-form';

View File

@ -0,0 +1,131 @@
/** @typedef { import('@woocommerce/type-defs/address-fields').CountryAddressFields } CountryAddressFields */
/**
* External dependencies
*/
import {
AddressField,
AddressFields,
CountryAddressFields,
defaultAddressFields,
getSetting,
KeyedAddressField,
LocaleSpecificAddressField,
} from '@woocommerce/settings';
import { __, sprintf } from '@wordpress/i18n';
import { isNumber, isString } from '@woocommerce/types';
/**
* This is locale data from WooCommerce countries class. This doesn't match the shape of the new field data blocks uses,
* but we can import part of it to set which fields are required.
*
* This supports new properties such as optionalLabel which are not used by core (yet).
*/
const coreLocale = getSetting< CountryAddressFields >( 'countryLocale', {} );
/**
* Gets props from the core locale, then maps them to the shape we require in the client.
*
* Ignores "class", "type", "placeholder", and "autocomplete" props from core.
*
* @param {Object} localeField Locale fields from WooCommerce.
* @return {Object} Supported locale fields.
*/
const getSupportedCoreLocaleProps = (
localeField: LocaleSpecificAddressField
): Partial< AddressField > => {
const fields: Partial< AddressField > = {};
if ( localeField.label !== undefined ) {
fields.label = localeField.label;
}
if ( localeField.required !== undefined ) {
fields.required = localeField.required;
}
if ( localeField.hidden !== undefined ) {
fields.hidden = localeField.hidden;
}
if ( localeField.label !== undefined && ! localeField.optionalLabel ) {
fields.optionalLabel = sprintf(
/* translators: %s Field label. */
__( '%s (optional)', 'woo-gutenberg-products-block' ),
localeField.label
);
}
if ( localeField.priority ) {
if ( isNumber( localeField.priority ) ) {
fields.index = localeField.priority;
}
if ( isString( localeField.priority ) ) {
fields.index = parseInt( localeField.priority, 10 );
}
}
if ( localeField.hidden ) {
fields.required = false;
}
return fields;
};
const countryAddressFields: CountryAddressFields = Object.entries( coreLocale )
.map( ( [ country, countryLocale ] ) => [
country,
Object.entries( countryLocale )
.map( ( [ localeFieldKey, localeField ] ) => [
localeFieldKey,
getSupportedCoreLocaleProps( localeField ),
] )
.reduce( ( obj, [ key, val ] ) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Ignoring because it should be fine as long as the data from the server is correct. TS won't catch it anyway if it's not.
obj[ key ] = val;
return obj;
}, {} ),
] )
.reduce( ( obj, [ key, val ] ) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Ignoring because it should be fine as long as the data from the server is correct. TS won't catch it anyway if it's not.
obj[ key ] = val;
return obj;
}, {} );
/**
* Combines address fields, including fields from the locale, and sorts them by index.
*
* @param {Array} fields List of field keys--only address fields matching these will be returned.
* @param {Object} fieldConfigs Fields config contains field specific overrides at block level which may, for example, hide a field.
* @param {string} addressCountry Address country code. If unknown, locale fields will not be merged.
* @return {CountryAddressFields} Object containing address fields.
*/
const prepareAddressFields = (
fields: ( keyof AddressFields )[],
fieldConfigs: Record< string, Partial< AddressField > >,
addressCountry = ''
): KeyedAddressField[] => {
const localeConfigs: AddressFields =
addressCountry && countryAddressFields[ addressCountry ] !== undefined
? countryAddressFields[ addressCountry ]
: ( {} as AddressFields );
return fields
.map( ( field ) => {
const defaultConfig = defaultAddressFields[ field ] || {};
const localeConfig = localeConfigs[ field ] || {};
const fieldConfig = fieldConfigs[ field ] || {};
return {
key: field,
...defaultConfig,
...localeConfig,
...fieldConfig,
};
} )
.sort( ( a, b ) => a.index - b.index );
};
export default prepareAddressFields;

View File

@ -0,0 +1,169 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CheckoutProvider } from '@woocommerce/base-context';
import { useCheckoutAddress } from '@woocommerce/base-context/hooks';
/**
* Internal dependencies
*/
import AddressForm from '../address-form';
const renderInCheckoutProvider = ( ui, options = {} ) => {
const Wrapper = ( { children } ) => {
return <CheckoutProvider>{ children }</CheckoutProvider>;
};
return render( ui, { wrapper: Wrapper, ...options } );
};
// Countries used in testing addresses must be in the wcSettings global.
// See: tests/js/setup-globals.js
const primaryAddress = {
country: 'United Kingdom',
countryKey: 'GB',
city: 'London',
state: 'Greater London',
postcode: 'ABCD',
};
const secondaryAddress = {
country: 'Austria', // We use Austria because it doesn't have states.
countryKey: 'AU',
city: 'Vienna',
postcode: 'DCBA',
};
const tertiaryAddress = {
country: 'Canada', // We use Canada because it has a select for the state.
countryKey: 'CA',
city: 'Toronto',
state: 'Ontario',
postcode: 'EFGH',
};
const countryRegExp = /country/i;
const cityRegExp = /city/i;
const stateRegExp = /county|province|state/i;
const postalCodeRegExp = /postal code|postcode|zip/i;
const inputAddress = async ( {
country = null,
city = null,
state = null,
postcode = null,
} ) => {
if ( country ) {
const countryInput = screen.getByLabelText( countryRegExp );
userEvent.type( countryInput, country + '{arrowdown}{enter}' );
}
if ( city ) {
const cityInput = screen.getByLabelText( cityRegExp );
userEvent.type( cityInput, city );
}
if ( state ) {
const stateButton = screen.queryByRole( 'combobox', {
name: stateRegExp,
} );
// State input might be a select or a text input.
if ( stateButton ) {
userEvent.click( stateButton );
userEvent.click( screen.getByRole( 'option', { name: state } ) );
} else {
const stateInput = screen.getByLabelText( stateRegExp );
userEvent.type( stateInput, state );
}
}
if ( postcode ) {
const postcodeInput = screen.getByLabelText( postalCodeRegExp );
userEvent.type( postcodeInput, postcode );
}
};
describe( 'AddressForm Component', () => {
const WrappedAddressForm = ( { type } ) => {
const {
defaultAddressFields,
setShippingFields,
shippingFields,
} = useCheckoutAddress();
return (
<AddressForm
type={ type }
onChange={ setShippingFields }
values={ shippingFields }
fields={ Object.keys( defaultAddressFields ) }
/>
);
};
const ShippingFields = () => {
const { shippingFields } = useCheckoutAddress();
return (
<ul>
{ Object.keys( shippingFields ).map( ( key ) => (
<li key={ key }>{ key + ': ' + shippingFields[ key ] }</li>
) ) }
</ul>
);
};
it( 'updates context value when interacting with form elements', () => {
renderInCheckoutProvider(
<>
<WrappedAddressForm type="shipping" />
<ShippingFields />
</>
);
inputAddress( primaryAddress );
expect( screen.getByText( /country/ ) ).toHaveTextContent(
`country: ${ primaryAddress.countryKey }`
);
expect( screen.getByText( /city/ ) ).toHaveTextContent(
`city: ${ primaryAddress.city }`
);
expect( screen.getByText( /state/ ) ).toHaveTextContent(
`state: ${ primaryAddress.state }`
);
expect( screen.getByText( /postcode/ ) ).toHaveTextContent(
`postcode: ${ primaryAddress.postcode }`
);
} );
it( 'input fields update when changing the country', () => {
renderInCheckoutProvider( <WrappedAddressForm type="shipping" /> );
inputAddress( primaryAddress );
// Verify correct labels are used.
expect( screen.getByLabelText( /City/ ) ).toBeInTheDocument();
expect( screen.getByLabelText( /County/ ) ).toBeInTheDocument();
expect( screen.getByLabelText( /Postcode/ ) ).toBeInTheDocument();
inputAddress( secondaryAddress );
// Verify state input has been removed.
expect( screen.queryByText( stateRegExp ) ).not.toBeInTheDocument();
inputAddress( tertiaryAddress );
// Verify postal code input label changed.
expect( screen.getByLabelText( /Postal code/ ) ).toBeInTheDocument();
} );
it( 'input values are reset after changing the country', () => {
renderInCheckoutProvider( <WrappedAddressForm type="shipping" /> );
inputAddress( secondaryAddress );
// Only update `country` to verify other values are reset.
inputAddress( { country: primaryAddress.country } );
expect( screen.getByLabelText( stateRegExp ).value ).toBe( '' );
// Repeat the test with an address which has a select for the state.
inputAddress( tertiaryAddress );
inputAddress( { country: primaryAddress.country } );
expect( screen.getByLabelText( stateRegExp ).value ).toBe( '' );
} );
} );

View File

@ -0,0 +1,95 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import PropTypes from 'prop-types';
import Title from '@woocommerce/base-components/title';
/**
* Internal dependencies
*/
import './style.scss';
const StepHeading = ( { title, stepHeadingContent } ) => (
<div className="wc-block-components-checkout-step__heading">
<Title
aria-hidden="true"
className="wc-block-components-checkout-step__title"
headingLevel="2"
>
{ title }
</Title>
{ !! stepHeadingContent && (
<span className="wc-block-components-checkout-step__heading-content">
{ stepHeadingContent }
</span>
) }
</div>
);
const FormStep = ( {
id,
className,
title,
legend,
description,
children,
disabled = false,
showStepNumber = true,
stepHeadingContent = () => {},
} ) => {
// If the form step doesn't have a legend or title, render a <div> instead
// of a <fieldset>.
const Element = legend || title ? 'fieldset' : 'div';
return (
<Element
className={ classnames(
className,
'wc-block-components-checkout-step',
{
'wc-block-components-checkout-step--with-step-number': showStepNumber,
'wc-block-components-checkout-step--disabled': disabled,
}
) }
id={ id }
disabled={ disabled }
>
{ !! ( legend || title ) && (
<legend className="screen-reader-text">
{ legend || title }
</legend>
) }
{ !! title && (
<StepHeading
title={ title }
stepHeadingContent={ stepHeadingContent() }
/>
) }
<div className="wc-block-components-checkout-step__container">
{ !! description && (
<p className="wc-block-components-checkout-step__description">
{ description }
</p>
) }
<div className="wc-block-components-checkout-step__content">
{ children }
</div>
</div>
</Element>
);
};
FormStep.propTypes = {
id: PropTypes.string,
className: PropTypes.string,
title: PropTypes.string,
description: PropTypes.string,
children: PropTypes.node,
showStepNumber: PropTypes.bool,
stepHeadingContent: PropTypes.func,
disabled: PropTypes.bool,
legend: PropTypes.string,
};
export default FormStep;

View File

@ -0,0 +1,129 @@
.wc-block-components-form {
counter-reset: checkout-step;
}
.wc-block-components-form .wc-block-components-checkout-step {
position: relative;
border: none;
padding: 0 0 0 $gap-large;
background: none;
margin: 0;
.is-mobile &,
.is-small & {
padding-left: 0;
}
}
.wc-block-components-checkout-step--disabled {
opacity: 0.6;
}
.wc-block-components-checkout-step__container {
position: relative;
}
.wc-block-components-checkout-step__content > * {
margin-bottom: em($gap);
}
.wc-block-components-checkout-step--with-step-number .wc-block-components-checkout-step__content > :last-child {
margin-bottom: 0;
padding-bottom: em($gap-large);
}
.wc-block-components-checkout-step__heading {
display: flex;
justify-content: space-between;
align-content: center;
flex-wrap: wrap;
margin: em($gap-small) 0 em($gap);
position: relative;
align-items: center;
gap: em($gap);
.wc-block-components-express-payment-continue-rule + .wc-block-components-checkout-step & {
margin-top: 0;
}
}
.wc-block-components-checkout-step:first-child .wc-block-components-checkout-step__heading {
margin-top: 0;
}
.wc-block-components-checkout-step__title {
margin: 0 $gap-small 0 0;
}
.wc-block-components-checkout-step__heading-content {
@include font-size(smaller);
a {
font-weight: bold;
color: inherit;
}
}
.wc-block-components-checkout-step__description {
@include font-size(small);
line-height: 1.25;
margin-bottom: $gap;
}
.wc-block-components-checkout-step--with-step-number {
.wc-block-components-checkout-step__title::before {
@include reset-box();
background: transparent;
counter-increment: checkout-step;
content: "\00a0" counter(checkout-step) ".";
content: "\00a0" counter(checkout-step) "." / "";
position: absolute;
width: $gap-large;
left: -$gap-large;
top: 0;
text-align: center;
transform: translateX(-50%);
.is-mobile &,
.is-small & {
position: static;
transform: none;
left: auto;
top: auto;
content: counter(checkout-step) ".\00a0";
content: counter(checkout-step) ".\00a0" / "";
}
}
.wc-block-components-checkout-step__container::after {
content: "";
height: 100%;
border-left: 1px solid;
opacity: 0.3;
position: absolute;
left: -$gap-large;
top: 0;
}
.is-mobile &,
.is-small & {
.wc-block-components-checkout-step__title::before {
position: static;
transform: none;
left: auto;
top: auto;
content: counter(checkout-step) ".\00a0";
content: counter(checkout-step) ".\00a0" / "";
}
.wc-block-components-checkout-step__container::after {
content: unset;
}
}
}
.editor-styles-wrapper {
.wp-block h4.wc-block-components-checkout-step__title {
@include font-size(regular);
line-height: 24px;
margin: 0 $gap-small 0 0;
}
}

View File

@ -0,0 +1,262 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FormStep fieldset legend should default to legend prop when title and legend are defined 1`] = `
<fieldset
className="wc-block-components-checkout-step wc-block-components-checkout-step--with-step-number"
disabled={false}
>
<legend
className="screen-reader-text"
>
Lorem Ipsum 2
</legend>
<div
className="wc-block-components-checkout-step__heading"
>
<h2
aria-hidden="true"
className="wc-block-components-title wc-block-components-checkout-step__title"
>
Lorem Ipsum
</h2>
</div>
<div
className="wc-block-components-checkout-step__container"
>
<div
className="wc-block-components-checkout-step__content"
>
Dolor sit amet
</div>
</div>
</fieldset>
`;
exports[`FormStep should apply id and className props 1`] = `
<div
className="my-classname wc-block-components-checkout-step wc-block-components-checkout-step--with-step-number"
disabled={false}
id="my-id"
>
<div
className="wc-block-components-checkout-step__container"
>
<div
className="wc-block-components-checkout-step__content"
>
Dolor sit amet
</div>
</div>
</div>
`;
exports[`FormStep should remove step number CSS class if prop is false 1`] = `
<fieldset
className="wc-block-components-checkout-step"
disabled={false}
>
<legend
className="screen-reader-text"
>
Lorem Ipsum
</legend>
<div
className="wc-block-components-checkout-step__heading"
>
<h2
aria-hidden="true"
className="wc-block-components-title wc-block-components-checkout-step__title"
>
Lorem Ipsum
</h2>
</div>
<div
className="wc-block-components-checkout-step__container"
>
<div
className="wc-block-components-checkout-step__content"
>
Dolor sit amet
</div>
</div>
</fieldset>
`;
exports[`FormStep should render a div if no title or legend is provided 1`] = `
<div
className="wc-block-components-checkout-step wc-block-components-checkout-step--with-step-number"
disabled={false}
>
<div
className="wc-block-components-checkout-step__container"
>
<div
className="wc-block-components-checkout-step__content"
>
Dolor sit amet
</div>
</div>
</div>
`;
exports[`FormStep should render a fieldset if a legend is provided 1`] = `
<fieldset
className="wc-block-components-checkout-step wc-block-components-checkout-step--with-step-number"
disabled={false}
>
<legend
className="screen-reader-text"
>
Lorem Ipsum 2
</legend>
<div
className="wc-block-components-checkout-step__container"
>
<div
className="wc-block-components-checkout-step__content"
>
Dolor sit amet
</div>
</div>
</fieldset>
`;
exports[`FormStep should render a fieldset with heading if a title is provided 1`] = `
<fieldset
className="wc-block-components-checkout-step wc-block-components-checkout-step--with-step-number"
disabled={false}
>
<legend
className="screen-reader-text"
>
Lorem Ipsum
</legend>
<div
className="wc-block-components-checkout-step__heading"
>
<h2
aria-hidden="true"
className="wc-block-components-title wc-block-components-checkout-step__title"
>
Lorem Ipsum
</h2>
</div>
<div
className="wc-block-components-checkout-step__container"
>
<div
className="wc-block-components-checkout-step__content"
>
Dolor sit amet
</div>
</div>
</fieldset>
`;
exports[`FormStep should render step description 1`] = `
<fieldset
className="wc-block-components-checkout-step wc-block-components-checkout-step--with-step-number"
disabled={false}
>
<legend
className="screen-reader-text"
>
Lorem Ipsum
</legend>
<div
className="wc-block-components-checkout-step__heading"
>
<h2
aria-hidden="true"
className="wc-block-components-title wc-block-components-checkout-step__title"
>
Lorem Ipsum
</h2>
</div>
<div
className="wc-block-components-checkout-step__container"
>
<p
className="wc-block-components-checkout-step__description"
>
This is the description
</p>
<div
className="wc-block-components-checkout-step__content"
>
Dolor sit amet
</div>
</div>
</fieldset>
`;
exports[`FormStep should render step heading content 1`] = `
<fieldset
className="wc-block-components-checkout-step wc-block-components-checkout-step--with-step-number"
disabled={false}
>
<legend
className="screen-reader-text"
>
Lorem Ipsum
</legend>
<div
className="wc-block-components-checkout-step__heading"
>
<h2
aria-hidden="true"
className="wc-block-components-title wc-block-components-checkout-step__title"
>
Lorem Ipsum
</h2>
<span
className="wc-block-components-checkout-step__heading-content"
>
<span>
Some context to render next to the heading
</span>
</span>
</div>
<div
className="wc-block-components-checkout-step__container"
>
<div
className="wc-block-components-checkout-step__content"
>
Dolor sit amet
</div>
</div>
</fieldset>
`;
exports[`FormStep should set disabled prop to the fieldset element when disabled is true 1`] = `
<fieldset
className="wc-block-components-checkout-step wc-block-components-checkout-step--with-step-number wc-block-components-checkout-step--disabled"
disabled={true}
>
<legend
className="screen-reader-text"
>
Lorem Ipsum
</legend>
<div
className="wc-block-components-checkout-step__heading"
>
<h2
aria-hidden="true"
className="wc-block-components-title wc-block-components-checkout-step__title"
>
Lorem Ipsum
</h2>
</div>
<div
className="wc-block-components-checkout-step__container"
>
<div
className="wc-block-components-checkout-step__content"
>
Dolor sit amet
</div>
</div>
</fieldset>
`;

View File

@ -0,0 +1,100 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import FormStep from '..';
describe( 'FormStep', () => {
test( 'should render a div if no title or legend is provided', () => {
const component = TestRenderer.create(
<FormStep>Dolor sit amet</FormStep>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should apply id and className props', () => {
const component = TestRenderer.create(
<FormStep id="my-id" className="my-classname">
Dolor sit amet
</FormStep>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render a fieldset if a legend is provided', () => {
const component = TestRenderer.create(
<FormStep legend="Lorem Ipsum 2">Dolor sit amet</FormStep>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render a fieldset with heading if a title is provided', () => {
const component = TestRenderer.create(
<FormStep title="Lorem Ipsum">Dolor sit amet</FormStep>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'fieldset legend should default to legend prop when title and legend are defined', () => {
const component = TestRenderer.create(
<FormStep title="Lorem Ipsum" legend="Lorem Ipsum 2">
Dolor sit amet
</FormStep>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should remove step number CSS class if prop is false', () => {
const component = TestRenderer.create(
<FormStep title="Lorem Ipsum" showStepNumber={ false }>
Dolor sit amet
</FormStep>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render step heading content', () => {
const component = TestRenderer.create(
<FormStep
title="Lorem Ipsum"
stepHeadingContent={ () => (
<span>Some context to render next to the heading</span>
) }
>
Dolor sit amet
</FormStep>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render step description', () => {
const component = TestRenderer.create(
<FormStep title="Lorem Ipsum" description="This is the description">
Dolor sit amet
</FormStep>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should set disabled prop to the fieldset element when disabled is true', () => {
const component = TestRenderer.create(
<FormStep title="Lorem Ipsum" disabled={ true }>
Dolor sit amet
</FormStep>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
} );

View File

@ -0,0 +1,20 @@
export * from './address-form';
export { default as FormStep } from './form-step';
export { default as OrderSummary } from './order-summary';
export { default as PlaceOrderButton } from './place-order-button';
export { default as Policies } from './policies';
export { default as ProductBackorderBadge } from './product-backorder-badge';
export { default as ProductDetails } from './product-details';
export { default as ProductImage } from './product-image';
export { default as ProductLowStockBadge } from './product-low-stock-badge';
export { default as ProductSummary } from './product-summary';
export { default as ProductMetadata } from './product-metadata';
export { default as ProductSaleBadge } from './product-sale-badge';
export { default as ReturnToCartButton } from './return-to-cart-button';
export { default as ShippingCalculator } from './shipping-calculator';
export { default as ShippingLocation } from './shipping-location';
export { default as ShippingRatesControl } from './shipping-rates-control';
export { default as ShippingRatesControlPackage } from './shipping-rates-control-package';
export { default as PaymentMethodIcons } from './payment-method-icons';
export { default as PaymentMethodLabel } from './payment-method-label';
export * from './totals';

View File

@ -0,0 +1,54 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import PropTypes from 'prop-types';
import { useContainerWidthContext } from '@woocommerce/base-context';
import { Panel } from '@woocommerce/blocks-checkout';
/**
* Internal dependencies
*/
import OrderSummaryItem from './order-summary-item.js';
import './style.scss';
const OrderSummary = ( { cartItems = [] } ) => {
const { isLarge, hasContainerWidth } = useContainerWidthContext();
if ( ! hasContainerWidth ) {
return null;
}
return (
<Panel
className="wc-block-components-order-summary"
initialOpen={ isLarge }
hasBorder={ false }
title={
<span className="wc-block-components-order-summary__button-text">
{ __( 'Order summary', 'woocommerce' ) }
</span>
}
titleTag="h2"
>
<div className="wc-block-components-order-summary__content">
{ cartItems.map( ( cartItem ) => {
return (
<OrderSummaryItem
key={ cartItem.key }
cartItem={ cartItem }
/>
);
} ) }
</div>
</Panel>
);
};
OrderSummary.propTypes = {
cartItems: PropTypes.arrayOf(
PropTypes.shape( { key: PropTypes.string.isRequired } )
),
};
export default OrderSummary;

View File

@ -0,0 +1,206 @@
/**
* External dependencies
*/
import { sprintf, _n } from '@wordpress/i18n';
import Label from '@woocommerce/base-components/label';
import ProductPrice from '@woocommerce/base-components/product-price';
import ProductName from '@woocommerce/base-components/product-name';
import {
getCurrencyFromPriceResponse,
formatPrice,
} from '@woocommerce/price-format';
import {
__experimentalApplyCheckoutFilter,
mustContain,
} from '@woocommerce/blocks-checkout';
import PropTypes from 'prop-types';
import Dinero from 'dinero.js';
import { getSetting } from '@woocommerce/settings';
import { useMemo } from '@wordpress/element';
import { useStoreCart } from '@woocommerce/base-context/hooks';
/**
* Internal dependencies
*/
import ProductBackorderBadge from '../product-backorder-badge';
import ProductImage from '../product-image';
import ProductLowStockBadge from '../product-low-stock-badge';
import ProductMetadata from '../product-metadata';
const productPriceValidation = ( value ) => mustContain( value, '<price/>' );
const OrderSummaryItem = ( { cartItem } ) => {
const {
images,
low_stock_remaining: lowStockRemaining,
show_backorder_badge: showBackorderBadge,
name: initialName,
permalink,
prices,
quantity,
short_description: shortDescription,
description: fullDescription,
item_data: itemData,
variation,
totals,
extensions,
} = cartItem;
// Prepare props to pass to the __experimentalApplyCheckoutFilter filter.
// We need to pluck out receiveCart.
// eslint-disable-next-line no-unused-vars
const { receiveCart, ...cart } = useStoreCart();
const arg = useMemo(
() => ( {
context: 'summary',
cartItem,
cart,
} ),
[ cartItem, cart ]
);
const priceCurrency = getCurrencyFromPriceResponse( prices );
const name = __experimentalApplyCheckoutFilter( {
filterName: 'itemName',
defaultValue: initialName,
extensions,
arg,
} );
const regularPriceSingle = Dinero( {
amount: parseInt( prices.raw_prices.regular_price, 10 ),
precision: parseInt( prices.raw_prices.precision, 10 ),
} )
.convertPrecision( priceCurrency.minorUnit )
.getAmount();
const priceSingle = Dinero( {
amount: parseInt( prices.raw_prices.price, 10 ),
precision: parseInt( prices.raw_prices.precision, 10 ),
} )
.convertPrecision( priceCurrency.minorUnit )
.getAmount();
const totalsCurrency = getCurrencyFromPriceResponse( totals );
let lineSubtotal = parseInt( totals.line_subtotal, 10 );
if ( getSetting( 'displayCartPricesIncludingTax', false ) ) {
lineSubtotal += parseInt( totals.line_subtotal_tax, 10 );
}
const subtotalPrice = Dinero( {
amount: lineSubtotal,
precision: totalsCurrency.minorUnit,
} ).getAmount();
const subtotalPriceFormat = __experimentalApplyCheckoutFilter( {
filterName: 'subtotalPriceFormat',
defaultValue: '<price/>',
extensions,
arg,
validation: productPriceValidation,
} );
// Allow extensions to filter how the price is displayed. Ie: prepending or appending some values.
const productPriceFormat = __experimentalApplyCheckoutFilter( {
filterName: 'cartItemPrice',
defaultValue: '<price/>',
extensions,
arg,
validation: productPriceValidation,
} );
return (
<div className="wc-block-components-order-summary-item">
<div className="wc-block-components-order-summary-item__image">
<div className="wc-block-components-order-summary-item__quantity">
<Label
label={ quantity }
screenReaderLabel={ sprintf(
/* translators: %d number of products of the same type in the cart */
_n(
'%d item',
'%d items',
quantity,
'woocommerce'
),
quantity
) }
/>
</div>
<ProductImage image={ images.length ? images[ 0 ] : {} } />
</div>
<div className="wc-block-components-order-summary-item__description">
<ProductName
disabled={ true }
name={ name }
permalink={ permalink }
/>
<ProductPrice
currency={ priceCurrency }
price={ priceSingle }
regularPrice={ regularPriceSingle }
className="wc-block-components-order-summary-item__individual-prices"
priceClassName="wc-block-components-order-summary-item__individual-price"
regularPriceClassName="wc-block-components-order-summary-item__regular-individual-price"
format={ subtotalPriceFormat }
/>
{ showBackorderBadge ? (
<ProductBackorderBadge />
) : (
!! lowStockRemaining && (
<ProductLowStockBadge
lowStockRemaining={ lowStockRemaining }
/>
)
) }
<ProductMetadata
shortDescription={ shortDescription }
fullDescription={ fullDescription }
itemData={ itemData }
variation={ variation }
/>
</div>
<span className="screen-reader-text">
{ sprintf(
/* translators: %1$d is the number of items, %2$s is the item name and %3$s is the total price including the currency symbol. */
_n(
'Total price for %1$d %2$s item: %3$s',
'Total price for %1$d %2$s items: %3$s',
quantity,
'woocommerce'
),
quantity,
name,
formatPrice( subtotalPrice, totalsCurrency )
) }
</span>
<div
className="wc-block-components-order-summary-item__total-price"
aria-hidden="true"
>
<ProductPrice
currency={ totalsCurrency }
format={ productPriceFormat }
price={ subtotalPrice }
/>
</div>
</div>
);
};
OrderSummaryItem.propTypes = {
cartItems: PropTypes.shape( {
images: PropTypes.array,
low_stock_remaining: PropTypes.number,
name: PropTypes.string.isRequired,
permalink: PropTypes.string,
prices: PropTypes.shape( {
price: PropTypes.string,
regular_price: PropTypes.string,
} ),
quantity: PropTypes.number,
summary: PropTypes.string,
variation: PropTypes.array,
} ),
};
export default OrderSummaryItem;

View File

@ -0,0 +1,104 @@
.wc-block-components-order-summary {
.wc-block-components-panel__button {
padding-top: 0;
margin-top: 0;
}
.wc-block-components-panel__content {
margin-bottom: 0;
}
}
.wc-block-components-order-summary__content {
display: table;
width: 100%;
}
.wc-block-components-order-summary-item {
@include with-translucent-border(0 0 1px);
@include font-size(small);
display: flex;
padding-bottom: 1px;
padding-top: $gap;
width: 100%;
&:first-child {
padding-top: 0;
}
&:last-child {
> div {
padding-bottom: 0;
}
&::after {
display: none;
}
}
.wc-block-components-product-metadata {
@include font-size(regular);
}
}
.wc-block-components-order-summary-item__image,
.wc-block-components-order-summary-item__description {
display: table-cell;
vertical-align: top;
}
.wc-block-components-order-summary-item__image {
width: #{$gap-large * 2};
padding-bottom: $gap;
position: relative;
> img {
width: #{$gap-large * 2};
max-width: #{$gap-large * 2};
}
}
.wc-block-components-order-summary-item__quantity {
align-items: center;
background: #fff;
border: 2px solid;
border-radius: 1em;
box-shadow: 0 0 0 2px #fff;
color: #000;
display: flex;
line-height: 1;
min-height: 20px;
padding: 0 0.4em;
position: absolute;
justify-content: center;
min-width: 20px;
right: 0;
top: 0;
transform: translate(50%, -50%);
white-space: nowrap;
z-index: 1;
}
.wc-block-components-order-summary-item__description {
padding-left: $gap-large;
padding-right: $gap-small;
padding-bottom: $gap;
p,
.wc-block-components-product-metadata {
line-height: 1.375;
margin-top: #{ ($gap-large - $gap) * 0.5 };
}
}
.wc-block-components-order-summary-item__total-price {
font-weight: bold;
margin-left: auto;
text-align: right;
}
.wc-block-components-order-summary-item__individual-prices {
display: block;
}

View File

@ -0,0 +1,43 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import { previewCart } from '@woocommerce/resource-previews';
/**
* Internal dependencies
*/
import OrderSummary from '../index';
jest.mock( '@woocommerce/base-context', () => ( {
...jest.requireActual( '@woocommerce/base-context' ),
useContainerWidthContext: () => ( {
isLarge: true,
hasContainerWidth: true,
} ),
} ) );
describe( 'Order Summary', () => {
it( 'renders correct cart line subtotal when currency has 0 decimals', async () => {
render(
<OrderSummary
cartItems={ [
{
...previewCart.items[ 0 ],
totals: {
...previewCart.items[ 0 ].totals,
// Change price format so there are no decimals.
currency_minor_unit: 0,
currency_prefix: '',
currency_suffix: '€',
line_subtotal: '16',
line_total: '18',
},
},
] }
/>
);
expect( screen.getByText( '16€' ) ).toBeTruthy();
} );
} );

View File

@ -0,0 +1,121 @@
/**
* External dependencies
*/
import { WC_BLOCKS_IMAGE_URL } from '@woocommerce/block-settings';
import type { PaymentMethodIcon } from '@woocommerce/type-defs/payments';
/**
* Array of common assets.
*/
export const commonIcons: PaymentMethodIcon[] = [
{
id: 'alipay',
alt: 'Alipay',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/alipay.svg',
},
{
id: 'amex',
alt: 'American Express',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/amex.svg',
},
{
id: 'bancontact',
alt: 'Bancontact',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/bancontact.svg',
},
{
id: 'diners',
alt: 'Diners Club',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/diners.svg',
},
{
id: 'discover',
alt: 'Discover',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/discover.svg',
},
{
id: 'eps',
alt: 'EPS',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/eps.svg',
},
{
id: 'giropay',
alt: 'Giropay',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/giropay.svg',
},
{
id: 'ideal',
alt: 'iDeal',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/ideal.svg',
},
{
id: 'jcb',
alt: 'JCB',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/jcb.svg',
},
{
id: 'laser',
alt: 'Laser',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/laser.svg',
},
{
id: 'maestro',
alt: 'Maestro',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/maestro.svg',
},
{
id: 'mastercard',
alt: 'Mastercard',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/mastercard.svg',
},
{
id: 'multibanco',
alt: 'Multibanco',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/multibanco.svg',
},
{
id: 'p24',
alt: 'Przelewy24',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/p24.svg',
},
{
id: 'sepa',
alt: 'Sepa',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/sepa.svg',
},
{
id: 'sofort',
alt: 'Sofort',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/sofort.svg',
},
{
id: 'unionpay',
alt: 'Union Pay',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/unionpay.svg',
},
{
id: 'visa',
alt: 'Visa',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/visa.svg',
},
{
id: 'wechat',
alt: 'WeChat',
src: WC_BLOCKS_IMAGE_URL + 'payment-methods/wechat.svg',
},
];
/**
* For a given ID, see if a common icon exists and return it's props.
*
* @param {string} id Icon ID.
*/
export const getCommonIconProps = (
id: string
): PaymentMethodIcon | Record< string, unknown > => {
return (
commonIcons.find( ( icon ) => {
return icon.id === id;
} ) || {}
);
};

View File

@ -0,0 +1,65 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import type { PaymentMethodIcons as PaymentMethodIconsType } from '@woocommerce/type-defs/payments';
/**
* Internal dependencies
*/
import PaymentMethodIcon from './payment-method-icon';
import { getCommonIconProps } from './common-icons';
import { normalizeIconConfig } from './utils';
import './style.scss';
interface PaymentMethodIconsProps {
icons: PaymentMethodIconsType;
align?: 'left' | 'right' | 'center';
}
/**
* For a given list of icons, render each as a list item, using common icons
* where available.
*
* @param {Object} props Component props.
* @param {Array} props.icons Array of icons object configs or ids as strings.
* @param {string} props.align How to align the icon.
*/
export const PaymentMethodIcons = ( {
icons = [],
align = 'center',
}: PaymentMethodIconsProps ): JSX.Element | null => {
const iconConfigs = normalizeIconConfig( icons );
if ( iconConfigs.length === 0 ) {
return null;
}
const containerClass = classnames(
'wc-block-components-payment-method-icons',
{
'wc-block-components-payment-method-icons--align-left':
align === 'left',
'wc-block-components-payment-method-icons--align-right':
align === 'right',
}
);
return (
<div className={ containerClass }>
{ iconConfigs.map( ( icon ) => {
const iconProps = {
...icon,
...getCommonIconProps( icon.id ),
};
return (
<PaymentMethodIcon
key={ 'payment-method-icon-' + icon.id }
{ ...iconProps }
/>
);
} ) }
</div>
);
};
export default PaymentMethodIcons;

View File

@ -0,0 +1,34 @@
/**
* Get a class name for an icon.
*
* @param {string} id Icon ID.
*/
const getIconClassName = ( id: string ): string => {
return `wc-block-components-payment-method-icon wc-block-components-payment-method-icon--${ id }`;
};
interface PaymentMethodIconProps {
id: string;
src?: string | null;
alt?: string;
}
/**
* Return an element for an icon.
*
* @param {Object} props Incoming props for component.
* @param {string} props.id Id for component.
* @param {string|null} props.src Optional src value for icon.
* @param {string} props.alt Optional alt value for icon.
*/
const PaymentMethodIcon = ( {
id,
src = null,
alt = '',
}: PaymentMethodIconProps ): JSX.Element | null => {
if ( ! src ) {
return null;
}
return <img className={ getIconClassName( id ) } src={ src } alt={ alt } />;
};
export default PaymentMethodIcon;

View File

@ -0,0 +1,48 @@
.wc-block-components-payment-method-icons {
margin: 0 0 #{$gap - 2px};
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
.wc-block-components-payment-method-icon {
display: inline-block;
margin: 0 4px 2px;
padding: 0;
width: auto;
max-width: 38px;
height: 24px;
max-height: 24px;
}
&--align-left {
justify-content: flex-start;
.wc-block-components-payment-method-icon {
margin-left: 0;
margin-right: 8px;
}
}
&--align-right {
justify-content: flex-end;
.wc-block-components-payment-method-icon {
margin-right: 0;
margin-left: 8px;
}
}
&:last-child {
margin-bottom: 0;
}
}
.is-mobile,
.is-small {
.wc-block-components-payment-method-icons {
.wc-block-components-payment-method-icon {
height: 16px;
}
}
}

View File

@ -0,0 +1,43 @@
/**
* External dependencies
*/
import type {
PaymentMethodIcon,
PaymentMethodIcons,
} from '@woocommerce/type-defs/payments';
import { isString } from '@woocommerce/types';
/**
* For an array of icons, normalize into objects and remove duplicates.
*/
export const normalizeIconConfig = (
icons: PaymentMethodIcons
): PaymentMethodIcon[] => {
const normalizedIcons: Record< string, PaymentMethodIcon > = {};
icons.forEach( ( raw ) => {
let icon: Partial< PaymentMethodIcon > = {};
if ( typeof raw === 'string' ) {
icon = {
id: raw,
alt: raw,
src: null,
};
}
if ( typeof raw === 'object' ) {
icon = {
id: raw.id || '',
alt: raw.alt || '',
src: raw.src || null,
};
}
if ( icon.id && isString( icon.id ) && ! normalizedIcons[ icon.id ] ) {
normalizedIcons[ icon.id ] = <PaymentMethodIcon>icon;
}
} );
return Object.values( normalizedIcons );
};

View File

@ -0,0 +1,72 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { Icon, bank, bill, card, checkPayment } from '@woocommerce/icons';
import { isString, objectHasProp } from '@woocommerce/types';
import { useCallback } from '@wordpress/element';
/**
* Internal dependencies
*/
import './style.scss';
interface NamedIcons {
bank: JSX.Element;
bill: JSX.Element;
card: JSX.Element;
checkPayment: JSX.Element;
}
const namedIcons: NamedIcons = {
bank,
bill,
card,
checkPayment,
};
interface PaymentMethodLabelProps {
icon: '' | keyof NamedIcons | SVGElement;
text: string;
}
/**
* Exposed to payment methods for the label shown on checkout. Allows icons to be added as well as
* text.
*
* @param {Object} props Component props.
* @param {*} props.icon Show an icon beside the text if provided. Can be a string to use a named
* icon, or an SVG element.
* @param {string} props.text Text shown next to icon.
*/
export const PaymentMethodLabel = ( {
icon = '',
text = '',
}: PaymentMethodLabelProps ): JSX.Element => {
const hasIcon = !! icon;
const hasNamedIcon = useCallback(
(
iconToCheck: '' | keyof NamedIcons | SVGElement
): iconToCheck is keyof NamedIcons =>
hasIcon &&
isString( iconToCheck ) &&
objectHasProp( namedIcons, iconToCheck ),
[ hasIcon ]
);
const className = classnames( 'wc-block-components-payment-method-label', {
'wc-block-components-payment-method-label--with-icon': hasIcon,
} );
return (
<span className={ className }>
{ hasNamedIcon( icon ) ? (
<Icon srcElement={ namedIcons[ icon ] } />
) : (
icon
) }
{ text }
</span>
);
};
export default PaymentMethodLabel;

View File

@ -0,0 +1,19 @@
.wc-block-components-payment-method-label--with-icon {
display: inline-block;
vertical-align: middle;
> img,
> svg {
vertical-align: middle;
margin: -2px 4px 0 0;
}
}
.is-mobile,
.is-small {
.wc-block-components-payment-method-label--with-icon {
> img,
> svg {
display: none;
}
}
}

View File

@ -0,0 +1,43 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useCheckoutSubmit } from '@woocommerce/base-context/hooks';
import { Icon, done } from '@woocommerce/icons';
import Button from '@woocommerce/base-components/button';
const PlaceOrderButton = (): JSX.Element => {
const {
submitButtonText,
onSubmit,
isCalculating,
isDisabled,
waitingForProcessing,
waitingForRedirect,
} = useCheckoutSubmit();
return (
<Button
className="wc-block-components-checkout-place-order-button"
onClick={ onSubmit }
disabled={
isCalculating ||
isDisabled ||
waitingForProcessing ||
waitingForRedirect
}
showSpinner={ waitingForProcessing }
>
{ waitingForRedirect ? (
<Icon
srcElement={ done }
alt={ __( 'Done', 'woo-gutenberg-products-block' ) }
/>
) : (
submitButtonText
) }
</Button>
);
};
export default PlaceOrderButton;

View File

@ -0,0 +1,57 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
PRIVACY_URL,
TERMS_URL,
PRIVACY_PAGE_NAME,
TERMS_PAGE_NAME,
} from '@woocommerce/block-settings';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
*/
import './style.scss';
const Policies = (): JSX.Element => {
return (
<ul className="wc-block-components-checkout-policies">
{ PRIVACY_URL && (
<li className="wc-block-components-checkout-policies__item">
<a
href={ PRIVACY_URL }
target="_blank"
rel="noopener noreferrer"
>
{ PRIVACY_PAGE_NAME
? decodeEntities( PRIVACY_PAGE_NAME )
: __(
'Privacy Policy',
'woo-gutenberg-products-block'
) }
</a>
</li>
) }
{ TERMS_URL && (
<li className="wc-block-components-checkout-policies__item">
<a
href={ TERMS_URL }
target="_blank"
rel="noopener noreferrer"
>
{ TERMS_PAGE_NAME
? decodeEntities( TERMS_PAGE_NAME )
: __(
'Terms and Conditions',
'woo-gutenberg-products-block'
) }
</a>
</li>
) }
</ul>
);
};
export default Policies;

View File

@ -0,0 +1,24 @@
.editor-styles-wrapper .wc-block-components-checkout-policies,
.wc-block-components-checkout-policies {
@include font-size(smaller);
text-align: center;
list-style: none outside;
line-height: 1;
margin: $gap-large 0;
}
.wc-block-components-checkout-policies__item {
list-style: none outside;
display: inline-block;
padding: 0 0.25em;
margin: 0;
&:not(:first-child) {
border-left: 1px solid $gray-400;
}
> a {
color: inherit;
padding: 0 0.25em;
}
}

View File

@ -0,0 +1,22 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import ProductBadge from '../product-badge';
/**
* Returns a backorder badge.
*/
const ProductBackorderBadge = (): JSX.Element => {
return (
<ProductBadge className="wc-block-components-product-backorder-badge">
{ __( 'Available on backorder', 'woo-gutenberg-products-block' ) }
</ProductBadge>
);
};
export default ProductBackorderBadge;

View File

@ -0,0 +1,32 @@
/**
* External dependencies
*/
import classNames from 'classnames';
import type { ReactNode } from 'react';
/**
* Internal dependencies
*/
import './style.scss';
interface ProductBadgeProps {
children?: ReactNode;
className?: string;
}
const ProductBadge = ( {
children,
className,
}: ProductBadgeProps ): JSX.Element => {
return (
<div
className={ classNames(
'wc-block-components-product-badge',
className
) }
>
{ children }
</div>
);
};
export default ProductBadge;

View File

@ -0,0 +1,10 @@
.wc-block-components-product-badge {
@include font-size(smaller);
border-radius: 2px;
border: 1px solid;
display: inline-block;
font-weight: 600;
padding: 0 0.66em;
text-transform: uppercase;
white-space: nowrap;
}

View File

@ -0,0 +1,60 @@
/**
* External dependencies
*/
import { kebabCase } from 'lodash';
import { decodeEntities } from '@wordpress/html-entities';
import type { ProductResponseItemData } from '@woocommerce/type-defs/product-response';
/**
* Internal dependencies
*/
import './style.scss';
interface ProductDetailsProps {
details: ProductResponseItemData[];
}
// Component to display cart item data and variations.
const ProductDetails = ( {
details = [],
}: ProductDetailsProps ): JSX.Element | null => {
if ( ! Array.isArray( details ) ) {
return null;
}
details = details.filter( ( detail ) => ! detail.hidden );
if ( details.length === 0 ) {
return null;
}
return (
<ul className="wc-block-components-product-details">
{ details.map( ( detail ) => {
const className = detail.name
? `wc-block-components-product-details__${ kebabCase(
detail.name
) }`
: '';
return (
<li
key={ detail.name + ( detail.display || detail.value ) }
className={ className }
>
{ detail.name && (
<>
<span className="wc-block-components-product-details__name">
{ decodeEntities( detail.name ) }:
</span>{ ' ' }
</>
) }
<span className="wc-block-components-product-details__value">
{ decodeEntities( detail.display || detail.value ) }
</span>
</li>
);
} ) }
</ul>
);
};
export default ProductDetails;

View File

@ -0,0 +1,25 @@
// Extra class added for specificity so styles are applied in the editor.
.wc-block-components-product-details.wc-block-components-product-details {
list-style: none;
margin: 0.5em 0;
padding: 0;
&:last-of-type {
margin-bottom: 0;
}
li {
margin-left: 0;
}
}
.wc-block-components-product-details__name,
.wc-block-components-product-details__value {
display: inline-block;
}
.is-large:not(.wc-block-checkout) {
.wc-block-components-product-details__name {
font-weight: bold;
}
}

View File

@ -0,0 +1,76 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ProductDetails should not render hidden details 1`] = `
<ul
className="wc-block-components-product-details"
>
<li
className="wc-block-components-product-details__lorem"
>
<span
className="wc-block-components-product-details__name"
>
LOREM
:
</span>
<span
className="wc-block-components-product-details__value"
>
IPSUM
</span>
</li>
</ul>
`;
exports[`ProductDetails should not rendering anything if all details are hidden 1`] = `null`;
exports[`ProductDetails should not rendering anything if details is an empty array 1`] = `null`;
exports[`ProductDetails should render details 1`] = `
<ul
className="wc-block-components-product-details"
>
<li
className="wc-block-components-product-details__lorem"
>
<span
className="wc-block-components-product-details__name"
>
Lorem
:
</span>
<span
className="wc-block-components-product-details__value"
>
Ipsum
</span>
</li>
<li
className="wc-block-components-product-details__lorem"
>
<span
className="wc-block-components-product-details__name"
>
LOREM
:
</span>
<span
className="wc-block-components-product-details__value"
>
IPSUM
</span>
</li>
<li
className=""
>
<span
className="wc-block-components-product-details__value"
>
Ipsum
</span>
</li>
</ul>
`;

View File

@ -0,0 +1,57 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import ProductDetails from '..';
describe( 'ProductDetails', () => {
test( 'should render details', () => {
const details = [
{ name: 'Lorem', value: 'Ipsum' },
{ name: 'LOREM', value: 'Ipsum', display: 'IPSUM' },
{ value: 'Ipsum' },
];
const component = TestRenderer.create(
<ProductDetails details={ details } />
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should not render hidden details', () => {
const details = [
{ name: 'Lorem', value: 'Ipsum', hidden: true },
{ name: 'LOREM', value: 'Ipsum', display: 'IPSUM' },
];
const component = TestRenderer.create(
<ProductDetails details={ details } />
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should not rendering anything if all details are hidden', () => {
const details = [
{ name: 'Lorem', value: 'Ipsum', hidden: true },
{ name: 'LOREM', value: 'Ipsum', display: 'IPSUM', hidden: true },
];
const component = TestRenderer.create(
<ProductDetails details={ details } />
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should not rendering anything if details is an empty array', () => {
const details = [];
const component = TestRenderer.create(
<ProductDetails details={ details } />
);
expect( component.toJSON() ).toMatchSnapshot();
} );
} );

View File

@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { decodeEntities } from '@wordpress/html-entities';
import { PLACEHOLDER_IMG_SRC } from '@woocommerce/settings';
interface ProductImageProps {
image: { alt?: string; thumbnail?: string };
}
/**
* Formats and returns an image element.
*
* @param {Object} props Incoming props for the component.
* @param {Object} props.image Image properties.
*/
const ProductImage = ( { image = {} }: ProductImageProps ): JSX.Element => {
const imageProps = {
src: image.thumbnail || PLACEHOLDER_IMG_SRC,
alt: decodeEntities( image.alt ) || '',
};
return <img { ...imageProps } alt={ imageProps.alt } />;
};
export default ProductImage;

View File

@ -0,0 +1,38 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import ProductBadge from '../product-badge';
interface ProductLowStockBadgeProps {
lowStockRemaining: number | null;
}
/**
* Returns a low stock badge.
*
* @param {Object} props Incoming props for the component.
* @param {number} props.lowStockRemaining Whether or not there is low stock remaining.
*/
const ProductLowStockBadge = ( {
lowStockRemaining,
}: ProductLowStockBadgeProps ): JSX.Element | null => {
if ( ! lowStockRemaining ) {
return null;
}
return (
<ProductBadge className="wc-block-components-product-low-stock-badge">
{ sprintf(
/* translators: %d stock amount (number of items in stock for product) */
__( '%d left in stock', 'woo-gutenberg-products-block' ),
lowStockRemaining
) }
</ProductBadge>
);
};
export default ProductLowStockBadge;

View File

@ -0,0 +1,45 @@
/**
* External dependencies
*/
import { ProductResponseItemData } from '@woocommerce/type-defs/product-response';
import { CartVariationItem } from '@woocommerce/type-defs/cart';
/**
* Internal dependencies
*/
import ProductDetails from '../product-details';
import ProductSummary from '../product-summary';
import './style.scss';
interface ProductMetadataProps {
shortDescription?: string;
fullDescription?: string;
itemData: ProductResponseItemData[];
variation?: CartVariationItem[];
}
const ProductMetadata = ( {
shortDescription = '',
fullDescription = '',
itemData = [],
variation = [],
}: ProductMetadataProps ): JSX.Element => {
return (
<div className="wc-block-components-product-metadata">
<ProductSummary
className="wc-block-components-product-metadata__description"
shortDescription={ shortDescription }
fullDescription={ fullDescription }
/>
<ProductDetails details={ itemData } />
<ProductDetails
details={ variation.map( ( { attribute = '', value } ) => ( {
name: attribute,
value,
} ) ) }
/>
</div>
);
};
export default ProductMetadata;

View File

@ -0,0 +1,8 @@
.wc-block-components-product-metadata {
@include font-size(smaller);
.wc-block-components-product-metadata__description > p,
.wc-block-components-product-metadata__variation-data {
margin: 0.25em 0;
}
}

View File

@ -0,0 +1,62 @@
/**
* External dependencies
*/
import { createInterpolateElement } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount';
import type { Currency } from '@woocommerce/price-format';
/**
* Internal dependencies
*/
import ProductBadge from '../product-badge';
interface ProductSaleBadgeProps {
currency: Currency;
saleAmount: number;
format: string;
}
/**
* ProductSaleBadge
*
* @param {Object} props Incoming props.
* @param {Object} props.currency Currency object.
* @param {number} props.saleAmount Discounted amount.
* @param {string} [props.format] Format to change the price.
* @return {*} The component.
*/
const ProductSaleBadge = ( {
currency,
saleAmount,
format = '<price/>',
}: ProductSaleBadgeProps ): JSX.Element | null => {
if ( ! saleAmount || saleAmount <= 0 ) {
return null;
}
if ( ! format.includes( '<price/>' ) ) {
format = '<price/>';
// eslint-disable-next-line no-console
console.error( 'Price formats need to include the `<price/>` tag.' );
}
const formattedMessage = sprintf(
/* translators: %s will be replaced by the discount amount */
__( `Save %s`, 'woo-gutenberg-products-block' ),
format
);
return (
<ProductBadge className="wc-block-components-sale-badge">
{ createInterpolateElement( formattedMessage, {
price: (
<FormattedMonetaryAmount
currency={ currency }
value={ saleAmount }
/>
),
} ) }
</ProductBadge>
);
};
export default ProductSaleBadge;

View File

@ -0,0 +1,41 @@
/**
* External dependencies
*/
import Summary from '@woocommerce/base-components/summary';
import { blocksConfig } from '@woocommerce/block-settings';
interface ProductSummaryProps {
className?: string;
shortDescription?: string;
fullDescription?: string;
}
/**
* Returns an element containing a summary of the product.
*
* @param {Object} props Incoming props for the component.
* @param {string} props.className CSS class name used.
* @param {string} props.shortDescription Short description for the product.
* @param {string} props.fullDescription Full description for the product.
*/
const ProductSummary = ( {
className,
shortDescription = '',
fullDescription = '',
}: ProductSummaryProps ): JSX.Element | null => {
const source = shortDescription ? shortDescription : fullDescription;
if ( ! source ) {
return null;
}
return (
<Summary
className={ className }
source={ source }
maxLength={ 15 }
countType={ blocksConfig.wordCountType || 'words' }
/>
);
};
export default ProductSummary;

View File

@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { CART_URL } from '@woocommerce/block-settings';
import { Icon, arrowBack } from '@woocommerce/icons';
/**
* Internal dependencies
*/
import './style.scss';
const ReturnToCartButton = ( { link } ) => {
return (
<a
href={ link || CART_URL }
className="wc-block-components-checkout-return-to-cart-button"
>
<Icon srcElement={ arrowBack } />
{ __( 'Return to Cart', 'woocommerce' ) }
</a>
);
};
export default ReturnToCartButton;

View File

@ -0,0 +1,14 @@
.wc-block-components-checkout-return-to-cart-button {
box-shadow: none;
color: inherit;
padding-left: calc(24px + 0.25em);
position: relative;
text-decoration: none;
svg {
left: 0;
position: absolute;
transform: translateY(-50%);
top: 50%;
}
}

View File

@ -0,0 +1,63 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import Button from '@woocommerce/base-components/button';
import { useState } from '@wordpress/element';
import isShallowEqual from '@wordpress/is-shallow-equal';
import { useValidationContext } from '@woocommerce/base-context';
import type { EnteredAddress, AddressFields } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import './style.scss';
import { AddressForm } from '../address-form';
interface ShippingCalculatorAddressProps {
address: EnteredAddress;
onUpdate: ( address: EnteredAddress ) => void;
addressFields: Partial< keyof AddressFields >[];
}
const ShippingCalculatorAddress = ( {
address: initialAddress,
onUpdate,
addressFields,
}: ShippingCalculatorAddressProps ): JSX.Element => {
const [ address, setAddress ] = useState( initialAddress );
const {
hasValidationErrors,
showAllValidationErrors,
} = useValidationContext();
const validateSubmit = () => {
showAllValidationErrors();
return ! hasValidationErrors;
};
return (
<form className="wc-block-components-shipping-calculator-address">
<AddressForm
fields={ addressFields }
onChange={ setAddress }
values={ address }
/>
<Button
className="wc-block-components-shipping-calculator-address__button"
disabled={ isShallowEqual( address, initialAddress ) }
onClick={ ( e ) => {
e.preventDefault();
const isAddressValid = validateSubmit();
if ( isAddressValid ) {
return onUpdate( address );
}
} }
type="submit"
>
{ __( 'Update', 'woo-gutenberg-products-block' ) }
</Button>
</form>
);
};
export default ShippingCalculatorAddress;

View File

@ -0,0 +1,39 @@
/**
* External dependencies
*/
import { useShippingDataContext } from '@woocommerce/base-context';
import type { EnteredAddress } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import ShippingCalculatorAddress from './address';
import './style.scss';
interface ShippingCalculatorProps {
onUpdate?: ( newAddress: EnteredAddress ) => void;
addressFields?: Partial< keyof EnteredAddress >[];
}
const ShippingCalculator = ( {
onUpdate = () => {
/* Do nothing */
},
addressFields = [ 'country', 'state', 'city', 'postcode' ],
}: ShippingCalculatorProps ): JSX.Element => {
const { shippingAddress, setShippingAddress } = useShippingDataContext();
return (
<div className="wc-block-components-shipping-calculator">
<ShippingCalculatorAddress
address={ shippingAddress }
addressFields={ addressFields }
onUpdate={ ( newAddress ) => {
setShippingAddress( newAddress );
onUpdate( newAddress );
} }
/>
</div>
);
};
export default ShippingCalculator;

View File

@ -0,0 +1,12 @@
.wc-block-components-shipping-calculator-address {
margin-bottom: 0;
}
.wc-block-components-shipping-calculator-address__button {
width: 100%;
margin-top: em($gap-large);
}
.wc-block-components-shipping-calculator {
padding: em($gap-smaller) 0 em($gap-small);
}

View File

@ -0,0 +1,70 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { EnteredAddress, getSetting } from '@woocommerce/settings';
import { decodeEntities } from '@wordpress/html-entities';
interface ShippingLocationProps {
address: EnteredAddress;
}
/**
* Shows a formatted shipping location.
*
* @param {Object} props Incoming props for the component.
* @param {Object} props.address Incoming address information.
*/
const ShippingLocation = ( {
address,
}: ShippingLocationProps ): JSX.Element | null => {
// we bail early if we don't have an address.
if ( Object.values( address ).length === 0 ) {
return null;
}
const shippingCountries = getSetting( 'shippingCountries', {} ) as Record<
string,
string
>;
const shippingStates = getSetting( 'shippingStates', {} ) as Record<
string,
Record< string, string >
>;
const formattedCountry =
typeof shippingCountries[ address.country ] === 'string'
? decodeEntities( shippingCountries[ address.country ] )
: '';
const formattedState =
typeof shippingStates[ address.country ] === 'object' &&
typeof shippingStates[ address.country ][ address.state ] === 'string'
? decodeEntities(
shippingStates[ address.country ][ address.state ]
)
: address.state;
const addressParts = [];
addressParts.push( address.postcode.toUpperCase() );
addressParts.push( address.city );
addressParts.push( formattedState );
addressParts.push( formattedCountry );
const formattedLocation = addressParts.filter( Boolean ).join( ', ' );
if ( ! formattedLocation ) {
return null;
}
return (
<span className="wc-block-components-shipping-address">
{ sprintf(
/* translators: %s location. */
__( 'Shipping to %s', 'woo-gutenberg-products-block' ),
formattedLocation
) + ' ' }
</span>
);
};
export default ShippingLocation;

View File

@ -0,0 +1,149 @@
/**
* External dependencies
*/
import classNames from 'classnames';
import { _n, sprintf } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities';
import type { ReactElement } from 'react';
import type { PackageRateOption } from '@woocommerce/type-defs/shipping';
import { Panel } from '@woocommerce/blocks-checkout';
import Label from '@woocommerce/base-components/label';
import { useSelectShippingRate } from '@woocommerce/base-context/hooks';
import type { CartShippingPackageShippingRate } from '@woocommerce/type-defs/cart';
/**
* Internal dependencies
*/
import PackageRates from './package-rates';
import './style.scss';
interface PackageItem {
name: string;
key: string;
quantity: number;
}
interface Destination {
address_1: string;
address_2: string;
city: string;
state: string;
postcode: string;
country: string;
}
export interface PackageData {
destination: Destination;
name: string;
shipping_rates: CartShippingPackageShippingRate[];
items: PackageItem[];
}
export type PackageRateRenderOption = (
option: CartShippingPackageShippingRate
) => PackageRateOption;
interface PackageProps {
/* PackageId can be a string, WooCommerce Subscriptions uses strings for example, but WooCommerce core uses numbers */
packageId: string | number;
renderOption: PackageRateRenderOption;
collapse?: boolean;
packageData: PackageData;
className?: string;
collapsible?: boolean;
noResultsMessage: ReactElement;
showItems?: boolean;
}
export const ShippingRatesControlPackage = ( {
packageId,
className,
noResultsMessage,
renderOption,
packageData,
collapsible = false,
collapse = false,
showItems = false,
}: PackageProps ): ReactElement => {
const { selectShippingRate, selectedShippingRate } = useSelectShippingRate(
packageId,
packageData.shipping_rates
);
const header = (
<>
{ ( showItems || collapsible ) && (
<div className="wc-block-components-shipping-rates-control__package-title">
{ packageData.name }
</div>
) }
{ showItems && (
<ul className="wc-block-components-shipping-rates-control__package-items">
{ Object.values( packageData.items ).map( ( v ) => {
const name = decodeEntities( v.name );
const quantity = v.quantity;
return (
<li
key={ v.key }
className="wc-block-components-shipping-rates-control__package-item"
>
<Label
label={
quantity > 1
? `${ name } × ${ quantity }`
: `${ name }`
}
screenReaderLabel={ sprintf(
/* translators: %1$s name of the product (ie: Sunglasses), %2$d number of units in the current cart package */
_n(
'%1$s (%2$d unit)',
'%1$s (%2$d units)',
quantity,
'woo-gutenberg-products-block'
),
name,
quantity
) }
/>
</li>
);
} ) }
</ul>
) }
</>
);
const body = (
<PackageRates
className={ className }
noResultsMessage={ noResultsMessage }
rates={ packageData.shipping_rates }
onSelectRate={ selectShippingRate }
selected={ selectedShippingRate }
renderOption={ renderOption }
/>
);
if ( collapsible ) {
return (
<Panel
className="wc-block-components-shipping-rates-control__package"
initialOpen={ ! collapse }
title={ header }
>
{ body }
</Panel>
);
}
return (
<div
className={ classNames(
'wc-block-components-shipping-rates-control__package',
className
) }
>
{ header }
{ body }
</div>
);
};
export default ShippingRatesControlPackage;

View File

@ -0,0 +1,69 @@
/**
* External dependencies
*/
import RadioControl, {
RadioControlOptionLayout,
} from '@woocommerce/base-components/radio-control';
import type { PackageRateOption } from '@woocommerce/type-defs/shipping';
import type { ReactElement } from 'react';
import type { CartShippingPackageShippingRate } from '@woocommerce/type-defs/cart';
/**
* Internal dependencies
*/
import { renderPackageRateOption } from './render-package-rate-option';
interface PackageRates {
onSelectRate: ( selectedRateId: string ) => void;
rates: CartShippingPackageShippingRate[];
renderOption?: (
option: CartShippingPackageShippingRate
) => PackageRateOption;
className?: string;
noResultsMessage: ReactElement;
selected?: string;
}
const PackageRates = ( {
className,
noResultsMessage,
onSelectRate,
rates,
renderOption = renderPackageRateOption,
selected,
}: PackageRates ): ReactElement => {
if ( rates.length === 0 ) {
return noResultsMessage;
}
if ( rates.length > 1 ) {
return (
<RadioControl
className={ className }
onChange={ ( selectedRateId: string ) => {
onSelectRate( selectedRateId );
} }
selected={ selected }
options={ rates.map( renderOption ) }
/>
);
}
const {
label,
secondaryLabel,
description,
secondaryDescription,
} = renderOption( rates[ 0 ] );
return (
<RadioControlOptionLayout
label={ label }
secondaryLabel={ secondaryLabel }
description={ description }
secondaryDescription={ secondaryDescription }
/>
);
};
export default PackageRates;

View File

@ -0,0 +1,46 @@
/**
* External dependencies
*/
import { decodeEntities } from '@wordpress/html-entities';
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount';
import type { PackageRateOption } from '@woocommerce/type-defs/shipping';
import { getSetting } from '@woocommerce/settings';
import { CartShippingPackageShippingRate } from '@woocommerce/type-defs/cart';
/**
* Default render function for package rate options.
*
* @param {Object} rate Rate data.
*/
export const renderPackageRateOption = (
rate: CartShippingPackageShippingRate
): PackageRateOption => {
const priceWithTaxes: number = getSetting(
'displayCartPricesIncludingTax',
false
)
? parseInt( rate.price, 10 ) + parseInt( rate.taxes, 10 )
: parseInt( rate.price, 10 );
return {
label: decodeEntities( rate.name ),
value: rate.rate_id,
description: (
<>
{ Number.isFinite( priceWithTaxes ) && (
<FormattedMonetaryAmount
currency={ getCurrencyFromPriceResponse( rate ) }
value={ priceWithTaxes }
/>
) }
{ Number.isFinite( priceWithTaxes ) && rate.delivery_time
? ' — '
: null }
{ decodeEntities( rate.delivery_time ) }
</>
),
};
};
export default renderPackageRateOption;

View File

@ -0,0 +1,43 @@
.wc-block-components-shipping-rates-control__package {
.wc-block-components-panel__button {
margin-bottom: 0;
margin-top: 0;
padding-bottom: em($gap-small);
padding-top: em($gap-small);
}
// Remove panel padding because we are adding bottom padding to `.wc-block-components-radio-control`
// and `.wc-block-components-radio-control__option-layout` in the next ruleset.
.wc-block-components-panel__content {
padding-bottom: 0;
}
.wc-block-components-radio-control,
.wc-block-components-radio-control__option-layout {
padding-bottom: 0;
}
.wc-block-components-radio-control .wc-block-components-radio-control__option-layout {
padding-bottom: 0;
}
}
.wc-block-components-shipping-rates-control__package-items {
@include font-size(small);
display: block;
list-style: none;
margin: 0;
padding: 0;
}
.wc-block-components-shipping-rates-control__package-item {
@include wrap-break-word();
display: inline-block;
margin: 0;
padding: 0;
}
.wc-block-components-shipping-rates-control__package-item:not(:last-child)::after {
content: ", ";
white-space: pre;
}

View File

@ -0,0 +1,200 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { useEffect } from '@wordpress/element';
import { speak } from '@wordpress/a11y';
import LoadingMask from '@woocommerce/base-components/loading-mask';
import { ExperimentalOrderShippingPackages } from '@woocommerce/blocks-checkout';
import {
getShippingRatesPackageCount,
getShippingRatesRateCount,
} from '@woocommerce/base-utils';
import { useStoreCart, useEditorContext } from '@woocommerce/base-context';
import { CartResponseShippingRate } from '@woocommerce/type-defs/cart-response';
import { ReactElement } from 'react';
/**
* Internal dependencies
*/
import ShippingRatesControlPackage, {
PackageRateRenderOption,
} from '../shipping-rates-control-package';
interface PackagesProps {
packages: CartResponseShippingRate[];
collapse?: boolean;
collapsible?: boolean;
showItems?: boolean;
noResultsMessage: ReactElement;
renderOption: PackageRateRenderOption;
}
/**
* Renders multiple packages within the slotfill.
*
* @param {Object} props Incoming props.
* @param {Array} props.packages Array of packages.
* @param {boolean} props.collapsible If the package should be rendered as a
* @param {ReactElement} props.noResultsMessage Rendered when there are no rates in a package.
* collapsible panel.
* @param {boolean} props.collapse If the panel should be collapsed by default,
* only works if collapsible is true.
* @param {boolean} props.showItems If we should items below the package name.
* @param {PackageRateRenderOption} [props.renderOption] Function to render a shipping rate.
* @return {JSX.Element|null} Rendered components.
*/
const Packages = ( {
packages,
collapse,
showItems,
collapsible,
noResultsMessage,
renderOption,
}: PackagesProps ): JSX.Element | null => {
// If there are no packages, return nothing.
if ( ! packages.length ) {
return null;
}
return (
<>
{ packages.map( ( { package_id: packageId, ...packageData } ) => (
<ShippingRatesControlPackage
key={ packageId }
packageId={ packageId }
packageData={ packageData }
collapsible={ collapsible }
collapse={ collapse }
showItems={ showItems }
noResultsMessage={ noResultsMessage }
renderOption={ renderOption }
/>
) ) }
</>
);
};
interface ShippingRatesControlProps {
collapsible?: boolean;
shippingRates: CartResponseShippingRate[];
className?: string;
shippingRatesLoading: boolean;
noResultsMessage: ReactElement;
renderOption: PackageRateRenderOption;
}
/**
* Renders the shipping rates control element.
*
* @param {Object} props Incoming props.
* @param {Array} props.shippingRates Array of packages containing shipping rates.
* @param {boolean} props.shippingRatesLoading True when rates are being loaded.
* @param {string} props.className Class name for package rates.
* @param {boolean} [props.collapsible] If true, when multiple packages are rendered they can be toggled open and closed.
* @param {ReactElement} props.noResultsMessage Rendered when there are no packages.
* @param {Function} [props.renderOption] Function to render a shipping rate.
*/
const ShippingRatesControl = ( {
shippingRates,
shippingRatesLoading,
className,
collapsible = false,
noResultsMessage,
renderOption,
}: ShippingRatesControlProps ): JSX.Element => {
useEffect( () => {
if ( shippingRatesLoading ) {
return;
}
const packageCount = getShippingRatesPackageCount( shippingRates );
const shippingOptions = getShippingRatesRateCount( shippingRates );
if ( packageCount === 1 ) {
speak(
sprintf(
/* translators: %d number of shipping options found. */
_n(
'%d shipping option was found.',
'%d shipping options were found.',
shippingOptions,
'woo-gutenberg-products-block'
),
shippingOptions
)
);
} else {
speak(
sprintf(
/* translators: %d number of shipping packages packages. */
_n(
'Shipping option searched for %d package.',
'Shipping options searched for %d packages.',
packageCount,
'woo-gutenberg-products-block'
),
packageCount
) +
' ' +
sprintf(
/* translators: %d number of shipping options available. */
_n(
'%d shipping option was found',
'%d shipping options were found',
shippingOptions,
'woo-gutenberg-products-block'
),
shippingOptions
)
);
}
}, [ shippingRatesLoading, shippingRates ] );
// Prepare props to pass to the ExperimentalOrderShippingPackages slot fill.
// We need to pluck out receiveCart.
// eslint-disable-next-line no-unused-vars
const { extensions, receiveCart, ...cart } = useStoreCart();
const slotFillProps = {
className,
collapsible,
noResultsMessage,
renderOption,
extensions,
cart,
components: {
ShippingRatesControlPackage,
},
};
const { isEditor } = useEditorContext();
return (
<LoadingMask
isLoading={ shippingRatesLoading }
screenReaderLabel={ __(
'Loading shipping rates…',
'woo-gutenberg-products-block'
) }
showSpinner={ true }
>
{ isEditor ? (
<Packages
packages={ shippingRates }
noResultsMessage={ noResultsMessage }
renderOption={ renderOption }
/>
) : (
<>
<ExperimentalOrderShippingPackages.Slot
{ ...slotFillProps }
/>
<ExperimentalOrderShippingPackages>
<Packages
packages={ shippingRates }
noResultsMessage={ noResultsMessage }
renderOption={ renderOption }
/>
</ExperimentalOrderShippingPackages>
</>
) }
</LoadingMask>
);
};
export default ShippingRatesControl;

View File

@ -0,0 +1,121 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useState, useEffect, useRef } from '@wordpress/element';
import Button from '@woocommerce/base-components/button';
import { ValidatedTextInput } from '@woocommerce/base-components/text-input';
import Label from '@woocommerce/base-components/label';
import LoadingMask from '@woocommerce/base-components/loading-mask';
import PropTypes from 'prop-types';
import { withInstanceId } from '@wordpress/compose';
import {
ValidationInputError,
useValidationContext,
} from '@woocommerce/base-context';
import { Panel } from '@woocommerce/blocks-checkout';
/**
* Internal dependencies
*/
import './style.scss';
const TotalsCoupon = ( {
instanceId,
isLoading = false,
initialOpen = false,
onSubmit = () => {},
} ) => {
const [ couponValue, setCouponValue ] = useState( '' );
const currentIsLoading = useRef( false );
const { getValidationError, getValidationErrorId } = useValidationContext();
const validationError = getValidationError( 'coupon' );
useEffect( () => {
if ( currentIsLoading.current !== isLoading ) {
if ( ! isLoading && couponValue && ! validationError ) {
setCouponValue( '' );
}
currentIsLoading.current = isLoading;
}
}, [ isLoading, couponValue, validationError ] );
const textInputId = `wc-block-components-totals-coupon__input-${ instanceId }`;
return (
<Panel
className="wc-block-components-totals-coupon"
hasBorder={ false }
initialOpen={ initialOpen }
title={
<Label
label={ __(
'Coupon code',
'woocommerce'
) }
screenReaderLabel={ __(
'Apply a coupon code',
'woocommerce'
) }
htmlFor={ textInputId }
/>
}
>
<LoadingMask
screenReaderLabel={ __(
'Applying coupon…',
'woocommerce'
) }
isLoading={ isLoading }
showSpinner={ false }
>
<div className="wc-block-components-totals-coupon__content">
<form className="wc-block-components-totals-coupon__form">
<ValidatedTextInput
id={ textInputId }
errorId="coupon"
className="wc-block-components-totals-coupon__input"
label={ __(
'Enter code',
'woocommerce'
) }
value={ couponValue }
ariaDescribedBy={ getValidationErrorId(
textInputId
) }
onChange={ ( newCouponValue ) => {
setCouponValue( newCouponValue );
} }
validateOnMount={ false }
focusOnMount={ true }
showError={ false }
/>
<Button
className="wc-block-components-totals-coupon__button"
disabled={ isLoading || ! couponValue }
showSpinner={ isLoading }
onClick={ ( e ) => {
e.preventDefault();
onSubmit( couponValue );
} }
type="submit"
>
{ __( 'Apply', 'woocommerce' ) }
</Button>
</form>
<ValidationInputError
propertyName="coupon"
elementId={ textInputId }
/>
</div>
</LoadingMask>
</Panel>
);
};
TotalsCoupon.propTypes = {
onSubmit: PropTypes.func,
isLoading: PropTypes.bool,
};
export default withInstanceId( TotalsCoupon );

View File

@ -0,0 +1,47 @@
/**
* External dependencies
*/
import { text, boolean } from '@storybook/addon-knobs';
import {
useValidationContext,
ValidationContextProvider,
} from '@woocommerce/base-context';
/**
* Internal dependencies
*/
import TotalsCoupon from '../';
export default {
title:
'WooCommerce Blocks/@base-components/cart-checkout/totals/TotalsCoupon',
component: TotalsCoupon,
};
const StoryComponent = ( { validCoupon, isLoading, invalidCouponText } ) => {
const { setValidationErrors } = useValidationContext();
const onSubmit = ( coupon ) => {
if ( coupon !== validCoupon ) {
setValidationErrors( { coupon: invalidCouponText } );
}
};
return <TotalsCoupon isLoading={ isLoading } onSubmit={ onSubmit } />;
};
export const Default = () => {
const validCoupon = text( 'A valid coupon code', 'validcoupon' );
const invalidCouponText = text(
'Error message for invalid code',
'Invalid coupon code.'
);
const isLoading = boolean( 'Toggle isLoading state', false );
return (
<ValidationContextProvider>
<StoryComponent
validCoupon={ validCoupon }
isLoading={ isLoading }
invalidCouponText={ invalidCouponText }
/>
</ValidationContextProvider>
);
};

View File

@ -0,0 +1,41 @@
.wc-block-components-totals-coupon {
.wc-block-components-panel__button {
margin-top: 0;
padding-top: 0;
}
.wc-block-components-panel__content {
padding-bottom: 0;
}
}
.wc-block-components-totals-coupon__form {
display: flex;
width: 100%;
margin-bottom: 0;
.wc-block-components-totals-coupon__input {
margin-bottom: 0;
margin-top: 0;
flex-grow: 1;
}
.wc-block-components-totals-coupon__button {
height: em(48px);
flex-shrink: 0;
margin-left: $gap-smaller;
padding-left: $gap-large;
padding-right: $gap-large;
white-space: nowrap;
}
.wc-block-components-totals-coupon__button.no-margin {
margin: 0;
}
}
.wc-block-components-totals-coupon__content {
flex-direction: column;
position: relative;
}

View File

@ -0,0 +1,128 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import LoadingMask from '@woocommerce/base-components/loading-mask';
import { RemovableChip } from '@woocommerce/base-components/chip';
import PropTypes from 'prop-types';
import {
__experimentalApplyCheckoutFilter,
TotalsItem,
} from '@woocommerce/blocks-checkout';
import { getSetting } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import './style.scss';
const filteredCartCouponsFilterArg = {
context: 'summary',
};
const TotalsDiscount = ( {
cartCoupons = [],
currency,
isRemovingCoupon,
removeCoupon,
values,
} ) => {
const {
total_discount: totalDiscount,
total_discount_tax: totalDiscountTax,
} = values;
const discountValue = parseInt( totalDiscount, 10 );
if ( ! discountValue && cartCoupons.length === 0 ) {
return null;
}
const discountTaxValue = parseInt( totalDiscountTax, 10 );
const discountTotalValue = getSetting(
'displayCartPricesIncludingTax',
false
)
? discountValue + discountTaxValue
: discountValue;
const filteredCartCoupons = __experimentalApplyCheckoutFilter( {
arg: filteredCartCouponsFilterArg,
filterName: 'coupons',
defaultValue: cartCoupons,
} );
return (
<TotalsItem
className="wc-block-components-totals-discount"
currency={ currency }
description={
filteredCartCoupons.length !== 0 && (
<LoadingMask
screenReaderLabel={ __(
'Removing coupon…',
'woocommerce'
) }
isLoading={ isRemovingCoupon }
showSpinner={ false }
>
<ul className="wc-block-components-totals-discount__coupon-list">
{ filteredCartCoupons.map( ( cartCoupon ) => {
return (
<RemovableChip
key={ 'coupon-' + cartCoupon.code }
className="wc-block-components-totals-discount__coupon-list-item"
text={ cartCoupon.label }
screenReaderText={ sprintf(
/* translators: %s Coupon code. */
__(
'Coupon: %s',
'woocommerce'
),
cartCoupon.label
) }
disabled={ isRemovingCoupon }
onRemove={ () => {
removeCoupon( cartCoupon.code );
} }
radius="large"
ariaLabel={ sprintf(
/* translators: %s is a coupon code. */
__(
'Remove coupon "%s"',
'woocommerce'
),
cartCoupon.label
) }
/>
);
} ) }
</ul>
</LoadingMask>
)
}
label={
!! discountTotalValue
? __( 'Discount', 'woocommerce' )
: __( 'Coupons', 'woocommerce' )
}
value={ discountTotalValue ? discountTotalValue * -1 : '-' }
/>
);
};
TotalsDiscount.propTypes = {
cartCoupons: PropTypes.arrayOf(
PropTypes.shape( {
code: PropTypes.string.isRequired,
} )
),
currency: PropTypes.object.isRequired,
isRemovingCoupon: PropTypes.bool.isRequired,
removeCoupon: PropTypes.func.isRequired,
values: PropTypes.shape( {
total_discount: PropTypes.string,
total_discount_tax: PropTypes.string,
} ).isRequired,
};
export default TotalsDiscount;

View File

@ -0,0 +1,37 @@
/**
* External dependencies
*/
import { text, boolean } from '@storybook/addon-knobs';
import { currencyKnob } from '@woocommerce/knobs';
/**
* Internal dependencies
*/
import TotalsDiscount from '../';
export default {
title:
'WooCommerce Blocks/@base-components/cart-checkout/totals/TotalsDiscount',
component: TotalsDiscount,
};
export const Default = () => {
const cartCoupons = [ { code: 'COUPON' } ];
const currency = currencyKnob();
const isRemovingCoupon = boolean( 'Toggle isRemovingCoupon state', false );
const totalDiscount = text( 'Total discount', '1000' );
const totalDiscountTax = text( 'Total discount tax', '200' );
return (
<TotalsDiscount
cartCoupons={ cartCoupons }
currency={ currency }
isRemovingCoupon={ isRemovingCoupon }
removeCoupon={ () => void null }
values={ {
total_discount: totalDiscount,
total_discount_tax: totalDiscountTax,
} }
/>
);
};

View File

@ -0,0 +1,9 @@
.wc-block-components-totals-discount__coupon-list {
list-style: none;
margin: 0;
padding: 0;
}
.wc-block-components-totals-discount .wc-block-components-totals-item__value {
color: $discount-color;
}

View File

@ -0,0 +1,80 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { createInterpolateElement } from '@wordpress/element';
import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount';
import PropTypes from 'prop-types';
import {
__experimentalApplyCheckoutFilter,
TotalsItem,
} from '@woocommerce/blocks-checkout';
import { useStoreCart } from '@woocommerce/base-context/hooks';
import { getSetting } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import './style.scss';
const TotalsFooterItem = ( { currency, values } ) => {
const SHOW_TAXES =
getSetting( 'taxesEnabled', true ) &&
getSetting( 'displayCartPricesIncludingTax', false );
const { total_price: totalPrice, total_tax: totalTax } = values;
// Prepare props to pass to the __experimentalApplyCheckoutFilter filter.
// We need to pluck out receiveCart.
// eslint-disable-next-line no-unused-vars
const { receiveCart, ...cart } = useStoreCart();
const label = __experimentalApplyCheckoutFilter( {
filterName: 'totalLabel',
defaultValue: __( 'Total', 'woocommerce' ),
extensions: cart.extensions,
arg: { cart },
} );
const parsedTaxValue = parseInt( totalTax, 10 );
return (
<TotalsItem
className="wc-block-components-totals-footer-item"
currency={ currency }
label={ label }
value={ parseInt( totalPrice, 10 ) }
description={
SHOW_TAXES &&
parsedTaxValue !== 0 && (
<p className="wc-block-components-totals-footer-item-tax">
{ createInterpolateElement(
__(
'Including <TaxAmount/> in taxes',
'woocommerce'
),
{
TaxAmount: (
<FormattedMonetaryAmount
className="wc-block-components-totals-footer-item-tax-value"
currency={ currency }
value={ parsedTaxValue }
/>
),
}
) }
</p>
)
}
/>
);
};
TotalsFooterItem.propTypes = {
currency: PropTypes.object.isRequired,
values: PropTypes.shape( {
total_price: PropTypes.string,
total_tax: PropTypes.string,
} ).isRequired,
};
export default TotalsFooterItem;

View File

@ -0,0 +1,32 @@
/**
* External dependencies
*/
import { text } from '@storybook/addon-knobs';
import { currencyKnob } from '@woocommerce/knobs';
/**
* Internal dependencies
*/
import TotalsFooterItem from '../';
export default {
title:
'WooCommerce Blocks/@base-components/cart-checkout/totals/TotalsFooterItem',
component: TotalsFooterItem,
};
export const Default = () => {
const currency = currencyKnob();
const totalPrice = text( 'Total price', '1200' );
const totalTax = text( 'Total tax', '200' );
return (
<TotalsFooterItem
currency={ currency }
values={ {
total_price: totalPrice,
total_tax: totalTax,
} }
/>
);
};

View File

@ -0,0 +1,14 @@
.wc-block-components-totals-footer-item {
.wc-block-components-totals-item__value,
.wc-block-components-totals-item__label {
@include font-size(large);
}
.wc-block-components-totals-item__label {
font-weight: 700;
}
.wc-block-components-totals-footer-item-tax {
margin-bottom: 0;
}
}

View File

@ -0,0 +1,79 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TotalsFooterItem Does not show the "including %s of tax" line if tax is 0 1`] = `
<div>
<div
class="wc-block-components-totals-item wc-block-components-totals-footer-item"
>
<span
class="wc-block-components-totals-item__label"
>
Total
</span>
<span
class="wc-block-formatted-money-amount wc-block-components-formatted-money-amount wc-block-components-totals-item__value"
>
£85.00
</span>
<div
class="wc-block-components-totals-item__description"
/>
</div>
</div>
`;
exports[`TotalsFooterItem Does not show the "including %s of tax" line if tax is disabled 1`] = `
<div>
<div
class="wc-block-components-totals-item wc-block-components-totals-footer-item"
>
<span
class="wc-block-components-totals-item__label"
>
Total
</span>
<span
class="wc-block-formatted-money-amount wc-block-components-formatted-money-amount wc-block-components-totals-item__value"
>
£85.00
</span>
<div
class="wc-block-components-totals-item__description"
/>
</div>
</div>
`;
exports[`TotalsFooterItem Shows the "including %s of tax" line if tax is greater than 0 1`] = `
<div>
<div
class="wc-block-components-totals-item wc-block-components-totals-footer-item"
>
<span
class="wc-block-components-totals-item__label"
>
Total
</span>
<span
class="wc-block-formatted-money-amount wc-block-components-formatted-money-amount wc-block-components-totals-item__value"
>
£85.00
</span>
<div
class="wc-block-components-totals-item__description"
>
<p
class="wc-block-components-totals-footer-item-tax"
>
Including
<span
class="wc-block-formatted-money-amount wc-block-components-formatted-money-amount wc-block-components-totals-footer-item-tax-value"
>
£1.00
</span>
in taxes
</p>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,81 @@
/**
* External dependencies
*/
import { render } from '@testing-library/react';
/**
* Internal dependencies
*/
import TotalsFooterItem from '../index';
import { allSettings } from '../../../../../../settings/shared/settings-init';
describe( 'TotalsFooterItem', () => {
beforeEach( () => {
allSettings.taxesEnabled = true;
allSettings.displayCartPricesIncludingTax = true;
} );
const currency = {
code: 'GBP',
decimalSeparator: '.',
minorUnit: 2,
prefix: '£',
suffix: '',
symbol: '£',
thousandSeparator: ',',
};
const values = {
currency_code: 'GBP',
currency_decimal_separator: '.',
currency_minor_unit: 2,
currency_prefix: '£',
currency_suffix: '',
currency_symbol: '£',
currency_thousand_separator: ',',
tax_lines: [],
length: 2,
total_discount: '0',
total_discount_tax: '0',
total_fees: '0',
total_fees_tax: '0',
total_items: '7100',
total_items_tax: '0',
total_price: '8500',
total_shipping: '0',
total_shipping_tax: '0',
total_tax: '0',
};
it( 'Does not show the "including %s of tax" line if tax is 0', () => {
const { container } = render(
<TotalsFooterItem currency={ currency } values={ values } />
);
expect( container ).toMatchSnapshot();
} );
it( 'Does not show the "including %s of tax" line if tax is disabled', () => {
allSettings.taxesEnabled = false;
/* This shouldn't ever happen if taxes are disabled, but this is to test whether the taxesEnabled setting works */
const valuesWithTax = {
...values,
total_tax: '100',
total_items_tax: '100',
};
const { container } = render(
<TotalsFooterItem currency={ currency } values={ valuesWithTax } />
);
expect( container ).toMatchSnapshot();
} );
it( 'Shows the "including %s of tax" line if tax is greater than 0', () => {
const valuesWithTax = {
...values,
total_tax: '100',
total_items_tax: '100',
};
const { container } = render(
<TotalsFooterItem currency={ currency } values={ valuesWithTax } />
);
expect( container ).toMatchSnapshot();
} );
} );

View File

@ -0,0 +1,4 @@
export { default as TotalsCoupon } from './coupon';
export { default as TotalsDiscount } from './discount';
export { default as TotalsFooterItem } from './footer-item';
export { default as TotalsShipping } from './shipping';

View File

@ -0,0 +1,14 @@
/**
* Searches an array of packages/rates to see if there are actually any rates
* available.
*
* @param {Array} shippingRatePackages An array of packages and rates.
* @return {boolean} True if a rate exists.
*/
const hasShippingRate = ( shippingRatePackages ) => {
return shippingRatePackages.some(
( shippingRatePackage ) => shippingRatePackage.shipping_rates.length
);
};
export default hasShippingRate;

View File

@ -0,0 +1,213 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import { useStoreCart } from '@woocommerce/base-context/hooks';
import { TotalsItem } from '@woocommerce/blocks-checkout';
import type { Currency } from '@woocommerce/price-format';
import type { ReactElement } from 'react';
import { getSetting, EnteredAddress } from '@woocommerce/settings';
import { ShippingVia } from '@woocommerce/base-components/cart-checkout/totals/shipping/shipping-via';
/**
* Internal dependencies
*/
import ShippingRateSelector from './shipping-rate-selector';
import hasShippingRate from './has-shipping-rate';
import ShippingCalculator from '../../shipping-calculator';
import ShippingLocation from '../../shipping-location';
import './style.scss';
interface CalculatorButtonProps {
label?: string;
isShippingCalculatorOpen: boolean;
setIsShippingCalculatorOpen: ( isShippingCalculatorOpen: boolean ) => void;
}
const CalculatorButton = ( {
label = __( 'Calculate', 'woo-gutenberg-products-block' ),
isShippingCalculatorOpen,
setIsShippingCalculatorOpen,
}: CalculatorButtonProps ): ReactElement => {
return (
<button
className="wc-block-components-totals-shipping__change-address-button"
onClick={ () => {
setIsShippingCalculatorOpen( ! isShippingCalculatorOpen );
} }
aria-expanded={ isShippingCalculatorOpen }
>
{ label }
</button>
);
};
interface ShippingAddressProps {
showCalculator: boolean;
isShippingCalculatorOpen: boolean;
setIsShippingCalculatorOpen: CalculatorButtonProps[ 'setIsShippingCalculatorOpen' ];
shippingAddress: EnteredAddress;
}
const ShippingAddress = ( {
showCalculator,
isShippingCalculatorOpen,
setIsShippingCalculatorOpen,
shippingAddress,
}: ShippingAddressProps ): ReactElement | null => {
return (
<>
<ShippingLocation address={ shippingAddress } />
{ showCalculator && (
<CalculatorButton
label={ __(
'(change address)',
'woo-gutenberg-products-block'
) }
isShippingCalculatorOpen={ isShippingCalculatorOpen }
setIsShippingCalculatorOpen={ setIsShippingCalculatorOpen }
/>
) }
</>
);
};
interface NoShippingPlaceholderProps {
showCalculator: boolean;
isShippingCalculatorOpen: boolean;
setIsShippingCalculatorOpen: CalculatorButtonProps[ 'setIsShippingCalculatorOpen' ];
}
const NoShippingPlaceholder = ( {
showCalculator,
isShippingCalculatorOpen,
setIsShippingCalculatorOpen,
}: NoShippingPlaceholderProps ): ReactElement => {
if ( ! showCalculator ) {
return (
<em>
{ __(
'Calculated during checkout',
'woo-gutenberg-products-block'
) }
</em>
);
}
return (
<CalculatorButton
isShippingCalculatorOpen={ isShippingCalculatorOpen }
setIsShippingCalculatorOpen={ setIsShippingCalculatorOpen }
/>
);
};
interface TotalShippingProps {
currency: Currency;
values: {
total_shipping: string;
total_shipping_tax: string;
}; // Values in use
showCalculator?: boolean; //Whether to display the rate selector below the shipping total.
showRateSelector?: boolean; // Whether to show shipping calculator or not.
className?: string;
}
const TotalsShipping = ( {
currency,
values,
showCalculator = true,
showRateSelector = true,
className,
}: TotalShippingProps ): ReactElement => {
const [ isShippingCalculatorOpen, setIsShippingCalculatorOpen ] = useState(
false
);
const {
shippingAddress,
cartHasCalculatedShipping,
shippingRates,
shippingRatesLoading,
} = useStoreCart();
const totalShippingValue = getSetting(
'displayCartPricesIncludingTax',
false
)
? parseInt( values.total_shipping, 10 ) +
parseInt( values.total_shipping_tax, 10 )
: parseInt( values.total_shipping, 10 );
const hasRates = hasShippingRate( shippingRates ) || totalShippingValue;
const calculatorButtonProps = {
isShippingCalculatorOpen,
setIsShippingCalculatorOpen,
};
const selectedShippingRates = shippingRates.flatMap(
( shippingPackage ) => {
return shippingPackage.shipping_rates
.filter( ( rate ) => rate.selected )
.flatMap( ( rate ) => rate.name );
}
);
return (
<div
className={ classnames(
'wc-block-components-totals-shipping',
className
) }
>
<TotalsItem
label={ __( 'Shipping', 'woo-gutenberg-products-block' ) }
value={
cartHasCalculatedShipping ? (
totalShippingValue
) : (
<NoShippingPlaceholder
showCalculator={ showCalculator }
{ ...calculatorButtonProps }
/>
)
}
description={
<>
{ cartHasCalculatedShipping && (
<>
<ShippingVia
selectedShippingRates={
selectedShippingRates
}
/>
<ShippingAddress
shippingAddress={ shippingAddress }
showCalculator={ showCalculator }
{ ...calculatorButtonProps }
/>
</>
) }
</>
}
currency={ currency }
/>
{ showCalculator && isShippingCalculatorOpen && (
<ShippingCalculator
onUpdate={ () => {
setIsShippingCalculatorOpen( false );
} }
/>
) }
{ showRateSelector && cartHasCalculatedShipping && (
<ShippingRateSelector
hasRates={ hasRates }
shippingRates={ shippingRates }
shippingRatesLoading={ shippingRatesLoading }
/>
) }
</div>
);
};
export default TotalsShipping;

View File

@ -0,0 +1,48 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Notice } from 'wordpress-components';
import classnames from 'classnames';
/**
* Internal dependencies
*/
import ShippingRatesControl from '../../shipping-rates-control';
const ShippingRateSelector = ( {
hasRates,
shippingRates,
shippingRatesLoading,
} ) => {
const legend = hasRates
? __( 'Shipping options', 'woocommerce' )
: __( 'Choose a shipping option', 'woocommerce' );
return (
<fieldset className="wc-block-components-totals-shipping__fieldset">
<legend className="screen-reader-text">{ legend }</legend>
<ShippingRatesControl
className="wc-block-components-totals-shipping__options"
collapsible={ true }
noResultsMessage={
<Notice
isDismissible={ false }
className={ classnames(
'wc-block-components-shipping-rates-control__no-results-notice',
'woocommerce-error'
) }
>
{ __(
'No shipping options were found.',
'woocommerce'
) }
</Notice>
}
shippingRates={ shippingRates }
shippingRatesLoading={ shippingRatesLoading }
/>
</fieldset>
);
};
export default ShippingRateSelector;

View File

@ -0,0 +1,17 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
export const ShippingVia = ( {
selectedShippingRates,
}: {
selectedShippingRates: string[];
} ): JSX.Element => {
return (
<div className="wc-block-components-totals-item__description wc-block-components-totals-shipping__via">
{ __( 'via', 'woo-gutenberg-products-block' ) }{ ' ' }
{ selectedShippingRates.join( ', ' ) }
</div>
);
};

View File

@ -0,0 +1,35 @@
/**
* External dependencies
*/
import { boolean, text } from '@storybook/addon-knobs';
import { currencyKnob } from '@woocommerce/knobs';
/**
* Internal dependencies
*/
import TotalsShipping from '../';
export default {
title: 'WooCommerce Blocks/@blocks-checkout/TotalsShipping',
component: TotalsShipping,
};
export const Default = () => {
const currency = currencyKnob();
const showCalculator = boolean( 'Show calculator', true );
const showRateSelector = boolean( 'Show rate selector', true );
const totalShipping = text( 'Total shipping', '1000' );
const totalShippingTax = text( 'Total shipping tax', '200' );
return (
<TotalsShipping
currency={ currency }
showCalculator={ showCalculator }
showRateSelector={ showRateSelector }
values={ {
total_shipping: totalShipping,
total_shipping_tax: totalShippingTax,
} }
/>
);
};

View File

@ -0,0 +1,42 @@
.wc-block-components-totals-shipping {
// Added extra label for specificity.
fieldset.wc-block-components-totals-shipping__fieldset {
background-color: transparent;
margin: 0;
padding: 0;
border: 0;
}
.wc-block-components-totals-shipping__via {
margin-bottom: $gap;
}
.wc-block-components-totals-shipping__options {
.wc-block-components-radio-control__label,
.wc-block-components-radio-control__description,
.wc-block-components-radio-control__secondary-label,
.wc-block-components-radio-control__secondary-description {
flex-basis: 100%;
text-align: left;
}
}
.wc-block-components-shipping-rates-control__no-results-notice {
margin: 0 0 em($gap-small);
}
.wc-block-components-totals-shipping__change-address-button {
@include link-button();
&:hover,
&:focus,
&:active {
opacity: 0.8;
}
}
}
// Extra classes for specificity.
.theme-twentytwentyone.theme-twentytwentyone.theme-twentytwentyone .wc-block-components-totals-shipping__change-address-button {
@include link-button();
}

View File

@ -0,0 +1,74 @@
/**
* External dependencies
*/
import { withInstanceId } from '@wordpress/compose';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import './style.scss';
type CheckboxControlProps = {
className?: string;
label?: string;
id?: string;
instanceId: string;
onChange: ( value: boolean ) => void;
children: React.ReactChildren;
hasError: boolean;
};
/**
* Component used to show a checkbox control with styles.
*/
const CheckboxControl = ( {
className,
label,
id,
instanceId,
onChange,
children,
hasError = false,
...rest
}: CheckboxControlProps ): JSX.Element => {
const checkboxId = id || `checkbox-control-${ instanceId }`;
return (
<label
className={ classNames(
'wc-block-components-checkbox',
{
'has-error': hasError,
},
className
) }
htmlFor={ checkboxId }
>
<input
id={ checkboxId }
className="wc-block-components-checkbox__input"
type="checkbox"
onChange={ ( event ) => onChange( event.target.checked ) }
aria-invalid={ hasError === true }
{ ...rest }
/>
<svg
className="wc-block-components-checkbox__mark"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 20"
>
<path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z" />
</svg>
{ label && (
<span className="wc-block-components-checkbox__label">
{ label }
</span>
) }
{ children }
</label>
);
};
export default withInstanceId( CheckboxControl );

View File

@ -0,0 +1,27 @@
/**
* External dependencies
*/
import { text } from '@storybook/addon-knobs';
import { useState } from 'react';
/**
* Internal dependencies
*/
import CheckboxControl from '../';
export default {
title: 'WooCommerce Blocks/@base-components/CheckboxControl',
component: CheckboxControl,
};
export const Default = () => {
const [ checked, setChecked ] = useState( false );
return (
<CheckboxControl
label={ text( 'Label', 'Yes please' ) }
checked={ checked }
onChange={ ( value ) => setChecked( value ) }
/>
);
};

View File

@ -0,0 +1,126 @@
.wc-block-components-checkbox {
@include reset-typography();
align-items: flex-start;
display: flex;
position: relative;
margin-top: em($gap-large);
.wc-block-components-checkbox__input[type="checkbox"] {
font-size: 1em;
appearance: none;
border: 2px solid $input-border-gray;
border-radius: 2px;
box-sizing: border-box;
height: em(24px);
width: em(24px);
margin: 0;
min-height: 24px;
min-width: 24px;
overflow: hidden;
position: static;
vertical-align: middle;
background-color: #fff;
&:checked {
background: #fff;
border-color: $input-border-gray;
}
&:focus {
outline: 2px solid $input-border-gray;
outline-offset: 2px;
}
&::before,
&::after {
content: "";
}
&:not(:checked) + .wc-block-components-checkbox__mark {
display: none;
}
.has-dark-controls & {
border-color: $controls-border-dark;
background-color: $input-background-dark;
&:checked {
background: $input-background-dark;
border-color: $controls-border-dark;
}
&:focus {
outline: 2px solid $controls-border-dark;
outline-offset: 2px;
}
}
}
&.has-error {
color: $alert-red;
a {
color: $alert-red;
}
.wc-block-components-checkbox__input {
&,
&:hover,
&:focus,
&:active {
border-color: $alert-red;
}
&:focus {
outline: 2px solid $alert-red;
outline-offset: 2px;
}
}
}
.wc-block-components-checkbox__mark {
fill: #000;
position: absolute;
margin-left: em(3px);
margin-top: em(1px);
width: em(18px);
height: em(18px);
.has-dark-controls & {
fill: #fff;
}
}
> span,
.wc-block-components-checkbox__label {
padding-left: $gap;
vertical-align: middle;
line-height: em(24px);
}
}
// Hack to hide the check mark in IE11
// See comment: https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/2320/#issuecomment-621936576
@include ie11() {
.wc-block-components-checkbox__mark {
display: none;
}
}
.theme-twentytwentyone {
.wc-block-components-checkbox__input[type="checkbox"],
.has-dark-controls .wc-block-components-checkbox__input[type="checkbox"] {
background-color: #fff;
border-color: var(--form--border-color);
position: relative;
}
.wc-block-components-checkbox__input[type="checkbox"]:checked,
.has-dark-controls
.wc-block-components-checkbox__input[type="checkbox"]:checked {
background-color: #fff;
border-color: var(--form--border-color);
}
.wc-block-components-checkbox__mark {
display: none;
}
}

View File

@ -0,0 +1,185 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import PropTypes from 'prop-types';
import { Fragment, useMemo, useState } from '@wordpress/element';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import './style.scss';
/**
* Component used to show a list of checkboxes in a group.
*
* @param {Object} props Incoming props for the component.
* @param {string} props.className CSS class used.
* @param {function(string):any} props.onChange Function called when inputs change.
* @param {Array} props.options Options for list.
* @param {Array} props.checked Which items are checked.
* @param {boolean} props.isLoading If loading or not.
* @param {boolean} props.isDisabled If inputs are disabled or not.
* @param {number} props.limit Whether to limit the number of inputs showing.
*/
const CheckboxList = ( {
className,
onChange = () => {},
options = [],
checked = [],
isLoading = false,
isDisabled = false,
limit = 10,
} ) => {
const [ showExpanded, setShowExpanded ] = useState( false );
const placeholder = useMemo( () => {
return [ ...Array( 5 ) ].map( ( x, i ) => (
<li
key={ i }
style={ {
/* stylelint-disable */
width: Math.floor( Math.random() * 75 ) + 25 + '%',
} }
/>
) );
}, [] );
const renderedShowMore = useMemo( () => {
const optionCount = options.length;
const remainingOptionsCount = optionCount - limit;
return (
! showExpanded && (
<li key="show-more" className="show-more">
<button
onClick={ () => {
setShowExpanded( true );
} }
aria-expanded={ false }
aria-label={ sprintf(
/* translators: %s is referring the remaining count of options */
_n(
'Show %s more option',
'Show %s more options',
remainingOptionsCount,
'woocommerce'
),
remainingOptionsCount
) }
>
{ sprintf(
/* translators: %s number of options to reveal. */
_n(
'Show %s more',
'Show %s more',
remainingOptionsCount,
'woocommerce'
),
remainingOptionsCount
) }
</button>
</li>
)
);
}, [ options, limit, showExpanded ] );
const renderedShowLess = useMemo( () => {
return (
showExpanded && (
<li key="show-less" className="show-less">
<button
onClick={ () => {
setShowExpanded( false );
} }
aria-expanded={ true }
aria-label={ __(
'Show less options',
'woocommerce'
) }
>
{ __( 'Show less', 'woocommerce' ) }
</button>
</li>
)
);
}, [ showExpanded ] );
const renderedOptions = useMemo( () => {
// Truncate options if > the limit + 5.
const optionCount = options.length;
const shouldTruncateOptions = optionCount > limit + 5;
return (
<>
{ options.map( ( option, index ) => (
<Fragment key={ option.value }>
<li
{ ...( shouldTruncateOptions &&
! showExpanded &&
index >= limit && { hidden: true } ) }
>
<input
type="checkbox"
id={ option.value }
value={ option.value }
onChange={ ( event ) => {
onChange( event.target.value );
} }
checked={ checked.includes( option.value ) }
disabled={ isDisabled }
/>
<label htmlFor={ option.value }>
{ option.label }
</label>
</li>
{ shouldTruncateOptions &&
index === limit - 1 &&
renderedShowMore }
</Fragment>
) ) }
{ shouldTruncateOptions && renderedShowLess }
</>
);
}, [
options,
onChange,
checked,
showExpanded,
limit,
renderedShowLess,
renderedShowMore,
isDisabled,
] );
const classes = classNames(
'wc-block-checkbox-list',
'wc-block-components-checkbox-list',
{
'is-loading': isLoading,
},
className
);
return (
<ul className={ classes }>
{ isLoading ? placeholder : renderedOptions }
</ul>
);
};
CheckboxList.propTypes = {
onChange: PropTypes.func,
options: PropTypes.arrayOf(
PropTypes.shape( {
label: PropTypes.node.isRequired,
value: PropTypes.string.isRequired,
} )
),
checked: PropTypes.array,
className: PropTypes.string,
isLoading: PropTypes.bool,
isDisabled: PropTypes.bool,
limit: PropTypes.number,
};
export default CheckboxList;

View File

@ -0,0 +1,29 @@
.editor-styles-wrapper .wc-block-components-checkbox-list,
.wc-block-components-checkbox-list {
margin: 0;
padding: 0;
list-style: none outside;
li {
margin: 0 0 $gap-smallest;
padding: 0;
list-style: none outside;
}
li.show-more,
li.show-less {
button {
background: none;
border: none;
padding: 0;
text-decoration: underline;
cursor: pointer;
}
}
&.is-loading {
li {
@include placeholder();
}
}
}

View File

@ -0,0 +1,74 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import './style.scss';
/** @typedef {import('react')} React */
/**
* Component used to render a "chip" -- a list item containing some text.
*
* Each chip defaults to a list element but this can be customized by providing
* a wrapperElement.
*
* @param {Object} props Incoming props for the component.
* @param {string} props.text Text for chip content.
* @param {string} props.screenReaderText Screenreader text for the content.
* @param {string} props.element The element type for the chip.
* @param {string} props.className CSS class used.
* @param {string} props.radius Radius size.
* @param {React.ReactChildren|null} props.children React children.
* @param {Object} props.props Rest of props passed through to component.
*/
const Chip = ( {
text,
screenReaderText = '',
element = 'li',
className = '',
radius = 'small',
children = null,
...props
} ) => {
const Wrapper = element;
const wrapperClassName = classNames(
className,
'wc-block-components-chip',
'wc-block-components-chip--radius-' + radius
);
const showScreenReaderText = Boolean(
screenReaderText && screenReaderText !== text
);
return (
// @ts-ignore
<Wrapper className={ wrapperClassName } { ...props }>
<span
aria-hidden={ showScreenReaderText }
className="wc-block-components-chip__text"
>
{ text }
</span>
{ showScreenReaderText && (
<span className="screen-reader-text">{ screenReaderText }</span>
) }
{ children }
</Wrapper>
);
};
Chip.propTypes = {
text: PropTypes.node.isRequired,
screenReaderText: PropTypes.string,
element: PropTypes.elementType,
className: PropTypes.string,
radius: PropTypes.oneOf( [ 'none', 'small', 'medium', 'large' ] ),
};
export default Chip;

View File

@ -0,0 +1,2 @@
export { default as Chip } from './chip';
export { default as RemovableChip } from './removable-chip';

View File

@ -0,0 +1,105 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { __, sprintf } from '@wordpress/i18n';
import { Icon, noAlt } from '@woocommerce/icons';
/**
* Internal dependencies
*/
import Chip from './chip.js';
/**
* Component used to render a "chip" -- an item containing some text with
* an X button to remove/dismiss each chip.
*
* @param {Object} props Incoming props for the component.
* @param {string} props.ariaLabel Aria label content.
* @param {string} props.className CSS class used.
* @param {boolean} props.disabled Whether action is disabled or not.
* @param {function():any} props.onRemove Function to call when remove event is fired.
* @param {boolean} props.removeOnAnyClick Whether to expand click area for remove event.
* @param {string} props.text The text for the chip.
* @param {string} props.screenReaderText The screen reader text for the chip.
* @param {Object} props.props Rest of props passed into component.
*/
const RemovableChip = ( {
ariaLabel = '',
className = '',
disabled = false,
onRemove = () => void null,
removeOnAnyClick = false,
text,
screenReaderText = '',
...props
} ) => {
const RemoveElement = removeOnAnyClick ? 'span' : 'button';
if ( ! ariaLabel ) {
const ariaLabelText =
screenReaderText && typeof screenReaderText === 'string'
? screenReaderText
: text;
ariaLabel =
typeof ariaLabelText !== 'string'
? /* translators: Remove chip. */
__( 'Remove', 'woocommerce' )
: sprintf(
/* translators: %s text of the chip to remove. */
__( 'Remove "%s"', 'woocommerce' ),
ariaLabelText
);
}
const clickableElementProps = {
'aria-label': ariaLabel,
disabled,
onClick: onRemove,
onKeyDown: ( e ) => {
if ( e.key === 'Backspace' || e.key === 'Delete' ) {
onRemove();
}
},
};
const chipProps = removeOnAnyClick ? clickableElementProps : {};
const removeProps = removeOnAnyClick
? { 'aria-hidden': true }
: clickableElementProps;
return (
<Chip
{ ...props }
{ ...chipProps }
className={ classNames( className, 'is-removable' ) }
element={ removeOnAnyClick ? 'button' : props.element }
screenReaderText={ screenReaderText }
text={ text }
>
<RemoveElement
className="wc-block-components-chip__remove"
{ ...removeProps }
>
<Icon
className="wc-block-components-chip__remove-icon"
srcElement={ noAlt }
size={ 16 }
/>
</RemoveElement>
</Chip>
);
};
RemovableChip.propTypes = {
text: PropTypes.node.isRequired,
ariaLabel: PropTypes.string,
className: PropTypes.string,
disabled: PropTypes.bool,
onRemove: PropTypes.func,
removeOnAnyClick: PropTypes.bool,
screenReaderText: PropTypes.string,
};
export default RemovableChip;

View File

@ -0,0 +1,42 @@
/**
* External dependencies
*/
import { text, select, boolean } from '@storybook/addon-knobs';
/**
* Internal dependencies
*/
import * as components from '../';
export default {
title: 'WooCommerce Blocks/@base-components/Chip',
component: Chip,
};
const radii = [ 'none', 'small', 'medium', 'large' ];
export const Chip = () => (
<components.Chip
text={ text( 'Text', 'example' ) }
radius={ select( 'Radius', radii ) }
screenReaderText={ text(
'Screen reader text',
'Example screen reader text'
) }
element={ select( 'Element', [ 'li', 'div', 'span' ], 'li' ) }
/>
);
export const RemovableChip = () => (
<components.RemovableChip
text={ text( 'Text', 'example' ) }
radius={ select( 'Radius', radii ) }
screenReaderText={ text(
'Screen reader text',
'Example screen reader text'
) }
disabled={ boolean( 'Disabled', false ) }
removeOnAnyClick={ boolean( 'Remove on any click', false ) }
element={ select( 'Element', [ 'li', 'div', 'span' ], 'li' ) }
/>
);

View File

@ -0,0 +1,77 @@
.wc-block-components-chip {
@include reset-typography();
align-items: center;
border: 0;
display: inline-flex;
padding: em($gap-smallest * 0.5) 0.5em em($gap-smallest);
margin: 0 0.365em 0.365em 0;
border-radius: 0;
line-height: 1;
max-width: 100%;
// Chip might be a button, so we need to override theme styles.
&,
&:hover,
&:focus,
&:active {
background: $gray-200;
color: $gray-900;
}
&.wc-block-components-chip--radius-small {
border-radius: 3px;
}
&.wc-block-components-chip--radius-medium {
border-radius: 0.433em;
}
&.wc-block-components-chip--radius-large {
border-radius: 2em;
padding-left: 0.75em;
padding-right: 0.75em;
}
.wc-block-components-chip__text {
flex-grow: 1;
}
&.is-removable {
padding-right: 0.5em;
}
&.is-removable .wc-block-components-chip__text {
padding-right: 0.25em;
}
.wc-block-components-chip__remove {
@include font-size(smaller);
background: transparent;
border: 0;
appearance: none;
padding: 0;
}
.wc-block-components-chip__remove-icon {
vertical-align: middle;
}
}
.theme-twentytwentyone {
.wc-block-components-chip,
.wc-block-components-chip:active,
.wc-block-components-chip:focus,
.wc-block-components-chip:hover {
background: #fff;
button.wc-block-components-chip__remove:not(:hover):not(:active):not(.has-background) {
background: transparent;
}
}
}
button.wc-block-components-chip:hover > .wc-block-components-chip__remove,
button.wc-block-components-chip:focus > .wc-block-components-chip__remove,
.wc-block-components-chip__remove:hover,
.wc-block-components-chip__remove:focus {
fill: $alert-red;
}
button.wc-block-components-chip:disabled > .wc-block-components-chip__remove,
.wc-block-components-chip__remove:disabled {
fill: $gray-600;
cursor: not-allowed;
}

View File

@ -0,0 +1,312 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Chip should render children nodes 1`] = `
<li
className="wc-block-components-chip wc-block-components-chip--radius-small"
>
<span
aria-hidden={false}
className="wc-block-components-chip__text"
>
Test
</span>
Lorem Ipsum
</li>
`;
exports[`Chip should render defined radius 1`] = `
<li
className="wc-block-components-chip wc-block-components-chip--radius-large"
>
<span
aria-hidden={false}
className="wc-block-components-chip__text"
>
Test
</span>
</li>
`;
exports[`Chip should render nodes as the text 1`] = `
<li
className="wc-block-components-chip wc-block-components-chip--radius-small"
>
<span
aria-hidden={false}
className="wc-block-components-chip__text"
>
<h1>
Test
</h1>
</span>
</li>
`;
exports[`Chip should render screen reader text 1`] = `
<li
className="wc-block-components-chip wc-block-components-chip--radius-small"
>
<span
aria-hidden={true}
className="wc-block-components-chip__text"
>
Test
</span>
<span
className="screen-reader-text"
>
Test 2
</span>
</li>
`;
exports[`Chip should render text 1`] = `
<li
className="wc-block-components-chip wc-block-components-chip--radius-small"
>
<span
aria-hidden={false}
className="wc-block-components-chip__text"
>
Test
</span>
</li>
`;
exports[`Chip with custom wrapper should render a chip made up of a div instead of a li 1`] = `
<div
className="wc-block-components-chip wc-block-components-chip--radius-small"
>
<span
aria-hidden={false}
className="wc-block-components-chip__text"
>
Test
</span>
</div>
`;
exports[`RemovableChip should render custom aria label 1`] = `
<li
className="is-removable wc-block-components-chip wc-block-components-chip--radius-small"
>
<span
aria-hidden={false}
className="wc-block-components-chip__text"
>
<h1>
Test
</h1>
</span>
<button
aria-label="Aria test"
className="wc-block-components-chip__remove"
disabled={false}
onClick={[Function]}
onKeyDown={[Function]}
>
<svg
aria-hidden={true}
className="wc-block-components-chip__remove-icon"
focusable={false}
height={16}
role="img"
viewBox="0 0 20 20"
width={16}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.95 6.46L11.41 10l3.54 3.54-1.41 1.41L10 11.42l-3.53 3.53-1.42-1.42L8.58 10 5.05 6.47l1.42-1.42L10 8.58l3.54-3.53z"
/>
</svg>
</button>
</li>
`;
exports[`RemovableChip should render default aria label if text is a node 1`] = `
<li
className="is-removable wc-block-components-chip wc-block-components-chip--radius-small"
>
<span
aria-hidden={true}
className="wc-block-components-chip__text"
>
<h1>
Test
</h1>
</span>
<span
className="screen-reader-text"
>
Test 2
</span>
<button
aria-label="Remove \\"Test 2\\""
className="wc-block-components-chip__remove"
disabled={false}
onClick={[Function]}
onKeyDown={[Function]}
>
<svg
aria-hidden={true}
className="wc-block-components-chip__remove-icon"
focusable={false}
height={16}
role="img"
viewBox="0 0 20 20"
width={16}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.95 6.46L11.41 10l3.54 3.54-1.41 1.41L10 11.42l-3.53 3.53-1.42-1.42L8.58 10 5.05 6.47l1.42-1.42L10 8.58l3.54-3.53z"
/>
</svg>
</button>
</li>
`;
exports[`RemovableChip should render screen reader text aria label 1`] = `
<li
className="is-removable wc-block-components-chip wc-block-components-chip--radius-small"
>
<span
aria-hidden={true}
className="wc-block-components-chip__text"
>
Test
</span>
<span
className="screen-reader-text"
>
Test 2
</span>
<button
aria-label="Remove \\"Test 2\\""
className="wc-block-components-chip__remove"
disabled={false}
onClick={[Function]}
onKeyDown={[Function]}
>
<svg
aria-hidden={true}
className="wc-block-components-chip__remove-icon"
focusable={false}
height={16}
role="img"
viewBox="0 0 20 20"
width={16}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.95 6.46L11.41 10l3.54 3.54-1.41 1.41L10 11.42l-3.53 3.53-1.42-1.42L8.58 10 5.05 6.47l1.42-1.42L10 8.58l3.54-3.53z"
/>
</svg>
</button>
</li>
`;
exports[`RemovableChip should render text and the remove button 1`] = `
<li
className="is-removable wc-block-components-chip wc-block-components-chip--radius-small"
>
<span
aria-hidden={false}
className="wc-block-components-chip__text"
>
Test
</span>
<button
aria-label="Remove \\"Test\\""
className="wc-block-components-chip__remove"
disabled={false}
onClick={[Function]}
onKeyDown={[Function]}
>
<svg
aria-hidden={true}
className="wc-block-components-chip__remove-icon"
focusable={false}
height={16}
role="img"
viewBox="0 0 20 20"
width={16}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.95 6.46L11.41 10l3.54 3.54-1.41 1.41L10 11.42l-3.53 3.53-1.42-1.42L8.58 10 5.05 6.47l1.42-1.42L10 8.58l3.54-3.53z"
/>
</svg>
</button>
</li>
`;
exports[`RemovableChip should render with disabled remove button 1`] = `
<li
className="is-removable wc-block-components-chip wc-block-components-chip--radius-small"
>
<span
aria-hidden={false}
className="wc-block-components-chip__text"
>
Test
</span>
<button
aria-label="Remove \\"Test\\""
className="wc-block-components-chip__remove"
disabled={true}
onClick={[Function]}
onKeyDown={[Function]}
>
<svg
aria-hidden={true}
className="wc-block-components-chip__remove-icon"
focusable={false}
height={16}
role="img"
viewBox="0 0 20 20"
width={16}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.95 6.46L11.41 10l3.54 3.54-1.41 1.41L10 11.42l-3.53 3.53-1.42-1.42L8.58 10 5.05 6.47l1.42-1.42L10 8.58l3.54-3.53z"
/>
</svg>
</button>
</li>
`;
exports[`RemovableChip with removeOnAnyClick should be a button when removeOnAnyClick is set to true 1`] = `
<button
aria-label="Remove \\"Test\\""
className="is-removable wc-block-components-chip wc-block-components-chip--radius-small"
disabled={false}
onClick={[Function]}
onKeyDown={[Function]}
>
<span
aria-hidden={false}
className="wc-block-components-chip__text"
>
Test
</span>
<span
aria-hidden={true}
className="wc-block-components-chip__remove"
>
<svg
aria-hidden={true}
className="wc-block-components-chip__remove-icon"
focusable={false}
height={16}
role="img"
viewBox="0 0 20 20"
width={16}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.95 6.46L11.41 10l3.54 3.54-1.41 1.41L10 11.42l-3.53 3.53-1.42-1.42L8.58 10 5.05 6.47l1.42-1.42L10 8.58l3.54-3.53z"
/>
</svg>
</span>
</button>
`;

View File

@ -0,0 +1,109 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import { Chip, RemovableChip } from '..';
describe( 'Chip', () => {
test( 'should render text', () => {
const component = TestRenderer.create( <Chip text="Test" /> );
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render nodes as the text', () => {
const component = TestRenderer.create(
<Chip text={ <h1>Test</h1> } />
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render defined radius', () => {
const component = TestRenderer.create(
<Chip text="Test" radius="large" />
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render screen reader text', () => {
const component = TestRenderer.create(
<Chip text="Test" screenReaderText="Test 2" />
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render children nodes', () => {
const component = TestRenderer.create(
<Chip text="Test">Lorem Ipsum</Chip>
);
expect( component.toJSON() ).toMatchSnapshot();
} );
describe( 'with custom wrapper', () => {
test( 'should render a chip made up of a div instead of a li', () => {
const component = TestRenderer.create(
<Chip text="Test" element="div" />
);
expect( component.toJSON() ).toMatchSnapshot();
} );
} );
} );
describe( 'RemovableChip', () => {
test( 'should render text and the remove button', () => {
const component = TestRenderer.create( <RemovableChip text="Test" /> );
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render with disabled remove button', () => {
const component = TestRenderer.create(
<RemovableChip text="Test" disabled={ true } />
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render custom aria label', () => {
const component = TestRenderer.create(
<RemovableChip text={ <h1>Test</h1> } ariaLabel="Aria test" />
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render default aria label if text is a node', () => {
const component = TestRenderer.create(
<RemovableChip text={ <h1>Test</h1> } screenReaderText="Test 2" />
);
expect( component.toJSON() ).toMatchSnapshot();
} );
test( 'should render screen reader text aria label', () => {
const component = TestRenderer.create(
<RemovableChip text="Test" screenReaderText="Test 2" />
);
expect( component.toJSON() ).toMatchSnapshot();
} );
describe( 'with removeOnAnyClick', () => {
test( 'should be a button when removeOnAnyClick is set to true', () => {
const component = TestRenderer.create(
<RemovableChip text="Test" removeOnAnyClick={ true } />
);
expect( component.toJSON() ).toMatchSnapshot();
} );
} );
} );

View File

@ -0,0 +1,152 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { __ } from '@wordpress/i18n';
import { useEffect, useRef } from '@wordpress/element';
import { withInstanceId } from '@wordpress/compose';
import { ComboboxControl } from 'wordpress-components';
import {
ValidationInputError,
useValidationContext,
} from '@woocommerce/base-context';
import { isObject } from '@woocommerce/types';
/**
* Internal dependencies
*/
import './style.scss';
export interface ComboboxControlOption {
label: string;
value: string;
}
/**
* Wrapper for the WordPress ComboboxControl which supports validation.
*/
const Combobox = ( {
id,
className,
label,
onChange,
options,
value,
required = false,
errorMessage = __(
'Please select a value.',
'woo-gutenberg-products-block'
),
errorId: incomingErrorId,
instanceId = '0',
autoComplete = 'off',
}: {
id: string;
className: string;
label: string;
onChange: ( filterValue: string ) => void;
options: ComboboxControlOption[];
value: string;
required: boolean;
errorMessage: string;
errorId: string;
instanceId: string;
autoComplete: string;
} ): JSX.Element => {
const {
getValidationError,
setValidationErrors,
clearValidationError,
} = useValidationContext();
const controlRef = useRef< HTMLDivElement >( null );
const controlId = id || 'control-' + instanceId;
const errorId = incomingErrorId || controlId;
const error = ( getValidationError( errorId ) || {
message: '',
hidden: false,
} ) as {
message: string;
hidden: boolean;
};
useEffect( () => {
if ( ! required || value ) {
clearValidationError( errorId );
} else {
setValidationErrors( {
[ errorId ]: {
message: errorMessage,
hidden: true,
},
} );
}
return () => {
clearValidationError( errorId );
};
}, [
clearValidationError,
value,
errorId,
errorMessage,
required,
setValidationErrors,
] );
// @todo Remove patch for ComboboxControl once https://github.com/WordPress/gutenberg/pull/33928 is released
// Also see https://github.com/WordPress/gutenberg/pull/34090
return (
<div
id={ controlId }
className={ classnames( 'wc-block-components-combobox', className, {
'is-active': value,
'has-error': error.message && ! error.hidden,
} ) }
ref={ controlRef }
>
<ComboboxControl
className={ 'wc-block-components-combobox-control' }
label={ label }
onChange={ onChange }
onFilterValueChange={ ( filterValue: string ) => {
if ( filterValue.length ) {
// If we have a value and the combobox is not focussed, this could be from browser autofill.
const activeElement = isObject( controlRef.current )
? controlRef.current.ownerDocument.activeElement
: undefined;
if (
activeElement &&
isObject( controlRef.current ) &&
controlRef.current.contains( activeElement )
) {
return;
}
// Try to match.
const normalizedFilterValue = filterValue.toLocaleUpperCase();
const foundOption = options.find(
( option ) =>
option.label
.toLocaleUpperCase()
.startsWith( normalizedFilterValue ) ||
option.value.toLocaleUpperCase() ===
normalizedFilterValue
);
if ( foundOption ) {
onChange( foundOption.value );
}
}
} }
options={ options }
value={ value || '' }
allowReset={ false }
autoComplete={ autoComplete }
aria-invalid={ error.message && ! error.hidden }
/>
<ValidationInputError propertyName={ errorId } />
</div>
);
};
export default withInstanceId( Combobox );

View File

@ -0,0 +1,158 @@
.wc-block-components-form .wc-block-components-combobox,
.wc-block-components-combobox {
.wc-block-components-combobox-control {
@include reset-typography();
@include reset-box();
.components-base-control__field {
@include reset-box();
}
.components-combobox-control__suggestions-container {
@include reset-typography();
@include reset-box();
position: relative;
}
input.components-combobox-control__input {
@include reset-typography();
@include font-size(regular);
box-sizing: border-box;
outline: inherit;
border: 1px solid $input-border-gray;
background: #fff;
box-shadow: none;
color: $input-text-active;
font-family: inherit;
font-weight: normal;
height: 3em;
letter-spacing: inherit;
line-height: 1;
padding: em($gap-large) $gap em($gap-smallest);
text-align: left;
text-overflow: ellipsis;
text-transform: none;
white-space: nowrap;
width: 100%;
opacity: initial;
border-radius: 4px;
&[aria-expanded="true"],
&:focus {
background-color: #fff;
color: $input-text-active;
outline: 0;
box-shadow: 0 0 0 1px $input-border-gray;
}
&[aria-expanded="true"] {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.has-dark-controls & {
background-color: $input-background-dark;
border-color: $input-border-dark;
color: $input-text-dark;
&:focus {
background-color: $input-background-dark;
color: $input-text-dark;
box-shadow: 0 0 0 1px $input-border-dark;
}
}
}
.components-form-token-field__suggestions-list {
position: absolute;
z-index: 10;
background-color: $select-dropdown-light;
border: 1px solid $input-border-gray;
border-top: 0;
margin: 3em 0 0 0;
padding: 0;
max-height: 300px;
min-width: 100%;
overflow: auto;
color: $input-text-active;
.has-dark-controls & {
background-color: $select-dropdown-dark;
color: $input-text-dark;
}
.components-form-token-field__suggestion {
@include font-size(regular);
color: $gray-700;
cursor: default;
list-style: none;
margin: 0;
padding: em($gap-smallest) $gap;
&.is-selected {
background-color: $gray-300;
.has-dark-controls & {
background-color: $select-item-dark;
}
}
&:hover,
&:focus,
&.is-highlighted,
&:active {
background-color: #00669e;
color: #fff;
}
}
}
label.components-base-control__label {
@include reset-typography();
@include font-size(regular);
line-height: 1.375; // =22px when font-size is 16px.
position: absolute;
transform: translateY(0.75em);
transform-origin: top left;
transition: all 200ms ease;
color: $gray-700;
z-index: 1;
margin: 0 0 0 #{$gap + 1px};
overflow: hidden;
text-overflow: ellipsis;
max-width: calc(100% - #{2 * $gap});
white-space: nowrap;
.has-dark-controls & {
color: $input-placeholder-dark;
}
@media screen and (prefers-reduced-motion: reduce) {
transition: none;
}
}
}
&.is-active,
&:focus-within {
.wc-block-components-combobox-control label.components-base-control__label {
transform: translateY(#{$gap-smallest}) scale(0.75);
}
}
&.has-error {
.wc-block-components-combobox-control {
label.components-base-control__label {
color: $alert-red;
}
input.components-combobox-control__input {
&,
&:hover,
&:focus,
&:active {
border-color: $alert-red;
}
&:focus {
box-shadow: 0 0 0 1px $alert-red;
}
}
}
}
}

View File

@ -0,0 +1,15 @@
export interface CountryInputProps {
className?: string;
label: string;
id: string;
autoComplete?: string;
value: string;
onChange: ( value: string ) => void;
required?: boolean;
errorMessage?: string;
errorId: null | 'shipping-missing-country';
}
export type CountryInputWithCountriesProps = CountryInputProps & {
countries: Record< string, string >;
};

View File

@ -0,0 +1,16 @@
/**
* External dependencies
*/
import { ALLOWED_COUNTRIES } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import CountryInput from './country-input';
import type { CountryInputProps } from './CountryInputProps';
const BillingCountryInput = ( props: CountryInputProps ): JSX.Element => {
return <CountryInput countries={ ALLOWED_COUNTRIES } { ...props } />;
};
export default BillingCountryInput;

View File

@ -0,0 +1,91 @@
/**
* External dependencies
*/
import { useMemo } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities';
import classnames from 'classnames';
/**
* Internal dependencies
*/
import Combobox from '../combobox';
import './style.scss';
import type { CountryInputWithCountriesProps } from './CountryInputProps';
const CountryInput = ( {
className,
countries,
id,
label,
onChange,
value = '',
autoComplete = 'off',
required = false,
errorId,
errorMessage = __(
'Please select a country.',
'woo-gutenberg-products-block'
),
}: CountryInputWithCountriesProps ): JSX.Element => {
const options = useMemo(
() =>
Object.keys( countries ).map( ( key ) => ( {
value: key,
label: decodeEntities( countries[ key ] ),
} ) ),
[ countries ]
);
return (
<div
className={ classnames(
className,
'wc-block-components-country-input'
) }
>
<Combobox
id={ id }
label={ label }
onChange={ onChange }
options={ options }
value={ value }
errorId={ errorId }
errorMessage={ errorMessage }
required={ required }
autoComplete={ autoComplete }
/>
{ autoComplete !== 'off' && (
<input
type="text"
aria-hidden={ true }
autoComplete={ autoComplete }
value={ value }
onChange={ ( event ) => {
const textValue = event.target.value.toLocaleUpperCase();
const foundOption = options.find(
( option ) =>
( textValue.length !== 2 &&
option.label.toLocaleUpperCase() ===
textValue ) ||
( textValue.length === 2 &&
option.value.toLocaleUpperCase() ===
textValue )
);
onChange( foundOption ? foundOption.value : '' );
} }
style={ {
minHeight: '0',
height: '0',
border: '0',
padding: '0',
position: 'absolute',
} }
tabIndex={ -1 }
/>
) }
</div>
);
};
export default CountryInput;

View File

@ -0,0 +1,3 @@
export { default as CountryInput } from './country-input';
export { default as BillingCountryInput } from './billing-country-input';
export { default as ShippingCountryInput } from './shipping-country-input';

View File

@ -0,0 +1,16 @@
/**
* External dependencies
*/
import { SHIPPING_COUNTRIES } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import CountryInput from './country-input';
import { CountryInputProps } from './CountryInputProps';
const ShippingCountryInput = ( props: CountryInputProps ): JSX.Element => {
return <CountryInput countries={ SHIPPING_COUNTRIES } { ...props } />;
};
export default ShippingCountryInput;

View File

@ -0,0 +1,251 @@
export const countries = {
AX: '&#197;land Islands',
AF: 'Afghanistan',
AL: 'Albania',
DZ: 'Algeria',
AS: 'American Samoa',
AD: 'Andorra',
AO: 'Angola',
AI: 'Anguilla',
AQ: 'Antarctica',
AG: 'Antigua and Barbuda',
AR: 'Argentina',
AM: 'Armenia',
AW: 'Aruba',
AU: 'Australia',
AT: 'Austria',
AZ: 'Azerbaijan',
BS: 'Bahamas',
BH: 'Bahrain',
BD: 'Bangladesh',
BB: 'Barbados',
BY: 'Belarus',
PW: 'Belau',
BE: 'Belgium',
BZ: 'Belize',
BJ: 'Benin',
BM: 'Bermuda',
BT: 'Bhutan',
BO: 'Bolivia',
BQ: 'Bonaire, Saint Eustatius and Saba',
BA: 'Bosnia and Herzegovina',
BW: 'Botswana',
BV: 'Bouvet Island',
BR: 'Brazil',
IO: 'British Indian Ocean Territory',
BN: 'Brunei',
BG: 'Bulgaria',
BF: 'Burkina Faso',
BI: 'Burundi',
KH: 'Cambodia',
CM: 'Cameroon',
CA: 'Canada',
CV: 'Cape Verde',
KY: 'Cayman Islands',
CF: 'Central African Republic',
TD: 'Chad',
CL: 'Chile',
CN: 'China',
CX: 'Christmas Island',
CC: 'Cocos (Keeling) Islands',
CO: 'Colombia',
KM: 'Comoros',
CG: 'Congo (Brazzaville)',
CD: 'Congo (Kinshasa)',
CK: 'Cook Islands',
CR: 'Costa Rica',
HR: 'Croatia',
CU: 'Cuba',
CW: 'Cura&ccedil;ao',
CY: 'Cyprus',
CZ: 'Czech Republic',
DK: 'Denmark',
DJ: 'Djibouti',
DM: 'Dominica',
DO: 'Dominican Republic',
EC: 'Ecuador',
EG: 'Egypt',
SV: 'El Salvador',
GQ: 'Equatorial Guinea',
ER: 'Eritrea',
EE: 'Estonia',
ET: 'Ethiopia',
FK: 'Falkland Islands',
FO: 'Faroe Islands',
FJ: 'Fiji',
FI: 'Finland',
FR: 'France',
GF: 'French Guiana',
PF: 'French Polynesia',
TF: 'French Southern Territories',
GA: 'Gabon',
GM: 'Gambia',
GE: 'Georgia',
DE: 'Germany',
GH: 'Ghana',
GI: 'Gibraltar',
GR: 'Greece',
GL: 'Greenland',
GD: 'Grenada',
GP: 'Guadeloupe',
GU: 'Guam',
GT: 'Guatemala',
GG: 'Guernsey',
GN: 'Guinea',
GW: 'Guinea-Bissau',
GY: 'Guyana',
HT: 'Haiti',
HM: 'Heard Island and McDonald Islands',
HN: 'Honduras',
HK: 'Hong Kong',
HU: 'Hungary',
IS: 'Iceland',
IN: 'India',
ID: 'Indonesia',
IR: 'Iran',
IQ: 'Iraq',
IE: 'Ireland',
IM: 'Isle of Man',
IL: 'Israel',
IT: 'Italy',
CI: 'Ivory Coast',
JM: 'Jamaica',
JP: 'Japan',
JE: 'Jersey',
JO: 'Jordan',
KZ: 'Kazakhstan',
KE: 'Kenya',
KI: 'Kiribati',
KW: 'Kuwait',
KG: 'Kyrgyzstan',
LA: 'Laos',
LV: 'Latvia',
LB: 'Lebanon',
LS: 'Lesotho',
LR: 'Liberia',
LY: 'Libya',
LI: 'Liechtenstein',
LT: 'Lithuania',
LU: 'Luxembourg',
MO: 'Macao',
MG: 'Madagascar',
MW: 'Malawi',
MY: 'Malaysia',
MV: 'Maldives',
ML: 'Mali',
MT: 'Malta',
MH: 'Marshall Islands',
MQ: 'Martinique',
MR: 'Mauritania',
MU: 'Mauritius',
YT: 'Mayotte',
MX: 'Mexico',
FM: 'Micronesia',
MD: 'Moldova',
MC: 'Monaco',
MN: 'Mongolia',
ME: 'Montenegro',
MS: 'Montserrat',
MA: 'Morocco',
MZ: 'Mozambique',
MM: 'Myanmar',
NA: 'Namibia',
NR: 'Nauru',
NP: 'Nepal',
NL: 'Netherlands',
NC: 'New Caledonia',
NZ: 'New Zealand',
NI: 'Nicaragua',
NE: 'Niger',
NG: 'Nigeria',
NU: 'Niue',
NF: 'Norfolk Island',
KP: 'North Korea',
MK: 'North Macedonia',
MP: 'Northern Mariana Islands',
NO: 'Norway',
OM: 'Oman',
PK: 'Pakistan',
PS: 'Palestinian Territory',
PA: 'Panama',
PG: 'Papua New Guinea',
PY: 'Paraguay',
PE: 'Peru',
PH: 'Philippines',
PN: 'Pitcairn',
PL: 'Poland',
PT: 'Portugal',
PR: 'Puerto Rico',
QA: 'Qatar',
RE: 'Reunion',
RO: 'Romania',
RU: 'Russia',
RW: 'Rwanda',
ST: 'S&atilde;o Tom&eacute; and Pr&iacute;ncipe',
BL: 'Saint Barth&eacute;lemy',
SH: 'Saint Helena',
KN: 'Saint Kitts and Nevis',
LC: 'Saint Lucia',
SX: 'Saint Martin (Dutch part)',
MF: 'Saint Martin (French part)',
PM: 'Saint Pierre and Miquelon',
VC: 'Saint Vincent and the Grenadines',
WS: 'Samoa',
SM: 'San Marino',
SA: 'Saudi Arabia',
SN: 'Senegal',
RS: 'Serbia',
SC: 'Seychelles',
SL: 'Sierra Leone',
SG: 'Singapore',
SK: 'Slovakia',
SI: 'Slovenia',
SB: 'Solomon Islands',
SO: 'Somalia',
ZA: 'South Africa',
GS: 'South Georgia/Sandwich Islands',
KR: 'South Korea',
SS: 'South Sudan',
ES: 'Spain',
LK: 'Sri Lanka',
SD: 'Sudan',
SR: 'Suriname',
SJ: 'Svalbard and Jan Mayen',
SZ: 'Swaziland',
SE: 'Sweden',
CH: 'Switzerland',
SY: 'Syria',
TW: 'Taiwan',
TJ: 'Tajikistan',
TZ: 'Tanzania',
TH: 'Thailand',
TL: 'Timor-Leste',
TG: 'Togo',
TK: 'Tokelau',
TO: 'Tonga',
TT: 'Trinidad and Tobago',
TN: 'Tunisia',
TR: 'Turkey',
TM: 'Turkmenistan',
TC: 'Turks and Caicos Islands',
TV: 'Tuvalu',
UG: 'Uganda',
UA: 'Ukraine',
AE: 'United Arab Emirates',
GB: 'United Kingdom (UK)',
US: 'United States (US)',
UM: 'United States (US) Minor Outlying Islands',
UY: 'Uruguay',
UZ: 'Uzbekistan',
VU: 'Vanuatu',
VA: 'Vatican',
VE: 'Venezuela',
VN: 'Vietnam',
VG: 'Virgin Islands (British)',
VI: 'Virgin Islands (US)',
WF: 'Wallis and Futuna',
EH: 'Western Sahara',
YE: 'Yemen',
ZM: 'Zambia',
ZW: 'Zimbabwe',
};

View File

@ -0,0 +1,53 @@
/**
* External dependencies
*/
import { text } from '@storybook/addon-knobs';
import { useState, useEffect } from '@wordpress/element';
import {
ValidationContextProvider,
useValidationContext,
} from '@woocommerce/base-context';
/**
* Internal dependencies
*/
import { CountryInput } from '../';
import { countries as exampleCountries } from './countries-filler';
export default {
title: 'WooCommerce Blocks/@base-components/CountryInput',
component: CountryInput,
};
const StoryComponent = ( { label, errorMessage } ) => {
const [ selectedCountry, selectCountry ] = useState();
const {
setValidationErrors,
clearValidationError,
} = useValidationContext();
useEffect( () => {
setValidationErrors( { country: errorMessage } );
}, [ errorMessage, setValidationErrors ] );
const updateCountry = ( country ) => {
clearValidationError( 'country' );
selectCountry( country );
};
return (
<CountryInput
countries={ exampleCountries }
label={ label }
value={ selectedCountry }
onChange={ updateCountry }
/>
);
};
export const Default = () => {
const label = text( 'Input Label', 'Countries:' );
const errorMessage = text( 'Error Message', '' );
return (
<ValidationContextProvider>
<StoryComponent label={ label } errorMessage={ errorMessage } />
</ValidationContextProvider>
);
};

View File

@ -0,0 +1,3 @@
.wc-block-components-country-input {
margin-top: em($gap-large);
}

View File

@ -0,0 +1,64 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Modal } from '@wordpress/components';
import { useDebounce } from 'use-debounce';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import './style.scss';
interface DrawerProps {
children: JSX.Element;
className?: string;
isOpen: boolean;
onClose: () => void;
slideIn?: boolean;
slideOut?: boolean;
title: string;
}
const Drawer = ( {
children,
className,
isOpen,
onClose,
slideIn = true,
slideOut = true,
title,
}: DrawerProps ): JSX.Element | null => {
const [ debouncedIsOpen ] = useDebounce< boolean >( isOpen, 300 );
const isClosing = ! isOpen && debouncedIsOpen;
if ( ! isOpen && ! isClosing ) {
return null;
}
return (
<Modal
title={ title }
focusOnMount={ true }
onRequestClose={ onClose }
className={ classNames( className, 'wc-block-components-drawer' ) }
overlayClassName={ classNames(
'wc-block-components-drawer__screen-overlay',
{
'wc-block-components-drawer__screen-overlay--is-hidden': ! isOpen,
'wc-block-components-drawer__screen-overlay--with-slide-in': slideIn,
'wc-block-components-drawer__screen-overlay--with-slide-out': slideOut,
}
) }
closeButtonLabel={ __(
'Close mini cart',
'woo-gutenberg-products-block'
) }
>
{ children }
</Modal>
);
};
export default Drawer;

View File

@ -0,0 +1,139 @@
$drawer-animation-duration: 0.3s;
$drawer-width: 480px;
$drawer-width-mobile: 100vw;
@keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slidein {
from {
transform: translateX(0);
}
to {
transform: translateX(-$drawer-width);
}
}
@media only screen and (max-width: 480px) {
@keyframes slidein {
from {
transform: translateX(0);
}
to {
transform: translateX(-$drawer-width-mobile);
}
}
}
.wc-block-components-drawer__screen-overlay {
background-color: rgba(95, 95, 95, 0.35);
bottom: 0;
left: 0;
position: fixed;
right: 0;
top: 0;
transition: opacity $drawer-animation-duration;
z-index: 999;
opacity: 1;
}
.wc-block-components-drawer__screen-overlay--with-slide-out {
transition: opacity $drawer-animation-duration;
}
// We can't use transition for the slide-in animation because the element
// doesn't exist in the DOM when not open. Instead, use an animation that
// is triggered when the element is appended to the DOM.
.wc-block-components-drawer__screen-overlay--with-slide-in {
animation-duration: $drawer-animation-duration;
animation-name: fadein;
}
.wc-block-components-drawer__screen-overlay--is-hidden {
pointer-events: none;
opacity: 0;
}
.wc-block-components-drawer {
@include with-translucent-border(0 0 0 1px);
background: #fff;
display: block;
height: 100%;
left: 100%;
overflow: auto;
position: fixed;
right: 0;
top: 0;
transform: translateX(-$drawer-width);
width: $drawer-width;
@media only screen and (max-width: 480px) {
transform: translateX(-$drawer-width-mobile);
width: $drawer-width-mobile;
}
}
.wc-block-components-drawer__screen-overlay--with-slide-out .wc-block-components-drawer {
transition: transform $drawer-animation-duration;
}
.wc-block-components-drawer__screen-overlay--with-slide-in .wc-block-components-drawer {
animation-duration: $drawer-animation-duration;
animation-name: slidein;
}
.wc-block-components-drawer__screen-overlay--is-hidden .wc-block-components-drawer {
transform: translateX(0);
}
@media screen and (prefers-reduced-motion: reduce) {
.wc-block-components-drawer__screen-overlay {
animation-name: none !important;
transition: none !important;
}
.wc-block-components-drawer {
animation-name: none !important;
transition: none !important;
}
}
.wc-block-components-drawer .components-modal__content {
padding: $gap-largest $gap;
}
.wc-block-components-drawer .components-modal__header {
position: relative;
// Close button.
.components-button {
@include reset-box();
background: transparent;
position: absolute;
right: 0;
top: 0;
// Increase clickable area.
padding: 1em;
margin: -1em;
> span {
@include visually-hidden();
}
}
}
// Same styles as `Title` component.
.wc-block-components-drawer .components-modal__header-heading {
@include reset-box();
// We need the font size to be in rem so it doesn't change depending on the parent element.
@include font-size(large, 1rem);
word-break: break-word;
}

View File

@ -0,0 +1,212 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import { useCallback, useRef } from '@wordpress/element';
import classNames from 'classnames';
import Downshift from 'downshift';
import { __, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import DropdownSelectorInput from './input';
import DropdownSelectorInputWrapper from './input-wrapper';
import DropdownSelectorMenu from './menu';
import DropdownSelectorSelectedChip from './selected-chip';
import DropdownSelectorSelectedValue from './selected-value';
import './style.scss';
/**
* Component used to show an input box with a dropdown with suggestions.
*
* @param {Object} props Incoming props for the component.
* @param {string} props.attributeLabel Label for the attributes.
* @param {string} props.className CSS class used.
* @param {Array} props.checked Which items are checked.
* @param {string} props.inputLabel Label used for the input.
* @param {boolean} props.isDisabled Whether the input is disabled or not.
* @param {boolean} props.isLoading Whether the input is loading.
* @param {boolean} props.multiple Whether multi-select is allowed.
* @param {function():any} props.onChange Function to be called when onChange event fires.
* @param {Array} props.options The option values to show in the select.
*/
const DropdownSelector = ( {
attributeLabel = '',
className,
checked = [],
inputLabel = '',
isDisabled = false,
isLoading = false,
multiple = false,
onChange = () => {},
options = [],
} ) => {
const inputRef = useRef( null );
const classes = classNames(
className,
'wc-block-dropdown-selector',
'wc-block-components-dropdown-selector',
{
'is-disabled': isDisabled,
'is-loading': isLoading,
}
);
/**
* State reducer for the downshift component.
* See: https://github.com/downshift-js/downshift#statereducer
*/
const stateReducer = useCallback(
( state, changes ) => {
switch ( changes.type ) {
case Downshift.stateChangeTypes.keyDownEnter:
case Downshift.stateChangeTypes.clickItem:
return {
...changes,
highlightedIndex: state.highlightedIndex,
isOpen: multiple,
inputValue: '',
};
case Downshift.stateChangeTypes.blurInput:
case Downshift.stateChangeTypes.mouseUp:
return {
...changes,
inputValue: state.inputValue,
};
default:
return changes;
}
},
[ multiple ]
);
return (
<Downshift
onChange={ onChange }
selectedItem={ null }
stateReducer={ stateReducer }
>
{ ( {
getInputProps,
getItemProps,
getLabelProps,
getMenuProps,
highlightedIndex,
inputValue,
isOpen,
openMenu,
} ) => (
<div
className={ classNames( classes, {
'is-multiple': multiple,
'is-single': ! multiple,
'has-checked': checked.length > 0,
'is-open': isOpen,
} ) }
>
{ /* eslint-disable-next-line jsx-a11y/label-has-for */ }
<label
{ ...getLabelProps( {
className: 'screen-reader-text',
} ) }
>
{ inputLabel }
</label>
<DropdownSelectorInputWrapper
isOpen={ isOpen }
onClick={ () => inputRef.current.focus() }
>
{ checked.map( ( value ) => {
const option = options.find(
( o ) => o.value === value
);
const onRemoveItem = ( val ) => {
onChange( val );
inputRef.current.focus();
};
return multiple ? (
<DropdownSelectorSelectedChip
key={ value }
onRemoveItem={ onRemoveItem }
option={ option }
/>
) : (
<DropdownSelectorSelectedValue
key={ value }
onClick={ () => inputRef.current.focus() }
onRemoveItem={ onRemoveItem }
option={ option }
/>
);
} ) }
<DropdownSelectorInput
checked={ checked }
getInputProps={ getInputProps }
inputRef={ inputRef }
isDisabled={ isDisabled }
onFocus={ openMenu }
onRemoveItem={ ( val ) => {
onChange( val );
inputRef.current.focus();
} }
placeholder={
checked.length > 0 && multiple
? null
: sprintf(
/* translators: %s attribute name. */
__(
'Any %s',
'woocommerce'
),
attributeLabel
)
}
tabIndex={
// When it's a single selector and there is one element selected,
// we make the input non-focusable with the keyboard because it's
// visually hidden. The input is still rendered, though, because it
// must be possible to focus it when pressing the select value chip.
! multiple && checked.length > 0 ? '-1' : '0'
}
value={ inputValue }
/>
</DropdownSelectorInputWrapper>
{ isOpen && ! isDisabled && (
<DropdownSelectorMenu
checked={ checked }
getItemProps={ getItemProps }
getMenuProps={ getMenuProps }
highlightedIndex={ highlightedIndex }
options={ options.filter(
( option ) =>
! inputValue ||
option.value.startsWith( inputValue )
) }
/>
) }
</div>
) }
</Downshift>
);
};
DropdownSelector.propTypes = {
attributeLabel: PropTypes.string,
checked: PropTypes.array,
className: PropTypes.string,
inputLabel: PropTypes.string,
isDisabled: PropTypes.bool,
isLoading: PropTypes.bool,
limit: PropTypes.number,
onChange: PropTypes.func,
options: PropTypes.arrayOf(
PropTypes.shape( {
label: PropTypes.node.isRequired,
value: PropTypes.string.isRequired,
} )
),
};
export default DropdownSelector;

View File

@ -0,0 +1,13 @@
const DropdownSelectorInputWrapper = ( { children, onClick } ) => {
return (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
<div
className="wc-block-dropdown-selector__input-wrapper wc-block-components-dropdown-selector__input-wrapper"
onClick={ onClick }
>
{ children }
</div>
);
};
export default DropdownSelectorInputWrapper;

View File

@ -0,0 +1,36 @@
const DropdownSelectorInput = ( {
checked,
getInputProps,
inputRef,
isDisabled,
onFocus,
onRemoveItem,
placeholder,
tabIndex,
value,
} ) => {
return (
<input
{ ...getInputProps( {
ref: inputRef,
className:
'wc-block-dropdown-selector__input wc-block-components-dropdown-selector__input',
disabled: isDisabled,
onFocus,
onKeyDown( e ) {
if (
e.key === 'Backspace' &&
! value &&
checked.length > 0
) {
onRemoveItem( checked[ checked.length - 1 ] );
}
},
placeholder,
tabIndex,
} ) }
/>
);
};
export default DropdownSelectorInput;

View File

@ -0,0 +1,59 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import classNames from 'classnames';
const DropdownSelectorMenu = ( {
checked,
getItemProps,
getMenuProps,
highlightedIndex,
options,
} ) => {
return (
<ul
{ ...getMenuProps( {
className:
'wc-block-dropdown-selector__list wc-block-components-dropdown-selector__list',
} ) }
>
{ options.map( ( option, index ) => {
const selected = checked.includes( option.value );
return (
// eslint-disable-next-line react/jsx-key
<li
{ ...getItemProps( {
key: option.value,
className: classNames(
'wc-block-dropdown-selector__list-item',
'wc-block-components-dropdown-selector__list-item',
{
'is-selected': selected,
'is-highlighted':
highlightedIndex === index,
}
),
index,
item: option.value,
'aria-label': selected
? sprintf(
/* translators: %s is referring to the filter option being removed. */
__(
'Remove %s filter',
'woocommerce'
),
option.name
)
: null,
} ) }
>
{ option.label }
</li>
);
} ) }
</ul>
);
};
export default DropdownSelectorMenu;

View File

@ -0,0 +1,26 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { RemovableChip } from '@woocommerce/base-components/chip';
const DropdownSelectorSelectedChip = ( { onRemoveItem, option } ) => {
return (
<RemovableChip
className="wc-block-dropdown-selector__selected-chip wc-block-components-dropdown-selector__selected-chip"
removeOnAnyClick={ true }
onRemove={ () => {
onRemoveItem( option.value );
} }
ariaLabel={ sprintf(
/* translators: %s is referring to the filter option being removed. */
__( 'Remove %s filter', 'woocommerce' ),
option.name
) }
text={ option.label }
radius="large"
/>
);
};
export default DropdownSelectorSelectedChip;

Some files were not shown because too many files have changed in this diff Show More