UNPKG

react-cm-ui

Version:
724 lines (644 loc) 25.1 kB
import { clone, find, isArray, isEmpty, isEqual, isFunction, isNil, isObject, map, partialRight, range as lodashRange, replace, sortBy, trim, } from 'lodash'; import ClassNames from 'classnames'; import moment from 'moment-timezone'; import PropTypes from 'prop-types'; import React from 'react'; import CreatableOptionComponent from './timePickerCreatableOption'; import Icon from '../../dataDisplay/icon'; import Input from '../input'; import Select from '../select/select'; const propTypes = { /** * Assign additional class names to the TimePicker. */ className: PropTypes.string, /** * Deprecated prop. Please use `disabled` instead. */ disable: PropTypes.bool, /** * A TimePicker can be disabled. */ disabled: PropTypes.bool, /** * A TimePicker can be in an error state for input validation purposes. */ error: PropTypes.oneOfType([ PropTypes.bool, PropTypes.string, ]), /** * The `id` of the TimePicker. * Individual subcomponents that comprise the TimePicker will have various * strings appended to this to make up their full `id` prop values. */ id: PropTypes.string, /** * The label for the TimePicker. */ label: PropTypes.string, /** * The TimePicker can have "nested" styling applied to it. */ nest: PropTypes.bool, /** * The `onChange` event handler. */ onChange: PropTypes.func, /** * Boolean flag indicating whether the TimePicker will be used to select a * range of time (i.e. 'from' time and 'to' time) or just a single time. */ range: PropTypes.bool, /** * A TimePicker can be required. */ required: PropTypes.bool, /** * Boolean flag indicating whether or not the TimePicker should include a * Time Zone Select as well. This prop is optional, and defaults to `true` * (meaning the Time Zone Select will be shown). */ showTimezone: PropTypes.bool, /** * Assign additional inline styles to the TimePicker. */ style: PropTypes.shape({}), /** * The value of the TimePicker. */ value: PropTypes.shape({ timeDisplay: PropTypes.string, timeFrom: PropTypes.string, timeTo: PropTypes.string, timeZone: PropTypes.oneOfType([ PropTypes.string, PropTypes.shape({}), ]), }), /** * This prop dictates how the TimePicker will handle searches on its * Time Zones select (i.e. which attribute will it use to find matches). */ zoneMatchProp: PropTypes.oneOf(['any', 'label', 'value']), /** * An array of select options representing Time Zones passed to the * TimePicker. * * These should be standard `{ label: '...', value: '...' }` objects. * * The `value`s should be IANA/Olson/TZDB style time zone identifiers, * e.g. 'America/Los_Angeles' for the U.S. Pacific Time Zone. * * This prop should be used if a particular style of Time Zone options * (i.e. with a specific style for the `label`s) is desired (e.g. it is * supplied by the API and is the standard list for the application). * * This prop is optional; if it is elided and if `showTimezone` is `true` * (or elided) then Moment JS will be used to produce a default set of * time zone options to use. */ zoneOptions: PropTypes.arrayOf(PropTypes.shape({})), /** * The placeholder text to be used in the Time Zones select in the * TimePicker. */ zonePlaceholderText: PropTypes.string, }; const defaultProps = { className: undefined, disable: false, disabled: false, error: undefined, id: undefined, label: undefined, nest: false, onChange: undefined, range: false, required: false, showTimezone: true, style: undefined, value: undefined, zoneMatchProp: 'label', zoneOptions: undefined, zonePlaceholderText: 'Select a Time Zone', }; const creatableSelectPromptTextCreator = (label) => (label); const createableValueComponent = ({ children, }) => ( <div className="Select-value"> <span className="Select-value-label" > <span> {children} </span> </span> </div> ); /** * The TimePicker component represents a control that allows the user to * select a single time of day or a range (with Time From and Time To). * Usually it also includes a Time Zone selector as well, although this may be * suppressed if so desired for particular use cases where the Time Zone is not * needed. */ class TimePicker extends React.Component { constructor(props) { super(props); const zoneOptions = this.renderZoneOptions(); const guessedTimeZone = moment.tz.guess(); this.state = { hourOptions: this.renderHourOptions(), isTimePopoverActive: false, minuteOptions: this.renderMinuteOptions(), periodOptions: this.renderPeriodOptions(), value: clone(props.value) || { timeDisplay: null, timeFrom: null, timeTo: null, timeZone: find(zoneOptions, (o) => o.value === guessedTimeZone), }, zoneOptions, }; this.onChange = this.onChange.bind(this); this.onClickOutsideRef = this.onClickOutside.bind(this); this.onInputKeyDown = this.onInputKeyDown.bind(this); this.onInputMask = this.onInputMask.bind(this); this.onTimeDropdownChange = this.onTimeDropdownChange.bind(this); this.onTimePopoverToggle = this.onTimePopoverToggle.bind(this); this.onZoneDropdownChange = this.onZoneDropdownChange.bind(this); this.onTimeHourDropdownChange = partialRight(this.onTimeDropdownChange.bind(null, 'hour'), true); this.onTimeHourToDropdownChange = partialRight(this.onTimeDropdownChange.bind(null, 'hourTo'), true); this.onTimeMinuteDropdownChange = partialRight(this.onTimeDropdownChange.bind(null, 'minute'), true); this.onTimeMinuteToDropdownChange = partialRight(this.onTimeDropdownChange.bind(null, 'minuteTo'), true); this.onTimePeriodDropdownChange = partialRight(this.onTimeDropdownChange.bind(null, 'period'), true); this.onTimePeriodToDropdownChange = partialRight(this.onTimeDropdownChange.bind(null, 'periodTo'), true); this.timePickerRef = null; } componentDidMount() { document.addEventListener('click', this.onClickOutsideRef); } componentDidUpdate(prevProps) { const { onChange, range, value: nextValue, } = this.props; const { value: prevValue, } = prevProps; if (!isEqual(nextValue, prevValue)) { let isDirty = false; const newValue = clone(nextValue); if (!isEmpty(nextValue)) { if (!isEmpty(nextValue.timeDisplay)) { newValue.timeDisplay = range ? `${newValue.timeFrom} - ${newValue.timeTo}` : newValue.timeFrom; isDirty = true; } } this.setState({ value: newValue }); if (isDirty && isFunction(onChange)) { onChange(newValue); } } } componentWillUnmount() { document.removeEventListener('click', this.onClickOutsideRef); } onChange(value, field) { const { onChange, range, } = this.props; const { value: prevValueFromState } = this.state; const newValue = clone(prevValueFromState); if (isObject(value)) { const mask = '__:__ __'; newValue.timeFrom = newValue.timeFrom || mask; if (field === 'hour') { newValue.timeFrom = replace(newValue.timeFrom, newValue.timeFrom[0] + newValue.timeFrom[1] + newValue.timeFrom[2], `${value.label}:`); } else if (field === 'minute') { newValue.timeFrom = replace(newValue.timeFrom, newValue.timeFrom[2] + newValue.timeFrom[3] + newValue.timeFrom[4], `:${value.label}`); } else if (field === 'period') { newValue.timeFrom = replace(newValue.timeFrom, newValue.timeFrom[5] + newValue.timeFrom[6] + newValue.timeFrom[7], ` ${value.label}`); } else if (field === 'zone') { newValue.timeZone = value; } if (range) { newValue.timeTo = newValue.timeTo || mask; if (field === 'hourTo') { newValue.timeTo = replace(newValue.timeTo, newValue.timeTo[0] + newValue.timeTo[1] + newValue.timeTo[2], `${value.label}:`); } else if (field === 'minuteTo') { newValue.timeTo = replace(newValue.timeTo, newValue.timeTo[2] + newValue.timeTo[3] + newValue.timeTo[4], `:${value.label}`); } else if (field === 'periodTo') { newValue.timeTo = replace(newValue.timeTo, newValue.timeTo[5] + newValue.timeTo[6] + newValue.timeTo[7], ` ${value.label}`); } } newValue.timeDisplay = range ? `${newValue.timeFrom} - ${newValue.timeTo}` : newValue.timeFrom; } else { const valueUpperCased = value.toUpperCase(); newValue.timeDisplay = valueUpperCased; newValue.timeFrom = range ? trim(valueUpperCased.split('-')[0]) : valueUpperCased; newValue.timeTo = range ? trim(valueUpperCased.split('-')[1]) : null; } if (isFunction(onChange)) { onChange(newValue); } else { this.setState({ value: newValue }); } } onClickOutside(event) { const isOutside = !isNil(this.timePickerRef) && this.timePickerRef.contains(event.target); const { isTimePopoverActive } = this.state; if (isOutside || !isTimePopoverActive || (event.target.className?.includes('Select-option') ?? false)) { return; } this.onTimePopoverToggle(); } onInputMask(value) { const { range } = this.props; // hh:dd am - hh:dd pm if (range) { return [ /[0-1]/, value.toString()[0] * 1 === 1 ? /[0-2]/ : /[0-9]/, ':', /[0-5]/, /[0-9]/, ' ', /[A,P]/i, /[M]/i, ' ', '-', ' ', /[0-1]/, value.toString()[12] * 1 === 1 ? /[0-2]/ : /[0-9]/, ':', /[0-5]/, /[0-9]/, ' ', /[A,P]/i, /[M]/i, ]; } return [ /[0-1]/, value.toString()[0] * 1 === 1 ? /[0-2]/ : /[0-9]/, ':', /[0-5]/, /[0-9]/, ' ', /[A,P]/i, /[M]/i, ]; } onInputKeyDown(event) { const { isTimePopoverActive } = this.state; if (!isTimePopoverActive && event.key === 'ArrowDown') { this.onTimePopoverToggle(); } if (isTimePopoverActive && event.key === 'ArrowUp') { this.onTimePopoverToggle(); } } onTimePopoverToggle() { const { disable } = this.props; if (!disable) { this.setState( ({ isTimePopoverActive }) => ({ isTimePopoverActive: !isTimePopoverActive }), ); } } onTimeDropdownChange(field, selectedOption) { this.onChange(selectedOption, field); } onZoneDropdownChange(selectedOption) { this.onChange(selectedOption, 'zone'); } renderHourOptions() { return map(lodashRange(1, 13), (v) => { const number = v.toString().length < 2 ? `0${v}` : v; return { label: number.toString(), value: number * 1 }; }); } renderMinuteOptions() { return map(lodashRange(0, 60, 5), (v) => { const number = v.toString().length < 2 ? `0${v}` : v; return { label: number.toString(), value: number * 1 }; }); } renderPeriodOptions() { return [ { label: 'AM', value: 0, }, { label: 'PM', value: 1, }, ]; } renderZoneOptions() { const { showTimezone, zoneOptions, } = this.props; // If `showTimezone` is `false` or if `zoneOptions` appears to be set // with an array or time zone options then just return what we have; no // need to calculate our own set of time zone options. if (!showTimezone || (isArray(zoneOptions) && !isEmpty(zoneOptions))) { return zoneOptions; } /* eslint-disable no-underscore-dangle */ const zoneNames = Object.keys(moment.tz._zones) .map((tz) => { // At some point _zones turns into an array of objects instead of strings. if (typeof moment.tz._zones[tz] === 'string') { return moment.tz._zones[tz].split('|')[0]; } return moment.tz._zones[tz].name; }) .filter((tz) => (tz.indexOf('/') >= 0)); /* eslint-enable no-underscore-dangle */ return sortBy( map( zoneNames, (tzName) => { const zone = moment.tz(tzName); return { abbr: zone.zoneAbbr(), label: `(${zone.format('Z z')}) ${tzName.split('/')[0].replace('_', ' ')} (${tzName.split('/').slice(-1)[0].replace('_', ' ')})`, offset: zone.format('Z'), value: tzName, }; }, ), (tz) => (tz.offset), ); } render() { const { className, disable, disabled, error, id, label, nest, range, required, showTimezone, style, zoneMatchProp, zonePlaceholderText, } = this.props; const { hourOptions, isTimePopoverActive, minuteOptions, periodOptions, value, zoneOptions, } = this.state; const isDisabled = disabled || disable; const containerClasses = ClassNames('ui', 'time-picker', className, { 'time-picker-disable': disable, 'time-picker-error': error, 'time-picker-nest': nest, 'time-picker-range': range, }); const hasTimeFrom = !isNil(value?.timeFrom); const hourFromDropdownValue = hasTimeFrom ? find( hourOptions, (o) => o.label === (value.timeFrom[0] + value.timeFrom[1]).toString(), ) : null; let timeFromMinutes = value?.timeFrom?.substring(3, 5) ?? ''; timeFromMinutes = timeFromMinutes === '__' ? '' : timeFromMinutes; const minuteFromDropdownValue = hasTimeFrom ? find( minuteOptions, (o) => o.label === timeFromMinutes, ) ?? { label: timeFromMinutes, value: timeFromMinutes, } : null; const periodFromDropdownValue = hasTimeFrom ? find( periodOptions, (o) => o.label === value.timeFrom[6] + value.timeFrom[7], ) : null; let hourToDropdownValue; let minuteToDropdownValue; let periodToDropdownValue; if (range) { const hasTimeTo = !isNil(value?.timeTo); hourToDropdownValue = hasTimeTo ? find( hourOptions, (o) => o.label === (value.timeTo[0] + value.timeTo[1]).toString(), ) : null; let timeToMinutes = value?.timeTo?.substring(3, 5) ?? ''; timeToMinutes = timeToMinutes === '__' ? '' : timeToMinutes; minuteToDropdownValue = hasTimeTo ? find( minuteOptions, (o) => o.label === timeToMinutes, ) ?? { label: timeToMinutes, value: timeToMinutes, } : null; periodToDropdownValue = hasTimeTo ? find( periodOptions, (o) => o.label === value.timeTo[6] + value.timeTo[7], ) : null; } return ( <div className={containerClasses} id={id} ref={(ref) => { this.timePickerRef = ref; }} style={style} > <Input className="time-picker-input" disabled={isDisabled} error={error} guide icon={( <Icon color={isTimePopoverActive ? 'highlight' : null} compact title="Show Time Picker" type="time" onClick={this.onTimePopoverToggle} /> )} id={id ? `${id}-input` : null} keepCharPositions label={label} mask={this.onInputMask} onChange={this.onChange} onKeyDown={this.onInputKeyDown} placeholder={range ? 'hh:mm AM - hh:mm PM' : 'hh:mm AM'} required={required} tabIndex={0} value={value && value.timeDisplay ? value.timeDisplay : null} /> {showTimezone && ( <Select className="time-picker-zone-dropdown" clearable={false} disabled={isDisabled} id={id ? `${id}-zone_dropdown` : null} matchProp={zoneMatchProp || 'label'} menuMaxHeight={448} onChange={this.onZoneDropdownChange} options={zoneOptions} placeholder={zonePlaceholderText || 'Select a Time Zone'} searchable tabIndex={0} value={value.timeZone} /> )} {isTimePopoverActive ? ( <div className="time-picker-popover" id={id ? `${id}-popover` : null} > {/* eslint-disable jsx-a11y/label-has-associated-control */} {range && <label className="label">From</label>} {/* eslint-enable jsx-a11y/label-has-associated-control */} <div> <Select clearable={false} id={id ? `${id}-hour_dropdown` : null} menuMaxHeight={448} onChange={this.onTimeHourDropdownChange} options={hourOptions} placeholder="hh" searchable style={{ minWidth: 72, }} tabIndex={0} value={hourFromDropdownValue} /> <span className="colon">:</span> <Select clearable={false} creatable id={id ? `${id}-minute_dropdown` : null} menuMaxHeight={448} onChange={this.onTimeMinuteDropdownChange} optionComponent={CreatableOptionComponent} options={minuteOptions} placeholder="mm" promptTextCreator={creatableSelectPromptTextCreator} searchable style={{ minWidth: 72, }} tabIndex={0} value={minuteFromDropdownValue} valueComponent={createableValueComponent} /> <Select clearable={false} id={id ? `${id}-period_dropdown` : null} menuMaxHeight={448} onChange={this.onTimePeriodDropdownChange} options={periodOptions} placeholder="AM" searchable style={{ minWidth: 72, }} tabIndex={0} value={periodFromDropdownValue} /> </div> {/* eslint-disable jsx-a11y/label-has-associated-control */} {range && <label className="label">To</label>} {/* eslint-enable jsx-a11y/label-has-associated-control */} {range && ( <div> <Select clearable={false} id={id ? `${id}-hour_to_dropdown` : null} menuMaxHeight={448} onChange={this.onTimeHourToDropdownChange} options={hourOptions} placeholder="hh" searchable style={{ minWidth: 72, }} tabIndex={0} value={hourToDropdownValue} /> <span className="colon">:</span> <Select clearable={false} creatable id={id ? `${id}-minute_to_dropdown` : null} menuMaxHeight={448} onChange={this.onTimeMinuteToDropdownChange} optionComponent={CreatableOptionComponent} options={minuteOptions} placeholder="mm" promptTextCreator={creatableSelectPromptTextCreator} searchable style={{ minWidth: 72, }} tabIndex={0} value={minuteToDropdownValue} valueComponent={createableValueComponent} /> <Select clearable={false} id={id ? `${id}-period_to_dropdown` : null} menuMaxHeight={448} onChange={this.onTimePeriodToDropdownChange} options={periodOptions} placeholder="PM" searchable style={{ minWidth: 72, }} tabIndex={0} value={periodToDropdownValue} /> </div> )} </div> ) : null} </div> ); } } TimePicker.propTypes = propTypes; TimePicker.defaultProps = defaultProps; export default TimePicker;