/** * External dependencies */ import { __, sprintf } from '@wordpress/i18n'; import { speak } from '@wordpress/a11y'; import clsx from 'clsx'; import { useCallback, useLayoutEffect, useRef } from '@wordpress/element'; import { DOWN, UP } from '@wordpress/keycodes'; import { useDebouncedCallback } from 'use-debounce'; /** * Internal dependencies */ import './style.scss'; export interface QuantitySelectorProps { /** * Component wrapper classname * * @default 'wc-block-components-quantity-selector' */ className?: string; /** * Current quantity */ quantity?: number; /** * Minimum quantity */ minimum?: number; /** * Maximum quantity */ maximum: number; /** * Input step attribute. */ step?: number; /** * Event handler triggered when the quantity is changed */ onChange: ( newQuantity: number ) => void; /** * Name of the item the quantity selector refers to * * Used for a11y purposes */ itemName?: string; /** * Whether the component should be interactable or not */ disabled: boolean; } const QuantitySelector = ( { className, quantity = 1, minimum = 1, maximum, onChange = () => void 0, step = 1, itemName = '', disabled, }: QuantitySelectorProps ): JSX.Element => { const classes = clsx( 'wc-block-components-quantity-selector', className ); const inputRef = useRef< HTMLInputElement | null >( null ); const decreaseButtonRef = useRef< HTMLButtonElement | null >( null ); const increaseButtonRef = useRef< HTMLButtonElement | null >( null ); const hasMaximum = typeof maximum !== 'undefined'; const canDecrease = ! disabled && quantity - step >= minimum; const canIncrease = ! disabled && ( ! 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 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. * * @param {Object} event event data. */ const quantityInputOnKeyDown = useCallback( ( event ) => { const isArrowDown = typeof event.key !== undefined ? event.key === 'ArrowDown' : event.keyCode === DOWN; const isArrowUp = typeof event.key !== undefined ? event.key === 'ArrowUp' : event.keyCode === UP; if ( isArrowDown && canDecrease ) { event.preventDefault(); onChange( quantity - step ); } if ( isArrowUp && canIncrease ) { event.preventDefault(); onChange( quantity + step ); } }, [ quantity, onChange, canIncrease, canDecrease, step ] ); return (