All Downloads are FREE. Search and download functionalities are using the official Maven repository.

package.src.components.TimePicker.TimePicker.tsx Maven / Gradle / Ivy

Go to download

This library provides a set of common React components for use with the PatternFly reference implementation.

The newest version!
import * as React from 'react';
import { css } from '@patternfly/react-styles';
import datePickerStyles from '@patternfly/react-styles/css/components/DatePicker/date-picker';
import menuStyles from '@patternfly/react-styles/css/components/Menu/menu';
import { getUniqueId } from '../../helpers';
import { Popper } from '../../helpers/Popper/Popper';
import { Menu, MenuContent, MenuList, MenuItem } from '../Menu';
import { InputGroup, InputGroupItem } from '../InputGroup';
import { TextInput, TextInputProps } from '../TextInput';
import { KeyTypes } from '../../helpers/constants';
import {
  parseTime,
  validateTime,
  makeTimeOptions,
  amSuffix,
  pmSuffix,
  getHours,
  getMinutes,
  isWithinMinMax,
  getSeconds
} from './TimePickerUtils';
import { HelperText, HelperTextItem } from '../HelperText';
import OutlinedClockIcon from '@patternfly/react-icons/dist/esm/icons/outlined-clock-icon';
import cssDatePickerFormControlWidth from '@patternfly/react-tokens/dist/esm/c_date_picker__input_c_form_control_Width';

export interface TimePickerProps
  extends Omit, 'onChange' | 'onFocus' | 'onBlur' | 'disabled' | 'ref'> {
  /** Additional classes added to the time picker. */
  className?: string;
  /** Accessible label for the time picker */
  'aria-label'?: string;
  /** Flag indicating the time picker is disabled */
  isDisabled?: boolean;
  /** String to display in the empty time picker field as a hint for the expected time format */
  placeholder?: string;
  /** Character to display between the hour and minute */
  delimiter?: string;
  /** A time string. The format could be  an ISO 8601 formatted date string or in 'HH{delimiter}MM' format */
  time?: string | Date;
  /** Error message to display when the time is provided in an invalid format. */
  invalidFormatErrorMessage?: string;
  /** Error message to display when the time provided is not within the minTime/maxTime constriants */
  invalidMinMaxErrorMessage?: string;
  /** True if the time is 24 hour time. False if the time is 12 hour time */
  is24Hour?: boolean;
  /** Optional event handler called each time the value in the time picker input changes. */
  onChange?: (
    event: React.FormEvent,
    time: string,
    hour?: number,
    minute?: number,
    seconds?: number,
    isValid?: boolean
  ) => void;
  /** Optional validator can be provided to override the internal time validator. */
  validateTime?: (time: string) => boolean;
  /** Id of the time picker */
  id?: string;
  /** Width of the time picker. */
  width?: string;
  /** The container to append the menu to. Defaults to 'inline'.
   * If your menu is being cut off you can append it to an element higher up the DOM tree.
   * Some examples:
   * menuAppendTo="parent"
   * menuAppendTo={() => document.body}
   * menuAppendTo={document.getElementById('target')}
   */
  menuAppendTo?: HTMLElement | (() => HTMLElement) | 'inline' | 'parent';
  /** Size of step between time options in minutes.*/
  stepMinutes?: number;
  /** Additional props for input field */
  inputProps?: TextInputProps;
  /** A time string indicating the minimum value allowed. The format could be an ISO 8601 formatted date string or in 'HH{delimiter}MM' format */
  minTime?: string | Date;
  /** A time string indicating the maximum value allowed. The format could be an ISO 8601 formatted date string or in 'HH{delimiter}MM' format */
  maxTime?: string | Date;
  /** Includes number of seconds with the chosen time and allows users to manually edit the seconds value. */
  includeSeconds?: boolean;
  /** Flag to control the opened state of the time picker menu */
  isOpen?: boolean;
  /** Handler invoked each time the open state of time picker updates */
  setIsOpen?: (isOpen?: boolean) => void;
  /** z-index of the time picker */
  zIndex?: number;
}

