grommet/grommet

View on GitHub
src/js/components/RangeSelector/RangeSelector.js

Summary

Maintainability
D
2 days
Test Coverage
import React, {
  forwardRef,
  useCallback,
  useContext,
  useEffect,
  useRef,
  useState,
} from 'react';
import styled, { ThemeContext } from 'styled-components';
import { useLayoutEffect } from '../../utils/use-isomorphic-layout-effect';

import { Box } from '../Box';
import { EdgeControl } from './EdgeControl';
import { FormContext } from '../Form/FormContext';
import { Text } from '../Text';
import { parseMetricToNum } from '../../utils';
import { MessageContext } from '../../contexts/MessageContext';
import { RangeSelectorPropTypes } from './propTypes';
import { DataFormContext } from '../../contexts/DataFormContext';

const Container = styled(Box)`
  user-select: none;
`;

export const getDecimalCount = (number) => {
  if (Number.isInteger(number)) {
    return 0;
  }
  // handle small numbers (0.00000001) which javascript
  // will turn into `e-`
  if (Math.abs(number) < 1) {
    const parts = number.toExponential().split('e-');
    const decimalPart = parts[0].split('.')[1] || '';
    return decimalPart.length + parseInt(parts[1], 10);
  }

  const decimalPart = number.toString().split('.')[1] || '';
  return decimalPart.length;
};

// avoid floating point issues like 0.15 + 0.3 = 0.44999999999999996
// and turn into 0.15 + 0.3 = 0.45
export const valueToStepPrecision = (value, step, min) => {
  const nearestTrueStep = Math.round((value - min) / step) * step + min;
  return Number(nearestTrueStep.toFixed(getDecimalCount(step)));
};

// ensure values are within min/max
const clamp = (value, min, max) => Math.min(Math.max(min, value), max);

