diff --git a/core/components/atoms/multiSlider/Handle.tsx b/core/components/atoms/multiSlider/Handle.tsx new file mode 100644 index 0000000000..e53901f2c5 --- /dev/null +++ b/core/components/atoms/multiSlider/Handle.tsx @@ -0,0 +1,190 @@ +import classNames from 'classnames'; +import { Tooltip } from '@/index'; +import * as React from 'react'; +import * as Keys from '@/utils/Keys'; +import { formatPercentage, clamp } from './SliderUtils'; + +export interface HandleProps { + value: number; + fillAfter?: boolean; + fillBefore?: boolean; + onChange?: (newValue: number) => void; + onRelease?: (newValue: number) => void; +} + +export interface InternalHandleProps extends HandleProps { + disabled?: boolean; + label: string; + max: number; + min: number; + stepSize: number; + tickSize: number; + tickSizeRatio: number; + zIndex?: number; +} + +export interface HandleState { + isMoving?: boolean; +} + +export class Handle extends React.Component { + state = { + isMoving: false, + }; + + handleElement: HTMLElement | null = null; + refHandlers = { + handle: (el: HTMLDivElement) => (this.handleElement = el), + }; + + componentWillUnmount() { + this.removeDocumentEventListeners(); + } + + componentDidUpdate(_prevProps: InternalHandleProps, prevState: HandleState) { + if (prevState.isMoving !== this.state.isMoving) { + if (this.handleElement) this.handleElement.focus(); + } + } + + mouseEventClientOffset = (event: MouseEvent | React.MouseEvent) => { + return event.clientX; + } + + clientToValue = (clientPixel: number) => { + const { stepSize, tickSize, value } = this.props; + if (this.handleElement == null) { + return value; + } + + const clientPixelNormalized = clientPixel; + const { handleMidpoint, handleOffset } = this.getHandleMidpointAndOffset(this.handleElement); + const handleCenterPixel = handleMidpoint + handleOffset; + const pixelDelta = clientPixelNormalized - handleCenterPixel; + + if (isNaN(pixelDelta)) { + return value; + } + + return value + Math.round(pixelDelta / (tickSize * stepSize)) * stepSize; + } + + changeValue = (newValue: number, callback = this.props.onChange) => { + const updatedValue = clamp(newValue, this.props.min, this.props.max); + + if (!isNaN(updatedValue) && this.props.value !== updatedValue) { + if (callback) callback(updatedValue); + } + return updatedValue; + } + + endHandleMovement = (event: MouseEvent) => { + const clientPixel = this.mouseEventClientOffset(event); + const { onRelease } = this.props; + + this.removeDocumentEventListeners(); + this.setState({ isMoving: false }); + + const finalValue = this.changeValue(this.clientToValue(clientPixel)); + if (onRelease) onRelease(finalValue); + } + + continueHandleMovement = (event: MouseEvent) => { + const clientPixel = this.mouseEventClientOffset(event); + if (this.state.isMoving && !this.props.disabled) { + const value = this.clientToValue(clientPixel); + this.changeValue(value); + } + } + + beginHandleMovement = (event: MouseEvent | React.MouseEvent) => { + if (this.props.disabled) return; + document.addEventListener('mousemove', this.continueHandleMovement); + document.addEventListener('mouseup', this.endHandleMovement); + + this.setState({ isMoving: true }); + + const value = this.clientToValue(event.clientX); + this.changeValue(value); + } + + handleKeyDown = (event: React.KeyboardEvent) => { + if (this.props.disabled) return; + + const { stepSize, value } = this.props; + const { which } = event; + + if (which === Keys.ARROW_LEFT) { + this.changeValue(value - stepSize); + event.preventDefault(); + } else if (which === Keys.ARROW_RIGHT) { + this.changeValue(value + stepSize); + event.preventDefault(); + } + } + + handleKeyUp = (event: React.KeyboardEvent) => { + if (this.props.disabled) return; + + if ([Keys.ARROW_LEFT, Keys.ARROW_RIGHT].indexOf(event.which) >= 0) { + const { onRelease } = this.props; + if (onRelease) onRelease(this.props.value); + } + } + + getHandleMidpointAndOffset = (handleElement: HTMLElement | null, useOppositeDimension = false) => { + if (handleElement == null) { + return { handleMidpoint: 0, handleOffset: 0 }; + } + + const handleRect = handleElement.getBoundingClientRect(); + const sizeKey = useOppositeDimension ? 'height' : 'width'; + const handleOffset = handleRect.left; + + return { handleOffset, handleMidpoint: handleRect[sizeKey] / 2 }; + } + + render() { + const { min, tickSizeRatio, value, disabled, label } = this.props; + + const { handleMidpoint } = this.getHandleMidpointAndOffset(this.handleElement, true); + const offsetRatio = (value - min) * tickSizeRatio; + const offsetCalc = `calc(${formatPercentage(offsetRatio)} - ${handleMidpoint}px)`; + const style = { left: offsetCalc }; + + const className = classNames({ + ['Slider-handle']: true, + ['Slider-handle--disabled']: disabled, + ['Slider-handle--active']: this.state.isMoving + }); + + return ( +
+ {!this.state.isMoving && ( + + + + )} +
+ ); + } + + removeDocumentEventListeners = () => { + document.removeEventListener('mousemove', this.continueHandleMovement); + document.removeEventListener('mouseup', this.endHandleMovement); + } +} + +export default Handle; diff --git a/core/components/atoms/multiSlider/SliderUtils.tsx b/core/components/atoms/multiSlider/SliderUtils.tsx new file mode 100644 index 0000000000..30670edb91 --- /dev/null +++ b/core/components/atoms/multiSlider/SliderUtils.tsx @@ -0,0 +1,72 @@ +export const formatPercentage = (ratio: number) => { + return `${(ratio * 100).toFixed(2)}%`; +}; + +export const countDecimalPlaces = (value: number) => { + if (!isFinite(value)) return 0; + + if (Math.floor(value) !== value) { + const valueArray = value.toString().split('.'); + return valueArray[1].length || 0; + } + + return 0; +}; + +export const approxEqual = (a: number, b: number) => { + const tolerance = 0.00001; + return Math.abs(a - b) <= tolerance; +}; + +export const clamp = (value: number, min: number, max: number) => { + if (value == null) { + return value; + } + + return Math.min(Math.max(value, min), max); +}; + +export const arraysEqual = (oldValues: number[], newValues: number[]) => { + + if (oldValues.length !== oldValues.length) return; + + return newValues.every((value, index) => value === oldValues[index]); +}; + +export function argMin(values: T[], argFn: (value: T) => any): T | undefined { + if (values.length === 0) { + return undefined; + } + + let minValue = values[0]; + let minArg = argFn(minValue); + + for (let index = 1; index < values.length; index++) { + const value = values[index]; + const arg = argFn(value); + if (arg < minArg) { + minValue = value; + minArg = arg; + } + } + + return minValue; +} + +export function fillValues(values: T[], startIndex: number, endIndex: number, fillValue: T) { + const inc = startIndex < endIndex ? 1 : -1; + for (let index = startIndex; index !== endIndex + inc; index += inc) { + values[index] = fillValue; + } + +} + +export function isElementOfType

