/** * * TimePicker * */ import React, { useCallback, useEffect, useState, useRef, useMemo, } from 'react'; import { isInteger, toNumber } from 'lodash'; import PropTypes from 'prop-types'; import { IconWrapper, TimePicker as StyledTimePicker, TimePickerWrapper, TimeList, } from '@buffetjs/styles'; import { useEventListener, useShortcutEffect } from '@buffetjs/hooks'; import Icon from '../Icon'; const MINUTES_IN_HOUR = 60; // Returns string with two digits padded at start with 0 const pad = num => `0${num}`.substr(-2); // Convert time array to formatted time string export const timeFormatter = time => { const newTime = Array(3) .fill('00') .concat(splitArray(time)) .reverse(); newTime.length = 3; return format(newTime).join(':'); }; // Convert time string to time array const splitArray = string => { if (isInteger(toNumber(string)) && string) { const stringFormat = string.length === 3 ? `0${string}` : string; return stringFormat.match(/.{1,2}/g).reverse(); } const lowercase = string ? string.toLowerCase() : '0'; const array = lowercase.includes('h') ? lowercase.split('h') : lowercase.split(':'); return array.reverse().filter(v => !!v); }; // Ensure two-digit format for minutes and seconds const format = array => array.map((string, i) => { if (string.length < 2) { return i === 0 ? `0${string}` : `${string}0`; } return string; }); // Hide seconds if needed const short = hour => { const array = hour.split(':'); if (array.length > 2) { return array.slice(0, -1).join(':'); } return hour; }; // return array of minutes in hours with current step const getMinutesArr = step => { const length = MINUTES_IN_HOUR / step; return Array.from({ length }, (_v, i) => step * i); }; // Generate options for TimeList display const getOptions = step => { const hours = Array.from({ length: 24 }, (_, i) => i); const minutes = getMinutesArr(step); const options = hours.reduce((acc, cur) => { const hour = pad(cur); const hourOptions = minutes.map(minute => { const label = `${hour}:${pad(minute)}`; return { value: `${label}:00`, label }; }); return acc.concat(hourOptions); }, []); return options; }; // Find the nearest time option to select a TimeList value const roundHour = (time, step) => { const arr = splitArray(time); const minutesArr = getMinutesArr(step); const nearMin = nearest( minutesArr.concat(MINUTES_IN_HOUR), parseInt(arr[1], 10) ); arr[1] = minutesArr.includes(arr[1]) ? '00' : pad(nearMin); arr[2] = nearMin === 60 ? `${parseInt(arr[2], 10) + 1}` : arr[2]; return format(arr.reverse()).join(':'); }; // Set the nearest option to select a TimeList value const nearest = (arr, val) => arr.reduce( (p, n) => (Math.abs(p) > Math.abs(n - val) ? n - val : p), Infinity ) + val; function TimePicker(props) { const { name, onChange, seconds, tabIndex, value, step } = props; const [inputVal, setInputVal] = useState(''); const [isOpen, setIsOpen] = useState(false); const options = useMemo(() => getOptions(step), [step]); const inputRef = useRef(); const wrapperRef = useRef(); const listRef = useRef(); const listRefs = options.reduce((acc, curr) => { acc[curr.value] = useRef(); return acc; }, {}); const currentTimeSelected = useMemo( () => roundHour(timeFormatter(inputVal), step), [inputVal, step] ); // Effect to set the time useEffect(() => { if (!isOpen) { const time = seconds ? value : short(value); setInputVal(time); } }, [value, seconds, isOpen]); // Effect to enable scrolling useEffect(() => { const currentRef = currentTimeSelected; if (isOpen && listRefs[currentRef]) { listRef.current.scrollTop = listRefs[currentRef].current.offsetTop; } }, [isOpen, currentTimeSelected, listRefs]); // Custom hook to close the TimeList useEventListener( 'click', event => { if (!wrapperRef.current.contains(event.target)) { setIsOpen(false); } }, isOpen ); // Custom hook to select a time using the keyboard's up arrow useShortcutEffect( 'arrowUp', () => { if (isOpen) { const currentIndex = options.findIndex( o => o.value === currentTimeSelected ); if (!currentIndex) return; const nextIndex = currentIndex - 1; const nextTime = options[nextIndex] || options[currentIndex]; updateTime(nextTime.value); } }, isOpen ); // Custom hook to select a time using the keyboard's down arrow useShortcutEffect( 'arrowDown', () => { if (isOpen) { const currentIndex = options.findIndex( o => o.value === currentTimeSelected ); const lastIndex = options.length - 1; if (currentIndex >= lastIndex) return; const nextIndex = currentIndex + 1; const nextTime = options[nextIndex] || options[lastIndex]; updateTime(nextTime.value); } }, isOpen ); // Custom hook to close the time list useShortcutEffect( 'enter', () => { if (isOpen) { setIsOpen(false); inputRef.current.blur(); } }, isOpen ); useShortcutEffect( 'tab', () => { if (isOpen) { setIsOpen(false); inputRef.current.blur(); } }, isOpen ); const handleChange = ({ target }) => { updateTime(target.value); }; const handleChangeRadio = useCallback(() => {}, []); const formatInputValue = time => { if (!seconds) { setInputVal(short(time)); } else { setInputVal(time); } }; const handleClick = ({ target }) => { updateTime(target.value); setIsOpen(false); }; const updateTime = time => { formatInputValue(time); onChange({ target: { name, type: 'time', value: timeFormatter(time), }, }); }; return ( setIsOpen(true)} ref={inputRef} type="text" value={inputVal} tabIndex={tabIndex} /> {isOpen && options.map(option => (
  • ))}
    ); } TimePicker.defaultProps = { className: null, onChange: () => {}, tabIndex: '0', seconds: false, value: '', step: 30, }; TimePicker.propTypes = { className: PropTypes.string, name: PropTypes.string.isRequired, onChange: PropTypes.func, seconds: PropTypes.bool, step: (props, propName) => MINUTES_IN_HOUR % props[propName] > 0 && new Error('step should be divisible by 60'), tabIndex: PropTypes.string, value: PropTypes.string, }; export default TimePicker;