interface TimePickerState {
  isInvalid: boolean;
  isTimeOptionsOpen: boolean;
  timeState: string;
  focusedIndex: number;
  scrollIndex: number;
  timeRegex: RegExp;
  minTimeState: string;
  maxTimeState: string;
}

class TimePicker extends React.Component {
  static displayName = 'TimePicker';
  private baseComponentRef = React.createRef();
  private toggleRef = React.createRef();
  private inputRef = React.createRef();
  private menuRef = React.createRef();

  static defaultProps = {
    className: '',
    isDisabled: false,
    time: '',
    is24Hour: false,
    invalidFormatErrorMessage: 'Invalid time format',
    invalidMinMaxErrorMessage: 'Invalid time entered',
    placeholder: 'hh:mm',
    delimiter: ':',
    'aria-label': 'Time picker',
    width: '150px',
    menuAppendTo: 'inline',
    stepMinutes: 30,
    inputProps: {},
    minTime: '',
    maxTime: '',
    isOpen: false,
    setIsOpen: () => {},
    zIndex: 9999
  };

  constructor(props: TimePickerProps) {
    super(props);
    const { is24Hour, delimiter, time, includeSeconds, isOpen } = this.props;
    let { minTime, maxTime } = this.props;
    if (minTime === '') {
      const minSeconds = includeSeconds ? `${delimiter}00` : '';
      minTime = is24Hour ? `00${delimiter}00${minSeconds}` : `12${delimiter}00${minSeconds} AM`;
    }
    if (maxTime === '') {
      const maxSeconds = includeSeconds ? `${delimiter}59` : '';
      maxTime = is24Hour ? `23${delimiter}59${maxSeconds}` : `11${delimiter}59${maxSeconds} PM`;
    }
    const timeRegex = this.getRegExp();
    this.state = {
      isInvalid: false,
      isTimeOptionsOpen: isOpen,
      timeState: parseTime(time, timeRegex, delimiter, !is24Hour, includeSeconds),
      focusedIndex: null,
      scrollIndex: 0,
      timeRegex,
      minTimeState: parseTime(minTime, timeRegex, delimiter, !is24Hour, includeSeconds),
      maxTimeState: parseTime(maxTime, timeRegex, delimiter, !is24Hour, includeSeconds)
    };
  }

  componentDidMount() {
    document.addEventListener('mousedown', this.onDocClick);
    document.addEventListener('touchstart', this.onDocClick);
    document.addEventListener('keydown', this.handleGlobalKeys);

    this.setState({ isInvalid: !this.isValid(this.state.timeState) });
  }

  componentWillUnmount() {
    document.removeEventListener('mousedown', this.onDocClick);
    document.removeEventListener('touchstart', this.onDocClick);
    document.removeEventListener('keydown', this.handleGlobalKeys);
  }

  onDocClick = (event: MouseEvent | TouchEvent) => {
    const clickedOnToggle = this.toggleRef?.current?.contains(event.target as Node);
    const clickedWithinMenu = this.menuRef?.current?.contains(event.target as Node);
    if (this.state.isTimeOptionsOpen && !(clickedOnToggle || clickedWithinMenu)) {
      this.onToggle(false);
    }
  };

