SymphonyPlatformSolutions/symphony-ui-toolkit

View on GitHub
packages/components/src/components/time-picker/TimePicker.tsx

Summary

Maintainability
C
1 day
Test Coverage
import { Dropdown } from '../dropdown';
import * as React from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Keys } from '../common/eventUtils';
import { ErrorMessages } from '../validation/interfaces';
import { DisabledTime, TimePickerOption, TimePickerProps, TimePickerPropTypes } from './interfaces';
import {
  formatTimeISO,
  formatISOTimeToSeconds,
  getFormattedTime,
  getISOTimeFromLocalTime,
  getNextSelectionIndexes,
  getOptions,
  getSteps,
  getUserFormat,
  isTimeDisabled,
  isTimeProposed,
  isTimeSelected,
  Time,
  ISO_TIME_SEPARATOR,
  getDelimiterPosition,
} from './utils';

enum STEP {
  MIN_STEP_VALUE = 600,
  DEFAULT_STEP_VALUE = 900,
  MAX_STEP_VALUE = 43200,
}

export const TimePicker: React.FC<TimePickerProps> = ({
  id,
  disabled,
  disabledTimes,
  format,
  label,
  min,
  max,
  name,
  onBlur,
  onChange,
  onCopy,
  onCut,
  onDrag,
  onFocus,
  onValidationChanged,
  placeholder,
  step,
  strict,
  tooltip,
  tooltipCloseLabel,
  showRequired,
  helperText,
  value,
  menuPortalStyles,
  menuPortalTarget,
  menuShouldBlockScroll,
}) => {
  const [hours, setHours] = useState('');
  const [minutes, setMinutes] = useState('');
  const [seconds, setSeconds] = useState('');
  const [selectedOption, setSelectedOption] = useState(null);
  const [inputValue, setInputValue] = useState(undefined);
  const [menuIsOpen, setMenuIsOpen] = useState(false);
  // Indicate if the user is navigating with arrow keys in the Dropdown menu
  const [navigationInMenu, setNavigationInMenu] = useState(false);

  useEffect(() => {
    // Called when the user select an option in the Dropdown menu
    if (selectedOption) {
      setHours(selectedOption.data.time.hours);
      setMinutes(selectedOption.data.time.minutes);
      setSeconds(selectedOption.data.time.seconds);
      setInputValue(selectedOption.label);
    }
  }, [selectedOption]);

  useEffect(() => {
    if (inputValue !== null && inputValue !== undefined) {
      // Called when the user enters a new date in the input field
      const newISOTime = getISOTimeFromLocalTime(inputValue, format);
      let newHours = '';
      let newMinutes = '';
      let newSeconds = '';
      let newSelectedOption : TimePickerOption = null;
      const errors = onValidationChanged ? computeError(
        inputValue,
        newISOTime,
        min,
        max,
        disabledTimes,
        strict,
        options
      ) : null;

      if (onValidationChanged) {
        onValidationChanged(errors);
      }

      if (newISOTime && !errors) {
        newSelectedOption = options.find(
          (option: TimePickerOption) => newISOTime.isEqual(option?.data?.time)
        );
        newHours = newSelectedOption?.data?.time?.hours;
        newMinutes = newSelectedOption?.data?.time?.minutes;
        newSeconds = newSelectedOption?.data?.time?.seconds;
      }
      // Called onChange prop
      if (onChange) {
        onChange({
          target: {
            value: inputValue === '' ? inputValue : formatTimeISO(newISOTime),
          },
        });
      }
      // Update values
      setHours(newHours);
      setMinutes(newMinutes);
      setSeconds(newSeconds);
      setSelectedOption(newSelectedOption)
    }
  }, [inputValue]);

  useEffect(() => {
    if (value !== null && value !== undefined) {
      // Value prop has changed
      if (value === '') {
        setInputValue(value);
      } else {
        const newTime = getISOTimeFromLocalTime(value);
        if (newTime) {
          setInputValue(getFormattedTime(newTime, format));
        }
      }
    }
  }, [value]);

  const validatedStep = useMemo(() => {
    if (step === null || step === undefined || isNaN(Number(step))) {
      const stepValue = STEP.DEFAULT_STEP_VALUE;
      console.error(
        `Invalid step value: Step value ${step} is not a number, the value ${stepValue} will be used.`
      );
      return stepValue;
    } else if (step < STEP.MIN_STEP_VALUE) {
      const stepValue = STEP.MIN_STEP_VALUE;
      console.error(
        `Invalid step value: Step value ${step} too small, the value ${stepValue} will be used.`
      );
      return stepValue;
    } else if (step > STEP.MAX_STEP_VALUE) {
      const stepValue = STEP.MAX_STEP_VALUE;
      console.error(
        `Invalid step value: Step value ${step} too big, the value ${stepValue} will be used.`
      );
      return stepValue;
    }
    return step;
  }, [step]);

  const options = useMemo(
    () =>
      getOptions(
        format,
        formatISOTimeToSeconds(min),
        formatISOTimeToSeconds(max),
        validatedStep
      ),
    [format, min, max, validatedStep]
  );

  const toggleMenu = () => setMenuIsOpen(!menuIsOpen);

  const steps = useMemo(() => getSteps(options, disabledTimes), [
    options,
    disabledTimes,
  ]);

  if (!placeholder) {
    placeholder = format ? format : getUserFormat();
  }

  const onFocusWrapped = (event: React.FocusEvent<HTMLInputElement>) => {
    if (onFocus) {
      onFocus(event);
    }
    handleFocus(event)
  };

  return (
    <Dropdown<TimePickerOption>
      autoScrollToCurrent={true}
      displayArrowIndicator={false}
      filterFunction={() => true}
      iconName="recent"
      id={id}
      isDisabled={disabled}
      isOptionDisabled={(option: TimePickerOption) => isTimeDisabled(option?.data?.time, disabledTimes)}
      isOptionSelected={(option: TimePickerOption) =>
        isTimeSelected(option?.data?.time, hours, minutes, seconds, disabledTimes)
      }
      inputAlwaysDisplayed={true}
      inputValue={inputValue}
      label={label}
      menuIsOpen={menuIsOpen}
      menuPortalStyles={menuPortalStyles}
      menuPortalTarget={menuPortalTarget}
      menuShouldBlockScroll={menuShouldBlockScroll}
      name={name}
      onMenuClose={() => setMenuIsOpen(false)}
      onMenuOpen={() => setMenuIsOpen(true)}
      options={options}
      onBlur={() => {
        onBlur && onBlur({
          target: {
            value: inputValue ? formatTimeISO(getISOTimeFromLocalTime(inputValue, format)) : '',
          },
        });
      }}
      onChange={(newValue) => {
        const option =
          newValue && newValue.target && newValue.target.value
            ? newValue.target.value
            : null;
        // Called when the user select an option in the Dropdown menu
        setSelectedOption(option);
      }}
      onCopy={onCopy}
      onCut={onCut}
      onDrag={onDrag}
      onFocus={onFocusWrapped}
      onKeyDown={(event) =>
        handleKeyDown(
          event,
          setInputValue,
          options,
          steps,
          format,
          toggleMenu,
          navigationInMenu,
          setNavigationInMenu
        )
      }
      onKeyUp={(event) => moveFocusOnNextField(event, setInputValue, format)}
      onInputChange={(newValue, metadata) => {
        // Called when the user set a new value in the Input field
        if (metadata.action === 'input-change') {
          setInputValue(newValue);
        }
        setNavigationInMenu(false);
      }}
      placeHolder={placeholder}
      showRequired={showRequired}
      tabSelectsValue={false}
      tooltip={tooltip}
      tooltipCloseLabel={tooltipCloseLabel}
      value={selectedOption}
      helperText={helperText}
    />
  );
};

