UNPKG

@patternfly/react-core

Version:

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

335 lines • 17.8 kB
import { __rest } from "tslib"; import * as React from 'react'; import { css } from '@patternfly/react-styles'; import datePickerStyles from '@patternfly/react-styles/css/components/DatePicker/date-picker'; import formStyles from '@patternfly/react-styles/css/components/FormControl/form-control'; 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 } from '../InputGroup'; import { TextInput } from '../TextInput'; import { KeyTypes } from '../../helpers/constants'; import { parseTime, validateTime, makeTimeOptions, amSuffix, pmSuffix, getHours, getMinutes, isWithinMinMax, getSeconds } from './TimePickerUtils'; export class TimePicker extends React.Component { constructor(props) { super(props); this.baseComponentRef = React.createRef(); this.toggleRef = React.createRef(); this.inputRef = React.createRef(); this.menuRef = React.createRef(); this.onDocClick = (event) => { var _a, _b, _c, _d; const clickedOnToggle = (_b = (_a = this.toggleRef) === null || _a === void 0 ? void 0 : _a.current) === null || _b === void 0 ? void 0 : _b.contains(event.target); const clickedWithinMenu = (_d = (_c = this.menuRef) === null || _c === void 0 ? void 0 : _c.current) === null || _d === void 0 ? void 0 : _d.contains(event.target); if (this.state.isOpen && !(clickedOnToggle || clickedWithinMenu)) { this.onToggle(false); } }; this.handleGlobalKeys = (event) => { var _a, _b, _c, _d; const { isOpen, focusedIndex, scrollIndex } = this.state; // keyboard pressed while focus on toggle if ((_b = (_a = this.inputRef) === null || _a === void 0 ? void 0 : _a.current) === null || _b === void 0 ? void 0 : _b.contains(event.target)) { if (!isOpen && event.key !== KeyTypes.Tab) { this.onToggle(true); } else if (isOpen) { 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 ((_d = (_c = this.menuRef) === null || _c === void 0 ? void 0 : _c.current) === null || _d === void 0 ? void 0 : _d.contains(event.target)) { 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); } } }; this.updateFocusedIndex = (increment) => { 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 this.getIndexToScroll = (index) => { if (this.props.menuAppendTo === 'inline') { return index > 0 ? index - 1 : 0; } return index; }; this.scrollToIndex = (index) => { this.getOptions()[index].closest(`.${menuStyles.menuContent}`).scrollTop = this.getOptions()[this.getIndexToScroll(index)].offsetTop; }; this.focusSelection = (index) => { var _a; const indexToFocus = index !== -1 ? index : 0; if ((_a = this.menuRef) === null || _a === void 0 ? void 0 : _a.current) { this.getOptions()[indexToFocus].querySelector(`.${menuStyles.menuItem}`).focus(); } }; this.scrollToSelection = (time) => { 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.innerText === 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.innerText === time); if (scrollIndex !== -1) { this.scrollToIndex(scrollIndex); } } this.setState({ focusedIndex, scrollIndex }); }; this.getRegExp = (includeSeconds = 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*$`); }; this.getOptions = () => { var _a; return (((_a = this.menuRef) === null || _a === void 0 ? void 0 : _a.current) ? Array.from(this.menuRef.current.querySelectorAll(`.${menuStyles.menuListItem}`)) : []); }; this.isValidFormat = (time) => { if (this.props.validateTime) { return this.props.validateTime(time); } const { delimiter, is24Hour, includeSeconds } = this.props; return validateTime(time, this.getRegExp(includeSeconds), delimiter, !is24Hour); }; this.isValidTime = (time) => { const { delimiter, includeSeconds } = this.props; const { minTimeState, maxTimeState } = this.state; return isWithinMinMax(minTimeState, maxTimeState, time, delimiter, includeSeconds); }; this.isValid = (time) => this.isValidFormat(time) && this.isValidTime(time); this.onToggle = (isOpen) => { // on close, parse and validate input this.setState(prevState => { const { timeRegex, isInvalid } = prevState; const { delimiter, is24Hour, includeSeconds } = this.props; const time = parseTime(prevState.timeState, timeRegex, delimiter, !is24Hour, includeSeconds); return { isOpen, timeState: time, isInvalid: isOpen ? isInvalid : !this.isValid(time) }; }); }; this.onSelect = (e) => { const { timeRegex, timeState } = this.state; const { delimiter, is24Hour, includeSeconds } = this.props; const time = parseTime(e.target.textContent, timeRegex, delimiter, !is24Hour, includeSeconds); if (time !== timeState) { this.onInputChange(time); } this.inputRef.current.focus(); this.setState({ isOpen: false, isInvalid: false }); }; this.onInputClick = (e) => { if (!this.state.isOpen) { this.onToggle(true); } e.stopPropagation(); }; this.onInputChange = (newTime) => { const { onChange } = this.props; const { timeRegex } = this.state; if (onChange) { onChange(newTime, getHours(newTime, timeRegex), getMinutes(newTime, timeRegex), getSeconds(newTime, timeRegex), this.isValid(newTime)); } this.scrollToSelection(newTime); this.setState({ timeState: newTime }); }; this.onBlur = (event) => { const { timeRegex } = this.state; const { delimiter, is24Hour, includeSeconds } = this.props; const time = parseTime(event.currentTarget.value, timeRegex, delimiter, !is24Hour, includeSeconds); this.setState({ isInvalid: !this.isValid(time) }); }; const { is24Hour, delimiter, time, includeSeconds } = 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, isOpen: false, 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); } componentWillUnmount() { document.removeEventListener('mousedown', this.onDocClick); document.removeEventListener('touchstart', this.onDocClick); document.removeEventListener('keydown', this.handleGlobalKeys); } componentDidUpdate(prevProps, prevState) { const { timeState, isOpen, isInvalid, timeRegex } = this.state; const { time, is24Hour, delimiter, includeSeconds } = this.props; if (isOpen && !prevState.isOpen && timeState && !isInvalid) { this.scrollToSelection(timeState); } if (delimiter !== prevProps.delimiter) { this.setState({ timeRegex: this.getRegExp() }); } if (time !== '' && time !== prevProps.time) { this.setState({ timeState: parseTime(time, timeRegex, delimiter, !is24Hour, includeSeconds) }); } } render() { const _a = this.props, { 'aria-label': ariaLabel, isDisabled, className, placeholder, id, menuAppendTo, is24Hour, invalidFormatErrorMessage, invalidMinMaxErrorMessage, stepMinutes, width, delimiter, inputProps, /* eslint-disable @typescript-eslint/no-unused-vars */ onChange, time, validateTime, minTime, maxTime, includeSeconds } = _a, /* eslint-enable @typescript-eslint/no-unused-vars */ props = __rest(_a, ['aria-label', "isDisabled", "className", "placeholder", "id", "menuAppendTo", "is24Hour", "invalidFormatErrorMessage", "invalidMinMaxErrorMessage", "stepMinutes", "width", "delimiter", "inputProps", "onChange", "time", "validateTime", "minTime", "maxTime", "includeSeconds"]); const { timeState, isOpen, isInvalid, minTimeState, maxTimeState } = this.state; const style = { '--pf-c-date-picker__input--c-form-control--Width': width }; 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 = (React.createElement(Menu, { ref: this.menuRef, isScrollable: true }, React.createElement(MenuContent, { maxMenuHeight: "200px" }, React.createElement(MenuList, { "aria-label": ariaLabel }, options.map((option, index) => (React.createElement(MenuItem, { onClick: this.onSelect, key: option, id: `${randomId}-option-${index}` }, option))))))); const textInput = (React.createElement(TextInput, Object.assign({ "aria-haspopup": "menu", className: css(formStyles.formControl), id: `${randomId}-input`, "aria-label": ariaLabel, validated: isInvalid ? 'error' : 'default', placeholder: placeholder, value: timeState || '', type: "text", iconVariant: "clock", onClick: this.onInputClick, onChange: this.onInputChange, onBlur: this.onBlur, autoComplete: "off", isDisabled: isDisabled, ref: this.inputRef }, inputProps))); return (React.createElement("div", { ref: this.baseComponentRef, className: css(datePickerStyles.datePicker, className) }, React.createElement("div", Object.assign({ className: css(datePickerStyles.datePickerInput), style: style }, props), React.createElement(InputGroup, null, React.createElement("div", { id: randomId }, React.createElement("div", { ref: this.toggleRef, style: { paddingLeft: '0' } }, menuAppendTo !== 'inline' ? (React.createElement(Popper, { appendTo: menuAppendTo === 'parent' ? getParentElement() : menuAppendTo, trigger: textInput, popper: menuContainer, isVisible: isOpen })) : (textInput)), isOpen && menuAppendTo === 'inline' && menuContainer)), isInvalid && (React.createElement("div", { className: css(datePickerStyles.datePickerHelperText, datePickerStyles.modifiers.error) }, !isValidFormat ? invalidFormatErrorMessage : invalidMinMaxErrorMessage))))); } } TimePicker.displayName = 'TimePicker'; TimePicker.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: '' }; //# sourceMappingURL=TimePicker.js.map