  handleGlobalKeys = (event: KeyboardEvent) => {
    const { isTimeOptionsOpen, focusedIndex, scrollIndex } = this.state;
    // keyboard pressed while focus on toggle
    if (this.inputRef?.current?.contains(event.target as Node)) {
      if (!isTimeOptionsOpen && event.key !== KeyTypes.Tab && event.key !== KeyTypes.Escape) {
        this.onToggle(true);
      } else if (isTimeOptionsOpen) {
        if (event.key === KeyTypes.Escape || event.key === KeyTypes.Tab) {
          this.onToggle(false);
        } else if (event.key === KeyTypes.Enter) {
          if (focusedIndex !== null) {
            this.focusSelection(focusedIndex);
            event.stopPropagation();
          } else {
            this.onToggle(false);
          }
        } else if (event.key === KeyTypes.ArrowDown || event.key === KeyTypes.ArrowUp) {
          this.focusSelection(scrollIndex);
          this.updateFocusedIndex(0);
          event.preventDefault();
        }
      }
      // keyboard pressed while focus on menu item
    } else if (this.menuRef?.current?.contains(event.target as Node)) {
      if (event.key === KeyTypes.ArrowDown) {
        this.updateFocusedIndex(1);
        event.preventDefault();
      } else if (event.key === KeyTypes.ArrowUp) {
        this.updateFocusedIndex(-1);
        event.preventDefault();
      } else if (event.key === KeyTypes.Escape || event.key === KeyTypes.Tab) {
        this.inputRef.current.focus();
        this.onToggle(false);
      }
    }
  };

  componentDidUpdate(prevProps: TimePickerProps, prevState: TimePickerState) {
    const { timeState, isTimeOptionsOpen, isInvalid, timeRegex } = this.state;
    const { time, is24Hour, delimiter, includeSeconds, isOpen, minTime, maxTime } = this.props;
    if (prevProps.isOpen !== isOpen) {
      this.onToggle(isOpen);
    }

    if (isTimeOptionsOpen && !prevState.isTimeOptionsOpen && timeState && !isInvalid) {
      this.scrollToSelection(timeState);
    }
    if (delimiter !== prevProps.delimiter) {
      this.setState({
        timeRegex: this.getRegExp()
      });
    }
    if (time !== '' && time !== prevProps.time) {
      const parsedTime = parseTime(time, timeRegex, delimiter, !is24Hour, includeSeconds);

      this.setState({
        timeState: parsedTime,
        isInvalid: !this.isValid(parsedTime)
      });
    }
    if (minTime !== '' && minTime !== prevProps.minTime) {
      this.setState({
        minTimeState: parseTime(minTime, timeRegex, delimiter, !is24Hour, includeSeconds)
      });
    }

    if (maxTime !== '' && maxTime !== prevProps.maxTime) {
      this.setState({
        maxTimeState: parseTime(maxTime, timeRegex, delimiter, !is24Hour, includeSeconds)
      });
    }
  }

  updateFocusedIndex = (increment: number) => {
    this.setState((prevState) => {
      const maxIndex = this.getOptions().length - 1;
      let nextIndex =
        prevState.focusedIndex !== null ? prevState.focusedIndex + increment : prevState.scrollIndex + increment;
      if (nextIndex < 0) {
        nextIndex = maxIndex;
      } else if (nextIndex > maxIndex) {
        nextIndex = 0;
      }
      this.scrollToIndex(nextIndex);
      return {
        focusedIndex: nextIndex
      };
    });
  };

  // fixes issue where menutAppendTo="inline" results in the menu item that should be scrolled to being out of view; this will select the menu item that comes before the intended one, causing that before-item to be placed out of view instead
  getIndexToScroll = (index: number) => {
    if (this.props.menuAppendTo === 'inline') {
      return index > 0 ? index - 1 : 0;
    }
    return index;
  };

  scrollToIndex = (index: number) => {
    this.getOptions()[index].closest(`.${menuStyles.menuContent}`).scrollTop =
      this.getOptions()[this.getIndexToScroll(index)].offsetTop;
  };

  focusSelection = (index: number) => {
    const indexToFocus = index !== -1 ? index : 0;

    if (this.menuRef?.current) {
      (this.getOptions()[indexToFocus].querySelector(`.${menuStyles.menuItem}`) as HTMLElement).focus();
    }
  };