TimePicker.propTypes = TimePickerPropTypes;

/**
 * Test if the input value raised an error to the Validation component
 * @param value Input value
 * @param time Value parsed in ISO Time
 * @param min Min Time value in ISO format
 * @param max Max Time value in ISO format
 * @param disabledTimes
 * @param strict
 * @param options
 */
const computeError = (
  value: string,
  time: Time,
  min: string,
  max: string,
  disabledTimes: DisabledTime | Array<DisabledTime>,
  strict: boolean,
  options: Array<TimePickerOption>
): ErrorMessages => {
  if (!value) {
    return null;
  }

  if (!time) {
    return { format: 'The time format is incorrect' };
  } else {
    if (formatTimeISO(time) < min) {
      return { minTime: 'Time too far in the past' };
    } else if (max < formatTimeISO(time)) {
      return { maxTime: 'Time too far in the future' };
    } else if (
      isTimeDisabled(time, disabledTimes) ||
      (strict && !isTimeProposed(time, options))
    ) {
      return { disabledTime: 'This time is not available' };
    } else {
      return null;
    }
  }
};

/**
 * Handle Keyboard navigation in the input Text field
 *
 * @param event Keyboard event
 */
const handleKeyboardNavigation = (event) => {
  const currentValue = event.target.value;

  // Get cursor position
  const cursor = event.target.selectionStart;

  if (event.key === Keys.TAB) {
    // Manage Tab and Tab + Shift navigation

    const moveForward = !event.shiftKey;
    const delimiterPositions = getNextSelectionIndexes(
      currentValue,
      cursor,
      moveForward
    );
    if (delimiterPositions) {
      event.target.setSelectionRange(
        delimiterPositions.start,
        delimiterPositions.end
      );
      event.preventDefault();
    }
  }
};

