+
)
+}
+```
+
+- Type: `Array|Boolean`
+- Required: No
+- Platform: Web
#### onChange
@@ -193,6 +229,54 @@ The maximum value accepted. If higher values are inserted onChange will not be c
- Required: No
- Platform: Web | Mobile
+#### renderTooltipContent
+
+A way to customize the rendered UI of the value. Example:
+
+```jsx
+const customTooltipContent = value => `${value}%`
+
+const MyRangeControl() {
+ return (
)
+}
+```
+
+- Type: `Function`
+- Required: No
+- Platform: Web
+
+#### showTooltip
+
+Forcing the Tooltip UI to show or hide.
+
+- Type: `Boolean`
+- Required: No
+- Platform: Web
+
+#### step
+
+The stepping interval between `min` and `max` values. Step is used both for user interface and validation purposes.
+
+- Type: `Number`
+- Required: No
+- Platform: Web
+
+#### value
+
+The current value of the range slider.
+
+- Type: `Number`
+- Required: Yes
+- Platform: Web | Mobile
+
+#### withInputField
+
+Determines if the `input` number field will render next to the RangeControl.
+
+- Type: `Boolean`
+- Required: No
+- Platform: Web
+
#### icon
An icon to be shown above the slider next to it's container title.
diff --git a/packages/components/src/range-control/index.js b/packages/components/src/range-control/index.js
index 988a32a363f3c3..25502c117d73a4 100644
--- a/packages/components/src/range-control/index.js
+++ b/packages/components/src/range-control/index.js
@@ -1,124 +1,345 @@
/**
* External dependencies
*/
-import { isFinite } from 'lodash';
import classnames from 'classnames';
+import { clamp, noop } from 'lodash';
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
-import { compose, withInstanceId, withState } from '@wordpress/compose';
+import {
+ useCallback,
+ useRef,
+ useEffect,
+ useState,
+ forwardRef,
+} from '@wordpress/element';
+import { compose, withInstanceId } from '@wordpress/compose';
/**
* Internal dependencies
*/
-import { BaseControl, Button, Dashicon } from '../';
-
-function RangeControl( {
- className,
- currentInput,
- label,
- value,
- instanceId,
- onChange,
- beforeIcon,
- afterIcon,
- help,
- allowReset,
- initialPosition,
- min,
- max,
- setState,
- ...props
-} ) {
- const id = `inspector-range-control-${ instanceId }`;
- const currentInputValue = currentInput === null ? value : currentInput;
- const resetValue = () => {
- resetCurrentInput();
- onChange();
- };
- const resetCurrentInput = () => {
- if ( currentInput !== null ) {
- setState( {
- currentInput: null,
- } );
- }
- };
+import BaseControl from '../base-control';
+import Button from '../button';
+import Dashicon from '../dashicon';
- const onChangeValue = ( event ) => {
- const newValue = event.target.value;
- // If the input value is invalid temporarily save it to the state,
- // without calling on change.
- if ( ! event.target.checkValidity() ) {
- setState( {
- currentInput: newValue,
- } );
- return;
- }
- // The input is valid, reset the local state property used to temporaly save the value,
- // and call onChange with the new value as a number.
- resetCurrentInput();
- onChange( newValue === '' ? undefined : parseFloat( newValue ) );
- };
+import { color } from '../utils/colors';
+import RangeRail from './rail';
+import SimpleTooltip from './tooltip';
+import {
+ ActionRightWrapper,
+ AfterIconWrapper,
+ BeforeIconWrapper,
+ InputRange,
+ InputNumber,
+ Root,
+ Track,
+ ThumbWrapper,
+ Thumb,
+ Wrapper,
+} from './styles/range-control-styles';
+import { useRtl } from '../utils/rtl';
+
+const BaseRangeControl = forwardRef(
+ (
+ {
+ afterIcon,
+ allowReset = false,
+ beforeIcon,
+ className,
+ color: colorProp = color( 'blue.wordpress.700' ),
+ disabled = false,
+ help,
+ instanceId,
+ initialPosition,
+ label,
+ marks = false,
+ max = 100,
+ min = 0,
+ onBlur = noop,
+ onChange = noop,
+ onFocus = noop,
+ onMouseEnter = noop,
+ onMouseLeave = noop,
+ renderTooltipContent = ( v ) => v,
+ showTooltip: showTooltipProp,
+ step = 1,
+ value: valueProp = 0,
+ withInputField = true,
+ ...props
+ },
+ ref
+ ) => {
+ const isRTL = useRtl();
+
+ const sliderValue = initialPosition || valueProp;
+ const [ value, setValue ] = useControlledRangeValue( {
+ min,
+ max,
+ value: sliderValue,
+ } );
+ const [ showTooltip, setShowTooltip ] = useState( showTooltipProp );
+ const [ isFocused, setIsFocused ] = useState( false );
+ const originalValueRef = useRef( value );
+
+ const inputRef = useRef();
+
+ const setRef = ( nodeRef ) => {
+ inputRef.current = nodeRef;
+
+ if ( ref ) {
+ ref( nodeRef );
+ }
+ };
+
+ const isThumbFocused = ! disabled && isFocused;
+ const fillValue = ( ( value - min ) / ( max - min ) ) * 100;
+ const fillValueOffset = `${ clamp( fillValue, 0, 100 ) }%`;
+
+ const classes = classnames( 'components-range-control', className );
+
+ const wrapperClasses = classnames(
+ 'components-range-control__wrapper',
+ !! marks && 'is-marked'
+ );
+
+ const id = `inspector-range-control-${ instanceId }`;
+
+ const describedBy = !! help ? `${ id }__help` : undefined;
+ const enableTooltip = showTooltipProp !== false;
+
+ const handleOnChange = ( event ) => {
+ if ( ! event.target.checkValidity() ) {
+ return;
+ }
+
+ const nextValue = parseFloat( event.target.value );
+
+ setValue( nextValue );
+ onChange( nextValue );
+ };
+
+ const handleOnReset = () => {
+ const nextValue = originalValueRef.current;
+
+ setValue( nextValue );
+ onChange( nextValue );
+ };
+
+ const handleShowTooltip = () => setShowTooltip( true );
+ const handleHideTooltip = () => setShowTooltip( false );
+
+ const handleOnBlur = ( event ) => {
+ onBlur( event );
+ setIsFocused( false );
+ handleHideTooltip();
+ };
+
+ const handleOnFocus = ( event ) => {
+ onFocus( event );
+ setIsFocused( true );
+ handleShowTooltip();
+ };
+
+ const hoverInteractions = useDebouncedHoverInteraction( {
+ onShow: handleShowTooltip,
+ onHide: handleHideTooltip,
+ onMouseEnter,
+ onMouseLeave,
+ } );
+
+ const offsetStyle = {
+ [ isRTL ? 'right' : 'left' ]: fillValueOffset,
+ };
- const initialFallbackValue = isFinite( initialPosition )
- ? initialPosition
- : '';
-
- const initialSliderValue = isFinite( currentInputValue )
- ? currentInputValue
- : initialFallbackValue;
-
- return (
-
- { beforeIcon && }
-
- { afterIcon && }
-
- { allowReset && (
-
- ) }
-
+ { beforeIcon && (
+
+
+
+ ) }
+
+
+
+
+
+
+
+ { enableTooltip && (
+
+ ) }
+
+ { afterIcon && (
+
+
+
+ ) }
+ { withInputField && (
+
+ ) }
+ { allowReset && (
+
+
+
+ ) }
+
+
+ );
+ }
+);
+
+/**
+ * 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 default compose( [
- withInstanceId,
- withState( {
- currentInput: null,
- } ),
-] )( RangeControl );
+export const RangeControlNext = compose( withInstanceId )( BaseRangeControl );
+
+export default RangeControlNext;
diff --git a/packages/components/src/range-control/mark.js b/packages/components/src/range-control/mark.js
new file mode 100644
index 00000000000000..38794ca96793b4
--- /dev/null
+++ b/packages/components/src/range-control/mark.js
@@ -0,0 +1,49 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
+/**
+ * Internal dependencies
+ */
+import { Mark, MarkLabel } from './styles/range-control-styles';
+
+export default function RangeMark( {
+ className,
+ isFilled = false,
+ label,
+ style = {},
+ ...props
+} ) {
+ const classes = classnames(
+ 'components-range-control__mark',
+ isFilled && 'is-filled',
+ className
+ );
+ const labelClasses = classnames(
+ 'components-range-control__mark-label',
+ isFilled && 'is-filled'
+ );
+
+ return (
+ <>
+
+ { label && (
+
+ { label }
+
+ ) }
+ >
+ );
+}
diff --git a/packages/components/src/range-control/rail.js b/packages/components/src/range-control/rail.js
new file mode 100644
index 00000000000000..0eea30a0d8762b
--- /dev/null
+++ b/packages/components/src/range-control/rail.js
@@ -0,0 +1,86 @@
+/**
+ * External dependencies
+ */
+import { isUndefined } from 'lodash';
+/**
+ * Internal dependencies
+ */
+import RangeMark from './mark';
+import { MarksWrapper, Rail } from './styles/range-control-styles';
+
+export default function RangeRail( {
+ marks = false,
+ min = 0,
+ max = 100,
+ step = 1,
+ value = 0,
+ ...restProps
+} ) {
+ return (
+ <>
+
+ { marks && (
+
+ ) }
+ >
+ );
+}
+
+function Marks( { marks = false, min = 0, max = 100, step = 1, value = 0 } ) {
+ const marksData = useMarks( { marks, min, max, step, value } );
+
+ return (
+
+ { marksData.map( ( mark ) => (
+
+ ) ) }
+
+ );
+}
+
+function useMarks( { marks, min = 0, max = 100, step = 1, value = 0 } ) {
+ const isRTL = document.documentElement.dir === 'rtl';
+
+ if ( ! marks ) {
+ return [];
+ }
+
+ const isCustomMarks = Array.isArray( marks );
+
+ const markCount = ( max - min ) / step;
+ const marksArray = isCustomMarks
+ ? marks
+ : [ ...Array( markCount + 1 ) ].map( ( _, index ) => ( {
+ value: index,
+ } ) );
+
+ const enhancedMarks = marksArray.map( ( mark, index ) => {
+ const markValue = ! isUndefined( mark.value ) ? mark.value : value;
+
+ const key = `mark-${ index }`;
+ const isFilled = markValue * step <= value;
+ const offset = `${ ( markValue / markCount ) * 100 }%`;
+
+ const offsetStyle = {
+ [ isRTL ? 'right' : 'left' ]: offset,
+ };
+
+ return {
+ ...mark,
+ isFilled,
+ key,
+ style: offsetStyle,
+ };
+ } );
+
+ return enhancedMarks;
+}
diff --git a/packages/components/src/range-control/stories/index.js b/packages/components/src/range-control/stories/index.js
index 97abc17aa11f53..28170196098da4 100644
--- a/packages/components/src/range-control/stories/index.js
+++ b/packages/components/src/range-control/stories/index.js
@@ -1,17 +1,18 @@
/**
* External dependencies
*/
-import { number, text } from '@storybook/addon-knobs';
+import styled from '@emotion/styled';
+import { boolean, number, text } from '@storybook/addon-knobs';
/**
* WordPress dependencies
*/
-import { useState } from '@wordpress/element';
+import { useEffect, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
-import RangeControl from '../';
+import RangeControl from '../index';
export default { title: 'Components/RangeControl', component: RangeControl };
@@ -22,10 +23,51 @@ const RangeControlWithState = ( props ) => {
return
;
};
-export const _default = () => {
- const label = text( 'Label', 'How many columns should this use?' );
+const DefaultExample = () => {
+ const [ isRtl, setIsRtl ] = useState( false );
+
+ const rtl = boolean( 'RTL', false );
+ const props = {
+ allowReset: boolean( 'allowReset', false ),
+ label: text( 'label', 'Range Label' ),
+ help: text( 'help', '' ),
+ min: number( 'min', 0 ),
+ max: number( 'max', 10 ),
+ step: number( 'step', 1 ),
+ marks: boolean( 'marks', false ),
+ showTooltip: boolean( 'showTooltip', false ),
+ beforeIcon: text( 'beforeIcon', '' ),
+ afterIcon: text( 'afterIcon', '' ),
+ withInputField: boolean( 'withInputField', true ),
+ };
+
+ useEffect( () => {
+ if ( rtl !== isRtl ) {
+ setIsRtl( rtl );
+ }
+ }, [ rtl, isRtl ] );
+
+ useEffect( () => {
+ if ( isRtl ) {
+ document.documentElement.setAttribute( 'dir', 'rtl' );
+ } else {
+ document.documentElement.setAttribute( 'dir', 'ltr' );
+ }
+
+ return () => {
+ document.documentElement.setAttribute( 'dir', 'ltr' );
+ };
+ }, [ isRtl ] );
- return
;
+ return (
+
+
+
+ );
+};
+
+export const _default = () => {
+ return
;
};
export const InitialValueZero = () => {
@@ -79,3 +121,38 @@ export const withReset = () => {
return
;
};
+
+export const customMarks = () => {
+ const marks = [
+ {
+ value: 0,
+ label: '0',
+ },
+ {
+ value: 1,
+ label: '1',
+ },
+ {
+ value: 2,
+ label: '2',
+ },
+ {
+ value: 8,
+ label: '8',
+ },
+ {
+ value: 10,
+ label: '10',
+ },
+ ];
+
+ return (
+
+
+
+ );
+};
+
+const Wrapper = styled.div`
+ padding: 60px 40px;
+`;
diff --git a/packages/components/src/range-control/style.scss b/packages/components/src/range-control/style.scss
deleted file mode 100644
index b67e503592650e..00000000000000
--- a/packages/components/src/range-control/style.scss
+++ /dev/null
@@ -1,129 +0,0 @@
-
-.components-range-control {
- .components-base-control__field {
- display: flex;
- justify-content: center;
- flex-wrap: wrap;
- align-items: center;
- }
-
- .dashicon {
- flex-shrink: 0;
- margin-right: 10px;
- }
-
- .components-base-control__label {
- width: 100%;
- }
-
- .components-range-control__slider {
- margin-left: 0;
- flex: 1;
- }
-}
-
-.components-range-control__reset {
- margin-left: $grid-size;
-}
-
-// creating mixin because we can't do multiline variables, and we can't comma-group the selectors for styling the range slider
-@mixin range-thumb() {
- height: 18px;
- width: 18px;
- border-radius: 50%;
- cursor: pointer;
- background: $dark-gray-500;
- border: 4px solid transparent;
- background-clip: padding-box;
- box-sizing: border-box;
-}
-
-@mixin range-track() {
- height: 3px;
- cursor: pointer;
- background: $light-gray-500;
- border-radius: 1.5px;
-}
-
-.components-range-control__slider {
- width: 100%;
- margin-left: $grid-size;
- padding: 0;
- -webkit-appearance: none;
- background: transparent;
-
- /**
- * Thumb
- */
-
- // webkit
- &::-webkit-slider-thumb {
- -webkit-appearance: none;
- @include range-thumb();
- margin-top: -7px; // necessary for chrome
- }
-
- // moz
- &::-moz-range-thumb {
- @include range-thumb();
- }
-
- // ie
- &::-ms-thumb {
- @include range-thumb();
- margin-top: 0; // necessary because edge inherits from chrome
- height: 14px;
- width: 14px;
- border: 2px solid transparent;
- }
-
- &:focus {
- outline: none;
- }
-
- // webkit
- &:focus::-webkit-slider-thumb {
- @include button-style__focus-active;
- }
-
- // moz
- &:focus::-moz-range-thumb {
- @include button-style__focus-active;
- }
-
- // ie
- &:focus::-ms-thumb {
- @include button-style__focus-active;
- }
-
- /**
- * Track
- */
-
- // webkit
- &::-webkit-slider-runnable-track {
- @include range-track();
- margin-top: -4px;
- }
-
- // moz
- &::-moz-range-track {
- @include range-track();
- }
-
- // ie
- &::-ms-track {
- margin-top: -4px;
- background: transparent;
- border-color: transparent;
- color: transparent;
- @include range-track();
- }
-}
-
-.components-range-control__number {
- display: inline-block;
- margin-left: $grid-size;
- font-weight: 500;
- width: 54px;
-}
diff --git a/packages/components/src/range-control/styles/range-control-styles.js b/packages/components/src/range-control/styles/range-control-styles.js
new file mode 100644
index 00000000000000..cdbf2af600b9db
--- /dev/null
+++ b/packages/components/src/range-control/styles/range-control-styles.js
@@ -0,0 +1,287 @@
+/**
+ * External dependencies
+ */
+import { css } from '@emotion/core';
+import styled from '@emotion/styled';
+
+/**
+ * Internal dependencies
+ */
+import { color, reduceMotion, rtl } from '../../utils/style-mixins';
+
+const rangeHeight = () => css( { height: 30, minHeight: 30 } );
+
+export const Root = styled.span`
+ -webkit-tap-highlight-color: transparent;
+ box-sizing: border-box;
+ cursor: pointer;
+ align-items: flex-start;
+ display: inline-flex;
+ justify-content: flex-start;
+ padding: 0;
+ position: relative;
+ touch-action: none;
+ width: 100%;
+`;
+
+const wrapperMargin = ( { marks } ) =>
+ css( { marginBottom: marks ? 16 : null } );
+
+export const Wrapper = styled.span`
+ box-sizing: border-box;
+ color: ${color( 'blue.medium.focus' )};
+ display: block;
+ padding-top: 15px;
+ position: relative;
+ width: 100%;
+
+ ${rangeHeight};
+ ${wrapperMargin};
+
+ ${rtl( { marginLeft: 10 } )}
+`;
+
+export const BeforeIconWrapper = styled.span`
+ margin-top: 3px;
+
+ ${rtl( { marginRight: 6 } )}
+`;
+
+export const AfterIconWrapper = styled.span`
+ margin-top: 3px;
+
+ ${rtl( { marginLeft: 16 } )}
+`;
+
+export const Rail = styled.span`
+ background-color: ${color( 'lightGray.600' )};
+ box-sizing: border-box;
+ left: 0;
+ pointer-events: none;
+ right: 0;
+ display: block;
+ height: 3px;
+ position: absolute;
+ margin-top: 14px;
+ top: 0;
+`;
+
+export const Track = styled.span`
+ background-color: currentColor;
+ border-radius: 1px;
+ box-sizing: border-box;
+ height: 3px;
+ pointer-events: none;
+ display: block;
+ position: absolute;
+ margin-top: 14px;
+ top: 0;
+`;
+
+export const MarksWrapper = styled.span`
+ box-sizing: border-box;
+ display: block;
+ position: relative;
+ width: 100%;
+ user-select: none;
+`;
+
+const markFill = ( { isFilled } ) => {
+ return css( {
+ backgroundColor: isFilled ? 'currentColor' : color( 'lightGray.600' ),
+ } );
+};
+
+export const Mark = styled.span`
+ box-sizing: border-box;
+ height: 9px;
+ left: 0;
+ position: absolute;
+ top: -4px;
+ width: 1px;
+
+ ${markFill};
+`;
+
+const markLabelFill = ( { isFilled } ) => {
+ return css( {
+ color: isFilled ? color( 'darkGray.300' ) : color( 'lightGray.600' ),
+ } );
+};
+
+export const MarkLabel = styled.span`
+ box-sizing: border-box;
+ color: ${color( 'lightGray.600' )};
+ left: 0;
+ font-size: 11px;
+ position: absolute;
+ top: 12px;
+ transform: translateX( -50% );
+ white-space: nowrap;
+
+ ${markLabelFill};
+`;
+
+export const ThumbWrapper = styled.span`
+ align-items: center;
+ box-sizing: border-box;
+ display: flex;
+ height: 20px;
+ justify-content: center;
+ margin-top: 5px;
+ outline: 0;
+ pointer-events: none;
+ position: absolute;
+ top: 0;
+ user-select: none;
+ width: 20px;
+
+ ${rtl( { marginLeft: -10 } )}
+`;
+
+const thumbFocus = ( { isFocused } ) => {
+ return css( {
+ borderColor: isFocused
+ ? color( 'blue.medium.focus' )
+ : color( 'darkGray.200' ),
+ boxShadow: isFocused
+ ? `
+ 0 0 0 1px ${ color( 'blue.medium.focus' ) }
+ `
+ : `
+ 0 0 0 rgba(0, 0, 0, 0)
+ `,
+ } );
+};
+
+export const Thumb = styled.span`
+ align-items: center;
+ background-color: white;
+ border-radius: 50%;
+ border: 1px solid ${color( 'darkGray.200' )};
+ box-sizing: border-box;
+ height: 100%;
+ outline: 0;
+ pointer-events: none;
+ position: absolute;
+ user-select: none;
+ width: 100%;
+
+ ${thumbFocus};
+`;
+
+export const InputRange = styled.input`
+ 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%;
+`;
+
+const tooltipShow = ( { show } ) => {
+ return css( {
+ opacity: show ? 1 : 0,
+ } );
+};
+
+const tooltipPosition = ( { position } ) => {
+ const isTop = position === 'top';
+
+ if ( isTop ) {
+ return css`
+ margin-top: -4px;
+ top: -100%;
+
+ &::after {
+ border-bottom: none;
+ border-top-style: solid;
+ bottom: -6px;
+ }
+ `;
+ }
+
+ return css`
+ margin-bottom: -4px;
+ bottom: -100%;
+
+ &::after {
+ border-bottom-style: solid;
+ border-top: none;
+ top: -6px;
+ }
+ `;
+};
+
+export const Tooltip = styled.span`
+ background: ${color( 'darkGray.800' )};
+ 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;
+ transition: opacity 120ms ease;
+ user-select: none;
+
+ &::after {
+ border: 6px solid ${color( 'darkGray.800' )};
+ 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;
+ }
+
+ ${tooltipShow};
+ ${tooltipPosition};
+ ${reduceMotion( 'transition' )};
+ ${rtl(
+ { transform: 'translateX(-50%)' },
+ { transform: 'translateX(50%)' }
+ )}
+`;
+
+export const InputNumber = styled.input`
+ box-sizing: border-box;
+ display: inline-block;
+ margin-top: 0;
+ min-width: 54px;
+ max-width: 120px;
+
+ input[type='number']& {
+ ${rangeHeight};
+ }
+
+ ${rtl( { marginLeft: 16 } )}
+`;
+
+export const ActionRightWrapper = styled.span`
+ box-sizing: border-box;
+ display: block;
+ margin-top: 0;
+
+ button,
+ button.is-small {
+ margin-left: 0;
+ ${rangeHeight};
+ }
+
+ ${rtl( { marginLeft: 8 } )}
+`;
diff --git a/packages/components/src/range-control/tooltip.js b/packages/components/src/range-control/tooltip.js
new file mode 100644
index 00000000000000..ae9a7791c9e4a3
--- /dev/null
+++ b/packages/components/src/range-control/tooltip.js
@@ -0,0 +1,82 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
+/**
+ * WordPress dependencies
+ */
+import { useCallback, useEffect, useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { Tooltip } from './styles/range-control-styles';
+
+const TOOLTIP_OFFSET_HEIGHT = 32;
+
+export default function SimpleTooltip( {
+ className,
+ inputRef,
+ position: positionProp = 'auto',
+ show = false,
+ style = {},
+ value = 0,
+ renderTooltipContent = ( v ) => v,
+ zIndex = 100,
+ ...restProps
+} ) {
+ const position = useTooltipPosition( { inputRef, position: positionProp } );
+ const classes = classnames( 'components-simple-tooltip', className );
+ const styles = {
+ ...style,
+ zIndex,
+ };
+
+ return (
+
+ { renderTooltipContent( value ) }
+
+ );
+}
+
+function useTooltipPosition( { inputRef, position: positionProp } ) {
+ const [ position, setPosition ] = useState( 'top' );
+
+ const calculatePosition = useCallback( () => {
+ if ( inputRef && inputRef.current ) {
+ let nextPosition = positionProp;
+
+ if ( positionProp === 'auto' ) {
+ const { top } = inputRef.current.getBoundingClientRect();
+ const isOffscreenTop = top - TOOLTIP_OFFSET_HEIGHT < 0;
+
+ nextPosition = isOffscreenTop ? 'bottom' : 'top';
+ }
+
+ setPosition( nextPosition );
+ }
+ }, [ positionProp ] );
+
+ useEffect( () => {
+ calculatePosition();
+ }, [ calculatePosition ] );
+
+ useEffect( () => {
+ window.addEventListener( 'resize', calculatePosition );
+
+ return () => {
+ window.removeEventListener( 'resize', calculatePosition );
+ };
+ } );
+
+ return position;
+}
diff --git a/packages/components/src/style.scss b/packages/components/src/style.scss
index 6677468995c38d..66b4eeb4a7d65f 100644
--- a/packages/components/src/style.scss
+++ b/packages/components/src/style.scss
@@ -34,7 +34,6 @@
@import "./placeholder/style.scss";
@import "./popover/style.scss";
@import "./radio-control/style.scss";
-@import "./range-control/style.scss";
@import "./resizable-box/style.scss";
@import "./responsive-wrapper/style.scss";
@import "./sandbox/style.scss";
diff --git a/packages/components/src/utils/colors-values.js b/packages/components/src/utils/colors-values.js
index 8dfca09543f5e9..537a8e7810300b 100644
--- a/packages/components/src/utils/colors-values.js
+++ b/packages/components/src/utils/colors-values.js
@@ -103,7 +103,7 @@ export const ALERT = {
export const COLORS = {
...BASE,
- darkGrey: DARK_GRAY,
+ darkGray: DARK_GRAY,
darkOpacity: DARK_OPACITY,
darkOpacityLight: DARK_OPACITY_LIGHT,
lightGray: LIGHT_GRAY,
diff --git a/packages/components/src/utils/reduce-motion.js b/packages/components/src/utils/reduce-motion.js
new file mode 100644
index 00000000000000..b3651d494c66eb
--- /dev/null
+++ b/packages/components/src/utils/reduce-motion.js
@@ -0,0 +1,31 @@
+/**
+ * Allows users to opt-out of animations via OS-level preferences.
+ *
+ * @param {string} prop CSS Property name
+ * @return {string}
+ */
+export function reduceMotion( prop = 'transition' ) {
+ let style;
+
+ switch ( prop ) {
+ case 'transition':
+ style = 'transition-duration: 0ms;';
+ break;
+
+ case 'animation':
+ style = 'animation-duration: 1ms;';
+ break;
+
+ default:
+ style = `
+ animation-duration: 1ms;
+ transition-duration: 0ms;
+ `;
+ }
+
+ return `
+ @media ( prefers-reduced-motion: reduce ) {
+ ${ style };
+ }
+ `;
+}
diff --git a/packages/components/src/utils/rtl.js b/packages/components/src/utils/rtl.js
new file mode 100644
index 00000000000000..3fa6c3757737a9
--- /dev/null
+++ b/packages/components/src/utils/rtl.js
@@ -0,0 +1,58 @@
+/**
+ * External dependencies
+ */
+import { css } from '@emotion/core';
+
+function getRtl() {
+ return !! ( document && document.documentElement.dir === 'rtl' );
+}
+
+/**
+ * Simple hook to retrieve RTL direction value
+ */
+export function useRtl() {
+ return getRtl();
+}
+
+/**
+ * An incredibly basic ltr -> rtl converter for style properties
+ *
+ * @param {Object} ltrStyles
+ * @return {Object} Converted ltr -> rtl styles
+ */
+const convertLtrToRtl = ( ltrStyles = {} ) => {
+ const nextStyles = {};
+
+ for ( const key in ltrStyles ) {
+ const value = ltrStyles[ key ];
+ let nextKey = key;
+ if ( /left/gi.test( key ) ) {
+ nextKey = [ key.replace( 'left', 'right' ) ];
+ }
+ if ( /Left/gi.test( key ) ) {
+ nextKey = [ key.replace( 'Left', 'Right' ) ];
+ }
+ nextStyles[ nextKey ] = value;
+ }
+
+ return nextStyles;
+};
+
+/**
+ * An incredibly basic ltr -> rtl style converter for CSS objects.
+ *
+ * @param {Object} ltrStyles Ltr styles. Converts and renders from ltr -> rtl styles, if applicable.
+ * @param {null|Object} rtlStyles Rtl styles. Renders if provided.
+ * @return {Object} Rendered CSS styles for Emotion's renderer
+ */
+export function rtl( ltrStyles = {}, rtlStyles ) {
+ return () => {
+ const isRtl = getRtl();
+
+ if ( rtlStyles ) {
+ return isRtl ? css( rtlStyles ) : css( ltrStyles );
+ }
+
+ return isRtl ? css( convertLtrToRtl( ltrStyles ) ) : css( ltrStyles );
+ };
+}
diff --git a/packages/components/src/utils/style-mixins.js b/packages/components/src/utils/style-mixins.js
new file mode 100644
index 00000000000000..26344df68a9d95
--- /dev/null
+++ b/packages/components/src/utils/style-mixins.js
@@ -0,0 +1,3 @@
+export { color, rgba } from './colors';
+export { reduceMotion } from './reduce-motion';
+export { rtl } from './rtl';
diff --git a/packages/e2e-tests/specs/editor/various/font-size-picker.test.js b/packages/e2e-tests/specs/editor/various/font-size-picker.test.js
index 6574eecbbae2b8..a7125e39f920a8 100644
--- a/packages/e2e-tests/specs/editor/various/font-size-picker.test.js
+++ b/packages/e2e-tests/specs/editor/various/font-size-picker.test.js
@@ -36,7 +36,7 @@ describe( 'Font Size Picker', () => {
await page.keyboard.type( 'Paragraph to be made "small"' );
await page.click(
- '.components-font-size-picker__controls .components-range-control__number'
+ '.components-font-size-picker__controls .components-font-size-picker__number'
);
// This should be the "small" font-size of the editor defaults.
await page.keyboard.type( '13' );
@@ -52,7 +52,7 @@ describe( 'Font Size Picker', () => {
await page.keyboard.type( 'Paragraph to be made "small"' );
await page.click(
- '.components-font-size-picker__controls .components-range-control__number'
+ '.components-font-size-picker__controls .components-font-size-picker__number'
);
await page.keyboard.type( '23' );
@@ -99,7 +99,7 @@ describe( 'Font Size Picker', () => {
// Clear the custom font size input.
await page.click(
- '.components-font-size-picker__controls .components-range-control__number'
+ '.components-font-size-picker__controls .components-font-size-picker__number'
);
await pressKeyTimes( 'ArrowRight', 5 );
await pressKeyTimes( 'Backspace', 5 );
@@ -115,14 +115,14 @@ describe( 'Font Size Picker', () => {
await page.keyboard.type( 'Paragraph to be made "small"' );
await page.click(
- '.components-font-size-picker__controls .components-range-control__number'
+ '.components-font-size-picker__controls .components-font-size-picker__number'
);
await page.keyboard.type( '23' );
await page.keyboard.press( 'Backspace' );
await page.click(
- '.components-font-size-picker__controls .components-range-control__number'
+ '.components-font-size-picker__controls .components-font-size-picker__number'
);
await page.keyboard.press( 'Backspace' );
await page.keyboard.press( 'Backspace' );
diff --git a/storybook/test/__snapshots__/index.js.snap b/storybook/test/__snapshots__/index.js.snap
index 9f9f93f7355eba..1060df854408b6 100644
--- a/storybook/test/__snapshots__/index.js.snap
+++ b/storybook/test/__snapshots__/index.js.snap
@@ -3101,17 +3101,17 @@ exports[`Storyshots Components/FontSizePicker Default 1`] = `
/>