  scrollToSelection = (time: string) => {
    const { delimiter, is24Hour } = this.props;
    let splitTime = time.split(this.props.delimiter);
    let focusedIndex = null;

    // build out the rest of the time assuming hh:00 if it's a partial time
    if (splitTime.length < 2) {
      time = `${time}${delimiter}00`;
      splitTime = time.split(delimiter);
      // due to only the input including seconds when includeSeconds=true, we need to build a temporary time here without those seconds so that an exact or close match can be scrolled to within the menu (which does not include seconds in any of the options)
    } else if (splitTime.length > 2) {
      time = parseTime(time, this.state.timeRegex, delimiter, !is24Hour, false);
      splitTime = time.split(delimiter);
    }

    // for 12hr variant, autoscroll to pm if it's currently the afternoon, otherwise autoscroll to am
    if (!is24Hour && splitTime.length > 1 && splitTime[1].length < 2) {
      const minutes = splitTime[1].length === 0 ? '00' : splitTime[1] + '0';
      time = `${splitTime[0]}${delimiter}${minutes}${new Date().getHours() > 11 ? pmSuffix : amSuffix}`;
    } else if (
      !is24Hour &&
      splitTime.length > 1 &&
      splitTime[1].length === 2 &&
      !time.toUpperCase().includes(amSuffix.toUpperCase().trim()) &&
      !time.toUpperCase().includes(pmSuffix.toUpperCase().trim())
    ) {
      time = `${time}${new Date().getHours() > 11 ? pmSuffix : amSuffix}`;
    }
    let scrollIndex = this.getOptions().findIndex((option) => option.textContent === time);

    // if we found an exact match, scroll to match and return index of match for focus
    if (scrollIndex !== -1) {
      this.scrollToIndex(scrollIndex);
      focusedIndex = scrollIndex;
    } else if (splitTime.length === 2) {
      // no exact match, scroll to closest hour but don't return index for focus
      let amPm = '';
      if (!is24Hour) {
        if (splitTime[1].toUpperCase().includes('P')) {
          amPm = pmSuffix;
        } else if (splitTime[1].toUpperCase().includes('A')) {
          amPm = amSuffix;
        }
      }
      time = `${splitTime[0]}${delimiter}00${amPm}`;
      scrollIndex = this.getOptions().findIndex((option) => option.textContent === time);
      if (scrollIndex !== -1) {
        this.scrollToIndex(scrollIndex);
      }
    }
    this.setState({
      focusedIndex,
      scrollIndex
    });
  };

  getRegExp = (includeSeconds: boolean = true) => {
    const { is24Hour, delimiter } = this.props;
    let baseRegex = `\\s*(\\d\\d?)${delimiter}([0-5]\\d)`;

    if (includeSeconds) {
      baseRegex += `${delimiter}?([0-5]\\d)?`;
    }

    return new RegExp(`^${baseRegex}${is24Hour ? '' : '\\s*([AaPp][Mm])?'}\\s*$`);
  };

  getOptions = () =>
    (this.menuRef?.current
      ? Array.from(this.menuRef.current.querySelectorAll(`.${menuStyles.menuListItem}`))
      : []) as HTMLElement[];

  isValidFormat = (time: string) => {
    if (this.props.validateTime) {
      return this.props.validateTime(time);
    }

    const { delimiter, is24Hour, includeSeconds } = this.props;
    return validateTime(time, this.getRegExp(includeSeconds), delimiter, !is24Hour);
  };

  isValidTime = (time: string) => {
    const { delimiter, includeSeconds } = this.props;
    const { minTimeState, maxTimeState } = this.state;

    return isWithinMinMax(minTimeState, maxTimeState, time, delimiter, includeSeconds);
  };

  isValid = (time: string) => this.isValidFormat(time) && this.isValidTime(time);

