fbredius/storybook

View on GitHub
lib/components/src/controls/Range.tsx

Summary

Maintainability
F
3 days
Test Coverage
import React, { FC, ChangeEvent, useMemo } from 'react';

import { styled } from '@storybook/theming';
import { lighten, darken, rgba } from 'polished';
import { getControlId } from './helpers';
import { ControlProps, NumberValue, RangeConfig } from './types';
import { parse } from './Number';

type RangeProps = ControlProps<NumberValue | null> & RangeConfig;

const RangeInput = styled.input<{ min: number; max: number; value: number }>(
  ({ theme, min, max, value }) => ({
    // Resytled using http://danielstern.ca/range.css/#/
    '&': {
      width: '100%',
      backgroundColor: 'transparent',
      appearance: 'none',
    },

    '&::-webkit-slider-runnable-track': {
      background:
        theme.base === 'light'
          ? `linear-gradient(to right, 
            ${theme.color.green} 0%, ${theme.color.green} ${((value - min) / (max - min)) * 100}%, 
            ${darken(0.02, theme.input.background)} ${((value - min) / (max - min)) * 100}%, 
            ${darken(0.02, theme.input.background)} 100%)`
          : `linear-gradient(to right, 
            ${theme.color.green} 0%, ${theme.color.green} ${((value - min) / (max - min)) * 100}%, 
            ${lighten(0.02, theme.input.background)} ${((value - min) / (max - min)) * 100}%, 
            ${lighten(0.02, theme.input.background)} 100%)`,
      boxShadow: `${theme.appBorderColor} 0 0 0 1px inset`,
      borderRadius: 6,
      width: '100%',
      height: 6,
      cursor: 'pointer',
    },

    '&::-webkit-slider-thumb': {
      marginTop: '-6px',
      width: 16,
      height: 16,

      border: `1px solid ${rgba(theme.appBorderColor, 0.2)}`,
      borderRadius: '50px',
      boxShadow: `0 1px 3px 0px ${rgba(theme.appBorderColor, 0.2)}`,
      cursor: 'grab',
      appearance: 'none',
      background: `${theme.input.background}`,
      transition: 'all 150ms ease-out',

      '&:hover': {
        background: `${darken(0.05, theme.input.background)}`,
        transform: 'scale3d(1.1, 1.1, 1.1) translateY(-1px)',
        transition: 'all 50ms ease-out',
      },

      '&:active': {
        background: `${theme.input.background}`,
        transform: 'scale3d(1, 1, 1) translateY(0px)',
        cursor: 'grabbing',
      },
    },

    '&:focus': {
      outline: 'none',

      '&::-webkit-slider-runnable-track': {
        borderColor: rgba(theme.color.secondary, 0.4),
      },

      '&::-webkit-slider-thumb': {
        borderColor: theme.color.secondary,
        boxShadow: `0 0px 5px 0px ${theme.color.secondary}`,
      },
    },

    '&::-moz-range-track': {
      background:
        theme.base === 'light'
          ? `linear-gradient(to right, 
            ${theme.color.green} 0%, ${theme.color.green} ${((value - min) / (max - min)) * 100}%, 
            ${darken(0.02, theme.input.background)} ${((value - min) / (max - min)) * 100}%, 
            ${darken(0.02, theme.input.background)} 100%)`
          : `linear-gradient(to right, 
            ${theme.color.green} 0%, ${theme.color.green} ${((value - min) / (max - min)) * 100}%, 
            ${lighten(0.02, theme.input.background)} ${((value - min) / (max - min)) * 100}%, 
            ${lighten(0.02, theme.input.background)} 100%)`,
      boxShadow: `${theme.appBorderColor} 0 0 0 1px inset`,
      borderRadius: 6,
      width: '100%',
      height: 6,
      cursor: 'pointer',
      outline: 'none',
    },

    '&::-moz-range-thumb': {
      width: 16,
      height: 16,
      border: `1px solid ${rgba(theme.color.border, 0.2)}`,
      borderRadius: '50px',
      boxShadow: `0 1px 3px 0px ${rgba(theme.color.border, 0.2)}`,
      cursor: 'grab',
      background: `${theme.input.background}`,
      transition: 'all 150ms ease-out',

      '&:hover': {
        background: `${darken(0.05, theme.input.background)}`,
        transform: 'scale3d(1.1, 1.1, 1.1) translateY(-1px)',
        transition: 'all 50ms ease-out',
      },

      '&:active': {
        background: `${theme.input.background}`,
        transform: 'scale3d(1, 1, 1) translateY(0px)',
        cursor: 'grabbing',
      },
    },
    '&::-ms-track': {
      background:
        theme.base === 'light'
          ? `linear-gradient(to right, 
            ${theme.color.green} 0%, ${theme.color.green} ${((value - min) / (max - min)) * 100}%, 
            ${darken(0.02, theme.input.background)} ${((value - min) / (max - min)) * 100}%, 
            ${darken(0.02, theme.input.background)} 100%)`
          : `linear-gradient(to right, 
            ${theme.color.green} 0%, ${theme.color.green} ${((value - min) / (max - min)) * 100}%, 
            ${lighten(0.02, theme.input.background)} ${((value - min) / (max - min)) * 100}%, 
            ${lighten(0.02, theme.input.background)} 100%)`,
      boxShadow: `${theme.appBorderColor} 0 0 0 1px inset`,
      color: 'transparent',
      width: '100%',
      height: '6px',
      cursor: 'pointer',
    },
    '&::-ms-fill-lower': {
      borderRadius: 6,
    },
    '&::-ms-fill-upper': {
      borderRadius: 6,
    },
    '&::-ms-thumb': {
      width: 16,
      height: 16,
      background: `${theme.input.background}`,
      border: `1px solid ${rgba(theme.appBorderColor, 0.2)}`,
      borderRadius: 50,
      cursor: 'grab',
      marginTop: 0,
    },
    '@supports (-ms-ime-align:auto)': { 'input[type=range]': { margin: '0' } },
  })
);

const RangeLabel = styled.span({
  paddingLeft: 5,
  paddingRight: 5,
  fontSize: 12,
  whiteSpace: 'nowrap',
  fontFeatureSettings: 'tnum',
  fontVariantNumeric: 'tabular-nums',
});

const RangeWrapper = styled.div({
  display: 'flex',
  alignItems: 'center',
  width: '100%',
});

function getNumberOfDecimalPlaces(number: number) {
  const match = number.toString().match(/(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/);
  return !match
    ? 0
    : Math.max(
        0,
        // Number of digits right of decimal point.
        (match[1] ? match[1].length : 0) -
          // Adjust for scientific notation.
          (match[2] ? +match[2] : 0)
      );
}

export const RangeControl: FC<RangeProps> = ({
  name,
  value,
  onChange,
  min = 0,
  max = 100,
  step = 1,
  onBlur,
  onFocus,
}) => {
  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    onChange(parse(event.target.value));
  };

  const hasValue = value !== undefined;
  const numberOFDecimalsPlaces = useMemo(() => getNumberOfDecimalPlaces(step), [step]);

  return (
    <RangeWrapper>
      <RangeLabel>{min}</RangeLabel>
      <RangeInput
        id={getControlId(name)}
        type="range"
        onChange={handleChange}
        {...{ name, value, min, max, step, onFocus, onBlur }}
      />
      <RangeLabel>
        {`${hasValue ? value.toFixed(numberOFDecimalsPlaces) : '--'}`} / {max}
      </RangeLabel>
    </RangeWrapper>
  );
};