const RangeSelector = forwardRef(
  (
    {
      color,
      defaultValues = [],
      direction = 'horizontal',
      invert,
      label,
      max = 100,
      messages,
      min = 0,
      name,
      onChange,
      opacity = 'medium',
      round,
      size = 'medium',
      step = 1,
      values: valuesProp,
      ...rest
    },
    ref,
  ) => {
    const theme = useContext(ThemeContext) || defaultProps.theme;
    const { format } = useContext(MessageContext);
    const formContext = useContext(FormContext);
    const [changing, setChanging] = useState();
    const [lastChange, setLastChange] = useState();
    const [moveValue, setMoveValue] = useState();
    const containerRef = useRef();
    const maxRef = useRef();
    const minRef = useRef();
    const labelWidthRef = useRef(0);

    const [values, setValues] = formContext.useFormInput({
      name,
      value: valuesProp?.map((n) => clamp(n, min, max)),
      initialValue: defaultValues,
    });

    // for DataFilters to know when RangeSelector is set to its min/max
    const { pendingReset } = useContext(DataFormContext);
    const updatePendingReset = useCallback(
      (nextMin, nextMax) => {
        if (nextMin === min && nextMax === max) {
          pendingReset?.current.add(name);
        } else if (pendingReset?.current?.has(name)) {
          pendingReset?.current.delete(name);
        }
      },
      [max, min, name, pendingReset],
    );

    const change = useCallback(
      (nextValues) => {
        let [nextMin, nextMax] = nextValues;
        // only adjust value to step precision if it's not the min/max
        if (nextMin !== min && nextMin !== max)
          nextMin = valueToStepPrecision(nextValues[0], step, min);
        if (nextMax !== min && nextMax !== max)
          nextMax = valueToStepPrecision(nextValues[1], step, min);

        // ensure values are within min/max
        nextMin = clamp(nextMin, min, max);
        nextMax = clamp(nextMax, min, max);

        // make sure this is only called if both of the values
        // are actually distinct from the previous values
        if (nextMin !== values[0] || nextMax !== values[1]) {
          updatePendingReset(nextMin, nextMax);
          setValues([nextMin, nextMax]);
          if (onChange) onChange([nextMin, nextMax]);
        }
      },
      [onChange, setValues, step, max, min, values, updatePendingReset],
    );

    const valueForMouseCoord = useCallback(
      (event) => {
        const rect = containerRef.current.getBoundingClientRect();
        let value;
        if (direction === 'vertical') {
          // there is no x and y in unit testing
          const y = event.clientY - (rect.top || 0); // test resilience
          const scaleY = rect.height / (max - min + 1) || 1; // test resilience
          value = Math.floor(y / scaleY) + min;
        } else {
          const x = event.clientX - (rect.left || 0); // test resilience
          const scaleX = rect.width / (max - min + 1) || 1; // test resilience
          value = Math.floor(x / scaleX) + min;
        }
        // align with closest step within [min, max]
        const result = Math.ceil(value / step) * step;
        if (result < min) {
          return min;
        }
        if (result > max) {
          return max;
        }
        return result;
      },
      [direction, max, min, step],
    );

    const onMouseMove = useCallback(
      (event) => {
        const value = valueForMouseCoord(event);
        let nextValues;
        if (changing === 'lower' && value <= values[1] && value !== moveValue) {
          nextValues = [value, values[1]];
        } else if (
          changing === 'upper' &&
          value >= values[0] &&
          value !== moveValue
        ) {
          nextValues = [values[0], value];
        } else if (changing === 'selection' && value !== moveValue) {
          if (value === max) {
            nextValues = [max - (values[1] - values[0]), max];
          } else if (value === min) {
            nextValues = [min, min + (values[1] - values[0])];
          } else {
            const delta = value - moveValue;
            if (values[0] + delta >= min && values[1] + delta <= max) {
              nextValues = [values[0] + delta, values[1] + delta];
            }
          }
        }
        if (nextValues) {
          setMoveValue(value);
          change(nextValues);
        }
      },
      [
        values,
        change,
        changing,
        moveValue,
        max,
        min,
        setMoveValue,
        valueForMouseCoord,
      ],
    );

    useEffect(() => {
      const onMouseUp = () => setChanging(undefined);

      if (changing) {
        window.addEventListener('mousemove', onMouseMove);
        window.addEventListener('mouseup', onMouseUp);

        return () => {
          window.removeEventListener('mousemove', onMouseMove);
          window.removeEventListener('mouseup', onMouseUp);
        };
      }
      return undefined;
    }, [changing, onMouseMove]);

    const onClick = useCallback(
      (event) => {
        const value = valueForMouseCoord(event);
        if (
          value <= values[0] ||
          (value < values[1] && lastChange === 'lower')
        ) {
          setLastChange('lower');
          change([value, values[1]]);
        } else if (
          value >= values[1] ||
          (value > values[0] && lastChange === 'upper')
        ) {
          setLastChange('upper');
          change([values[0], value]);
        }
      },
      [change, lastChange, valueForMouseCoord, values],
    );

    const onTouchMove = useCallback(
      (event) => {
        const touchEvent = event.changedTouches[0];
        onMouseMove(touchEvent);
      },
      [onMouseMove],
    );

    // keep the text values size consistent
    useLayoutEffect(() => {
      if (maxRef.current && minRef.current) {
        maxRef.current.style.width = '';
        minRef.current.style.width = '';
        const width = Math.max(
          labelWidthRef.current,
          maxRef.current.getBoundingClientRect().width,
          minRef.current.getBoundingClientRect().width,
        );
        maxRef.current.style.width = `${width}px`;
        minRef.current.style.width = `${width}px`;
        labelWidthRef.current = width;
      }
    });

    const [lower, upper] = values;
    // It needs to be true when vertical, due to how browsers manage height
    // const fill = direction === 'vertical' ? true : 'horizontal';
    const thickness =
      size === 'full'
        ? undefined
        : `${parseMetricToNum(theme.global.edgeSize[size] || size)}px`;
    const layoutProps = { fill: direction, round };
    if (direction === 'vertical') layoutProps.width = thickness;
    else layoutProps.height = thickness;
    if (size === 'full') layoutProps.alignSelf = 'stretch';

    let content = (
      <Container
        ref={containerRef}
        direction={direction === 'vertical' ? 'column' : 'row'}
        align="center"
        fill
        {...(label ? {} : rest)}
        tabIndex="-1"
        onClick={onClick}
        onTouchMove={onTouchMove}
      >
        <Box
          style={{ flex: `${lower - min} 0 0` }}
          background={
            invert
              ? // preserve existing dark, instead of using darknes
                // of this color
                {
                  color: color || theme.rangeSelector.background.invert.color,
                  opacity,
                  dark: theme.dark,
                }
              : undefined
          }
          {...layoutProps}
        />
        <EdgeControl
          a11yTitle={format({ id: 'rangeSelector.lower', messages })}
          role="slider"
          aria-valuenow={lower}
          aria-valuemin={min}
          aria-valuemax={max}
          tabIndex={0}
          ref={ref}
          color={color}
          direction={direction}
          thickness={thickness}
          edge="lower"
          onMouseDown={() => setChanging('lower')}
          onTouchStart={() => setChanging('lower')}
          onDecrease={() => change([lower - step, upper])}
          onIncrease={
            lower + step <= upper
              ? () => change([lower + step, upper])
              : () => change([upper, upper])
          }
        />
        <Box
          style={{
            flex: `${upper - lower + 1} 0 0`,
            cursor: direction === 'vertical' ? 'ns-resize' : 'ew-resize',
          }}
          background={
            invert
              ? undefined
              : // preserve existing dark, instead of using darknes of
                // this color
                { color: color || 'control', opacity, dark: theme.dark }
          }
          {...layoutProps}
          onMouseDown={(event) => {
            const nextMoveValue = valueForMouseCoord(event);
            setChanging('selection');
            setMoveValue(nextMoveValue);
          }}
        />
        <EdgeControl
          a11yTitle={format({ id: 'rangeSelector.upper', messages })}
          role="slider"
          aria-valuenow={upper}
          aria-valuemin={min}
          aria-valuemax={max}
          tabIndex={0}
          color={color}
          direction={direction}
          thickness={thickness}
          edge="upper"
          onMouseDown={() => setChanging('upper')}
          onTouchStart={() => setChanging('upper')}
          onDecrease={
            upper - step >= lower
              ? () => change([lower, upper - step])
              : () => change([lower, lower])
          }
          onIncrease={() => change([lower, upper + step])}
        />
        <Box
          style={{ flex: `${max - upper} 0 0` }}
          background={
            invert
              ? // preserve existing dark, instead of using darknes of this
                // color
                {
                  color: color || theme.rangeSelector.background.invert.color,
                  opacity,
                  dark: theme.dark,
                }
              : undefined
          }
          {...layoutProps}
          round={round}
        />
      </Container>
    );

    if (label) {
      content = (
        <Box
          direction={direction === 'vertical' ? 'column' : 'row'}
          align="center"
          fill
          {...rest}
        >
          <Text
            ref={minRef}
            textAlign="end"
            size="small"
            margin={{ horizontal: 'small' }}
          >
            {typeof label === 'function' ? label(lower) : lower}
          </Text>
          {content}
          <Text ref={maxRef} size="small" margin={{ horizontal: 'small' }}>
            {typeof label === 'function' ? label(upper) : upper}
          </Text>
        </Box>
      );
    }

    return content;
  },
);

RangeSelector.displayName = 'RangeSelector';
RangeSelector.propTypes = RangeSelectorPropTypes;

export { RangeSelector };