UNPKG

17.9 kBTypeScriptView Raw
1/*
2 * Copyright 2015 Palantir Technologies, Inc. All rights reserved.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import classNames from "classnames";
18import * as React from "react";
19
20import { Classes as CoreClasses, DISPLAYNAME_PREFIX, HTMLSelect, Icon, Intent, Keys, Props } from "@blueprintjs/core";
21
22import * as Classes from "./common/classes";
23import * as DateUtils from "./common/dateUtils";
24import {
25 getDefaultMaxTime,
26 getDefaultMinTime,
27 getTimeUnit,
28 getTimeUnitClassName,
29 isTimeUnitValid,
30 setTimeUnit,
31 TimeUnit,
32 wrapTimeAtUnit,
33} from "./common/timeUnit";
34import * as Utils from "./common/utils";
35
36export const TimePrecision = {
37 MILLISECOND: "millisecond" as "millisecond",
38 MINUTE: "minute" as "minute",
39 SECOND: "second" as "second",
40};
41// eslint-disable-next-line @typescript-eslint/no-redeclare
42export type TimePrecision = typeof TimePrecision[keyof typeof TimePrecision];
43
44// eslint-disable-next-line deprecation/deprecation
45export type TimePickerProps = ITimePickerProps;
46/** @deprecated use TimePickerProps */
47export interface ITimePickerProps extends Props {
48 /**
49 * Whether to focus the first input when it opens initially.
50 *
51 * @default false
52 */
53 autoFocus?: boolean;
54
55 /**
56 * Initial time the `TimePicker` will display.
57 * This should not be set if `value` is set.
58 */
59 defaultValue?: Date;
60
61 /**
62 * Whether the time picker is non-interactive.
63 *
64 * @default false
65 */
66 disabled?: boolean;
67
68 /**
69 * Callback invoked on blur event emitted by specific time unit input
70 */
71 onBlur?: (event: React.FocusEvent<HTMLInputElement>, unit: TimeUnit) => void;
72
73 /**
74 * Callback invoked when the user changes the time.
75 */
76 onChange?: (newTime: Date) => void;
77
78 /**
79 * Callback invoked on focus event emitted by specific time unit input
80 */
81 onFocus?: (event: React.FocusEvent<HTMLInputElement>, unit: TimeUnit) => void;
82
83 /**
84 * Callback invoked on keydown event emitted by specific time unit input
85 */
86 onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>, unit: TimeUnit) => void;
87
88 /**
89 * Callback invoked on keyup event emitted by specific time unit input
90 */
91 onKeyUp?: (event: React.KeyboardEvent<HTMLInputElement>, unit: TimeUnit) => void;
92
93 /**
94 * The precision of time the user can set.
95 *
96 * @default TimePrecision.MINUTE
97 */
98 precision?: TimePrecision;
99
100 /**
101 * Whether all the text in each input should be selected on focus.
102 *
103 * @default false
104 */
105 selectAllOnFocus?: boolean;
106
107 /**
108 * Whether to show arrows buttons for changing the time.
109 *
110 * @default false
111 */
112 showArrowButtons?: boolean;
113
114 /**
115 * Whether to use a 12 hour format with an AM/PM dropdown.
116 *
117 * @default false
118 */
119 useAmPm?: boolean;
120
121 /**
122 * The latest time the user can select. The year, month, and day parts of the `Date` object are ignored.
123 * While the `maxTime` will be later than the `minTime` in the basic case,
124 * it is also allowed to be earlier than the `minTime`.
125 * This is useful, for example, to express a time range that extends before and after midnight.
126 * If the `maxTime` and `minTime` are equal, then the valid time range is constrained to only that one value.
127 */
128 maxTime?: Date;
129
130 /**
131 * The earliest time the user can select. The year, month, and day parts of the `Date` object are ignored.
132 * While the `minTime` will be earlier than the `maxTime` in the basic case,
133 * it is also allowed to be later than the `maxTime`.
134 * This is useful, for example, to express a time range that extends before and after midnight.
135 * If the `maxTime` and `minTime` are equal, then the valid time range is constrained to only that one value.
136 */
137 minTime?: Date;
138
139 /**
140 * The currently set time.
141 * If this prop is provided, the component acts in a controlled manner.
142 */
143 value?: Date | null;
144}
145
146export interface ITimePickerState {
147 hourText?: string;
148 minuteText?: string;
149 secondText?: string;
150 millisecondText?: string;
151 value?: Date;
152 isPm?: boolean;
153}
154
155export class TimePicker extends React.Component<TimePickerProps, ITimePickerState> {
156 public static defaultProps: TimePickerProps = {
157 autoFocus: false,
158 disabled: false,
159 maxTime: getDefaultMaxTime(),
160 minTime: getDefaultMinTime(),
161 precision: TimePrecision.MINUTE,
162 selectAllOnFocus: false,
163 showArrowButtons: false,
164 useAmPm: false,
165 };
166
167 public static displayName = `${DISPLAYNAME_PREFIX}.TimePicker`;
168
169 public constructor(props?: TimePickerProps, context?: any) {
170 super(props, context);
171
172 this.state = this.getFullStateFromValue(this.getInitialValue(), props.useAmPm);
173 }
174
175 public render() {
176 const shouldRenderMilliseconds = this.props.precision === TimePrecision.MILLISECOND;
177 const shouldRenderSeconds = shouldRenderMilliseconds || this.props.precision === TimePrecision.SECOND;
178 const hourUnit = this.props.useAmPm ? TimeUnit.HOUR_12 : TimeUnit.HOUR_24;
179 const classes = classNames(Classes.TIMEPICKER, this.props.className, {
180 [CoreClasses.DISABLED]: this.props.disabled,
181 });
182
183 return (
184 <div className={classes}>
185 <div className={Classes.TIMEPICKER_ARROW_ROW}>
186 {this.maybeRenderArrowButton(true, hourUnit)}
187 {this.maybeRenderArrowButton(true, TimeUnit.MINUTE)}
188 {shouldRenderSeconds && this.maybeRenderArrowButton(true, TimeUnit.SECOND)}
189 {shouldRenderMilliseconds && this.maybeRenderArrowButton(true, TimeUnit.MS)}
190 </div>
191 <div className={Classes.TIMEPICKER_INPUT_ROW}>
192 {this.renderInput(Classes.TIMEPICKER_HOUR, hourUnit, this.state.hourText)}
193 {this.renderDivider()}
194 {this.renderInput(Classes.TIMEPICKER_MINUTE, TimeUnit.MINUTE, this.state.minuteText)}
195 {shouldRenderSeconds && this.renderDivider()}
196 {shouldRenderSeconds &&
197 this.renderInput(Classes.TIMEPICKER_SECOND, TimeUnit.SECOND, this.state.secondText)}
198 {shouldRenderMilliseconds && this.renderDivider(".")}
199 {shouldRenderMilliseconds &&
200 this.renderInput(Classes.TIMEPICKER_MILLISECOND, TimeUnit.MS, this.state.millisecondText)}
201 </div>
202 {this.maybeRenderAmPm()}
203 <div className={Classes.TIMEPICKER_ARROW_ROW}>
204 {this.maybeRenderArrowButton(false, hourUnit)}
205 {this.maybeRenderArrowButton(false, TimeUnit.MINUTE)}
206 {shouldRenderSeconds && this.maybeRenderArrowButton(false, TimeUnit.SECOND)}
207 {shouldRenderMilliseconds && this.maybeRenderArrowButton(false, TimeUnit.MS)}
208 </div>
209 </div>
210 );
211 }
212
213 public componentDidUpdate(prevProps: TimePickerProps) {
214 const didMinTimeChange = prevProps.minTime !== this.props.minTime;
215 const didMaxTimeChange = prevProps.maxTime !== this.props.maxTime;
216 const didBoundsChange = didMinTimeChange || didMaxTimeChange;
217 const didPropValueChange = prevProps.value !== this.props.value;
218 const shouldStateUpdate = didBoundsChange || didPropValueChange;
219
220 let value = this.state.value;
221 if (this.props.value == null) {
222 value = this.getInitialValue();
223 }
224 if (didBoundsChange) {
225 value = DateUtils.getTimeInRange(this.state.value, this.props.minTime, this.props.maxTime);
226 }
227 if (this.props.value != null && !DateUtils.areSameTime(this.props.value, prevProps.value)) {
228 value = this.props.value;
229 }
230
231 if (shouldStateUpdate) {
232 this.setState(this.getFullStateFromValue(value, this.props.useAmPm));
233 }
234 }
235
236 // begin method definitions: rendering
237
238 private maybeRenderArrowButton(isDirectionUp: boolean, timeUnit: TimeUnit) {
239 if (!this.props.showArrowButtons) {
240 return null;
241 }
242 const classes = classNames(Classes.TIMEPICKER_ARROW_BUTTON, getTimeUnitClassName(timeUnit));
243 const onClick = () => (isDirectionUp ? this.incrementTime : this.decrementTime)(timeUnit);
244 // set tabIndex=-1 to ensure a valid FocusEvent relatedTarget when focused
245 return (
246 <span tabIndex={-1} className={classes} onClick={onClick}>
247 <Icon
248 icon={isDirectionUp ? "chevron-up" : "chevron-down"}
249 title={isDirectionUp ? "Increase" : "Decrease"}
250 />
251 </span>
252 );
253 }
254
255 private renderDivider(text = ":") {
256 return <span className={Classes.TIMEPICKER_DIVIDER_TEXT}>{text}</span>;
257 }
258
259 private renderInput(className: string, unit: TimeUnit, value: string) {
260 const isValid = isTimeUnitValid(unit, parseInt(value, 10));
261 const isHour = unit === TimeUnit.HOUR_12 || unit === TimeUnit.HOUR_24;
262
263 return (
264 <input
265 className={classNames(
266 Classes.TIMEPICKER_INPUT,
267 { [CoreClasses.intentClass(Intent.DANGER)]: !isValid },
268 className,
269 )}
270 onBlur={this.getInputBlurHandler(unit)}
271 onChange={this.getInputChangeHandler(unit)}
272 onFocus={this.getInputFocusHandler(unit)}
273 onKeyDown={this.getInputKeyDownHandler(unit)}
274 onKeyUp={this.getInputKeyUpHandler(unit)}
275 value={value}
276 disabled={this.props.disabled}
277 autoFocus={isHour && this.props.autoFocus}
278 />
279 );
280 }
281
282 private maybeRenderAmPm() {
283 if (!this.props.useAmPm) {
284 return null;
285 }
286 return (
287 <HTMLSelect
288 className={Classes.TIMEPICKER_AMPM_SELECT}
289 disabled={this.props.disabled}
290 onChange={this.handleAmPmChange}
291 value={this.state.isPm ? "pm" : "am"}
292 >
293 <option value="am">AM</option>
294 <option value="pm">PM</option>
295 </HTMLSelect>
296 );
297 }
298
299 // begin method definitions: event handlers
300
301 private getInputChangeHandler = (unit: TimeUnit) => (e: React.SyntheticEvent<HTMLInputElement>) => {
302 const text = getStringValueFromInputEvent(e);
303 switch (unit) {
304 case TimeUnit.HOUR_12:
305 case TimeUnit.HOUR_24:
306 this.setState({ hourText: text });
307 break;
308 case TimeUnit.MINUTE:
309 this.setState({ minuteText: text });
310 break;
311 case TimeUnit.SECOND:
312 this.setState({ secondText: text });
313 break;
314 case TimeUnit.MS:
315 this.setState({ millisecondText: text });
316 break;
317 }
318 };
319
320 private getInputBlurHandler = (unit: TimeUnit) => (e: React.FocusEvent<HTMLInputElement>) => {
321 const text = getStringValueFromInputEvent(e);
322 this.updateTime(parseInt(text, 10), unit);
323 this.props.onBlur?.(e, unit);
324 };
325
326 private getInputFocusHandler = (unit: TimeUnit) => (e: React.FocusEvent<HTMLInputElement>) => {
327 if (this.props.selectAllOnFocus) {
328 e.currentTarget.select();
329 }
330 this.props.onFocus?.(e, unit);
331 };
332
333 private getInputKeyDownHandler = (unit: TimeUnit) => (e: React.KeyboardEvent<HTMLInputElement>) => {
334 handleKeyEvent(e, {
335 [Keys.ARROW_UP]: () => this.incrementTime(unit),
336 [Keys.ARROW_DOWN]: () => this.decrementTime(unit),
337 [Keys.ENTER]: () => {
338 (e.currentTarget as HTMLInputElement).blur();
339 },
340 });
341 this.props.onKeyDown?.(e, unit);
342 };
343
344 private getInputKeyUpHandler = (unit: TimeUnit) => (e: React.KeyboardEvent<HTMLInputElement>) => {
345 this.props.onKeyUp?.(e, unit);
346 };
347
348 private handleAmPmChange = (e: React.SyntheticEvent<HTMLSelectElement>) => {
349 const isNextPm = e.currentTarget.value === "pm";
350 if (isNextPm !== this.state.isPm) {
351 const hour = DateUtils.convert24HourMeridiem(this.state.value.getHours(), isNextPm);
352 this.setState({ isPm: isNextPm }, () => this.updateTime(hour, TimeUnit.HOUR_24));
353 }
354 };
355
356 // begin method definitions: state modification
357
358 /**
359 * Generates a full ITimePickerState object with all text fields set to formatted strings based on value
360 */
361 private getFullStateFromValue(value: Date, useAmPm: boolean): ITimePickerState {
362 const timeInRange = DateUtils.getTimeInRange(value, this.props.minTime, this.props.maxTime);
363 const hourUnit = useAmPm ? TimeUnit.HOUR_12 : TimeUnit.HOUR_24;
364 /* tslint:disable:object-literal-sort-keys */
365 return {
366 hourText: formatTime(timeInRange.getHours(), hourUnit),
367 minuteText: formatTime(timeInRange.getMinutes(), TimeUnit.MINUTE),
368 secondText: formatTime(timeInRange.getSeconds(), TimeUnit.SECOND),
369 millisecondText: formatTime(timeInRange.getMilliseconds(), TimeUnit.MS),
370 value: timeInRange,
371 isPm: DateUtils.getIsPmFrom24Hour(timeInRange.getHours()),
372 };
373 /* tslint:enable:object-literal-sort-keys */
374 }
375
376 private incrementTime = (unit: TimeUnit) => this.shiftTime(unit, 1);
377
378 private decrementTime = (unit: TimeUnit) => this.shiftTime(unit, -1);
379
380 private shiftTime(unit: TimeUnit, amount: number) {
381 if (this.props.disabled) {
382 return;
383 }
384 const newTime = getTimeUnit(unit, this.state.value) + amount;
385 this.updateTime(wrapTimeAtUnit(unit, newTime), unit);
386 }
387
388 private updateTime(time: number, unit: TimeUnit) {
389 const newValue = DateUtils.clone(this.state.value);
390
391 if (isTimeUnitValid(unit, time)) {
392 setTimeUnit(unit, time, newValue, this.state.isPm);
393 if (DateUtils.isTimeInRange(newValue, this.props.minTime, this.props.maxTime)) {
394 this.updateState({ value: newValue });
395 } else {
396 this.updateState(this.getFullStateFromValue(this.state.value, this.props.useAmPm));
397 }
398 } else {
399 this.updateState(this.getFullStateFromValue(this.state.value, this.props.useAmPm));
400 }
401 }
402
403 private updateState(state: ITimePickerState) {
404 let newState = state;
405 const hasNewValue = newState.value != null && !DateUtils.areSameTime(newState.value, this.state.value);
406
407 if (this.props.value == null) {
408 // component is uncontrolled
409 if (hasNewValue) {
410 newState = this.getFullStateFromValue(newState.value, this.props.useAmPm);
411 }
412 this.setState(newState);
413 } else {
414 // component is controlled, and there's a new value
415 // so set inputs' text based off of _old_ value and later fire onChange with new value
416 if (hasNewValue) {
417 this.setState(this.getFullStateFromValue(this.state.value, this.props.useAmPm));
418 } else {
419 // no new value, this means only text has changed (from user typing)
420 // we want inputs to change, so update state with new text for the inputs
421 // but don't change actual value
422 this.setState({ ...newState, value: DateUtils.clone(this.state.value) });
423 }
424 }
425
426 if (hasNewValue) {
427 this.props.onChange?.(newState.value);
428 }
429 }
430
431 private getInitialValue(): Date {
432 let value = this.props.minTime;
433 if (this.props.value != null) {
434 value = this.props.value;
435 } else if (this.props.defaultValue != null) {
436 value = this.props.defaultValue;
437 }
438
439 return value;
440 }
441}
442
443function formatTime(time: number, unit: TimeUnit) {
444 switch (unit) {
445 case TimeUnit.HOUR_24:
446 return time.toString();
447 case TimeUnit.HOUR_12:
448 return DateUtils.get12HourFrom24Hour(time).toString();
449 case TimeUnit.MINUTE:
450 case TimeUnit.SECOND:
451 return Utils.padWithZeroes(time.toString(), 2);
452 case TimeUnit.MS:
453 return Utils.padWithZeroes(time.toString(), 3);
454 default:
455 throw Error("Invalid TimeUnit");
456 }
457}
458
459function getStringValueFromInputEvent(e: React.SyntheticEvent<HTMLInputElement>) {
460 return (e.target as HTMLInputElement).value;
461}
462
463interface IKeyEventMap {
464 [key: number]: () => void;
465}
466
467function handleKeyEvent(e: React.KeyboardEvent<HTMLInputElement>, actions: IKeyEventMap, preventDefault = true) {
468 for (const k of Object.keys(actions)) {
469 const key = Number(k);
470 // HACKHACK: https://github.com/palantir/blueprint/issues/4165
471 // eslint-disable-next-line deprecation/deprecation
472 if (e.which === key) {
473 if (preventDefault) {
474 e.preventDefault();
475 }
476 actions[key]();
477 }
478 }
479}