Skip to content

Commit

Permalink
new(Range): Add input range component (#362)
Browse files Browse the repository at this point in the history
  • Loading branch information
williaster authored Apr 30, 2020
1 parent 7ed7201 commit 3996ebf
Show file tree
Hide file tree
Showing 8 changed files with 570 additions and 0 deletions.
205 changes: 205 additions & 0 deletions packages/core/src/components/Range/BaseRange.tsx
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);
22 changes: 22 additions & 0 deletions packages/core/src/components/Range/index.tsx
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>
);
}
106 changes: 106 additions & 0 deletions packages/core/src/components/Range/story.tsx
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',
};
Loading

0 comments on commit 3996ebf

Please sign in to comment.