  onToggle = (isOpen: boolean) => {
    // on close, parse and validate input
    this.setState((prevState) => {
      const { timeRegex, isInvalid, timeState } = prevState;
      const { delimiter, is24Hour, includeSeconds, onChange } = this.props;
      const time = parseTime(timeState, timeRegex, delimiter, !is24Hour, includeSeconds);

      // Call onChange when Enter is pressed in input and timeoption does not exist in menu
      if (onChange && !isOpen && time !== timeState) {
        onChange(
          null,
          time,
          getHours(time, timeRegex),
          getMinutes(time, timeRegex),
          getSeconds(time, timeRegex),
          this.isValid(time)
        );
      }

      return {
        isTimeOptionsOpen: isOpen,
        timeState: time,
        isInvalid: isOpen ? isInvalid : !this.isValid(time)
      };
    });
    this.props.setIsOpen(isOpen);
    if (!isOpen) {
      this.inputRef.current.focus();
    }
  };

  onSelect = (e: any) => {
    const { timeRegex, timeState } = this.state;
    const { delimiter, is24Hour, includeSeconds, setIsOpen } = this.props;
    const time = parseTime(e.target.textContent, timeRegex, delimiter, !is24Hour, includeSeconds);
    if (time !== timeState) {
      this.onInputChange(e, time);
    }

    this.inputRef.current.focus();
    this.setState({
      isTimeOptionsOpen: false,
      isInvalid: false
    });
    setIsOpen(false);
  };

  onInputClick = (e: any) => {
    if (!this.state.isTimeOptionsOpen) {
      this.onToggle(true);
    }
    e.stopPropagation();
  };

  onInputChange = (event: React.FormEvent, newTime: string) => {
    const { onChange } = this.props;
    const { timeRegex } = this.state;
    if (onChange) {
      onChange(
        event,
        newTime,
        getHours(newTime, timeRegex),
        getMinutes(newTime, timeRegex),
        getSeconds(newTime, timeRegex),
        this.isValid(newTime)
      );
    }
    this.scrollToSelection(newTime);
    this.setState({
      timeState: newTime
    });
  };

  render() {
    const {
      'aria-label': ariaLabel,
      isDisabled,
      className,
      placeholder,
      id,
      menuAppendTo,
      is24Hour,
      invalidFormatErrorMessage,
      invalidMinMaxErrorMessage,
      stepMinutes,
      width,
      delimiter,
      inputProps,
      /* eslint-disable @typescript-eslint/no-unused-vars */
      onChange,
      /* eslint-disable @typescript-eslint/no-unused-vars */
      setIsOpen,
      /* eslint-disable @typescript-eslint/no-unused-vars */
      isOpen,
      time,
      validateTime,
      minTime,
      maxTime,
      includeSeconds,
      zIndex,
      ...props
    } = this.props;
    const { timeState, isTimeOptionsOpen, isInvalid, minTimeState, maxTimeState } = this.state;
    const style = { [cssDatePickerFormControlWidth.name]: width } as React.CSSProperties;
    const options = makeTimeOptions(stepMinutes, !is24Hour, delimiter, minTimeState, maxTimeState, includeSeconds);
    const isValidFormat = this.isValidFormat(timeState);
    const randomId = id || getUniqueId('time-picker');

    const getParentElement = () => {
      if (this.baseComponentRef && this.baseComponentRef.current) {
        return this.baseComponentRef.current.parentElement;
      }
      return null;
    };

    const menuContainer = (
      
        
          
            {options.map((option, index) => (
              
                {option}
              
            ))}
          
        
      
    );

    const textInput = (
      }
        onClick={this.onInputClick}
        onChange={this.onInputChange}
        autoComplete="off"
        isDisabled={isDisabled}
        isExpanded={isTimeOptionsOpen}
        ref={this.inputRef}
        {...inputProps}
      />
    );

    let calculatedAppendTo;
    switch (menuAppendTo) {
      case 'inline':
        calculatedAppendTo = () => this.toggleRef.current;
        break;
      case 'parent':
        calculatedAppendTo = getParentElement;
        break;
      default:
        calculatedAppendTo = menuAppendTo as HTMLElement;
    }

    return (
      
{isInvalid && (
{!isValidFormat ? invalidFormatErrorMessage : invalidMinMaxErrorMessage}
)}
); } } export { TimePicker };




© 2015 - 2024 Weber Informatics LLC | Privacy Policy