/** * External dependencies */ import { __ } from '@wordpress/i18n'; import { useState, useEffect, useCallback, useMemo, useRef, } from '@wordpress/element'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount'; import { isObject } from '@woocommerce/base-utils'; /** * Internal dependencies */ import './style.scss'; import { constrainRangeSliderValues } from './constrain-range-slider-values'; import FilterSubmitButton from '../filter-submit-button'; /** * Price slider component. * * @param {Object} props Component props. * @param {number} props.minPrice Minimum price for slider. * @param {number} props.maxPrice Maximum price for slider. * @param {number} props.minConstraint Minimum constraint. * @param {number} props.maxConstraint Maximum constraint. * @param {function(any):any} props.onChange Function to call on the change event. * @param {number} props.step What step values the slider uses. * @param {Object} props.currency Currency configuration object. * @param {boolean} props.showInputFields Whether to show input fields for the values or not. * @param {boolean} props.showFilterButton Whether to show the filter button for the slider. * @param {boolean} props.isLoading Whether values are loading or not. * @param {function():any} props.onSubmit Function to call when submit event fires. */ const PriceSlider = ( { minPrice, maxPrice, minConstraint, maxConstraint, onChange = () => {}, step, currency, showInputFields = true, showFilterButton = false, isLoading = false, onSubmit = () => {}, } ) => { const minRange = useRef(); const maxRange = useRef(); // We want step to default to 10 major units, e.g. $10. const stepValue = step ? step : 10 * 10 ** currency.minorUnit; const [ minPriceInput, setMinPriceInput ] = useState( minPrice ); const [ maxPriceInput, setMaxPriceInput ] = useState( maxPrice ); useEffect( () => { setMinPriceInput( minPrice ); }, [ minPrice ] ); useEffect( () => { setMaxPriceInput( maxPrice ); }, [ maxPrice ] ); /** * Checks if the min and max constraints are valid. */ const hasValidConstraints = useMemo( () => { return isFinite( minConstraint ) && isFinite( maxConstraint ); }, [ minConstraint, maxConstraint ] ); /** * Handles styles for the shaded area of the range slider. */ const progressStyles = useMemo( () => { if ( ! isFinite( minPrice ) || ! isFinite( maxPrice ) || ! hasValidConstraints ) { return { '--low': '0%', '--high': '100%', }; } const low = Math.round( 100 * ( ( minPrice - minConstraint ) / ( maxConstraint - minConstraint ) ) ) - 0.5; const high = Math.round( 100 * ( ( maxPrice - minConstraint ) / ( maxConstraint - minConstraint ) ) ) + 0.5; return { '--low': low + '%', '--high': high + '%', }; }, [ minPrice, maxPrice, minConstraint, maxConstraint, hasValidConstraints, ] ); /** * Works around an IE issue where only one range selector is visible by changing the display order * based on the mouse position. * * @param {Object} event event data. */ const findClosestRange = useCallback( ( event ) => { if ( isLoading || ! hasValidConstraints ) { return; } const bounds = event.target.getBoundingClientRect(); const x = event.clientX - bounds.left; const minWidth = minRange.current.offsetWidth; const minValue = minRange.current.value; const maxWidth = maxRange.current.offsetWidth; const maxValue = maxRange.current.value; const minX = minWidth * ( minValue / maxConstraint ); const maxX = maxWidth * ( maxValue / maxConstraint ); const minXDiff = Math.abs( x - minX ); const maxXDiff = Math.abs( x - maxX ); /** * The default z-index in the stylesheet as 20. 20 vs 21 is just for determining which range * slider should be at the front and has no meaning beyond */ if ( minXDiff > maxXDiff ) { minRange.current.style.zIndex = 20; maxRange.current.style.zIndex = 21; } else { minRange.current.style.zIndex = 21; maxRange.current.style.zIndex = 20; } }, [ isLoading, maxConstraint, hasValidConstraints ] ); /** * Called when the slider is dragged. * * @param {Object} event Event object. */ const rangeInputOnChange = useCallback( ( event ) => { const isMin = event.target.classList.contains( 'wc-block-price-filter__range-input--min' ); const targetValue = event.target.value; const currentValues = isMin ? [ Math.round( targetValue / stepValue ) * stepValue, maxPrice, ] : [ minPrice, Math.round( targetValue / stepValue ) * stepValue, ]; const values = constrainRangeSliderValues( currentValues, minConstraint, maxConstraint, stepValue, isMin ); onChange( [ parseInt( values[ 0 ], 10 ), parseInt( values[ 1 ], 10 ), ] ); }, [ onChange, minPrice, maxPrice, minConstraint, maxConstraint, stepValue, ] ); /** * Called when a price input loses focus - commit changes to slider. * * @param {Object} event Event object. */ const priceInputOnBlur = useCallback( ( event ) => { // Only refresh when finished editing the min and max fields. if ( event.relatedTarget && event.relatedTarget.classList && event.relatedTarget.classList.contains( 'wc-block-price-filter__amount' ) ) { return; } const isMin = event.target.classList.contains( 'wc-block-price-filter__amount--min' ); const values = constrainRangeSliderValues( [ minPriceInput, maxPriceInput ], null, null, stepValue, isMin ); onChange( [ parseInt( values[ 0 ], 10 ), parseInt( values[ 1 ], 10 ), ] ); }, [ onChange, stepValue, minPriceInput, maxPriceInput ] ); const classes = classnames( 'wc-block-price-filter', 'wc-block-components-price-slider', showInputFields && 'wc-block-price-filter--has-input-fields', showInputFields && 'wc-block-components-price-slider--has-input-fields', showFilterButton && 'wc-block-price-filter--has-filter-button', showFilterButton && 'wc-block-components-price-slider--has-filter-button', isLoading && 'is-loading', ! hasValidConstraints && 'is-disabled' ); const activeElement = isObject( minRange.current ) ? minRange.current.ownerDocument.activeElement : undefined; const minRangeStep = activeElement && activeElement === minRange.current ? stepValue : 1; const maxRangeStep = activeElement && activeElement === maxRange.current ? stepValue : 1; return (