From 0eb175fec6783eefb741089c4b5c247481041953 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Fri, 8 Jul 2022 17:39:38 +1000 Subject: [PATCH] First pass porting G2 Slider component --- packages/components/src/slider/index.ts | 2 + .../src/slider/slider/component.tsx | 32 + packages/components/src/slider/slider/hook.ts | 119 +++ .../components/src/slider/stories/index.tsx | 44 ++ packages/components/src/slider/styles.ts | 176 +++++ .../slider/test/__snapshots__/index.tsx.snap | 739 ++++++++++++++++++ packages/components/src/slider/test/index.tsx | 98 +++ packages/components/src/slider/types.ts | 26 + .../components/src/utils/config-values.js | 107 +-- packages/components/src/utils/flow.js | 55 ++ packages/components/src/utils/interpolate.ts | 105 +++ packages/components/src/utils/style-mixins.js | 1 + 12 files changed, 1458 insertions(+), 46 deletions(-) create mode 100644 packages/components/src/slider/index.ts create mode 100644 packages/components/src/slider/slider/component.tsx create mode 100644 packages/components/src/slider/slider/hook.ts create mode 100644 packages/components/src/slider/stories/index.tsx create mode 100644 packages/components/src/slider/styles.ts create mode 100644 packages/components/src/slider/test/__snapshots__/index.tsx.snap create mode 100644 packages/components/src/slider/test/index.tsx create mode 100644 packages/components/src/slider/types.ts create mode 100644 packages/components/src/utils/flow.js create mode 100644 packages/components/src/utils/interpolate.ts diff --git a/packages/components/src/slider/index.ts b/packages/components/src/slider/index.ts new file mode 100644 index 0000000000000..b850d3a85c069 --- /dev/null +++ b/packages/components/src/slider/index.ts @@ -0,0 +1,2 @@ +export { default as Slider } from './slider/component'; +export { useSlider } from './slider/hook'; diff --git a/packages/components/src/slider/slider/component.tsx b/packages/components/src/slider/slider/component.tsx new file mode 100644 index 0000000000000..a2e39ad3b52da --- /dev/null +++ b/packages/components/src/slider/slider/component.tsx @@ -0,0 +1,32 @@ +/** + * Internal dependencies + */ +import { contextConnect, WordPressComponentProps } from '../../ui/context'; +import { useSlider } from './hook'; + +import type { SliderProps } from '../types'; + +const UnconnectedSlider = ( + props: WordPressComponentProps< SliderProps, 'input', false >, + forwardedRef: React.ForwardedRef< any > +) => { + const inputProps = useSlider( props ); + return ; +}; + +/** + * `Slider` is a form component that lets users choose a value within a range. + * + * @example + * ```jsx + * import { Slider } from `@wordpress/components` + * + * function Example() { + * return ( + * + * ); + * } + * ``` + */ +export const Slider = contextConnect( UnconnectedSlider, 'Slider' ); +export default Slider; diff --git a/packages/components/src/slider/slider/hook.ts b/packages/components/src/slider/slider/hook.ts new file mode 100644 index 0000000000000..21e286cc42c8b --- /dev/null +++ b/packages/components/src/slider/slider/hook.ts @@ -0,0 +1,119 @@ +/** + * WordPress dependencies + */ +import { useCallback, useMemo, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import * as styles from '../styles'; +import { useContextSystem, WordPressComponentProps } from '../../ui/context'; +import { useControlledValue } from '../../utils/hooks'; +import { useCx } from '../../utils/hooks/use-cx'; +import { useFormGroupContextId } from '../../ui/form-group'; +import { parseCSSUnitValue, createCSSUnitValue } from '../../utils/unit-values'; +import { isValueNumeric } from '../../utils/values'; +import { interpolate } from '../../utils/interpolate'; + +import type { SliderProps } from '../types'; + +const noop = () => {}; + +export function useSlider( + props: WordPressComponentProps< SliderProps, 'input', false > +) { + const { + className, + defaultValue, + error, + onBlur = noop, + onChange: onChangeProp = noop, + onFocus = noop, + id: idProp, + isFocused: isFocusedProp = false, + max = 100, + min = 0, + size = 'medium', + style, + value: valueProp, + ...otherProps + } = useContextSystem( props, 'Slider' ); + + const [ _value, onChange ] = useControlledValue( { + defaultValue, + onChange: onChangeProp, + value: valueProp, + } ); + const [ value, initialUnit ] = parseCSSUnitValue( `${ _value }` ); + + const id = useFormGroupContextId( idProp ); + const [ isFocused, setIsFocused ] = useState( isFocusedProp ); + + const handleOnBlur = useCallback( + ( event ) => { + onBlur( event ); + setIsFocused( false ); + }, + [ onBlur ] + ); + + const handleOnChange = useCallback( + ( event ) => { + const nextValue = parseFloat( event.target.value ); + if ( ! isValueNumeric( nextValue ) ) { + return; + } + + let next = `${ nextValue }`; + + if ( initialUnit ) { + next = createCSSUnitValue( nextValue, initialUnit ); + } + + onChange( next ); + }, + [ onChange, initialUnit ] + ); + + const handleOnFocus = useCallback( + ( event ) => { + onFocus( event ); + setIsFocused( true ); + }, + [ onFocus ] + ); + + const currentValue = interpolate( + value, + [ parseFloat( `${ min }` ), parseFloat( `${ max }` ) ], + [ 0, 100 ] + ); + const componentStyles = { ...style, '--progress': `${ currentValue }%` }; + + // Generate dynamic class names. + const cx = useCx(); + const classes = useMemo( () => { + return cx( + styles.slider, + error && styles.error, + styles[ size ], + isFocused && styles.focused, + error && isFocused && styles.focusedError, + className + ); + }, [ className, cx, error, isFocused, size ] ); + + return { + ...otherProps, + className: classes, + id: id ? `${ id }` : undefined, + max, + min, + onBlur: handleOnBlur, + onChange: handleOnChange, + onFocus: handleOnFocus, + style: componentStyles, + type: 'range', + value, + }; +} diff --git a/packages/components/src/slider/stories/index.tsx b/packages/components/src/slider/stories/index.tsx new file mode 100644 index 0000000000000..eaa9774703977 --- /dev/null +++ b/packages/components/src/slider/stories/index.tsx @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { Slider } from '../'; + +const meta: ComponentMeta< typeof Slider > = { + title: 'Components (Experimental)/Slider', + component: Slider, + argTypes: { onChange: { action: 'onChange' } }, + parameters: { + controls: { expanded: true, exclude: [ 'heading' ] }, + docs: { source: { state: 'open' } }, + }, +}; +export default meta; + +const DefaultTemplate: ComponentStory< typeof Slider > = ( { + onChange, + value: valueProp, + ...args +} ) => { + const [ value, setValue ] = useState( valueProp ); + const handleChange = ( newValue ) => { + setValue( newValue ); + onChange?.( newValue ); + }; + + return ; +}; + +export const Default: ComponentStory< typeof Slider > = DefaultTemplate.bind( + {} +); +Default.args = {}; diff --git a/packages/components/src/slider/styles.ts b/packages/components/src/slider/styles.ts new file mode 100644 index 0000000000000..6e79eb33bf056 --- /dev/null +++ b/packages/components/src/slider/styles.ts @@ -0,0 +1,176 @@ +/** + * External dependencies + */ +import { css } from '@emotion/react'; + +/** + * Internal dependencies + */ +import { COLORS, CONFIG, flow } from '../utils'; +import { space } from '../ui/utils/space'; + +const boxShadow = flow( + [ + '0 0 0', + CONFIG.controlPseudoBoxShadowFocusWidth, + CONFIG.surfaceBackgroundColor, + ], + [ + '0 0 0', + `calc(${ CONFIG.controlPseudoBoxShadowFocusWidth } + 1px)`, + COLORS.admin.theme, + ] +); +const errorBoxShadow = flow( + [ + '0 0 0', + CONFIG.controlPseudoBoxShadowFocusWidth, + CONFIG.surfaceBackgroundColor, + ], + [ + '0 0 0', + `calc(${ CONFIG.controlPseudoBoxShadowFocusWidth } + 1px)`, + COLORS.alert.red, + ] +); + +function getFocusBoxShadow( color = boxShadow ) { + return css` + &::-webkit-slider-thumb { + box-shadow: ${ color }; + } + &::-moz-range-thumb { + box-shadow: ${ color }; + } + `; +} + +export const focusedError = css` + ${ getFocusBoxShadow( errorBoxShadow ) }; +`; + +export const slider = css` + appearance: none; + background-color: transparent; + border: 1px solid transparent; + border-radius: ${ CONFIG.controlBorderRadius }; + cursor: pointer; + display: block; + height: ${ CONFIG.controlHeight }; + max-width: 100%; + min-width: 0; + padding: ${ space( 1 ) }; + width: 100%; + + &:focus { + outline: none; + } + + &::-moz-focus-outer { + border: 0; + } + + &::-webkit-slider-runnable-track { + background: linear-gradient( + to right, + ${ COLORS.admin.theme } calc( var( --progress ) ), + ${ CONFIG.controlBackgroundDimColor } calc( var( --progress ) ) + ); + border-radius: 2px; + height: 2px; + + *:disabled& { + background: ${ CONFIG.controlBackgroundDimColor }; + } + } + &::-moz-range-track { + background: linear-gradient( + to right, + ${ COLORS.admin.theme } calc( var( --progress ) ), + ${ CONFIG.controlBackgroundDimColor } calc( var( --progress ) ) + ); + border-radius: 2px; + height: 2px; + will-change: transform; + + *:disabled& { + background: ${ CONFIG.controlBackgroundDimColor }; + } + } + + &::-webkit-slider-thumb { + appearance: none; + background-color: ${ CONFIG.sliderThumbBackgroundColor }; + border: 1px solid ${ CONFIG.sliderThumbBorderColor }; + border-radius: 50%; + box-shadow: ${ CONFIG.sliderThumbBoxShadow }; + cursor: pointer; + height: 12px; + margin-top: -5px; + opacity: 1; + width: 12px; + transition: box-shadow ease ${ CONFIG.transitionDurationFast }; + + *:disabled& { + background: ${ COLORS.ui.textDisabled }; + border-color: ${ COLORS.ui.textDisabled }; + } + } + &::-moz-range-thumb { + appearance: none; + background-color: ${ CONFIG.sliderThumbBackgroundColor }; + border: 1px solid ${ CONFIG.sliderThumbBorderColor }; + border-radius: 50%; + box-shadow: ${ CONFIG.sliderThumbBoxShadow }; + cursor: pointer; + height: 12px; + margin-top: -5px; + opacity: 1; + width: 12px; + transition: box-shadow ease ${ CONFIG.transitionDurationFast }; + will-change: transform; + + *:disabled& { + background: ${ COLORS.ui.textDisabled }; + border-color: ${ COLORS.ui.textDisabled }; + } + } + + &:focus { + ${ getFocusBoxShadow() } + } +`; + +export const focused = css` + ${ getFocusBoxShadow() } +`; + +export const error = css` + &::-webkit-slider-runnable-track { + background: linear-gradient( + to right, + ${ CONFIG.controlDestructiveBorderColor } calc( var( --progress ) ), + ${ CONFIG.controlBackgroundDimColor } calc( var( --progress ) ) + ); + } + &::-moz-range-track { + background: linear-gradient( + to right, + ${ CONFIG.controlDestructiveBorderColor } calc( var( --progress ) ), + ${ CONFIG.controlBackgroundDimColor } calc( var( --progress ) ) + ); + } + + &::-webkit-slider-thumb { + background-color: ${ CONFIG.controlDestructiveBorderColor }; + border: 1px solid ${ CONFIG.controlDestructiveBorderColor }; + } + &::-moz-range-thumb { + background-color: ${ CONFIG.controlDestructiveBorderColor }; + border: 1px solid ${ CONFIG.controlDestructiveBorderColor }; + } + + &:focus { + ${ getFocusBoxShadow( errorBoxShadow ) }; + } +`; diff --git a/packages/components/src/slider/test/__snapshots__/index.tsx.snap b/packages/components/src/slider/test/__snapshots__/index.tsx.snap new file mode 100644 index 0000000000000..df8cad0ecf185 --- /dev/null +++ b/packages/components/src/slider/test/__snapshots__/index.tsx.snap @@ -0,0 +1,739 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Slider should render correctly 1`] = ` +.emotion-0 { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + background-color: transparent; + border: 1px solid transparent; + border-radius: 2px; + cursor: pointer; + display: block; + height: 36px; + max-width: 100%; + min-width: 0; + padding: calc(4px * 1); + width: 100%; +} + +.emotion-0:focus { + outline: none; +} + +.emotion-0::-moz-focus-outer { + border: 0; +} + +.emotion-0::-webkit-slider-runnable-track { + background: linear-gradient( + to right, + var( --wp-admin-theme-color, #00669b) calc( var( --progress ) ), + #f3f4f5 calc( var( --progress ) ) + ); + border-radius: 2px; + height: 2px; +} + +*:disabled.emotion-0::-webkit-slider-runnable-track { + background: #f3f4f5; +} + +.emotion-0::-moz-range-track { + background: linear-gradient( + to right, + var( --wp-admin-theme-color, #00669b) calc( var( --progress ) ), + #f3f4f5 calc( var( --progress ) ) + ); + border-radius: 2px; + height: 2px; + will-change: transform; +} + +*:disabled.emotion-0::-moz-range-track { + background: #f3f4f5; +} + +.emotion-0::-webkit-slider-thumb { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + background-color: var( --wp-admin-theme-color, #00669b); + border: 1px solid transparent; + border-radius: 50%; + box-shadow: none; + cursor: pointer; + height: 12px; + margin-top: -5px; + opacity: 1; + width: 12px; + -webkit-transition: box-shadow ease 160ms; + transition: box-shadow ease 160ms; +} + +*:disabled.emotion-0::-webkit-slider-thumb { + background: #8d96a0; + border-color: #8d96a0; +} + +.emotion-0::-moz-range-thumb { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + background-color: var( --wp-admin-theme-color, #00669b); + border: 1px solid transparent; + border-radius: 50%; + box-shadow: none; + cursor: pointer; + height: 12px; + margin-top: -5px; + opacity: 1; + width: 12px; + -webkit-transition: box-shadow ease 160ms; + transition: box-shadow ease 160ms; + will-change: transform; +} + +*:disabled.emotion-0::-moz-range-thumb { + background: #8d96a0; + border-color: #8d96a0; +} + +.emotion-0:focus::-webkit-slider-thumb { + box-shadow: 0 0 0 2px #fff,0 0 0 calc(2px + 1px) var( --wp-admin-theme-color, #00669b); +} + +.emotion-0:focus::-moz-range-thumb { + box-shadow: 0 0 0 2px #fff,0 0 0 calc(2px + 1px) var( --wp-admin-theme-color, #00669b); +} + + +`; + +exports[`Slider should render max 1`] = ` +.emotion-0 { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + background-color: transparent; + border: 1px solid transparent; + border-radius: 2px; + cursor: pointer; + display: block; + height: 36px; + max-width: 100%; + min-width: 0; + padding: calc(4px * 1); + width: 100%; +} + +.emotion-0:focus { + outline: none; +} + +.emotion-0::-moz-focus-outer { + border: 0; +} + +.emotion-0::-webkit-slider-runnable-track { + background: linear-gradient( + to right, + var( --wp-admin-theme-color, #00669b) calc( var( --progress ) ), + #f3f4f5 calc( var( --progress ) ) + ); + border-radius: 2px; + height: 2px; +} + +*:disabled.emotion-0::-webkit-slider-runnable-track { + background: #f3f4f5; +} + +.emotion-0::-moz-range-track { + background: linear-gradient( + to right, + var( --wp-admin-theme-color, #00669b) calc( var( --progress ) ), + #f3f4f5 calc( var( --progress ) ) + ); + border-radius: 2px; + height: 2px; + will-change: transform; +} + +*:disabled.emotion-0::-moz-range-track { + background: #f3f4f5; +} + +.emotion-0::-webkit-slider-thumb { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + background-color: var( --wp-admin-theme-color, #00669b); + border: 1px solid transparent; + border-radius: 50%; + box-shadow: none; + cursor: pointer; + height: 12px; + margin-top: -5px; + opacity: 1; + width: 12px; + -webkit-transition: box-shadow ease 160ms; + transition: box-shadow ease 160ms; +} + +*:disabled.emotion-0::-webkit-slider-thumb { + background: #8d96a0; + border-color: #8d96a0; +} + +.emotion-0::-moz-range-thumb { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + background-color: var( --wp-admin-theme-color, #00669b); + border: 1px solid transparent; + border-radius: 50%; + box-shadow: none; + cursor: pointer; + height: 12px; + margin-top: -5px; + opacity: 1; + width: 12px; + -webkit-transition: box-shadow ease 160ms; + transition: box-shadow ease 160ms; + will-change: transform; +} + +*:disabled.emotion-0::-moz-range-thumb { + background: #8d96a0; + border-color: #8d96a0; +} + +.emotion-0:focus::-webkit-slider-thumb { + box-shadow: 0 0 0 2px #fff,0 0 0 calc(2px + 1px) var( --wp-admin-theme-color, #00669b); +} + +.emotion-0:focus::-moz-range-thumb { + box-shadow: 0 0 0 2px #fff,0 0 0 calc(2px + 1px) var( --wp-admin-theme-color, #00669b); +} + + +`; + +exports[`Slider should render min 1`] = ` +.emotion-0 { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + background-color: transparent; + border: 1px solid transparent; + border-radius: 2px; + cursor: pointer; + display: block; + height: 36px; + max-width: 100%; + min-width: 0; + padding: calc(4px * 1); + width: 100%; +} + +.emotion-0:focus { + outline: none; +} + +.emotion-0::-moz-focus-outer { + border: 0; +} + +.emotion-0::-webkit-slider-runnable-track { + background: linear-gradient( + to right, + var( --wp-admin-theme-color, #00669b) calc( var( --progress ) ), + #f3f4f5 calc( var( --progress ) ) + ); + border-radius: 2px; + height: 2px; +} + +*:disabled.emotion-0::-webkit-slider-runnable-track { + background: #f3f4f5; +} + +.emotion-0::-moz-range-track { + background: linear-gradient( + to right, + var( --wp-admin-theme-color, #00669b) calc( var( --progress ) ), + #f3f4f5 calc( var( --progress ) ) + ); + border-radius: 2px; + height: 2px; + will-change: transform; +} + +*:disabled.emotion-0::-moz-range-track { + background: #f3f4f5; +} + +.emotion-0::-webkit-slider-thumb { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + background-color: var( --wp-admin-theme-color, #00669b); + border: 1px solid transparent; + border-radius: 50%; + box-shadow: none; + cursor: pointer; + height: 12px; + margin-top: -5px; + opacity: 1; + width: 12px; + -webkit-transition: box-shadow ease 160ms; + transition: box-shadow ease 160ms; +} + +*:disabled.emotion-0::-webkit-slider-thumb { + background: #8d96a0; + border-color: #8d96a0; +} + +.emotion-0::-moz-range-thumb { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + background-color: var( --wp-admin-theme-color, #00669b); + border: 1px solid transparent; + border-radius: 50%; + box-shadow: none; + cursor: pointer; + height: 12px; + margin-top: -5px; + opacity: 1; + width: 12px; + -webkit-transition: box-shadow ease 160ms; + transition: box-shadow ease 160ms; + will-change: transform; +} + +*:disabled.emotion-0::-moz-range-thumb { + background: #8d96a0; + border-color: #8d96a0; +} + +.emotion-0:focus::-webkit-slider-thumb { + box-shadow: 0 0 0 2px #fff,0 0 0 calc(2px + 1px) var( --wp-admin-theme-color, #00669b); +} + +.emotion-0:focus::-moz-range-thumb { + box-shadow: 0 0 0 2px #fff,0 0 0 calc(2px + 1px) var( --wp-admin-theme-color, #00669b); +} + + +`; + +exports[`Slider should render size 1`] = ` +.emotion-0 { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + background-color: transparent; + border: 1px solid transparent; + border-radius: 2px; + cursor: pointer; + display: block; + height: 36px; + max-width: 100%; + min-width: 0; + padding: calc(4px * 1); + width: 100%; +} + +.emotion-0:focus { + outline: none; +} + +.emotion-0::-moz-focus-outer { + border: 0; +} + +.emotion-0::-webkit-slider-runnable-track { + background: linear-gradient( + to right, + var( --wp-admin-theme-color, #00669b) calc( var( --progress ) ), + #f3f4f5 calc( var( --progress ) ) + ); + border-radius: 2px; + height: 2px; +} + +*:disabled.emotion-0::-webkit-slider-runnable-track { + background: #f3f4f5; +} + +.emotion-0::-moz-range-track { + background: linear-gradient( + to right, + var( --wp-admin-theme-color, #00669b) calc( var( --progress ) ), + #f3f4f5 calc( var( --progress ) ) + ); + border-radius: 2px; + height: 2px; + will-change: transform; +} + +*:disabled.emotion-0::-moz-range-track { + background: #f3f4f5; +} + +.emotion-0::-webkit-slider-thumb { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + background-color: var( --wp-admin-theme-color, #00669b); + border: 1px solid transparent; + border-radius: 50%; + box-shadow: none; + cursor: pointer; + height: 12px; + margin-top: -5px; + opacity: 1; + width: 12px; + -webkit-transition: box-shadow ease 160ms; + transition: box-shadow ease 160ms; +} + +*:disabled.emotion-0::-webkit-slider-thumb { + background: #8d96a0; + border-color: #8d96a0; +} + +.emotion-0::-moz-range-thumb { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + background-color: var( --wp-admin-theme-color, #00669b); + border: 1px solid transparent; + border-radius: 50%; + box-shadow: none; + cursor: pointer; + height: 12px; + margin-top: -5px; + opacity: 1; + width: 12px; + -webkit-transition: box-shadow ease 160ms; + transition: box-shadow ease 160ms; + will-change: transform; +} + +*:disabled.emotion-0::-moz-range-thumb { + background: #8d96a0; + border-color: #8d96a0; +} + +.emotion-0:focus::-webkit-slider-thumb { + box-shadow: 0 0 0 2px #fff,0 0 0 calc(2px + 1px) var( --wp-admin-theme-color, #00669b); +} + +.emotion-0:focus::-moz-range-thumb { + box-shadow: 0 0 0 2px #fff,0 0 0 calc(2px + 1px) var( --wp-admin-theme-color, #00669b); +} + + +`; + +exports[`Slider should render unit value 1`] = ` +.emotion-0 { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + background-color: transparent; + border: 1px solid transparent; + border-radius: 2px; + cursor: pointer; + display: block; + height: 36px; + max-width: 100%; + min-width: 0; + padding: calc(4px * 1); + width: 100%; +} + +.emotion-0:focus { + outline: none; +} + +.emotion-0::-moz-focus-outer { + border: 0; +} + +.emotion-0::-webkit-slider-runnable-track { + background: linear-gradient( + to right, + var( --wp-admin-theme-color, #00669b) calc( var( --progress ) ), + #f3f4f5 calc( var( --progress ) ) + ); + border-radius: 2px; + height: 2px; +} + +*:disabled.emotion-0::-webkit-slider-runnable-track { + background: #f3f4f5; +} + +.emotion-0::-moz-range-track { + background: linear-gradient( + to right, + var( --wp-admin-theme-color, #00669b) calc( var( --progress ) ), + #f3f4f5 calc( var( --progress ) ) + ); + border-radius: 2px; + height: 2px; + will-change: transform; +} + +*:disabled.emotion-0::-moz-range-track { + background: #f3f4f5; +} + +.emotion-0::-webkit-slider-thumb { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + background-color: var( --wp-admin-theme-color, #00669b); + border: 1px solid transparent; + border-radius: 50%; + box-shadow: none; + cursor: pointer; + height: 12px; + margin-top: -5px; + opacity: 1; + width: 12px; + -webkit-transition: box-shadow ease 160ms; + transition: box-shadow ease 160ms; +} + +*:disabled.emotion-0::-webkit-slider-thumb { + background: #8d96a0; + border-color: #8d96a0; +} + +.emotion-0::-moz-range-thumb { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + background-color: var( --wp-admin-theme-color, #00669b); + border: 1px solid transparent; + border-radius: 50%; + box-shadow: none; + cursor: pointer; + height: 12px; + margin-top: -5px; + opacity: 1; + width: 12px; + -webkit-transition: box-shadow ease 160ms; + transition: box-shadow ease 160ms; + will-change: transform; +} + +*:disabled.emotion-0::-moz-range-thumb { + background: #8d96a0; + border-color: #8d96a0; +} + +.emotion-0:focus::-webkit-slider-thumb { + box-shadow: 0 0 0 2px #fff,0 0 0 calc(2px + 1px) var( --wp-admin-theme-color, #00669b); +} + +.emotion-0:focus::-moz-range-thumb { + box-shadow: 0 0 0 2px #fff,0 0 0 calc(2px + 1px) var( --wp-admin-theme-color, #00669b); +} + + +`; + +exports[`Slider should render value 1`] = ` +.emotion-0 { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + background-color: transparent; + border: 1px solid transparent; + border-radius: 2px; + cursor: pointer; + display: block; + height: 36px; + max-width: 100%; + min-width: 0; + padding: calc(4px * 1); + width: 100%; +} + +.emotion-0:focus { + outline: none; +} + +.emotion-0::-moz-focus-outer { + border: 0; +} + +.emotion-0::-webkit-slider-runnable-track { + background: linear-gradient( + to right, + var( --wp-admin-theme-color, #00669b) calc( var( --progress ) ), + #f3f4f5 calc( var( --progress ) ) + ); + border-radius: 2px; + height: 2px; +} + +*:disabled.emotion-0::-webkit-slider-runnable-track { + background: #f3f4f5; +} + +.emotion-0::-moz-range-track { + background: linear-gradient( + to right, + var( --wp-admin-theme-color, #00669b) calc( var( --progress ) ), + #f3f4f5 calc( var( --progress ) ) + ); + border-radius: 2px; + height: 2px; + will-change: transform; +} + +*:disabled.emotion-0::-moz-range-track { + background: #f3f4f5; +} + +.emotion-0::-webkit-slider-thumb { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + background-color: var( --wp-admin-theme-color, #00669b); + border: 1px solid transparent; + border-radius: 50%; + box-shadow: none; + cursor: pointer; + height: 12px; + margin-top: -5px; + opacity: 1; + width: 12px; + -webkit-transition: box-shadow ease 160ms; + transition: box-shadow ease 160ms; +} + +*:disabled.emotion-0::-webkit-slider-thumb { + background: #8d96a0; + border-color: #8d96a0; +} + +.emotion-0::-moz-range-thumb { + -webkit-appearance: none; + -moz-appearance: none; + -ms-appearance: none; + appearance: none; + background-color: var( --wp-admin-theme-color, #00669b); + border: 1px solid transparent; + border-radius: 50%; + box-shadow: none; + cursor: pointer; + height: 12px; + margin-top: -5px; + opacity: 1; + width: 12px; + -webkit-transition: box-shadow ease 160ms; + transition: box-shadow ease 160ms; + will-change: transform; +} + +*:disabled.emotion-0::-moz-range-thumb { + background: #8d96a0; + border-color: #8d96a0; +} + +.emotion-0:focus::-webkit-slider-thumb { + box-shadow: 0 0 0 2px #fff,0 0 0 calc(2px + 1px) var( --wp-admin-theme-color, #00669b); +} + +.emotion-0:focus::-moz-range-thumb { + box-shadow: 0 0 0 2px #fff,0 0 0 calc(2px + 1px) var( --wp-admin-theme-color, #00669b); +} + + +`; diff --git a/packages/components/src/slider/test/index.tsx b/packages/components/src/slider/test/index.tsx new file mode 100644 index 0000000000000..b1f001e568b13 --- /dev/null +++ b/packages/components/src/slider/test/index.tsx @@ -0,0 +1,98 @@ +/** + * External dependencies + */ +import { fireEvent, render, screen } from '@testing-library/react'; + +/** + * WordPress dependencies + */ +import React from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { Slider } from '../index'; + +const renderSlider = ( props = {} ) => { + // Disabled because of our rule restricting literal IDs, preferring + // `withInstanceId`. In this case, it's fine to use literal IDs. + // eslint-disable-next-line no-restricted-syntax + return render( ); +}; + +const rerenderSlider = ( props = {}, rerender ) => { + // Disabled because of our rule restricting literal IDs, preferring + // `withInstanceId`. In this case, it's fine to use literal IDs. + // eslint-disable-next-line no-restricted-syntax + return rerender( ); +}; + +const getSliderInput = (): HTMLInputElement => { + return screen.getByRole( 'slider' ); +}; + +describe( 'Slider', () => { + test( 'should render correctly', () => { + const { container } = renderSlider(); + expect( container.firstChild ).toMatchSnapshot(); + } ); + + test( 'should render min', () => { + const { container } = renderSlider( { min: 5 } ); + expect( container.firstChild ).toMatchSnapshot(); + } ); + + test( 'should render max', () => { + const { container } = renderSlider( { max: 50 } ); + expect( container.firstChild ).toMatchSnapshot(); + } ); + + test( 'should render size', () => { + const { container } = renderSlider( { size: 'small' } ); + expect( container.firstChild ).toMatchSnapshot(); + } ); + + test( 'should render value', () => { + const { container } = renderSlider( { value: 40 } ); + expect( container.firstChild ).toMatchSnapshot(); + } ); + + test( 'should render unit value', () => { + const { container } = renderSlider( { value: '40px' } ); + const input = getSliderInput(); + + expect( container.firstChild ).toMatchSnapshot(); + expect( input.value ).toEqual( '40' ); + } ); + + test( 'should include unit in onChange callback (if value contains unit)', () => { + let value = '40px'; + const setValue = ( next ) => ( value = next ); + + renderSlider( { onChange: setValue, value } ); + const input = getSliderInput(); + fireEvent.change( input, { target: { value: 13 } } ); + + // onChange callback value + expect( value ).toBe( '13px' ); + } ); + + test( 'should change unit in onChange callback, if incoming value unit changes', () => { + let value = '40px'; + const setValue = ( next ) => ( value = next ); + + const { rerender } = renderSlider( { onChange: setValue, value } ); + const input = getSliderInput(); + + expect( input.value ).toBe( '40' ); + + rerenderSlider( { onChange: setValue, value: '100%' }, rerender ); + + expect( input.value ).toBe( '100' ); + + fireEvent.change( input, { target: { value: 13 } } ); + + // onChange callback value + expect( value ).toBe( '13%' ); + } ); +} ); diff --git a/packages/components/src/slider/types.ts b/packages/components/src/slider/types.ts new file mode 100644 index 0000000000000..28d7bbe3188bc --- /dev/null +++ b/packages/components/src/slider/types.ts @@ -0,0 +1,26 @@ +export type SliderProps = { + /** + * Default value for input. + */ + defaultValue?: string; + /** + * Renders an error state. + * + * @default false + */ + error?: boolean; + /** + * Renders focused styles. + * + * @default false + */ + isFocused?: boolean; + /** + * Callback function when the `value` is committed. + */ + onChange?: ( value: string ) => void; + /** + * The Slider's current value. + */ + value?: string; +}; diff --git a/packages/components/src/utils/config-values.js b/packages/components/src/utils/config-values.js index 8f3e044261d73..66280ecabde3d 100644 --- a/packages/components/src/utils/config-values.js +++ b/packages/components/src/utils/config-values.js @@ -14,11 +14,14 @@ const CONTROL_PROPS = { controlPaddingXLarge: `calc(${ CONTROL_PADDING_X } * 1.3334)`, controlPaddingXSmall: `calc(${ CONTROL_PADDING_X } / 1.3334)`, controlBackgroundColor: COLORS.white, + controlBackgroundDimColor: COLORS.ui.backgroundDisabled, controlBorderRadius: '2px', controlBorderColor: COLORS.gray[ 700 ], controlBoxShadow: 'transparent', controlBorderColorHover: COLORS.gray[ 700 ], controlBoxShadowFocus: `0 0 0 0.5px ${ COLORS.admin.theme }`, + controlBoxShadowFocusSize: '0.5px', + controlPseudoBoxShadowFocusWidth: '2px', controlDestructiveBorderColor: COLORS.alert.red, controlHeight: CONTROL_HEIGHT, controlHeightXSmall: `calc( ${ CONTROL_HEIGHT } * 0.6 )`, @@ -27,6 +30,12 @@ const CONTROL_PROPS = { controlHeightXLarge: `calc( ${ CONTROL_HEIGHT } * 1.4 )`, }; +const SLIDER_PROPS = { + sliderThumbBorderColor: 'transparent', + sliderThumbBoxShadow: 'none', + sliderThumbBackgroundColor: COLORS.admin.theme, +}; + const TOGGLE_GROUP_CONTROL_PROPS = { toggleGroupControlBackgroundColor: CONTROL_PROPS.controlBackgroundColor, toggleGroupControlBorderColor: COLORS.ui.border, @@ -39,49 +48,55 @@ const TOGGLE_GROUP_CONTROL_PROPS = { // Using Object.assign to avoid creating circular references when emitting // TypeScript type declarations. -export default Object.assign( {}, CONTROL_PROPS, TOGGLE_GROUP_CONTROL_PROPS, { - colorDivider: 'rgba(0, 0, 0, 0.1)', - colorScrollbarThumb: 'rgba(0, 0, 0, 0.2)', - colorScrollbarThumbHover: 'rgba(0, 0, 0, 0.5)', - colorScrollbarTrack: 'rgba(0, 0, 0, 0.04)', - elevationIntensity: 1, - radiusBlockUi: '2px', - borderWidth: '1px', - borderWidthFocus: '1.5px', - borderWidthTab: '4px', - spinnerSize: 16, - fontSize: '13px', - fontSizeH1: 'calc(2.44 * 13px)', - fontSizeH2: 'calc(1.95 * 13px)', - fontSizeH3: 'calc(1.56 * 13px)', - fontSizeH4: 'calc(1.25 * 13px)', - fontSizeH5: '13px', - fontSizeH6: 'calc(0.8 * 13px)', - fontSizeInputMobile: '16px', - fontSizeMobile: '15px', - fontSizeSmall: 'calc(0.92 * 13px)', - fontSizeXSmall: 'calc(0.75 * 13px)', - fontLineHeightBase: '1.2', - fontWeight: 'normal', - fontWeightHeading: '600', - gridBase: '4px', - cardBorderRadius: '2px', - cardPaddingXSmall: `${ space( 2 ) }`, - cardPaddingSmall: `${ space( 4 ) }`, - cardPaddingMedium: `${ space( 4 ) } ${ space( 6 ) }`, - cardPaddingLarge: `${ space( 6 ) } ${ space( 8 ) }`, - surfaceBackgroundColor: COLORS.white, - surfaceBackgroundSubtleColor: '#F3F3F3', - surfaceBackgroundTintColor: '#F5F5F5', - surfaceBorderColor: 'rgba(0, 0, 0, 0.1)', - surfaceBorderBoldColor: 'rgba(0, 0, 0, 0.15)', - surfaceBorderSubtleColor: 'rgba(0, 0, 0, 0.05)', - surfaceBackgroundTertiaryColor: COLORS.white, - surfaceColor: COLORS.white, - transitionDuration: '200ms', - transitionDurationFast: '160ms', - transitionDurationFaster: '120ms', - transitionDurationFastest: '100ms', - transitionTimingFunction: 'cubic-bezier(0.08, 0.52, 0.52, 1)', - transitionTimingFunctionControl: 'cubic-bezier(0.12, 0.8, 0.32, 1)', -} ); +export default Object.assign( + {}, + CONTROL_PROPS, + SLIDER_PROPS, + TOGGLE_GROUP_CONTROL_PROPS, + { + colorDivider: 'rgba(0, 0, 0, 0.1)', + colorScrollbarThumb: 'rgba(0, 0, 0, 0.2)', + colorScrollbarThumbHover: 'rgba(0, 0, 0, 0.5)', + colorScrollbarTrack: 'rgba(0, 0, 0, 0.04)', + elevationIntensity: 1, + radiusBlockUi: '2px', + borderWidth: '1px', + borderWidthFocus: '1.5px', + borderWidthTab: '4px', + spinnerSize: 16, + fontSize: '13px', + fontSizeH1: 'calc(2.44 * 13px)', + fontSizeH2: 'calc(1.95 * 13px)', + fontSizeH3: 'calc(1.56 * 13px)', + fontSizeH4: 'calc(1.25 * 13px)', + fontSizeH5: '13px', + fontSizeH6: 'calc(0.8 * 13px)', + fontSizeInputMobile: '16px', + fontSizeMobile: '15px', + fontSizeSmall: 'calc(0.92 * 13px)', + fontSizeXSmall: 'calc(0.75 * 13px)', + fontLineHeightBase: '1.2', + fontWeight: 'normal', + fontWeightHeading: '600', + gridBase: '4px', + cardBorderRadius: '2px', + cardPaddingXSmall: `${ space( 2 ) }`, + cardPaddingSmall: `${ space( 4 ) }`, + cardPaddingMedium: `${ space( 4 ) } ${ space( 6 ) }`, + cardPaddingLarge: `${ space( 6 ) } ${ space( 8 ) }`, + surfaceBackgroundColor: COLORS.white, + surfaceBackgroundSubtleColor: '#F3F3F3', + surfaceBackgroundTintColor: '#F5F5F5', + surfaceBorderColor: 'rgba(0, 0, 0, 0.1)', + surfaceBorderBoldColor: 'rgba(0, 0, 0, 0.15)', + surfaceBorderSubtleColor: 'rgba(0, 0, 0, 0.05)', + surfaceBackgroundTertiaryColor: COLORS.white, + surfaceColor: COLORS.white, + transitionDuration: '200ms', + transitionDurationFast: '160ms', + transitionDurationFaster: '120ms', + transitionDurationFastest: '100ms', + transitionTimingFunction: 'cubic-bezier(0.08, 0.52, 0.52, 1)', + transitionTimingFunctionControl: 'cubic-bezier(0.12, 0.8, 0.32, 1)', + } +); diff --git a/packages/components/src/utils/flow.js b/packages/components/src/utils/flow.js new file mode 100644 index 0000000000000..618d5ef79f6ce --- /dev/null +++ b/packages/components/src/utils/flow.js @@ -0,0 +1,55 @@ +/** @typedef {number | string} FlowValue */ + +/** + * Combines CSS values. Useful for complex shorthand values, + * functions (e.g. calc()), and mixed string/JS values. + * + * @example + * ``` + * const boxShadow = flow( + * '0 1px', + * get('boxShadowSpreadValue'), + * '2px', + * get('boxShadowColor') + * ) + * ``` + * + * ##### Combining groups + * + * Groups (Array) can be passed into `flow()`, which are combined and + * comma separated. Useful for compounded CSS values (e.g. `box-shadow`). + * + * @example + * ``` + * const boxShadow = flow([ + * '0 1px', + * get('boxShadowSpreadValue'), + * '2px', + * get('boxShadowColor') + * ], [ + * '0 10px', + * get('boxShadowSpreadValue'), + * '20px', + * get('boxShadowColor') + * ] + * ) + * ``` + * + * @param {(FlowValue | FlowValue[])[]} args CSS values to combine. + * @return {string} The combined CSS string value. + */ +export function flow( ...args ) { + /** @type {FlowValue[]} */ + const results = []; + + for ( const arg of args ) { + if ( typeof arg === 'number' || typeof arg === 'string' ) { + results.push( arg ); + } + if ( Array.isArray( arg ) ) { + results.push( flow( ...arg ), ',' ); + } + } + + return results.join( ' ' ).trim().replace( /,$/, '' ); +} diff --git a/packages/components/src/utils/interpolate.ts b/packages/components/src/utils/interpolate.ts new file mode 100644 index 0000000000000..3c46f38363369 --- /dev/null +++ b/packages/components/src/utils/interpolate.ts @@ -0,0 +1,105 @@ +/** + * Internal dependencies + */ +import { clamp } from './math'; + +/** + * Interpolation from: + * https://github.com/react-spring/react-spring/blob/master/src/animated/createInterpolator.ts + */ + +function findRange( input: number, inputRange: number[] ): number { + let i; + for ( i = 1; i < inputRange.length - 1; ++i ) { + if ( inputRange[ i ] >= input ) { + break; + } + } + return i - 1; +} + +/** + * Base interpolate function. + * + * @param {number} [input=0] + * @param {number} [inputMin=0] + * @param {number} [inputMax=1] + * @param {number} [outputMin=0] + * @param {number} [outputMax=1] + * + * @return {number} The interpolated value. + */ +function baseInterpolate( + input: number = 0, + inputMin: number = 0, + inputMax: number = 1, + outputMin: number = 0, + outputMax: number = 1 +) { + let result = input; + + if ( outputMin === outputMax ) return outputMin; + if ( inputMin === inputMax ) + return input <= inputMin ? outputMin : outputMax; + + // Input Range + if ( inputMin === -Infinity ) result = -result; + else if ( inputMax === Infinity ) result = result - inputMin; + else result = ( result - inputMin ) / ( inputMax - inputMin ); + + // Output Range + if ( outputMin === -Infinity ) result = -result; + else if ( outputMax === Infinity ) result = result + outputMin; + else result = result * ( outputMax - outputMin ) + outputMin; + + let clampMin = outputMin; + let clampMax = outputMax; + + if ( outputMax < outputMin ) { + clampMin = outputMax; + clampMax = outputMin; + } + + return clamp( result, clampMin, clampMax ); +} + +/** + * Gets a value based on an input range and an output range. + * Can be used for a set of numbers or a set of colors. + * + * @param {number} [input=0] + * @param {[number, number]} [inputRange=[0,1]] + * @param {[number, number]} [outputRange=[0,1]] + * + * @return {number} The interpolated value. + */ +export function interpolate( + input: number = 0, + inputRange: [ number, number ] = [ 0, 1 ], + outputRange: [ number, number ] = [ 0, 1 ] +) { + const range = findRange( input, inputRange ); + const outputRange1 = outputRange[ range ]; + const outputRange2 = outputRange[ range + 1 ]; + + return baseInterpolate( + input, + inputRange[ range ], + inputRange[ range + 1 ], + outputRange1, + outputRange2 + ); +} + +/** + * Gets a rounded value based on an input range and an output range. + * + * @param {Parameters} args + * + * @return {number} The rounded interpolated value. + */ +export function interpolateRounded( + ...args: [ number, [ number, number ], [ number, number ] ] +) { + return Math.round( interpolate( ...args ) ); +} diff --git a/packages/components/src/utils/style-mixins.js b/packages/components/src/utils/style-mixins.js index dfacc79b0fb39..007c16e8ac4f9 100644 --- a/packages/components/src/utils/style-mixins.js +++ b/packages/components/src/utils/style-mixins.js @@ -3,5 +3,6 @@ export { reduceMotion } from './reduce-motion'; export { rtl } from './rtl'; export { font } from './font'; export { breakpoint } from './breakpoint'; +export { flow } from './flow'; export { default as CONFIG } from './config-values'; export { COLORS } from './colors-values';