const moveFocusOnNextField = (event, setInputValue, format) => {
  const isValidCharacter = /^[0-9aAmMpP]$/i.test(event.key);
  const cursorPosition = event.target.selectionStart;
  let inputValue = event.target.value;
  if (isValidCharacter) {
    if (
      cursorPosition === inputValue.length && // The input was empty, the user is typing a new value
      getDelimiterPosition(format.substring(cursorPosition)) === 0
    ) {
      // Append a delimiter
      inputValue = `${inputValue}${format.charAt(cursorPosition)}`;
      setInputValue(inputValue);
    }
    const delimiterPositionInInput = getDelimiterPosition(
      inputValue.substring(cursorPosition)
    );
    const delimiterPositionInFormat = getDelimiterPosition(
      format.substring(cursorPosition)
    );
    if (
      delimiterPositionInInput === 0 &&
      // Test if delimiter is at the same position in the format, otherwise it means that the user has not finished the entry
      delimiterPositionInInput === delimiterPositionInFormat
    ) {
      // Move focus selection
      const delimiterPositions = getNextSelectionIndexes(
        inputValue,
        cursorPosition
      );
      if (delimiterPositions) {
        event.target.setSelectionRange(
          delimiterPositions.start,
          delimiterPositions.end
        );
      }
    }
  }
};

const handleKeyDown = (
  event,
  setInputValue,
  options,
  steps,
  format,
  toggleMenu,
  navigationInMenu,
  setNavigationInMenu
) => {
  if (event.target && event.target.tagName === 'INPUT') {
    if (event.key === Keys.ARROW_UP || event.key === Keys.ARROW_DOWN) {
      setNavigationInMenu(true);
    } else if (event.key === Keys.TAB) {
      handleKeyboardNavigation(event);
    } else if (event.key === Keys.ENTER) {
      toggleMenu();
      if (
        !navigationInMenu &&
        event.target.value &&
        event.target.value.trim() !== ''
      ) {
        // To prevent the input value from being overwritten by the value of the focused Dropdown option
        event.preventDefault();
      }
      setNavigationInMenu(false);
    }
  }
};

const handleFocus = (event) => {
  if (event && event.target) {
    const currentValue = event.target.value;
    const cursor = event.target.selectionStart;
    if (cursor === 0 && currentValue) {
      // Set focus on hours
      const separatorPosition = currentValue.indexOf(ISO_TIME_SEPARATOR);
      event.target.setSelectionRange(0, separatorPosition);
    }
  }
};

TimePicker.defaultProps = {
  disabledTimes: [],
  format: getUserFormat(),
  max: '23:59:59',
  min: '00:00:00',
  step: STEP.DEFAULT_STEP_VALUE,
  strict: true,
}

export default TimePicker;