diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/simple.js b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/simple.js index a08fe69ee38..ce3670bcd36 100644 --- a/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/simple.js +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/simple.js @@ -18,6 +18,7 @@ const Simple = () => { quantity, minQuantity, maxQuantity, + multipleOf, dispatchActions, isDisabled, } = useAddToCartFormContext(); @@ -43,6 +44,7 @@ const Simple = () => { value={ quantity } min={ minQuantity } max={ maxQuantity } + step={ multipleOf } disabled={ isDisabled } onChange={ dispatchActions.setQuantity } /> diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/index.js b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/index.js index fb7d707e306..4cc5e9e16fd 100644 --- a/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/index.js +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/product-types/variable/index.js @@ -23,6 +23,7 @@ const Variable = () => { quantity, minQuantity, maxQuantity, + multipleOf, dispatchActions, isDisabled, } = useAddToCartFormContext(); @@ -52,6 +53,7 @@ const Variable = () => { value={ quantity } min={ minQuantity } max={ maxQuantity } + step={ multipleOf } disabled={ isDisabled } onChange={ dispatchActions.setQuantity } /> diff --git a/assets/js/atomic/blocks/product-elements/add-to-cart/shared/quantity-input.js b/assets/js/atomic/blocks/product-elements/add-to-cart/shared/quantity-input.js index 5f275ab98ac..a58a7250ada 100644 --- a/assets/js/atomic/blocks/product-elements/add-to-cart/shared/quantity-input.js +++ b/assets/js/atomic/blocks/product-elements/add-to-cart/shared/quantity-input.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import { useDebouncedCallback } from 'use-debounce'; + /** * Quantity Input Component. * @@ -5,10 +10,49 @@ * @param {boolean} props.disabled Whether input is disabled or not. * @param {number} props.min Minimum value for input. * @param {number} props.max Maximum value for input. + * @param {number} props.step Step attribute for input. * @param {number} props.value Value for input. * @param {function():any} props.onChange Function to call on input change event. */ -const QuantityInput = ( { disabled, min, max, value, onChange } ) => { +const QuantityInput = ( { disabled, min, max, step = 1, value, onChange } ) => { + const hasMaximum = typeof max !== 'undefined'; + + /** + * The goal of this function is to normalize what was inserted, + * but after the customer has stopped typing. + * + * It's important to wait before normalizing or we end up with + * a frustrating experience, for example, if the minimum is 2 and + * the customer is trying to type "10", premature normalizing would + * always kick in at "1" and turn that into 2. + * + * Copied from + */ + const normalizeQuantity = useDebouncedCallback( ( initialValue ) => { + // We copy the starting value. + let newValue = initialValue; + + // We check if we have a maximum value, and select the lowest between what was inserted and the maximum. + if ( hasMaximum ) { + newValue = Math.min( + newValue, + // the maximum possible value in step increments. + Math.floor( max / step ) * step + ); + } + + // Select the biggest between what's inserted, the the minimum value in steps. + newValue = Math.max( newValue, Math.ceil( min / step ) * step ); + + // We round off the value to our steps. + newValue = Math.floor( newValue / step ) * step; + + // Only commit if the value has changed + if ( newValue !== initialValue ) { + onChange( newValue ); + } + }, 300 ); + return ( { value={ value } min={ min } max={ max } + step={ step } hidden={ max === 1 } disabled={ disabled } onChange={ ( e ) => { onChange( e.target.value ); + normalizeQuantity( e.target.value ); } } /> ); diff --git a/assets/js/base/components/quantity-selector/index.tsx b/assets/js/base/components/quantity-selector/index.tsx index 516740236c4..1076c3c6dd4 100644 --- a/assets/js/base/components/quantity-selector/index.tsx +++ b/assets/js/base/components/quantity-selector/index.tsx @@ -4,8 +4,9 @@ import { __, sprintf } from '@wordpress/i18n'; import { speak } from '@wordpress/a11y'; import classNames from 'classnames'; -import { useCallback } from '@wordpress/element'; +import { useCallback, useLayoutEffect } from '@wordpress/element'; import { DOWN, UP } from '@wordpress/keycodes'; +import { useDebouncedCallback } from 'use-debounce'; /** * Internal dependencies @@ -31,6 +32,10 @@ export interface QuantitySelectorProps { * Maximum quantity */ maximum: number; + /** + * Input step attribute. + */ + step?: number; /** * Event handler triggered when the quantity is changed */ @@ -53,6 +58,7 @@ const QuantitySelector = ( { minimum = 1, maximum, onChange = () => void 0, + step = 1, itemName = '', disabled, }: QuantitySelectorProps ): JSX.Element => { @@ -62,8 +68,59 @@ const QuantitySelector = ( { ); const hasMaximum = typeof maximum !== 'undefined'; - const canDecrease = quantity > minimum; - const canIncrease = ! hasMaximum || quantity < maximum; + const canDecrease = quantity - step >= minimum; + const canIncrease = ! hasMaximum || quantity + step <= maximum; + + /** + * The goal of this function is to normalize what was inserted, + * but after the customer has stopped typing. + */ + const normalizeQuantity = useCallback( + ( initialValue: number ) => { + // We copy the starting value. + let value = initialValue; + + // We check if we have a maximum value, and select the lowest between what was inserted and the maximum. + if ( hasMaximum ) { + value = Math.min( + value, + // the maximum possible value in step increments. + Math.floor( maximum / step ) * step + ); + } + + // Select the biggest between what's inserted, the the minimum value in steps. + value = Math.max( value, Math.ceil( minimum / step ) * step ); + + // We round off the value to our steps. + value = Math.floor( value / step ) * step; + + // Only commit if the value has changed + if ( value !== initialValue ) { + onChange( value ); + } + }, + [ hasMaximum, maximum, minimum, onChange, step ] + ); + + /* + * It's important to wait before normalizing or we end up with + * a frustrating experience, for example, if the minimum is 2 and + * the customer is trying to type "10", premature normalizing would + * always kick in at "1" and turn that into 2. + */ + const debouncedNormalizeQuantity = useDebouncedCallback( + normalizeQuantity, + // This value is deliberately smaller than what's in useStoreCartItemQuantity so we don't end up with two requests. + 300 + ); + + /** + * Normalize qty on mount before render. + */ + useLayoutEffect( () => { + normalizeQuantity( quantity ); + }, [ quantity, normalizeQuantity ] ); /** * Handles keyboard up and down keys to change quantity value. @@ -83,15 +140,15 @@ const QuantitySelector = ( { if ( isArrowDown && canDecrease ) { event.preventDefault(); - onChange( quantity - 1 ); + onChange( quantity - step ); } if ( isArrowUp && canIncrease ) { event.preventDefault(); - onChange( quantity + 1 ); + onChange( quantity + step ); } }, - [ quantity, onChange, canIncrease, canDecrease ] + [ quantity, onChange, canIncrease, canDecrease, step ] ); return ( @@ -100,22 +157,23 @@ const QuantitySelector = ( { className="wc-block-components-quantity-selector__input" disabled={ disabled } type="number" - step="1" - min="0" + step={ step } + min={ minimum } + max={ maximum } value={ quantity } onKeyDown={ quantityInputOnKeyDown } onChange={ ( event ) => { - let value = - Number.isNaN( event.target.value ) || - ! event.target.value - ? 0 - : parseInt( event.target.value, 10 ); - if ( hasMaximum ) { - value = Math.min( value, maximum ); - } - value = Math.max( value, minimum ); + // Inputs values are strings, we parse them here. + let value = parseInt( event.target.value, 10 ); + // parseInt would throw NaN for anything not a number, + // so we revert value to the quantity value. + value = isNaN( value ) ? quantity : value; + if ( value !== quantity ) { + // we commit this value immediately. onChange( value ); + // but once the customer has stopped typing, we make sure his value is respecting the bounds (maximum value, minimum value, step value), and commit the normalized value. + debouncedNormalizeQuantity( value ); } } } aria-label={ sprintf( @@ -135,7 +193,7 @@ const QuantitySelector = ( { className="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus" disabled={ disabled || ! canDecrease } onClick={ () => { - const newQuantity = quantity - 1; + const newQuantity = quantity - step; onChange( newQuantity ); speak( sprintf( @@ -147,6 +205,7 @@ const QuantitySelector = ( { newQuantity ) ); + normalizeQuantity( newQuantity ); } } > - @@ -159,7 +218,7 @@ const QuantitySelector = ( { disabled={ disabled || ! canIncrease } className="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--plus" onClick={ () => { - const newQuantity = quantity + 1; + const newQuantity = quantity + step; onChange( newQuantity ); speak( sprintf( @@ -171,6 +230,7 @@ const QuantitySelector = ( { newQuantity ) ); + normalizeQuantity( newQuantity ); } } > + diff --git a/assets/js/base/components/quantity-selector/style.scss b/assets/js/base/components/quantity-selector/style.scss index 3a7095e9eea..a2ab45f775f 100644 --- a/assets/js/base/components/quantity-selector/style.scss +++ b/assets/js/base/components/quantity-selector/style.scss @@ -41,6 +41,7 @@ line-height: 1; vertical-align: middle; -moz-appearance: textfield; + font-weight: 600; &:focus { background: $gray-100; @@ -70,11 +71,12 @@ .wc-block-components-quantity-selector__button { @include reset-button; - @include font-size(regular); + @include font-size(regular, 0.9em); min-width: 30px; cursor: pointer; - color: $gray-900; + color: $gray-600; font-style: normal; + font-weight: normal; text-align: center; text-decoration: none; diff --git a/assets/js/base/context/providers/add-to-cart-form/form-state/constants.js b/assets/js/base/context/providers/add-to-cart-form/form-state/constants.js index 9f3633ced93..c9ebea0b330 100644 --- a/assets/js/base/context/providers/add-to-cart-form/form-state/constants.js +++ b/assets/js/base/context/providers/add-to-cart-form/form-state/constants.js @@ -13,7 +13,7 @@ export const STATUS = { export const DEFAULT_STATE = { status: STATUS.PRISTINE, hasError: false, - quantity: 1, + quantity: 0, processingResponse: null, requestParams: {}, }; diff --git a/assets/js/base/context/providers/add-to-cart-form/form-state/index.js b/assets/js/base/context/providers/add-to-cart-form/form-state/index.js index 4a409eb5f9b..b8438f0b46b 100644 --- a/assets/js/base/context/providers/add-to-cart-form/form-state/index.js +++ b/assets/js/base/context/providers/add-to-cart-form/form-state/index.js @@ -296,9 +296,11 @@ export const AddToCartFormStateContextProvider = ( { productHasOptions: product.has_options || false, supportsFormElements, showFormElements: showFormElements && supportsFormElements, - quantity: addToCartFormState.quantity, - minQuantity: 1, - maxQuantity: product.quantity_limit || 99, + quantity: + addToCartFormState.quantity || product?.add_to_cart?.minimum || 1, + minQuantity: product?.add_to_cart?.minimum || 1, + maxQuantity: product?.add_to_cart?.maximum || 99, + multipleOf: product?.add_to_cart?.multiple_of || 1, requestParams: addToCartFormState.requestParams, isIdle: addToCartFormState.status === STATUS.IDLE, isDisabled: addToCartFormState.status === STATUS.DISABLED, diff --git a/assets/js/blocks/cart-checkout/cart/cart-line-items-table/cart-line-item-row.tsx b/assets/js/blocks/cart-checkout/cart/cart-line-items-table/cart-line-item-row.tsx index 94bc2b0e05d..26894b8250a 100644 --- a/assets/js/blocks/cart-checkout/cart/cart-line-items-table/cart-line-item-row.tsx +++ b/assets/js/blocks/cart-checkout/cart/cart-line-items-table/cart-line-item-row.tsx @@ -70,7 +70,13 @@ const CartLineItemRow = forwardRef< HTMLTableRowElement, CartLineItemRowProps >( description: fullDescription = '', low_stock_remaining: lowStockRemaining = null, show_backorder_badge: showBackorderBadge = false, - quantity_limit: quantityLimit = 99, + quantity_limits: quantityLimits = { + minimum: 1, + maximum: 99, + multiple_of: 1, + editable: true, + }, + sold_individually: soldIndividually = false, permalink = '', images = [], variation = [], @@ -278,19 +284,26 @@ const CartLineItemRow = forwardRef< HTMLTableRowElement, CartLineItemRowProps >( />
- { - setItemQuantity( newQuantity ); - dispatchStoreEvent( 'cart-set-item-quantity', { - product: lineItem, - quantity: newQuantity, - } ); - } } - itemName={ name } - /> + { ! soldIndividually && !! quantityLimits.editable && ( + { + setItemQuantity( newQuantity ); + dispatchStoreEvent( + 'cart-set-item-quantity', + { + product: lineItem, + quantity: newQuantity, + } + ); + } } + itemName={ name } + /> + ) }