From c898977708f89599ce6cd98b7e416477eab24378 Mon Sep 17 00:00:00 2001 From: Jon Q Date: Thu, 13 Feb 2020 12:44:26 -0500 Subject: [PATCH 1/7] RangeControl: Improve initial hover interaction with Tooltip This update improves the initial hover/mouseenter experience with the RangeControl's Tooltip. This is achieved be debouncing the interaction. By debouncing, the Tooltip rendering is less jarring when there are multiple `RangeControl` components next to each other. A Storybook story was added to test and simulate this experience. Lastly, the utility functions/hooks from `RangeControl` was abstracted to a dedicated `utils.js` file under the component directory. --- .../components/src/range-control/index.js | 88 +-- .../src/range-control/stories/index.js | 11 + .../components/src/range-control/utils.js | 96 ++++ storybook/test/__snapshots__/index.js.snap | 543 ++++++++++++++++++ 4 files changed, 653 insertions(+), 85 deletions(-) create mode 100644 packages/components/src/range-control/utils.js diff --git a/packages/components/src/range-control/index.js b/packages/components/src/range-control/index.js index 25502c117d73a4..e822af8a009750 100644 --- a/packages/components/src/range-control/index.js +++ b/packages/components/src/range-control/index.js @@ -8,13 +8,7 @@ import { clamp, noop } from 'lodash'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { - useCallback, - useRef, - useEffect, - useState, - forwardRef, -} from '@wordpress/element'; +import { useRef, useState, forwardRef } from '@wordpress/element'; import { compose, withInstanceId } from '@wordpress/compose'; /** @@ -25,6 +19,8 @@ import Button from '../button'; import Dashicon from '../dashicon'; import { color } from '../utils/colors'; + +import { useControlledRangeValue, useDebouncedHoverInteraction } from './utils'; import RangeRail from './rail'; import SimpleTooltip from './tooltip'; import { @@ -262,84 +258,6 @@ const BaseRangeControl = forwardRef( } ); -/** - * A float supported clamp function for a specific value. - * - * @param {number} value The value to clamp - * @param {number} min The minimum value - * @param {number} max The maxinum value - * @return {number} A (float) number - */ -function floatClamp( value, min, max ) { - return parseFloat( clamp( value, min, max ) ); -} - -/** - * Hook to store a clamped value, derived from props. - */ -function useControlledRangeValue( { min, max, value: valueProp = 0 } ) { - const [ value, _setValue ] = useState( floatClamp( valueProp, min, max ) ); - const valueRef = useRef( value ); - - const setValue = useCallback( - ( nextValue ) => { - _setValue( floatClamp( nextValue, min, max ) ); - }, - [ _setValue, min, max ] - ); - - useEffect( () => { - if ( valueRef.current !== valueProp ) { - setValue( valueProp ); - valueRef.current = valueProp; - } - }, [ valueRef, valueProp, setValue ] ); - - return [ value, setValue ]; -} - -/** - * Hook to encapsulate the debouncing "hover" to better handle the showing - * and hiding of the Tooltip. - */ -function useDebouncedHoverInteraction( { - onShow = noop, - onHide = noop, - onMouseEnter = noop, - onMouseLeave = noop, - timeout = 250, -} ) { - const [ show, setShow ] = useState( false ); - const timeoutRef = useRef(); - - const handleOnMouseEnter = useCallback( ( event ) => { - onMouseEnter( event ); - - if ( timeoutRef.current ) { - window.clearTimeout( timeoutRef.current ); - } - - if ( ! show ) { - setShow( true ); - onShow(); - } - }, [] ); - - const handleOnMouseLeave = useCallback( ( event ) => { - onMouseLeave( event ); - - timeoutRef.current = setTimeout( () => { - setShow( false ); - onHide(); - }, timeout ); - }, [] ); - - return { - onMouseEnter: handleOnMouseEnter, - onMouseLeave: handleOnMouseLeave, - }; -} - export const RangeControlNext = compose( withInstanceId )( BaseRangeControl ); export default RangeControlNext; diff --git a/packages/components/src/range-control/stories/index.js b/packages/components/src/range-control/stories/index.js index 28170196098da4..44c7953ece94e9 100644 --- a/packages/components/src/range-control/stories/index.js +++ b/packages/components/src/range-control/stories/index.js @@ -153,6 +153,17 @@ export const customMarks = () => { ); }; +export const multiple = () => { + return ( + + + + + + + ); +}; + const Wrapper = styled.div` padding: 60px 40px; `; diff --git a/packages/components/src/range-control/utils.js b/packages/components/src/range-control/utils.js new file mode 100644 index 00000000000000..0477777741f605 --- /dev/null +++ b/packages/components/src/range-control/utils.js @@ -0,0 +1,96 @@ +/** + * External dependencies + */ +import { clamp, noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { useCallback, useRef, useEffect, useState } from '@wordpress/element'; + +/** + * A float supported clamp function for a specific value. + * + * @param {number} value The value to clamp + * @param {number} min The minimum value + * @param {number} max The maxinum value + * @return {number} A (float) number + */ +function floatClamp( value, min, max ) { + return parseFloat( clamp( value, min, max ) ); +} + +/** + * Hook to store a clamped value, derived from props. + */ +export function useControlledRangeValue( { min, max, value: valueProp = 0 } ) { + const [ value, _setValue ] = useState( floatClamp( valueProp, min, max ) ); + const valueRef = useRef( value ); + + const setValue = useCallback( + ( nextValue ) => { + _setValue( floatClamp( nextValue, min, max ) ); + }, + [ _setValue, min, max ] + ); + + useEffect( () => { + if ( valueRef.current !== valueProp ) { + setValue( valueProp ); + valueRef.current = valueProp; + } + }, [ valueRef, valueProp, setValue ] ); + + return [ value, setValue ]; +} + +/** + * Hook to encapsulate the debouncing "hover" to better handle the showing + * and hiding of the Tooltip. + */ +export function useDebouncedHoverInteraction( { + onShow = noop, + onHide = noop, + onMouseEnter = noop, + onMouseLeave = noop, + timeout = 300, +} ) { + const [ show, setShow ] = useState( false ); + const timeoutRef = useRef(); + + const setDebouncedTimeout = useCallback( + ( callback ) => { + if ( timeoutRef.current ) { + window.clearTimeout( timeoutRef.current ); + } + + timeoutRef.current = setTimeout( callback, timeout ); + }, + [ timeout ] + ); + + const handleOnMouseEnter = useCallback( ( event ) => { + onMouseEnter( event ); + + setDebouncedTimeout( () => { + if ( ! show ) { + setShow( true ); + onShow(); + } + } ); + }, [] ); + + const handleOnMouseLeave = useCallback( ( event ) => { + onMouseLeave( event ); + + setDebouncedTimeout( () => { + setShow( false ); + onHide(); + } ); + }, [] ); + + return { + onMouseEnter: handleOnMouseEnter, + onMouseLeave: handleOnMouseLeave, + }; +} diff --git a/storybook/test/__snapshots__/index.js.snap b/storybook/test/__snapshots__/index.js.snap index 1060df854408b6..11feddb0f771ed 100644 --- a/storybook/test/__snapshots__/index.js.snap +++ b/storybook/test/__snapshots__/index.js.snap @@ -5674,6 +5674,549 @@ input[type='number'].emotion-14 { `; +exports[`Storyshots Components/RangeControl Multiple 1`] = ` +.emotion-16 { + -webkit-tap-highlight-color: transparent; + box-sizing: border-box; + cursor: pointer; + -webkit-align-items: flex-start; + -webkit-box-align: flex-start; + -ms-flex-align: flex-start; + align-items: flex-start; + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + padding: 0; + position: relative; + touch-action: none; + width: 100%; +} + +.emotion-12 { + box-sizing: border-box; + color: #007cba; + display: block; + padding-top: 15px; + position: relative; + width: 100%; + height: 30px; + min-height: 30px; + margin-left: 10px; +} + +.emotion-0 { + box-sizing: border-box; + cursor: pointer; + display: block; + height: 100%; + left: 0; + margin: 0; + opacity: 0; + outline: none; + position: absolute; + right: 0; + top: 0; + width: 100%; +} + +.emotion-2 { + background-color: #d7dade; + box-sizing: border-box; + left: 0; + pointer-events: none; + right: 0; + display: block; + height: 3px; + position: absolute; + margin-top: 14px; + top: 0; +} + +.emotion-4 { + background-color: currentColor; + border-radius: 1px; + box-sizing: border-box; + height: 3px; + pointer-events: none; + display: block; + position: absolute; + margin-top: 14px; + top: 0; +} + +.emotion-8 { + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + box-sizing: border-box; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + height: 20px; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + margin-top: 5px; + outline: 0; + pointer-events: none; + position: absolute; + top: 0; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + width: 20px; + margin-left: -10px; +} + +.emotion-6 { + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + background-color: white; + border-radius: 50%; + border: 1px solid #7e8993; + box-sizing: border-box; + height: 100%; + outline: 0; + pointer-events: none; + position: absolute; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + width: 100%; + border-color: #7e8993; + box-shadow: 0 0 0 rgba(0,0,0,0); +} + +.emotion-10 { + background: #23282d; + border-radius: 3px; + box-sizing: border-box; + color: white; + display: inline-block; + font-size: 11px; + min-width: 32px; + opacity: 0; + padding: 8px; + position: absolute; + text-align: center; + -webkit-transition: opacity 120ms ease; + transition: opacity 120ms ease; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + opacity: 0; + margin-top: -4px; + top: -100%; + -webkit-transform: translateX(-50%); + -ms-transform: translateX(-50%); + transform: translateX(-50%); +} + +.emotion-10::after { + border: 6px solid #23282d; + border-left-color: transparent; + border-right-color: transparent; + bottom: -6px; + box-sizing: border-box; + content: ''; + height: 0; + left: 50%; + line-height: 0; + margin-left: -6px; + position: absolute; + width: 0; +} + +.emotion-10::after { + border-bottom: none; + border-top-style: solid; + bottom: -6px; +} + +@media ( prefers-reduced-motion:reduce ) { + .emotion-10 { + -webkit-transition-duration: 0ms; + transition-duration: 0ms; + } +} + +.emotion-14 { + box-sizing: border-box; + display: inline-block; + margin-top: 0; + min-width: 54px; + max-width: 120px; + margin-left: 16px; +} + +input[type='number'].emotion-14 { + height: 30px; + min-height: 30px; +} + +.emotion-72 { + padding: 60px 40px; +} + +
+
+
+ + + + + + + + + + 5 + + + + +
+
+
+
+ + + + + + + + + + 5 + + + + +
+
+
+
+ + + + + + + + + + 5 + + + + +
+
+
+
+ + + + + + + + + + 5 + + + + +
+
+
+`; + exports[`Storyshots Components/RangeControl With Help 1`] = ` .emotion-16 { -webkit-tap-highlight-color: transparent; From 345464693252e02cc9925421eafa112d6a3d0f13 Mon Sep 17 00:00:00 2001 From: Jon Q Date: Fri, 14 Feb 2020 10:16:54 -0500 Subject: [PATCH 2/7] Simplifies setValue to be a plain function vs using useCallback --- packages/components/src/range-control/utils.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/components/src/range-control/utils.js b/packages/components/src/range-control/utils.js index 0477777741f605..52c5533b63d98e 100644 --- a/packages/components/src/range-control/utils.js +++ b/packages/components/src/range-control/utils.js @@ -27,12 +27,9 @@ export function useControlledRangeValue( { min, max, value: valueProp = 0 } ) { const [ value, _setValue ] = useState( floatClamp( valueProp, min, max ) ); const valueRef = useRef( value ); - const setValue = useCallback( - ( nextValue ) => { - _setValue( floatClamp( nextValue, min, max ) ); - }, - [ _setValue, min, max ] - ); + const setValue = ( nextValue ) => { + _setValue( floatClamp( nextValue, min, max ) ); + }; useEffect( () => { if ( valueRef.current !== valueProp ) { From 1e6528aac1bc94ba92314264cda6269bcd23f0fa Mon Sep 17 00:00:00 2001 From: Jon Q Date: Fri, 14 Feb 2020 10:18:03 -0500 Subject: [PATCH 3/7] Remove local ref from useCallback hook --- packages/components/src/range-control/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/range-control/utils.js b/packages/components/src/range-control/utils.js index 52c5533b63d98e..a35656e8911194 100644 --- a/packages/components/src/range-control/utils.js +++ b/packages/components/src/range-control/utils.js @@ -36,7 +36,7 @@ export function useControlledRangeValue( { min, max, value: valueProp = 0 } ) { setValue( valueProp ); valueRef.current = valueProp; } - }, [ valueRef, valueProp, setValue ] ); + }, [ valueProp, setValue ] ); return [ value, setValue ]; } From ad29531e850596f9c189f8c6c84fdd52b91a0ae3 Mon Sep 17 00:00:00 2001 From: Jon Q Date: Fri, 14 Feb 2020 10:18:39 -0500 Subject: [PATCH 4/7] Remove guard for clearTimeout --- packages/components/src/range-control/utils.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/components/src/range-control/utils.js b/packages/components/src/range-control/utils.js index a35656e8911194..40788c89404394 100644 --- a/packages/components/src/range-control/utils.js +++ b/packages/components/src/range-control/utils.js @@ -57,9 +57,7 @@ export function useDebouncedHoverInteraction( { const setDebouncedTimeout = useCallback( ( callback ) => { - if ( timeoutRef.current ) { - window.clearTimeout( timeoutRef.current ); - } + window.clearTimeout( timeoutRef.current ); timeoutRef.current = setTimeout( callback, timeout ); }, From 733c5d4188ee0ac7cf3f6c4f640b49f317a572f7 Mon Sep 17 00:00:00 2001 From: Jon Q Date: Fri, 14 Feb 2020 10:30:00 -0500 Subject: [PATCH 5/7] Add clearTimeout callback on unmount --- packages/components/src/range-control/utils.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/components/src/range-control/utils.js b/packages/components/src/range-control/utils.js index 40788c89404394..93c60c490572c9 100644 --- a/packages/components/src/range-control/utils.js +++ b/packages/components/src/range-control/utils.js @@ -84,6 +84,12 @@ export function useDebouncedHoverInteraction( { } ); }, [] ); + useEffect( () => { + return () => { + window.clearTimeout( timeoutRef.current ); + }; + } ); + return { onMouseEnter: handleOnMouseEnter, onMouseLeave: handleOnMouseLeave, From c8e6cf69009d5633e553e9eec7c320d3dd8da86b Mon Sep 17 00:00:00 2001 From: Jon Q Date: Fri, 14 Feb 2020 10:31:12 -0500 Subject: [PATCH 6/7] Improve Tooltip rendering for current RangeControl changes... ...with other RangeControl hover interactions. --- packages/components/src/range-control/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/components/src/range-control/index.js b/packages/components/src/range-control/index.js index e822af8a009750..597e1e5eb5f9a6 100644 --- a/packages/components/src/range-control/index.js +++ b/packages/components/src/range-control/index.js @@ -89,7 +89,9 @@ const BaseRangeControl = forwardRef( } }; + const isCurrentlyFocused = inputRef.current?.matches( ':focus' ); const isThumbFocused = ! disabled && isFocused; + const fillValue = ( ( value - min ) / ( max - min ) ) * 100; const fillValueOffset = `${ clamp( fillValue, 0, 100 ) }%`; @@ -215,7 +217,7 @@ const BaseRangeControl = forwardRef( className="components-range-control__tooltip" inputRef={ inputRef } renderTooltipContent={ renderTooltipContent } - show={ showTooltip || showTooltip } + show={ isCurrentlyFocused || showTooltip } style={ offsetStyle } value={ value } /> From ddc350771cc38e838f115284f5113614c3622c5f Mon Sep 17 00:00:00 2001 From: Jon Q Date: Thu, 20 Feb 2020 10:51:13 -0500 Subject: [PATCH 7/7] Removed whitespace + adjusted setState naming --- packages/components/src/range-control/index.js | 2 -- packages/components/src/range-control/utils.js | 13 +++++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/components/src/range-control/index.js b/packages/components/src/range-control/index.js index 1506392b606f2d..365eb921e85efa 100644 --- a/packages/components/src/range-control/index.js +++ b/packages/components/src/range-control/index.js @@ -17,9 +17,7 @@ import { compose, withInstanceId } from '@wordpress/compose'; import BaseControl from '../base-control'; import Button from '../button'; import Icon from '../icon'; - import { color } from '../utils/colors'; - import { useControlledRangeValue, useDebouncedHoverInteraction } from './utils'; import RangeRail from './rail'; import SimpleTooltip from './tooltip'; diff --git a/packages/components/src/range-control/utils.js b/packages/components/src/range-control/utils.js index 93c60c490572c9..4c39afebe99109 100644 --- a/packages/components/src/range-control/utils.js +++ b/packages/components/src/range-control/utils.js @@ -14,6 +14,7 @@ import { useCallback, useRef, useEffect, useState } from '@wordpress/element'; * @param {number} value The value to clamp * @param {number} min The minimum value * @param {number} max The maxinum value + * * @return {number} A (float) number */ function floatClamp( value, min, max ) { @@ -24,21 +25,21 @@ function floatClamp( value, min, max ) { * Hook to store a clamped value, derived from props. */ export function useControlledRangeValue( { min, max, value: valueProp = 0 } ) { - const [ value, _setValue ] = useState( floatClamp( valueProp, min, max ) ); + const [ value, setValue ] = useState( floatClamp( valueProp, min, max ) ); const valueRef = useRef( value ); - const setValue = ( nextValue ) => { - _setValue( floatClamp( nextValue, min, max ) ); + const setClampValue = ( nextValue ) => { + setValue( floatClamp( nextValue, min, max ) ); }; useEffect( () => { if ( valueRef.current !== valueProp ) { - setValue( valueProp ); + setClampValue( valueProp ); valueRef.current = valueProp; } - }, [ valueProp, setValue ] ); + }, [ valueProp, setClampValue ] ); - return [ value, setValue ]; + return [ value, setClampValue ]; } /**