( + element: any, + _ComponentType: React.ComponentType

, +): element is React.ReactElement

{ + return ( + element != null && + element.type != null + ); +} diff --git a/core/components/atoms/multiSlider/index.tsx b/core/components/atoms/multiSlider/index.tsx new file mode 100644 index 0000000000..566e3fe490 --- /dev/null +++ b/core/components/atoms/multiSlider/index.tsx @@ -0,0 +1,402 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import { BaseProps, extractBaseProps } from '@/utils/types'; +import { Text, Label } from '@/index'; +import Handle, { HandleProps } from './Handle'; +import { + approxEqual, + formatPercentage, + countDecimalPlaces, + clamp, + arraysEqual, + argMin, + fillValues, + isElementOfType +} from './SliderUtils'; + +type NumberRange = [number, number]; + +export interface MultiSliderProps extends BaseProps { + /** + * Determines whether the `Slider` is non-interactive. + * @default false + */ + disabled?: boolean; + /** + * Indicates increment between successive labels (Must be greater than zero). + * @default 1 + */ + labelStepSize?: number; + /** + * Number of decimal places to use when rendering label value.
+ * Default value is the number of decimals used in the `stepSize` prop. + */ + labelPrecision?: number; + /** + * Maximum value of the `Slider`. + * @default 10 + */ + max?: number; + /** + * Minimum value of the `Slider`. + * @default 0 + */ + min?: number; + /** + * Indicates the amount by which the handle moves (Must be greater than zero). + * @default 1 + */ + stepSize?: number; + /** + * Label of `Slider` + */ + label?: string; + /** + * Callback to render a custom label. + * If `true`, labels will use number value formatted to `labelPrecision` decimal places. + * If `false`, labels will not be shown. + * @default true + */ + labelRenderer?: boolean | ((value: number) => string); +} + +interface SliderBaserProps extends MultiSliderProps { + onChange?: (values: number) => void; + onRelease?: (values: number) => void; +} + +interface RangeSliderBaseProps extends MultiSliderProps { + onRangeChange?: (values: NumberRange) => void; + onRangeRelease?: (values: NumberRange) => void; +} + +interface MultiSliderState { + labelPrecision: number; + tickSize: number; + tickSizeRatio: number; +} + +const defaultProps = { + disabled: false, + labelStepSize: 1, + max: 10, + min: 0, + stepSize: 1, +}; + +type DefaultProps = Readonly; + +type InternalMultiSliderProps = SliderBaserProps & RangeSliderBaseProps & DefaultProps; + +const MultiSliderHandle: React.FunctionComponent = () => null; + +export class MultiSlider extends React.Component { + static defaultProps = defaultProps; + static Handle = MultiSliderHandle; + + handleElements: Handle[] = []; + trackElement: HTMLElement | null = null; + + constructor(props: InternalMultiSliderProps) { + super(props); + + this.state = { + labelPrecision: this.getLabelPrecision(this.props), + tickSize: 0, + tickSizeRatio: 0, + }; + } + + getDerivedStateFromProps(props: InternalMultiSliderProps) { + return { labelPrecision: this.getLabelPrecision(props) }; + } + + getSnapshotBeforeUpdate(prevProps: InternalMultiSliderProps) { + const prevHandleProps = this.getHandleValues(prevProps); + const newHandleProps = this.getHandleValues(this.props); + if (newHandleProps.length !== prevHandleProps.length) { + this.handleElements = []; + } + return null; + } + + componentDidMount() { + this.updateTickSize(); + } + + getLabelPrecision = ({ labelPrecision, stepSize }: InternalMultiSliderProps) => { + return labelPrecision == null ? countDecimalPlaces(stepSize) : labelPrecision; + } + + getOffsetRatio = (value: number) => { + return clamp((value - this.props.min) * this.state.tickSizeRatio, 0, 1); + } + + addHandleRef = (ref: Handle) => { + if (ref != null) { + this.handleElements.push(ref); + } + } + + getHandleValues = ( + props: React.PropsWithChildren, + ) => { + const maybeHandles = React.Children.map(props.children, child => + isElementOfType(child, MultiSlider.Handle) ? child.props : null, + ); + + let handles = maybeHandles != null ? maybeHandles : []; + handles = handles.filter(handle => handle !== null); + handles.sort((left, right) => left.value - right.value); + return handles; + } + + updateTickSize = () => { + if (this.trackElement != null) { + const trackSize = this.trackElement.clientWidth; + const tickSizeRatio = 1 / ((this.props.max) - (this.props.min)); + const tickSize = trackSize * tickSizeRatio; + this.setState({ tickSize, tickSizeRatio }); + } + } + + getTrackFill = (start: HandleProps, end?: HandleProps) => { + if (start.fillAfter !== undefined) { + return start.fillAfter; + } + + if (end !== undefined && end.fillBefore !== undefined) { + return end.fillBefore; + } + return false; + } + + nearestHandleForValue(handles: Handle[], getOffset: (handle: Handle) => number) { + return argMin(handles, handle => { + const offset = getOffset(handle); + const offsetValue = handle.clientToValue(offset); + const handleValue = handle.props.value!; + return Math.abs(offsetValue - handleValue); + }); + } + + maybeHandleTrackClick = (event: React.MouseEvent) => { + const target = event.target as HTMLElement; + const canHandleTrackEvent = !this.props.disabled && target.closest('.Slider-handle') == null; + + if (canHandleTrackEvent) { + const foundHandle = this.nearestHandleForValue(this.handleElements, handle => + handle.mouseEventClientOffset(event), + ); + + if (foundHandle) { + foundHandle.beginHandleMovement(event); + } + } + } + + getLockedHandleIndex = (startIndex: number, endIndex: number) => { + const inc = startIndex < endIndex ? 1 : -1; + + for (let index = startIndex + inc; index !== endIndex + inc; index += inc) { + return index; + } + + return -1; + } + + getNewHandleValues = (newValue: number, oldIndex: number) => { + const handleProps = this.getHandleValues(this.props); + const oldValues = handleProps.map(handle => handle.value); + const newValues = oldValues.slice(); + newValues[oldIndex] = newValue; + if (newValues.length > 1) newValues.sort((left, right) => left - right); + + const newIndex = newValues.indexOf(newValue); + const lockIndex = this.getLockedHandleIndex(oldIndex, newIndex); + + if (lockIndex === -1) { + fillValues(newValues, oldIndex, newIndex, newValue); + } else { + const lockValue = oldValues[lockIndex]; + fillValues(oldValues, oldIndex, lockIndex, lockValue); + return oldValues; + } + return newValues; + } + + onReleaseHandler = (newValue: number, index: number) => { + const { onRangeRelease } = this.props; + + const handleProps = this.getHandleValues(this.props); + const newValues = this.getNewHandleValues(newValue, index); + + // Range Slider callback + if (onRangeRelease) { + const range = newValues as NumberRange; + onRangeRelease(range); + } + + // Slider callback + handleProps.forEach((handle, i) => { + if (handle.onRelease) handle.onRelease(newValues[i]); + }); + } + + onChangeHandler = (newValue: number, index: number) => { + const { onRangeChange } = this.props; + + const handleProps = this.getHandleValues(this.props); + const oldValues = handleProps.map(handle => handle.value); + const newValues = this.getNewHandleValues(newValue, index); + + if (!arraysEqual(newValues, oldValues)) { + // Range Slider Callback + if (onRangeChange) { + const range = newValues as NumberRange; + onRangeChange(range); + } + + // Slider callback + handleProps.forEach((handle, i) => { + if (handle.onChange) handle.onChange(newValues[i]); + }); + } + } + + renderHandles = () => { + const { disabled, max, min, stepSize } = this.props; + const handleProps = this.getHandleValues(this.props); + + if (handleProps.length === 0) { + return null; + } + + return handleProps.map(({ value }, index) => ( + this.onReleaseHandler(newValue, index)} + onChange={newValue => this.onChangeHandler(newValue, index)} + label={value.toFixed(this.state.labelPrecision)} + ref={this.addHandleRef} + stepSize={stepSize} + tickSize={this.state.tickSize} + tickSizeRatio={this.state.tickSizeRatio} + value={value} + /> + )); + } + + formatLabel = (value: number) => { + const { labelRenderer } = this.props; + + if (typeof labelRenderer === 'function') { + return labelRenderer(value); + } + + return value.toFixed(this.state.labelPrecision); + } + + renderLabels = () => { + const { labelStepSize, max, min, labelRenderer, disabled } = this.props; + + const labels = []; + const stepSizeRatio = this.state.tickSizeRatio * labelStepSize; + const handles = this.getHandleValues(this.props); + const activeLabels = handles.map(handle => handle.value.toFixed(this.state.labelPrecision)); + + for ( + let i = min, offsetRatio = 0; + i < max || approxEqual(i, max); + i += labelStepSize, offsetRatio += stepSizeRatio + ) { + const offsetPercentage = formatPercentage(offsetRatio); + const style = { left: offsetPercentage }; + const active = !disabled && activeLabels.indexOf(i.toFixed(this.state.labelPrecision)) !== -1; + + labels.push( +

+ + {labelRenderer !== false && ( + + {this.formatLabel(i)} + + )} +
+ ); + } + return labels; + } + + renderTrackFill = (index: number, start: HandleProps, end: HandleProps) => { + const [startRatio, endRatio] = [this.getOffsetRatio(start.value), this.getOffsetRatio(end.value)].sort( + (left, right) => left - right, + ); + const startOffset = Number((startRatio * 100).toFixed(2)); + const endOffset = Number(((1 - endRatio) * 100).toFixed(2)); + + const width = `${100 - endOffset - startOffset}%`; + const orientationStyle: React.CSSProperties = { width }; + const style: React.CSSProperties = { ...orientationStyle }; + const fillTrack = this.getTrackFill(start, end); + + const classes = classNames({ + ['Slider-progress']: true, + ['Slider-progress--disabled']: this.props.disabled, + ['Slider-progress--inRange']: fillTrack, + ['Slider-progress--inRangeDisabled']: fillTrack && this.props.disabled, + }); + + return
; + } + + renderTracks = () => { + const trackStops = this.getHandleValues(this.props); + trackStops.push({ value: this.props.max }); + + let previous: HandleProps = { value: this.props.min || 0 }; + const handles: JSX.Element[] = []; + + trackStops.forEach((track, index) => { + const current = track; + handles.push(this.renderTrackFill(index, previous, current)); + previous = current; + }); + + return handles; + } + + render() { + const { label, className } = this.props; + const baseProps = extractBaseProps(this.props); + + const SliderClass = classNames({ + ['Slider']: true + }, className); + + const WrapperClass = classNames({ + ['Slider-wrapper']: true, + ['Slider-wrapper--disabled']: this.props.disabled, + }); + + return ( +
+ {label && ( + + )} +
+
(this.trackElement = ref)}> + {this.renderTracks()} +
+
{this.renderLabels()}
+ {this.renderHandles()} +
+
+ ); + } +} + +export default MultiSlider; diff --git a/core/components/atoms/rangeSlider/RangeSlider.tsx b/core/components/atoms/rangeSlider/RangeSlider.tsx new file mode 100644 index 0000000000..c59c144cbf --- /dev/null +++ b/core/components/atoms/rangeSlider/RangeSlider.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import MultiSlider, { MultiSliderProps } from '@/components/atoms/multiSlider'; + +export type NumberRange = [number, number]; + +enum RangeIndex { + START = 0, + END = 1, +} + +export interface RangeSliderProps extends MultiSliderProps { + /** + * Gives default value to `RangeSlider` (Used in case of uncontrolled `RangeSlider`). + * @default 0 + */ + defaultValue?: NumberRange; + /** + * Denotes range value of slider. Handles will be rendered at each position in the range.
+ * (Used in case of controlled `RangeSlider`) + * @default [0, 10] + */ + value?: NumberRange; + /** + * Callback invoked when the range value changes. + */ + onChange?: (value: NumberRange) => void; + /** + * Callback invoked when a handle is released. + */ + onRelease?: (value: NumberRange) => void; +} + +export const RangeSlider = (props: RangeSliderProps) => { + const { + value: valueProp, + defaultValue = [0, 10], + onChange, + onRelease, + ...rest + } = props; + + const [value, setValue] = React.useState(valueProp === undefined ? defaultValue : valueProp); + + React.useEffect(() => { + if (valueProp !== undefined) { + setValue(valueProp); + } + }, [valueProp]); + + const onChangeHandler = (range: NumberRange) => { + if (valueProp === undefined) { + setValue(range); + } + if (onChange) onChange(range); + }; + + return ( + + + + + ); +}; + +export default RangeSlider; diff --git a/core/components/atoms/rangeSlider/__stories__/index.story.tsx b/core/components/atoms/rangeSlider/__stories__/index.story.tsx new file mode 100644 index 0000000000..f86a8f5e8b --- /dev/null +++ b/core/components/atoms/rangeSlider/__stories__/index.story.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { text, boolean, number } from '@storybook/addon-knobs'; +import { action } from '@storybook/addon-actions'; +import RangeSlider from '../RangeSlider'; + +type NumberRange = [number, number]; + +// CSF format story +export const all = () => { + + const min = number('min', 0) || undefined; + const max = number('max', 10) || undefined; + const stepSize = number('step size', 0.1) || undefined; + const labelStepSize = number('label step size', 1) || undefined; + const label = text('Label', 'Slider Label'); + const disabled = boolean('disabled', false); + + const onChange = (value: NumberRange) => { + return action(`new value: ${value}`); + }; + + const options = { + min, + max, + stepSize, + labelStepSize, + label, + disabled, + onChange, + defaultValue: [2, 4] as NumberRange + }; + + return ( + + ); +}; + +export default { + title: 'Atoms|RangeSlider', + component: RangeSlider, +}; diff --git a/core/components/atoms/rangeSlider/__stories__/variants/Controlled.story.tsx b/core/components/atoms/rangeSlider/__stories__/variants/Controlled.story.tsx new file mode 100644 index 0000000000..5f4d827019 --- /dev/null +++ b/core/components/atoms/rangeSlider/__stories__/variants/Controlled.story.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { RangeSlider } from '@/index'; + +type NumberRange = [number, number]; + +// CSF format story +export const controlledSlider = () => { + const [value, setValue] = React.useState([2, 4]); + + const onChange = (newValue: NumberRange) => { + setTimeout(() => { + setValue(newValue); + }, 1000); + }; + + return ( + + ); +}; + +const customCode = `() => { + const [value, setValue] = React.useState([2, 4]); + + const onChange = (value) => { + setTimeout(() => { + setValue(value); + }, 1000); + }; + + return ( + + ); +}`; + +export default { + title: 'Atoms|RangeSlider/Variants', + component: RangeSlider, + parameters: { + docs: { + docPage: { + customCode, + } + } + } +}; diff --git a/core/components/atoms/rangeSlider/__stories__/variants/CustomLabels.story.tsx b/core/components/atoms/rangeSlider/__stories__/variants/CustomLabels.story.tsx new file mode 100644 index 0000000000..60ebfc0a55 --- /dev/null +++ b/core/components/atoms/rangeSlider/__stories__/variants/CustomLabels.story.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { RangeSlider } from '@/index'; + +type NumberRange = [number, number]; + +// CSF format story +export const cutsomLabels = () => { + const [value, setValue] = React.useState([2, 4]); + + const onChange = (newValue: NumberRange) => { + setValue(newValue); + }; + + const labelRenderer = (newValue: number) => { + return `${newValue}%`; + }; + + return ( + + ); +}; + +const customCode = `() => { + const [value, setValue] = React.useState([2, 4]); + + const onChange = (value) => { + setValue(value); + }; + + const labelRenderer = (value) => { + return \`\${value}%\`; + }; + + return ( + + ); +}`; + +export default { + title: 'Atoms|RangeSlider/Variants', + component: RangeSlider, + parameters: { + docs: { + docPage: { + customCode, + } + } + } +}; diff --git a/core/components/atoms/rangeSlider/__stories__/variants/Disabled.story.tsx b/core/components/atoms/rangeSlider/__stories__/variants/Disabled.story.tsx new file mode 100644 index 0000000000..350cb048b3 --- /dev/null +++ b/core/components/atoms/rangeSlider/__stories__/variants/Disabled.story.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { RangeSlider } from '@/index'; + +// CSF format story +export const disabled = () => { + + return ( +
+ + +
+ ); +}; + +export default { + title: 'Atoms|RangeSlider/Variants', + component: RangeSlider, +}; diff --git a/core/components/atoms/rangeSlider/__stories__/variants/DiscreteSlider.story.tsx b/core/components/atoms/rangeSlider/__stories__/variants/DiscreteSlider.story.tsx new file mode 100644 index 0000000000..63cecb14ea --- /dev/null +++ b/core/components/atoms/rangeSlider/__stories__/variants/DiscreteSlider.story.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { RangeSlider } from '@/index'; + +// CSF format story +export const disctereSlider = () => { + return ( + + ); +}; + +export default { + title: 'Atoms|RangeSlider/Variants', + component: RangeSlider, +}; diff --git a/core/components/atoms/rangeSlider/__stories__/variants/FreeSlider.story.tsx b/core/components/atoms/rangeSlider/__stories__/variants/FreeSlider.story.tsx new file mode 100644 index 0000000000..1053827db9 --- /dev/null +++ b/core/components/atoms/rangeSlider/__stories__/variants/FreeSlider.story.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { RangeSlider } from '@/index'; + +// CSF format story +export const freeSlider = () => { + return ( + + ); +}; + +export default { + title: 'Atoms|RangeSlider/Variants', + component: RangeSlider, +}; diff --git a/core/components/atoms/rangeSlider/__stories__/variants/SliderLabel.story.tsx b/core/components/atoms/rangeSlider/__stories__/variants/SliderLabel.story.tsx new file mode 100644 index 0000000000..1aee5a3277 --- /dev/null +++ b/core/components/atoms/rangeSlider/__stories__/variants/SliderLabel.story.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { RangeSlider, Text } from '@/index'; + +// CSF format story +export const sliderLabel = () => { + + return ( +
+
+ With Slider Label
+ +
+
+ Without Slider Label
+ +
+
+ ); +}; + +export default { + title: 'Atoms|RangeSlider/Variants', + component: RangeSlider, +}; diff --git a/core/components/atoms/rangeSlider/__stories__/variants/Uncontrolled.story.tsx b/core/components/atoms/rangeSlider/__stories__/variants/Uncontrolled.story.tsx new file mode 100644 index 0000000000..4bb1dfeb30 --- /dev/null +++ b/core/components/atoms/rangeSlider/__stories__/variants/Uncontrolled.story.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { RangeSlider } from '@/index'; + +// CSF format story +export const uncontrolledSlider = () => { + return ( + + ); +}; + +export default { + title: 'Atoms|RangeSlider/Variants', + component: RangeSlider, +}; diff --git a/core/components/atoms/rangeSlider/index.tsx b/core/components/atoms/rangeSlider/index.tsx new file mode 100644 index 0000000000..3c0e54b1f6 --- /dev/null +++ b/core/components/atoms/rangeSlider/index.tsx @@ -0,0 +1,2 @@ +export { default } from './RangeSlider'; +export * from './RangeSlider'; diff --git a/core/components/atoms/slider/Slider.tsx b/core/components/atoms/slider/Slider.tsx new file mode 100644 index 0000000000..736cfe4131 --- /dev/null +++ b/core/components/atoms/slider/Slider.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import MultiSlider, { MultiSliderProps } from '@/components/atoms/multiSlider'; + +export interface SliderProps extends MultiSliderProps { + /** + * Gives default value to `Slider` (Used in case of uncontrolled `Slider`). + * @default 0 + */ + defaultValue?: number; + /** + * Value of `Slider`(Used in case of controlled `Slider`). + */ + value?: number; + /** + * Callback invoked when the value changes. + */ + onChange?: (value: number) => void; + /** + * Callback invoked when the handle is released. + */ + onRelease?: (value: number) => void; +} + +export const Slider = (props: SliderProps) => { + const { + value: valueProp, + defaultValue = 0, + onRelease, + onChange, + ...rest + } = props; + + const [value, setValue] = React.useState(valueProp === undefined ? defaultValue : valueProp); + + React.useEffect(() => { + if (valueProp !== undefined) { + setValue(valueProp); + } + }, [valueProp]); + + const onChangeHandler = (newValue: number) => { + if (valueProp === undefined) { + setValue(newValue); + } + if (onChange) onChange(newValue); + }; + + return ( + + + + ); +}; + +export default Slider; diff --git a/core/components/atoms/slider/__stories__/index.story.tsx b/core/components/atoms/slider/__stories__/index.story.tsx new file mode 100644 index 0000000000..94c821c484 --- /dev/null +++ b/core/components/atoms/slider/__stories__/index.story.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { text, boolean, number } from '@storybook/addon-knobs'; +import { action } from '@storybook/addon-actions'; +import Slider from '../Slider'; + +// CSF format story +export const all = () => { + + const min = number('min', 0) || undefined; + const max = number('max', 10) || undefined; + const stepSize = number('step size', 0.1) || undefined; + const labelStepSize = number('label step size', 1) || undefined; + const defaultValue = number('default value', 4) || undefined; + const label = text('Label', 'Slider Label'); + const disabled = boolean('disabled', false); + + const onChange = (value: number) => { + return action(`new value: ${value}`); + }; + + const options = { + min, + max, + stepSize, + labelStepSize, + label, + disabled, + defaultValue, + onChange + }; + + return ( + + ); +}; + +export default { + title: 'Atoms|Slider', + component: Slider, +}; diff --git a/core/components/atoms/slider/__stories__/variants/Controlled.story.tsx b/core/components/atoms/slider/__stories__/variants/Controlled.story.tsx new file mode 100644 index 0000000000..32a8b8387a --- /dev/null +++ b/core/components/atoms/slider/__stories__/variants/Controlled.story.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { Slider } from '@/index'; + +// CSF format story +export const controlledSlider = () => { + const [value, setValue] = React.useState(4); + + const onChange = (newValue: number) => { + setTimeout(() => { + setValue(newValue); + }, 1000); + }; + + return ( + + ); +}; + +const customCode = `() => { + const [value, setValue] = React.useState(4); + + const onChange = (value) => { + setTimeout(() => { + setValue(value); + }, 1000); + }; + + return ( + + ); +}`; + +export default { + title: 'Atoms|Slider/Variants', + component: Slider, + parameters: { + docs: { + docPage: { + customCode, + } + } + } +}; diff --git a/core/components/atoms/slider/__stories__/variants/CustomLabels.story.tsx b/core/components/atoms/slider/__stories__/variants/CustomLabels.story.tsx new file mode 100644 index 0000000000..b1f66fdd9c --- /dev/null +++ b/core/components/atoms/slider/__stories__/variants/CustomLabels.story.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { Slider } from '@/index'; + +// CSF format story +export const cutsomLabels = () => { + const [value, setValue] = React.useState(4); + + const onChange = (newValue: number) => { + setValue(newValue); + }; + + const labelRenderer = (newValue: number) => { + return `${newValue}%`; + }; + + return ( + + ); +}; + +const customCode = `() => { + const [value, setValue] = React.useState(4); + + const onChange = (value) => { + setValue(value); + }; + + const labelRenderer = (value) => { + return \`\${value}%\`; + }; + + return ( + + ); +}`; + +export default { + title: 'Atoms|Slider/Variants', + component: Slider, + parameters: { + docs: { + docPage: { + customCode, + } + } + } +}; diff --git a/core/components/atoms/slider/__stories__/variants/Disabled.story.tsx b/core/components/atoms/slider/__stories__/variants/Disabled.story.tsx new file mode 100644 index 0000000000..4c07c91335 --- /dev/null +++ b/core/components/atoms/slider/__stories__/variants/Disabled.story.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { Slider } from '@/index'; + +// CSF format story +export const disabled = () => { + + return ( +
+ + +
+ ); +}; + +export default { + title: 'Atoms|Slider/Variants', + component: Slider, +}; diff --git a/core/components/atoms/slider/__stories__/variants/DiscreteSlider.story.tsx b/core/components/atoms/slider/__stories__/variants/DiscreteSlider.story.tsx new file mode 100644 index 0000000000..bfe73a1eab --- /dev/null +++ b/core/components/atoms/slider/__stories__/variants/DiscreteSlider.story.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { Slider } from '@/index'; + +// CSF format story +export const disctereSlider = () => { + return ( + + ); +}; + +export default { + title: 'Atoms|Slider/Variants', + component: Slider, +}; diff --git a/core/components/atoms/slider/__stories__/variants/FreeSlider.story.tsx b/core/components/atoms/slider/__stories__/variants/FreeSlider.story.tsx new file mode 100644 index 0000000000..b3deb62052 --- /dev/null +++ b/core/components/atoms/slider/__stories__/variants/FreeSlider.story.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { Slider } from '@/index'; + +// CSF format story +export const freeSlider = () => { + return ( + + ); +}; + +export default { + title: 'Atoms|Slider/Variants', + component: Slider, +}; diff --git a/core/components/atoms/slider/__stories__/variants/SliderLabel.story.tsx b/core/components/atoms/slider/__stories__/variants/SliderLabel.story.tsx new file mode 100644 index 0000000000..edd72fd4d8 --- /dev/null +++ b/core/components/atoms/slider/__stories__/variants/SliderLabel.story.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { Slider, Text } from '@/index'; + +// CSF format story +export const sliderLabel = () => { + + return ( +
+
+ With Slider Label
+ +
+
+ Without Slider Label
+ +
+
+ ); +}; + +export default { + title: 'Atoms|Slider/Variants', + component: Slider, +}; diff --git a/core/components/atoms/slider/__stories__/variants/Uncontrolled.story.tsx b/core/components/atoms/slider/__stories__/variants/Uncontrolled.story.tsx new file mode 100644 index 0000000000..faac84a25e --- /dev/null +++ b/core/components/atoms/slider/__stories__/variants/Uncontrolled.story.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { Slider } from '@/index'; + +// CSF format story +export const uncontrolledSlider = () => { + return ( + + ); +}; + +export default { + title: 'Atoms|Slider/Variants', + component: Slider, +}; diff --git a/core/components/atoms/slider/index.tsx b/core/components/atoms/slider/index.tsx new file mode 100644 index 0000000000..006f966fe2 --- /dev/null +++ b/core/components/atoms/slider/index.tsx @@ -0,0 +1,2 @@ +export { default } from './Slider'; +export * from './Slider'; diff --git a/core/index.tsx b/core/index.tsx index 944b1ea0db..ae88cd5f22 100644 --- a/core/index.tsx +++ b/core/index.tsx @@ -29,6 +29,8 @@ export { Row } from './components/atoms/row'; export { StatusHint } from './components/atoms/statusHint'; export { Pills } from './components/atoms/pills'; export { Spinner } from './components/atoms/spinner'; +export { Slider } from './components/atoms/slider'; +export { RangeSlider } from './components/atoms/rangeSlider'; export { Subheading } from './components/atoms/subheading'; export { Switch } from './components/atoms/switchInput'; export { Text } from './components/atoms/text'; diff --git a/core/index.type.tsx b/core/index.type.tsx index 978338584c..68581c33f6 100644 --- a/core/index.type.tsx +++ b/core/index.type.tsx @@ -27,6 +27,8 @@ export { ParagraphProps } from './components/atoms/paragraph'; export { RadioProps } from './components/atoms/radio'; export { RowProps } from './components/atoms/row'; export { SpinnerProps } from './components/atoms/spinner'; +export { SliderProps } from './components/atoms/slider'; +export { RangeSliderProps } from './components/atoms/rangeSlider'; export { StatusHintProps } from './components/atoms/statusHint'; export { PillsProps } from './components/atoms/pills'; export { SubheadingProps } from './components/atoms/subheading'; diff --git a/core/utils/Keys.ts b/core/utils/Keys.ts new file mode 100644 index 0000000000..15e890c5ec --- /dev/null +++ b/core/utils/Keys.ts @@ -0,0 +1,11 @@ +export const BACKSPACE = 8; +export const TAB = 9; +export const ENTER = 13; +export const SHIFT = 16; +export const ESCAPE = 27; +export const SPACE = 32; +export const ARROW_LEFT = 37; +export const ARROW_UP = 38; +export const ARROW_RIGHT = 39; +export const ARROW_DOWN = 40; +export const DELETE = 46; \ No newline at end of file diff --git a/css/src/components/slider.css b/css/src/components/slider.css new file mode 100644 index 0000000000..eea8fc33e7 --- /dev/null +++ b/css/src/components/slider.css @@ -0,0 +1,100 @@ + +.Slider { + width: 100%; +} + +.Slider-wrapper { + position: relative; + outline: none; + cursor: pointer; +} + +.Slider-wrapper--disabled { + pointer-events: none; +} + +.Slider-track { + border-radius: var(--spacing-m); + height: var(--spacing-2); + display: flex; + align-items: center; + overflow: hidden; +} + +.Slider-progress { + background: var(--secondary-lighter); + height: var(--spacing-s); + box-sizing: border-box; +} + +.Slider-progress--inRange { + background: var(--primary); +} + +.Slider-progress--inRangeDisabled { + background: var(--secondary-light); + border: var(--border); +} + +.Slider-label { + margin-top: var(--spacing-m); + -webkit-transform: translate(-50%,0px); + transform: translate(-50%,0px); + display: flex; + align-items: center; + flex-direction: column; + position: absolute; + user-select: none; + -moz-user-select: none; + -ms-user-select: none; +} + +.Slider-ticks { + width: var(--spacing-s); + height: var(--spacing-m); + border-radius: var(--spacing-xs); + background-color: var(--secondary-dark); +} + +.Slider-tooltip--visible { + visibility: visible; +} + +.Slider-tooltip--hidden { + visibility: hidden; +} + +.Slider-tooltip { + height: 100%; + width: 100%; + outline: none; +} + +.Slider-handle { + height: var(--spacing-2); + width: var(--spacing-2); + position: absolute; + left: 0; + top: 0; + border-radius: 50%; + background-color: var(--white); + box-shadow: var(--shadow-s); + cursor: pointer; + box-sizing: border-box; + outline: none; + display: flex; +} + +.Slider-handle:hover { + background-color: var(--secondary-lightest); + border: var(--border); +} + +.Slider-handle:focus, .Slider-handle:active { + border: var(--spacing-s) solid var(--primary); +} + +.Slider-handle--disabled { + pointer-events: none; + background-color: var(--secondary-light); +}