initial commit
This commit is contained in:
@ -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;
|
@ -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>:
|
||||
{ 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;
|
@ -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;
|
||||
}
|
@ -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;
|
@ -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>;
|
@ -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;
|
||||
}
|
||||
}
|
@ -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 );
|
@ -0,0 +1 @@
|
||||
export { default as AddressForm } from './address-form';
|
@ -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;
|
@ -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( '' );
|
||||
} );
|
||||
} );
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
`;
|
@ -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();
|
||||
} );
|
||||
} );
|
@ -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';
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
@ -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();
|
||||
} );
|
||||
} );
|
@ -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;
|
||||
} ) || {}
|
||||
);
|
||||
};
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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 );
|
||||
};
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
`;
|
@ -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();
|
||||
} );
|
||||
} );
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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%;
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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);
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
@ -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;
|
@ -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 );
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
||||
}
|
@ -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;
|
@ -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,
|
||||
} }
|
||||
/>
|
||||
);
|
||||
};
|
@ -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;
|
||||
}
|
@ -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;
|
@ -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,
|
||||
} }
|
||||
/>
|
||||
);
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
`;
|
@ -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();
|
||||
} );
|
||||
} );
|
@ -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';
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
} }
|
||||
/>
|
||||
);
|
||||
};
|
@ -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();
|
||||
}
|
@ -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 );
|
@ -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 ) }
|
||||
/>
|
||||
);
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
@ -0,0 +1,2 @@
|
||||
export { default as Chip } from './chip';
|
||||
export { default as RemovableChip } from './removable-chip';
|
@ -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;
|
@ -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' ) }
|
||||
/>
|
||||
);
|
@ -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;
|
||||
}
|
@ -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>
|
||||
`;
|
@ -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();
|
||||
} );
|
||||
} );
|
||||
} );
|
@ -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 );
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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 >;
|
||||
};
|
@ -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;
|
@ -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;
|
@ -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';
|
@ -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;
|
@ -0,0 +1,251 @@
|
||||
export const countries = {
|
||||
AX: 'Å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ç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ão Tomé and Príncipe',
|
||||
BL: 'Saint Barthé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',
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
.wc-block-components-country-input {
|
||||
margin-top: em($gap-large);
|
||||
}
|
@ -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;
|
@ -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;
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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
Reference in New Issue
Block a user