Checkout: Collapsible Order Summary in mobile view (#52253)

This commit is contained in:
Sam Seay 2024-11-01 05:48:54 +13:00 committed by GitHub
parent f5d5caa3f0
commit 601e14a253
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 2330 additions and 480 deletions

View File

@ -54,6 +54,7 @@
"@wordpress/data",
"@wordpress/data-controls",
"@wordpress/date",
"@wordpress/editor",
"@wordpress/dependency-extraction-webpack-plugin",
"@wordpress/deprecated",
"@wordpress/dom",

View File

@ -1,10 +1,9 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useContainerWidthContext } from '@woocommerce/base-context';
import { Panel } from '@woocommerce/blocks-components';
import type { CartItem } from '@woocommerce/types';
import clsx from 'clsx';
/**
* Internal dependencies
@ -26,15 +25,10 @@ const OrderSummary = ( {
}
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>
}
<div
className={ clsx( 'wc-block-components-order-summary', {
'is-large': isLarge,
} ) }
>
<div className="wc-block-components-order-summary__content">
{ cartItems.map( ( cartItem ) => {
@ -46,7 +40,7 @@ const OrderSummary = ( {
);
} ) }
</div>
</Panel>
</div>
);
};

View File

@ -1,4 +1,7 @@
.wc-block-components-order-summary {
// Compensate for removing Panel.
padding: 0 $gap;
.wc-block-components-order-summary__button-text {
font-weight: 500;
}

View File

@ -16,6 +16,10 @@
gap: $gap-smaller;
flex-wrap: wrap;
.wc-block-components-text-input.wc-block-components-totals-coupon__input {
margin: 0;
}
.wc-block-components-totals-coupon__input,
.wc-block-components-totals-coupon__button {
margin: 0;

View File

@ -20,8 +20,11 @@ import { previewCart } from '@woocommerce/resource-previews';
*/
import { useStoreEvents } from '../use-store-events';
import type { ShippingData } from './types';
import { useEditorContext } from '../../providers';
export const useShippingData = (): ShippingData => {
const { isEditor } = useEditorContext();
const {
shippingRates,
needsShipping,
@ -29,32 +32,37 @@ export const useShippingData = (): ShippingData => {
isLoadingRates,
isCollectable,
isSelectingRate,
} = useSelect( ( select ) => {
const isEditor = !! select( 'core/editor' );
const store = select( storeKey );
const rates = isEditor
? previewCart.shipping_rates
: store.getShippingRates();
return {
shippingRates: rates,
needsShipping: isEditor
? previewCart.needs_shipping
: store.getNeedsShipping(),
hasCalculatedShipping: isEditor
? previewCart.has_calculated_shipping
: store.getHasCalculatedShipping(),
isLoadingRates: isEditor ? false : store.isCustomerDataUpdating(),
isCollectable: rates.every(
( { shipping_rates: packageShippingRates } ) =>
packageShippingRates.find( ( { method_id: methodId } ) =>
hasCollectableRate( methodId )
)
),
isSelectingRate: isEditor
? false
: store.isShippingRateBeingSelected(),
};
} );
} = useSelect(
( select ) => {
const store = select( storeKey );
const rates = isEditor
? previewCart.shipping_rates
: store.getShippingRates();
return {
shippingRates: rates,
needsShipping: isEditor
? previewCart.needs_shipping
: store.getNeedsShipping(),
hasCalculatedShipping: isEditor
? previewCart.has_calculated_shipping
: store.getHasCalculatedShipping(),
isLoadingRates: isEditor
? false
: store.isCustomerDataUpdating(),
isCollectable: rates.every(
( { shipping_rates: packageShippingRates } ) =>
packageShippingRates.find(
( { method_id: methodId } ) =>
hasCollectableRate( methodId )
)
),
isSelectingRate: isEditor
? false
: store.isShippingRateBeingSelected(),
};
},
[ isEditor ]
);
// set selected rates on ref so it's always current.
const selectedRates = useRef< Record< string, string > >( {} );

View File

@ -62,6 +62,7 @@
}
.wc-block-components-address-form__address_2-toggle {
display: inline-block;
background: none;
border: none;
color: inherit;

View File

@ -7,6 +7,13 @@ import { innerBlockAreas } from '@woocommerce/blocks-checkout';
import { TotalsFooterItem } from '@woocommerce/base-components/cart-checkout';
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
import { useStoreCart } from '@woocommerce/base-context/hooks';
import { __ } from '@wordpress/i18n';
import { useId, useState } from '@wordpress/element';
import { Icon } from '@wordpress/components';
import { chevronDown, chevronUp } from '@wordpress/icons';
import clsx from 'clsx';
import { FormattedMonetaryAmount } from '@woocommerce/blocks-components';
import { useContainerWidthContext } from '@woocommerce/base-context';
/**
* Internal dependencies
@ -21,9 +28,29 @@ export const Edit = ( { clientId }: { clientId: string } ): JSX.Element => {
const blockProps = useBlockProps();
const { cartTotals } = useStoreCart();
const totalsCurrency = getCurrencyFromPriceResponse( cartTotals );
const totalPrice = parseInt( cartTotals.total_price, 10 );
const allowedBlocks = getAllowedBlocks(
innerBlockAreas.CHECKOUT_ORDER_SUMMARY
);
const { isLarge } = useContainerWidthContext();
const [ isOpen, setIsOpen ] = useState( false );
const ariaControlsId = useId();
const orderSummaryProps = ! isLarge
? {
role: 'button',
onClick: () => setIsOpen( ! isOpen ),
'aria-expanded': isOpen,
'aria-controls': ariaControlsId,
tabIndex: 0,
onKeyDown: ( event: React.KeyboardEvent ) => {
if ( event.key === 'Enter' || event.key === ' ' ) {
setIsOpen( ! isOpen );
}
},
}
: {};
const defaultTemplate = [
[ 'woocommerce/checkout-order-summary-cart-items-block', {}, [] ],
[ 'woocommerce/checkout-order-summary-coupon-form-block', {}, [] ],
@ -38,17 +65,48 @@ export const Edit = ( { clientId }: { clientId: string } ): JSX.Element => {
return (
<div { ...blockProps }>
<InnerBlocks
allowedBlocks={ allowedBlocks }
template={ defaultTemplate }
/>
<div className="wc-block-components-totals-wrapper">
<TotalsFooterItem
currency={ totalsCurrency }
values={ cartTotals }
/>
<div
className="wc-block-components-checkout-order-summary__title"
{ ...orderSummaryProps }
>
<p
className="wc-block-components-checkout-order-summary__title-text"
role="heading"
>
{ __( 'Order summary', 'woocommerce' ) }
</p>
{ ! isLarge && (
<>
<FormattedMonetaryAmount
currency={ totalsCurrency }
value={ totalPrice }
/>
<Icon icon={ isOpen ? chevronUp : chevronDown } />
</>
) }
</div>
<div
className={ clsx(
'wc-block-components-checkout-order-summary__content',
{
'is-open': isOpen,
}
) }
id={ ariaControlsId }
>
<InnerBlocks
allowedBlocks={ allowedBlocks }
template={ defaultTemplate }
/>
<div className="wc-block-components-totals-wrapper">
<TotalsFooterItem
currency={ totalsCurrency }
values={ cartTotals }
/>
</div>
<OrderMetaSlotFill />
</div>
<OrderMetaSlotFill />
</div>
);
};

View File

@ -4,11 +4,17 @@
import { TotalsFooterItem } from '@woocommerce/base-components/cart-checkout';
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
import { useStoreCart } from '@woocommerce/base-context/hooks';
import { __ } from '@wordpress/i18n';
import { Icon, chevronDown, chevronUp } from '@wordpress/icons';
import { useId, useState } from '@wordpress/element';
import clsx from 'clsx';
/**
* Internal dependencies
*/
import { OrderMetaSlotFill } from './slotfills';
import { OrderMetaSlotFill, CheckoutOrderSummaryFill } from './slotfills';
import { useContainerWidthContext } from '../../../../base/context';
import { FormattedMonetaryAmount } from '../../../../../../packages/components';
import { FormStepHeading } from '../../form-step';
const FrontendBlock = ( {
children,
@ -18,19 +24,104 @@ const FrontendBlock = ( {
className?: string;
} ): JSX.Element | null => {
const { cartTotals } = useStoreCart();
const totalsCurrency = getCurrencyFromPriceResponse( cartTotals );
const { isLarge } = useContainerWidthContext();
const [ isOpen, setIsOpen ] = useState( false );
const totalsCurrency = getCurrencyFromPriceResponse( cartTotals );
const totalPrice = parseInt( cartTotals.total_price, 10 );
const ariaControlsId = useId();
const orderSummaryProps = ! isLarge
? {
role: 'button',
onClick: () => setIsOpen( ! isOpen ),
'aria-expanded': isOpen,
'aria-controls': ariaControlsId,
tabIndex: 0,
onKeyDown: ( event: React.KeyboardEvent ) => {
if ( event.key === 'Enter' || event.key === ' ' ) {
setIsOpen( ! isOpen );
}
},
}
: {};
// Render the summary once here in the block and once in the fill. The fill can be slotted once elsewhere. The fill is only
// rendered on small and mobile screens.
return (
<div className={ className }>
{ children }
<div className="wc-block-components-totals-wrapper">
<TotalsFooterItem
currency={ totalsCurrency }
values={ cartTotals }
/>
<>
<div className={ className }>
<div
className={ clsx(
'wc-block-components-checkout-order-summary__title',
{
'is-open': isOpen,
}
) }
{ ...orderSummaryProps }
>
<p
className="wc-block-components-checkout-order-summary__title-text"
role="heading"
>
{ __( 'Order summary', 'woocommerce' ) }
</p>
{ ! isLarge && (
<>
<FormattedMonetaryAmount
currency={ totalsCurrency }
value={ totalPrice }
/>
<Icon
className="wc-block-components-checkout-order-summary__title-icon"
icon={ isOpen ? chevronUp : chevronDown }
/>
</>
) }
</div>
<div
className={ clsx(
'wc-block-components-checkout-order-summary__content',
{
'is-open': isOpen,
}
) }
id={ ariaControlsId }
>
{ children }
<div className="wc-block-components-totals-wrapper">
<TotalsFooterItem
currency={ totalsCurrency }
values={ cartTotals }
/>
</div>
<OrderMetaSlotFill />
</div>
</div>
<OrderMetaSlotFill />
</div>
{ ! isLarge && (
<CheckoutOrderSummaryFill>
<div
className={ `${ className } checkout-order-summary-block-fill-wrapper` }
>
<FormStepHeading>
<>{ __( 'Order summary', 'woocommerce' ) }</>
</FormStepHeading>
<div className="checkout-order-summary-block-fill">
{ children }
<div className="wc-block-components-totals-wrapper">
<TotalsFooterItem
currency={ totalsCurrency }
values={ cartTotals }
/>
</div>
<OrderMetaSlotFill />
</div>
</div>
</CheckoutOrderSummaryFill>
) }
</>
);
};

View File

@ -11,6 +11,7 @@ import { registerBlockType } from '@wordpress/blocks';
import { Edit, Save } from './edit';
import attributes from './attributes';
import deprecated from './deprecated';
import './style.scss';
registerBlockType( 'woocommerce/checkout-order-summary-block', {
icon: {

View File

@ -1,7 +1,10 @@
/**
* External dependencies
*/
import { ExperimentalOrderMeta } from '@woocommerce/blocks-checkout';
import {
ExperimentalOrderMeta,
createSlotFill,
} from '@woocommerce/blocks-checkout';
import { useStoreCart } from '@woocommerce/base-context/hooks';
// @todo Consider deprecating OrderMetaSlotFill and DiscountSlotFill in favour of inner block areas.
@ -17,3 +20,10 @@ export const OrderMetaSlotFill = (): JSX.Element => {
return <ExperimentalOrderMeta.Slot { ...slotFillProps } />;
};
const checkoutOrderSummarySlotName = 'checkoutOrderSummaryActionArea';
export const {
Fill: CheckoutOrderSummaryFill,
Slot: CheckoutOrderSummarySlot,
} = createSlotFill( checkoutOrderSummarySlotName );

View File

@ -0,0 +1,98 @@
.wp-block-woocommerce-checkout-order-summary-block {
border: 1px solid $universal-border-light;
border-radius: 5px;
.wc-block-components-formatted-money-amount {
font-weight: 600;
}
.wc-block-components-totals-wrapper:first-of-type {
border-top: 0;
}
.wc-block-components-checkout-order-summary__title {
margin-top: $gap;
display: flex;
justify-content: space-between;
align-items: center;
.wc-block-components-checkout-order-summary__title-text {
margin: 0 0 $gap $gap;
flex-grow: 1;
font-weight: 500;
}
.wc-block-components-checkout-order-summary__title-open-close {
cursor: pointer;
}
}
.checkout-order-summary-block-fill {
border: 1px solid $universal-border-light;
border-radius: 5px;
.wc-block-components-totals-wrapper:first-of-type {
border-top: 0;
}
.wc-block-components-totals-item {
padding-left: $gap;
padding-right: $gap;
}
.wc-block-components-totals-coupon {
padding-left: $gap;
padding-right: $gap;
}
}
}
.has-dark-controls {
.wp-block-woocommerce-checkout-order-summary-block {
border: 1px solid $input-border-dark;
}
}
.is-small,
.is-medium,
.is-mobile {
.wp-block-woocommerce-checkout-order-summary-block {
margin-top: 0;
border: none;
&.checkout-order-summary-block-fill-wrapper {
padding-top: $gap-larger;
}
.wc-block-components-checkout-order-summary__title {
padding: 20px 0;
cursor: pointer;
border-top: 1px solid $universal-border-light;
border-bottom: 1px solid $universal-border-light;
&.is-open {
border-bottom: none;
}
.wc-block-components-checkout-order-summary__title-text {
margin: 0;
}
.wc-block-components-checkout-order-summary__title-icon {
fill: currentColor;
}
}
.wc-block-components-checkout-order-summary__content {
display: none;
&.is-open {
.wc-block-components-totals-wrapper:first-child {
border-top: none;
}
display: block;
}
}
}
}

View File

@ -14,6 +14,7 @@ import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
* Internal dependencies
*/
import { termsConsentDefaultText, termsCheckboxDefaultText } from './constants';
import { CheckoutOrderSummarySlot } from '../checkout-order-summary-block/slotfills';
const FrontendBlock = ( {
text,
@ -73,41 +74,47 @@ const FrontendBlock = ( {
] );
return (
<div
className={ clsx(
'wc-block-checkout__terms',
{
'wc-block-checkout__terms--disabled': isDisabled,
'wc-block-checkout__terms--with-separator':
showSeparator !== 'false' && showSeparator !== false,
},
className
) }
>
{ checkbox ? (
<>
<CheckboxControl
id="terms-and-conditions"
checked={ checked }
onChange={ () => setChecked( ( value ) => ! value ) }
hasError={ hasError }
disabled={ isDisabled }
>
<span
dangerouslySetInnerHTML={ {
__html: text || termsCheckboxDefaultText,
} }
/>
</CheckboxControl>
</>
) : (
<span
dangerouslySetInnerHTML={ {
__html: text || termsConsentDefaultText,
} }
/>
) }
</div>
<>
<CheckoutOrderSummarySlot />
<div
className={ clsx(
'wc-block-checkout__terms',
{
'wc-block-checkout__terms--disabled': isDisabled,
'wc-block-checkout__terms--with-separator':
showSeparator !== 'false' &&
showSeparator !== false,
},
className
) }
>
{ checkbox ? (
<>
<CheckboxControl
id="terms-and-conditions"
checked={ checked }
onChange={ () =>
setChecked( ( value ) => ! value )
}
hasError={ hasError }
disabled={ isDisabled }
>
<span
dangerouslySetInnerHTML={ {
__html: text || termsCheckboxDefaultText,
} }
/>
</CheckboxControl>
</>
) : (
<span
dangerouslySetInnerHTML={ {
__html: text || termsConsentDefaultText,
} }
/>
) }
</div>
</>
);
};

View File

@ -14,8 +14,8 @@
padding-top: $gap-largest;
border-top: 1px solid $universal-border-light;
.is-mobile &,
.is-medium &,
.is-small & {
border-top: 0;
}

View File

@ -5,6 +5,8 @@ import clsx from 'clsx';
import { Sidebar } from '@woocommerce/base-components/sidebar-layout';
import { StoreNoticesContainer } from '@woocommerce/blocks-components';
import { useObservedViewport } from '@woocommerce/base-hooks';
import { useContainerWidthContext } from '@woocommerce/base-context';
const FrontendBlock = ( {
children,
className,
@ -15,11 +17,14 @@ const FrontendBlock = ( {
const [ observedRef, observedElement, viewWindow ] =
useObservedViewport< HTMLDivElement >();
const isSticky = observedElement.height < viewWindow.height;
const { isLarge } = useContainerWidthContext();
return (
<Sidebar
ref={ observedRef }
className={ clsx( 'wc-block-checkout__sidebar', className, {
'is-sticky': isSticky,
'is-large': isLarge,
} ) }
>
<StoreNoticesContainer

View File

@ -27,23 +27,6 @@
}
}
.is-large {
.wp-block-woocommerce-checkout-order-summary-block {
border: 1px solid $universal-border-light;
border-radius: 5px;
.wc-block-components-totals-wrapper:first-of-type {
border-top: 0;
}
}
&.has-dark-controls {
.wp-block-woocommerce-checkout-order-summary-block {
border-color: $input-border-dark;
}
}
}
.wp-block-woocommerce-checkout.is-loading {
display: flex;
flex-wrap: wrap;

View File

@ -170,6 +170,7 @@
"@wordpress/data": "6.15.0",
"@wordpress/data-controls": "2.2.7",
"@wordpress/date": "4.44.0",
"@wordpress/editor": "wp-6.7",
"@wordpress/dependency-extraction-webpack-plugin": "4.28.0",
"@wordpress/dom": "3.27.0",
"@wordpress/dom-ready": "3.27.0",

View File

@ -23,11 +23,26 @@ import BlockErrorBoundary from '../components/error-boundary';
* @param {Array} fills The list of fills to check for a valid one in.
* @return {boolean} True if this slot contains any valid fills.
*/
export const hasValidFills = ( fills ) =>
export const hasValidFills = ( fills: [] ) =>
Array.isArray( fills ) && fills.filter( Boolean ).length > 0;
export { useSlot, useSlotFills };
type SlotFill = {
Fill: (
props: Partial< {
bubblesVirtually: boolean;
children: React.ReactNode;
} >
) => JSX.Element;
Slot: (
props: Partial< {
bubblesVirtually: boolean;
children: React.ReactNode;
} >
) => JSX.Element;
};
/**
* Abstracts @wordpress/components createSlotFill, wraps Fill in an error boundary and passes down fillProps.
*
@ -36,8 +51,10 @@ export { useSlot, useSlotFills };
*
* @return {Object} Returns a newly wrapped Fill and Slot.
*/
export const createSlotFill = ( slotName, onError = null ) => {
const { Fill: BaseFill, Slot: BaseSlot } = baseCreateSlotFill( slotName );
export const createSlotFill = ( slotName: string, onError = null ) => {
const { Fill: BaseFill, Slot: BaseSlot } = baseCreateSlotFill(
slotName
) as SlotFill;
/**
* A Fill that will get rendered inside associate slot.
@ -47,9 +64,9 @@ export const createSlotFill = ( slotName, onError = null ) => {
* @param {Object} props Items props.
* @param {Array} props.children Children to be rendered.
*/
const Fill = ( { children } ) => (
const Fill = ( { children }: { children: React.ReactNode } ) => (
<BaseFill>
{ ( fillProps ) =>
{ ( fillProps: unknown ) =>
Children.map( children, ( fill ) => (
<BlockErrorBoundary
/* Returning null would trigger the default error display.
@ -59,6 +76,7 @@ export const createSlotFill = ( slotName, onError = null ) => {
CURRENT_USER_IS_ADMIN ? onError : () => null
}
>
{ /* @ts-expect-error It's not clear how to accurately type `fill`. */ }
{ cloneElement( fill, fillProps ) }
</BlockErrorBoundary>
) )
@ -76,7 +94,9 @@ export const createSlotFill = ( slotName, onError = null ) => {
* @param {Element|string} props.as Element used to render the slot, defaults to div.
*
*/
const Slot = ( props ) => <BaseSlot { ...props } bubblesVirtually />;
const Slot = ( props: object ) => (
<BaseSlot { ...props } bubblesVirtually />
);
return {
Fill,

View File

@ -99,7 +99,7 @@ test.describe( 'Shopper → Translations', () => {
).toBeVisible();
await expect(
page.getByRole( 'button', {
page.getByRole( 'heading', {
name: 'Besteloverzicht',
} )
).toBeVisible();

View File

@ -1,3 +1,10 @@
const { webcrypto } = require( 'node:crypto' );
global.crypto = webcrypto;
global.TextEncoder = require( 'util' ).TextEncoder;
global.TextDecoder = require( 'util' ).TextDecoder;
// Set up `wp.*` aliases. Doing this because any tests importing wp stuff will likely run into this.
global.wp = {};
require( '@wordpress/data' );

View File

@ -0,0 +1,4 @@
Significance: minor
Type: enhancement
Checkout: Add a collapsible order summary for smaller screens.

View File

@ -786,10 +786,8 @@
"node_modules/@woocommerce/e2e-core-tests/CHANGELOG.md",
"node_modules/@woocommerce/api/dist/",
"node_modules/@woocommerce/admin-e2e-tests/build",
"node_modules/@woocommerce/classic-assets/build",
"node_modules/@woocommerce/block-library/build",
"node_modules/@woocommerce/block-library/blocks.ini",
"node_modules/@woocommerce/admin-library/build",
"package.json",
"!node_modules/@woocommerce/admin-e2e-tests/*.ts.map",
"!node_modules/@woocommerce/admin-e2e-tests/*.tsbuildinfo",

File diff suppressed because it is too large Load Diff