-
Notifications
You must be signed in to change notification settings - Fork 77
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
new(Range): Add input range component (#362)
- Loading branch information
1 parent
7ed7201
commit 3996ebf
Showing
8 changed files
with
570 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,205 @@ | ||
import React from 'react'; | ||
import memoize from 'lodash/memoize'; | ||
|
||
import Text from '../Text'; | ||
import withStyles, { WithStylesProps } from '../../composers/withStyles'; | ||
import { stylesheetInputRange, HANDLE_SIZE, HALF_HANDLE_SIZE, ANNOTATION_SIZE } from './styles'; | ||
|
||
/** | ||
* By default, the **edges** (not the _center_) of the slider handle align with | ||
* the **edges** of the slider track. Thus the total width of the slider is not | ||
* `100%` but `100% - HANDLE_SIZE_PX`. The default position is correct at 50%, | ||
* so we offset based on a fractional distance from the center position. | ||
*/ | ||
const getPxPosition = memoize( | ||
(value: number, min: number, max: number, width: number) => { | ||
const valueRange = max - min; | ||
const centerX = width / 2; | ||
const pxPosition = ((value - min) / valueRange) * width; | ||
// positive offset if value < center, negative offset if value > center | ||
const pxFromCenter = centerX - pxPosition; | ||
const fractionFromCenter = pxFromCenter / centerX; | ||
const pxOffset = fractionFromCenter * HALF_HANDLE_SIZE; | ||
const leftPosition = pxPosition + pxOffset; | ||
return leftPosition; | ||
}, | ||
(...args) => JSON.stringify(args), | ||
); | ||
|
||
export type BaseRangeProps = { | ||
/** Whether to always show a tooltip with value. */ | ||
alwaysShowTooltip?: boolean; | ||
/** Any values to annotate. */ | ||
annotations?: { value: number; label?: string }[]; | ||
/** Whether to disable the input. */ | ||
disabled?: boolean; | ||
/** Unique id of the input. */ | ||
id: string; | ||
/** Whether to invert tooltip colors. */ | ||
invertTooltip?: boolean; | ||
/** Max range value. */ | ||
max?: number; | ||
/** Min range value. */ | ||
min?: number; | ||
/** Callback invoked on input value change. */ | ||
onChange: (value: number, event: React.ChangeEvent<HTMLInputElement>) => void; | ||
/** Override rendering of tooltip content. */ | ||
renderTooltipContent?: (value: number) => React.ReactNode; | ||
/** Whether to show a tooltip with value on hover. */ | ||
showTooltip?: boolean; | ||
/** Step size for the range. */ | ||
step?: number; | ||
/** Current value. */ | ||
value?: number; | ||
/** Width of the input. */ | ||
width?: number; | ||
}; | ||
|
||
type BaseRangeState = { | ||
showPopup: boolean; | ||
}; | ||
|
||
class BaseRange extends React.Component<BaseRangeProps & WithStylesProps, BaseRangeState> { | ||
static defaultProps = { | ||
renderTooltipContent: (value: number) => value.toFixed(0), | ||
}; | ||
|
||
state = { | ||
showPopup: !!this.props.alwaysShowTooltip, | ||
}; | ||
|
||
private handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { | ||
this.props.onChange(Number(event.currentTarget.value), event); | ||
}; | ||
|
||
private handleMouseEnter = () => { | ||
if (this.props.showTooltip && !this.props.alwaysShowTooltip) { | ||
this.setState({ showPopup: true }); | ||
} | ||
}; | ||
|
||
private handleMouseLeave = () => { | ||
if (this.props.showTooltip && !this.props.alwaysShowTooltip) { | ||
this.setState({ showPopup: false }); | ||
} | ||
}; | ||
|
||
renderTooltip(leftOffset: number) { | ||
const { invertTooltip, renderTooltipContent, value = 0, cx, styles } = this.props; | ||
return ( | ||
<div | ||
role="tooltip" | ||
className={cx(styles.tooltip, { | ||
marginLeft: leftOffset, | ||
})} | ||
> | ||
<div className={cx(styles.tooltipContent, invertTooltip && styles.tooltipContent_inverted)}> | ||
<Text inverted={invertTooltip}>{renderTooltipContent!(value)}</Text> | ||
</div> | ||
</div> | ||
); | ||
} | ||
|
||
render() { | ||
const { | ||
annotations, | ||
disabled, | ||
id, | ||
max = 100, | ||
min = 0, | ||
step, | ||
value = 0, | ||
width = 300, | ||
cx, | ||
styles, | ||
theme, | ||
} = this.props; | ||
const { | ||
accent: { borderActive }, | ||
core: { neutral }, | ||
} = theme!.color; | ||
const { showPopup } = this.state; | ||
const minPx = getPxPosition(min, min, max, width); | ||
const maxPx = getPxPosition(max, min, max, width); | ||
const handlePositionPx = getPxPosition(value, min, max, width); | ||
|
||
return ( | ||
<div className={cx(styles.container, disabled && styles.container_disabled, { width })}> | ||
<input | ||
id={id} | ||
disabled={disabled} | ||
className={cx(styles.input, { | ||
// fill from start to current value, with transparent edges | ||
background: `linear-gradient(to right, | ||
transparent 0px, | ||
transparent ${minPx}px, | ||
${borderActive} ${minPx}px, | ||
${borderActive} ${handlePositionPx}px, | ||
${neutral[2]} ${handlePositionPx}px, | ||
${neutral[2]} ${maxPx}px, | ||
transparent ${maxPx}px, | ||
transparent ${width}px)`, | ||
})} | ||
min={`${min}`} | ||
max={`${max}`} | ||
value={`${value}`} | ||
step={`${step}`} | ||
type="range" | ||
onChange={this.handleChange} | ||
onMouseEnter={this.handleMouseEnter} | ||
onMouseLeave={this.handleMouseLeave} | ||
onFocus={this.handleMouseEnter} | ||
onBlur={this.handleMouseLeave} | ||
/> | ||
|
||
{/** Give illusion of border radius with two dots on end (background gradient above removes it) */} | ||
{[min, max].map((bound) => { | ||
const pxPosition = getPxPosition(bound, min, max, width); | ||
const isOverlappingHandle = Math.abs(handlePositionPx - pxPosition) <= HANDLE_SIZE; | ||
|
||
return isOverlappingHandle ? null : ( | ||
<div | ||
key={bound} | ||
className={cx( | ||
styles.annotation, | ||
styles.annotation_bounds, | ||
bound <= value && styles.annotation_bounds_active, | ||
{ | ||
left: pxPosition, | ||
transform: `translateX(-${ANNOTATION_SIZE / 2}px)`, | ||
}, | ||
)} | ||
/> | ||
); | ||
})} | ||
|
||
{/** Annotations with optional labels */} | ||
{annotations?.map(({ value: annotationValue, label }) => { | ||
const pxPosition = getPxPosition(annotationValue, min, max, width); | ||
const isOverlappingHandle = Math.abs(handlePositionPx - pxPosition) <= HANDLE_SIZE; | ||
|
||
return ( | ||
<div | ||
key={annotationValue} | ||
className={cx( | ||
styles.annotation, | ||
annotationValue <= value && styles.annotation_active, | ||
isOverlappingHandle && styles.annotation_hidden, | ||
{ | ||
left: pxPosition, | ||
transform: `translateX(-${ANNOTATION_SIZE / 2}px)`, | ||
}, | ||
)} | ||
> | ||
{label && <div className={cx(styles.annotationLabel)}>{label}</div>} | ||
</div> | ||
); | ||
})} | ||
|
||
{showPopup && this.renderTooltip(handlePositionPx)} | ||
</div> | ||
); | ||
} | ||
} | ||
|
||
export default withStyles(stylesheetInputRange, { passThemeProp: true })(BaseRange); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
import React, { useState } from 'react'; | ||
import { v4 as uuid } from 'uuid'; | ||
import FormField, { FormFieldProps, partitionFieldProps } from '../FormField'; | ||
import BaseRange, { BaseRangeProps } from './BaseRange'; | ||
import { IgnoreAttributes } from '../private/FormInput'; | ||
|
||
export type RangeProps = Omit<BaseRangeProps, 'id'> & | ||
FormFieldProps & | ||
Omit<React.InputHTMLAttributes<HTMLInputElement>, IgnoreAttributes>; | ||
|
||
/** A controlled range input. */ | ||
export default function Range(props: RangeProps) { | ||
const { fieldProps, inputProps } = partitionFieldProps(props); | ||
const [id] = useState(() => uuid()); | ||
|
||
return ( | ||
<FormField {...fieldProps} id={id}> | ||
{/** inputProps.value is typed as a string, BaseRange takes a number. */} | ||
<BaseRange {...inputProps} value={props.value} id={id} /> | ||
</FormField> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
import React, { useState } from 'react'; | ||
import Range, { RangeProps } from '.'; | ||
|
||
export default { | ||
title: 'Core/Range', | ||
parameters: { | ||
inspectComponents: [Range], | ||
}, | ||
}; | ||
|
||
export function StandardRangeSlider() { | ||
const [value, setValue] = useState(50); | ||
return ( | ||
<> | ||
<Range label="Range" value={value} onChange={(v) => setValue(v)} /> | ||
<Range disabled label="Disabled" value={50} onChange={(v) => setValue(v)} /> | ||
</> | ||
); | ||
} | ||
|
||
StandardRangeSlider.story = { | ||
name: 'A standard range slider.', | ||
}; | ||
|
||
export function RangeSliderWithAnnotations() { | ||
const [value, setValue] = useState(40); | ||
const [[annotations1, annotations2]] = useState(() => [ | ||
[...new Array(6)].map((_, i) => ({ | ||
value: i * 20, | ||
label: `${i * 20}`, | ||
})), | ||
[{ value: 40, label: 'Default' }], | ||
]); | ||
const props: RangeProps = { | ||
label: 'Inline label', | ||
inline: true, | ||
step: 20, | ||
value, | ||
onChange: (v: number) => setValue(v), | ||
}; | ||
|
||
return ( | ||
<> | ||
<Range {...props} annotations={annotations1} /> | ||
<Range {...props} annotations={annotations2} /> | ||
</> | ||
); | ||
} | ||
|
||
RangeSliderWithAnnotations.story = { | ||
name: 'With annotations and step size', | ||
}; | ||
|
||
export function RangeSliderWithTooltip() { | ||
const [value, setValue] = useState(60); | ||
return ( | ||
<> | ||
<Range | ||
alwaysShowTooltip | ||
label="Always show tooltip" | ||
value={value} | ||
onChange={(v) => setValue(v)} | ||
/> | ||
<Range showTooltip label="Tooltip on hover" value={value} onChange={(v) => setValue(v)} /> | ||
<Range | ||
showTooltip | ||
invertTooltip | ||
label="Inverted with custom renderer" | ||
value={value} | ||
renderTooltipContent={() => '🍍'} | ||
onChange={(v) => setValue(v)} | ||
/> | ||
</> | ||
); | ||
} | ||
|
||
RangeSliderWithTooltip.story = { | ||
name: 'With tooltips', | ||
}; | ||
|
||
export function RangeSliderWithWidthAndCustomValues() { | ||
const [value, setValue] = useState(-2); | ||
const [annotations] = useState([-10, -8, -6, -4, -2, 0].map((val) => ({ value: val }))); | ||
const props: RangeProps = { | ||
label: 'range', | ||
hideLabel: true, | ||
alwaysShowTooltip: true, | ||
min: -10, | ||
max: 0, | ||
step: 2, | ||
value, | ||
annotations, | ||
onChange: (v: number) => setValue(v), | ||
}; | ||
return ( | ||
<> | ||
<Range width={200} {...props} /> | ||
<Range width={400} {...props} /> | ||
<Range width={600} {...props} /> | ||
</> | ||
); | ||
} | ||
|
||
RangeSliderWithWidthAndCustomValues.story = { | ||
name: 'Custom width and min/max/step', | ||
}; |
Oops, something went wrong.