UNPKG

15.4 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 {
21 Classes as CoreClasses,
22 Utils as CoreUtils,
23 DISPLAYNAME_PREFIX,
24 HTMLSelect,
25 Icon,
26 Intent,
27} from "@blueprintjs/core";
28
29import { Classes, DateUtils, type TimePickerProps, TimePrecision } from "../../common";
30import {
31 getDefaultMaxTime,
32 getDefaultMinTime,
33 getTimeUnit,
34 getTimeUnitClassName,
35 getTimeUnitMax,
36 getTimeUnitPrintStr,
37 isTimeUnitValid,
38 setTimeUnit,
39 TimeUnit,
40 wrapTimeAtUnit,
41} from "../../common/timeUnit";
42import * as Utils from "../../common/utils";
43
44export interface TimePickerState {
45 hourText?: string;
46 minuteText?: string;
47 secondText?: string;
48 millisecondText?: string;
49 value?: Date;
50 isPm?: boolean;
51}
52
53/**
54 * Time picker component.
55 *
56 * @see https://blueprintjs.com/docs/#datetime/timepicker
57 */
58export class TimePicker extends React.Component<TimePickerProps, TimePickerState> {
59 public static defaultProps: TimePickerProps = {
60 autoFocus: false,
61 disabled: false,
62 maxTime: getDefaultMaxTime(),
63 minTime: getDefaultMinTime(),
64 precision: TimePrecision.MINUTE,
65 selectAllOnFocus: false,
66 showArrowButtons: false,
67 useAmPm: false,
68 };
69
70 public static displayName = `${DISPLAYNAME_PREFIX}.TimePicker`;
71
72 public constructor(props?: TimePickerProps) {
73 super(props);
74
75 this.state = this.getFullStateFromValue(this.getInitialValue(), props.useAmPm);
76 }
77
78 private timeInputIds: { [key in TimeUnit]: string } = {
79 [TimeUnit.HOUR_24]: CoreUtils.uniqueId(TimeUnit.HOUR_24 + "-input"),
80 [TimeUnit.HOUR_12]: CoreUtils.uniqueId(TimeUnit.HOUR_12 + "-input"),
81 [TimeUnit.MINUTE]: CoreUtils.uniqueId(TimeUnit.MINUTE + "-input"),
82 [TimeUnit.SECOND]: CoreUtils.uniqueId(TimeUnit.SECOND + "-input"),
83 [TimeUnit.MS]: CoreUtils.uniqueId(TimeUnit.MS + "-input"),
84 };
85
86 public render() {
87 const shouldRenderMilliseconds = this.props.precision === TimePrecision.MILLISECOND;
88 const shouldRenderSeconds = shouldRenderMilliseconds || this.props.precision === TimePrecision.SECOND;
89 const hourUnit = this.props.useAmPm ? TimeUnit.HOUR_12 : TimeUnit.HOUR_24;
90 const classes = classNames(Classes.TIMEPICKER, this.props.className, {
91 [CoreClasses.DISABLED]: this.props.disabled,
92 });
93
94 return (
95 <div className={classes}>
96 <div className={Classes.TIMEPICKER_ARROW_ROW}>
97 {this.maybeRenderArrowButton(true, hourUnit)}
98 {this.maybeRenderArrowButton(true, TimeUnit.MINUTE)}
99 {shouldRenderSeconds && this.maybeRenderArrowButton(true, TimeUnit.SECOND)}
100 {shouldRenderMilliseconds && this.maybeRenderArrowButton(true, TimeUnit.MS)}
101 </div>
102 <div className={Classes.TIMEPICKER_INPUT_ROW}>
103 {this.renderInput(Classes.TIMEPICKER_HOUR, hourUnit, this.state.hourText)}
104 {this.renderDivider()}
105 {this.renderInput(Classes.TIMEPICKER_MINUTE, TimeUnit.MINUTE, this.state.minuteText)}
106 {shouldRenderSeconds && this.renderDivider()}
107 {shouldRenderSeconds &&
108 this.renderInput(Classes.TIMEPICKER_SECOND, TimeUnit.SECOND, this.state.secondText)}
109 {shouldRenderMilliseconds && this.renderDivider(".")}
110 {shouldRenderMilliseconds &&
111 this.renderInput(Classes.TIMEPICKER_MILLISECOND, TimeUnit.MS, this.state.millisecondText)}
112 </div>
113 {this.maybeRenderAmPm()}
114 <div className={Classes.TIMEPICKER_ARROW_ROW}>
115 {this.maybeRenderArrowButton(false, hourUnit)}
116 {this.maybeRenderArrowButton(false, TimeUnit.MINUTE)}
117 {shouldRenderSeconds && this.maybeRenderArrowButton(false, TimeUnit.SECOND)}
118 {shouldRenderMilliseconds && this.maybeRenderArrowButton(false, TimeUnit.MS)}
119 </div>
120 </div>
121 );
122 }
123
124 public componentDidUpdate(prevProps: TimePickerProps) {
125 const didMinTimeChange = prevProps.minTime !== this.props.minTime;
126 const didMaxTimeChange = prevProps.maxTime !== this.props.maxTime;
127 const didBoundsChange = didMinTimeChange || didMaxTimeChange;
128 const didPropValueChange = prevProps.value !== this.props.value;
129 const shouldStateUpdate = didBoundsChange || didPropValueChange;
130
131 let value = this.state.value;
132 if (this.props.value == null) {
133 value = this.getInitialValue();
134 }
135 if (didBoundsChange) {
136 value = DateUtils.getTimeInRange(this.state.value, this.props.minTime, this.props.maxTime);
137 }
138 if (this.props.value != null && !DateUtils.isSameTime(this.props.value, prevProps.value)) {
139 value = this.props.value;
140 }
141
142 if (shouldStateUpdate) {
143 this.setState(this.getFullStateFromValue(value, this.props.useAmPm));
144 }
145 }
146
147 // begin method definitions: rendering
148
149 private maybeRenderArrowButton(isDirectionUp: boolean, timeUnit: TimeUnit) {
150 if (!this.props.showArrowButtons) {
151 return null;
152 }
153 const classes = classNames(Classes.TIMEPICKER_ARROW_BUTTON, getTimeUnitClassName(timeUnit));
154 const onClick = () => (isDirectionUp ? this.incrementTime : this.decrementTime)(timeUnit);
155 const label = `${isDirectionUp ? "Increase" : "Decrease"} ${getTimeUnitPrintStr(timeUnit)}`;
156
157 // set tabIndex=-1 to ensure a valid FocusEvent relatedTarget when focused
158 return (
159 <span
160 aria-controls={this.timeInputIds[timeUnit]}
161 aria-label={label}
162 tabIndex={-1}
163 className={classes}
164 onClick={onClick}
165 >
166 <Icon icon={isDirectionUp ? "chevron-up" : "chevron-down"} title={label} />
167 </span>
168 );
169 }
170
171 private renderDivider(text = ":") {
172 return <span className={Classes.TIMEPICKER_DIVIDER_TEXT}>{text}</span>;
173 }
174
175 private renderInput(className: string, unit: TimeUnit, value: string) {
176 const valueNumber = parseInt(value, 10);
177 const isValid = isTimeUnitValid(unit, valueNumber);
178 const isHour = unit === TimeUnit.HOUR_12 || unit === TimeUnit.HOUR_24;
179
180 return (
181 <input
182 aria-label={getTimeUnitPrintStr(unit)}
183 className={classNames(
184 Classes.TIMEPICKER_INPUT,
185 { [CoreClasses.intentClass(Intent.DANGER)]: !isValid },
186 className,
187 )}
188 id={this.timeInputIds[unit]}
189 min={0}
190 max={getTimeUnitMax(unit)}
191 onBlur={this.getInputBlurHandler(unit)}
192 onChange={this.getInputChangeHandler(unit)}
193 onFocus={this.getInputFocusHandler(unit)}
194 onKeyDown={this.getInputKeyDownHandler(unit)}
195 onKeyUp={this.getInputKeyUpHandler(unit)}
196 role={this.props.showArrowButtons ? "spinbutton" : undefined}
197 type="number"
198 value={value}
199 disabled={this.props.disabled}
200 autoFocus={isHour && this.props.autoFocus}
201 />
202 );
203 }
204
205 private maybeRenderAmPm() {
206 if (!this.props.useAmPm) {
207 return null;
208 }
209 return (
210 <HTMLSelect
211 className={Classes.TIMEPICKER_AMPM_SELECT}
212 disabled={this.props.disabled}
213 onChange={this.handleAmPmChange}
214 value={this.state.isPm ? "pm" : "am"}
215 >
216 <option value="am">AM</option>
217 <option value="pm">PM</option>
218 </HTMLSelect>
219 );
220 }
221
222 // begin method definitions: event handlers
223
224 private getInputChangeHandler = (unit: TimeUnit) => (e: React.SyntheticEvent<HTMLInputElement>) => {
225 const text = getStringValueFromInputEvent(e);
226 switch (unit) {
227 case TimeUnit.HOUR_12:
228 case TimeUnit.HOUR_24:
229 this.setState({ hourText: text });
230 break;
231 case TimeUnit.MINUTE:
232 this.setState({ minuteText: text });
233 break;
234 case TimeUnit.SECOND:
235 this.setState({ secondText: text });
236 break;
237 case TimeUnit.MS:
238 this.setState({ millisecondText: text });
239 break;
240 }
241 };
242
243 private getInputBlurHandler = (unit: TimeUnit) => (e: React.FocusEvent<HTMLInputElement>) => {
244 const text = getStringValueFromInputEvent(e);
245 this.updateTime(parseInt(text, 10), unit);
246 this.props.onBlur?.(e, unit);
247 };
248
249 private getInputFocusHandler = (unit: TimeUnit) => (e: React.FocusEvent<HTMLInputElement>) => {
250 if (this.props.selectAllOnFocus) {
251 e.currentTarget.select();
252 }
253 this.props.onFocus?.(e, unit);
254 };
255
256 private getInputKeyDownHandler = (unit: TimeUnit) => (e: React.KeyboardEvent<HTMLInputElement>) => {
257 handleKeyEvent(e, {
258 ArrowDown: () => this.decrementTime(unit),
259 ArrowUp: () => this.incrementTime(unit),
260 Enter: () => {
261 (e.currentTarget as HTMLInputElement).blur();
262 },
263 });
264 this.props.onKeyDown?.(e, unit);
265 };
266
267 private getInputKeyUpHandler = (unit: TimeUnit) => (e: React.KeyboardEvent<HTMLInputElement>) => {
268 this.props.onKeyUp?.(e, unit);
269 };
270
271 private handleAmPmChange = (e: React.SyntheticEvent<HTMLSelectElement>) => {
272 const isNextPm = e.currentTarget.value === "pm";
273 if (isNextPm !== this.state.isPm) {
274 const hour = DateUtils.convert24HourMeridiem(this.state.value.getHours(), isNextPm);
275 this.setState({ isPm: isNextPm }, () => this.updateTime(hour, TimeUnit.HOUR_24));
276 }
277 };
278
279 // begin method definitions: state modification
280
281 /**
282 * Generates a full TimePickerState object with all text fields set to formatted strings based on value
283 */
284 private getFullStateFromValue(value: Date, useAmPm: boolean): TimePickerState {
285 const timeInRange = DateUtils.getTimeInRange(value, this.props.minTime, this.props.maxTime);
286 const hourUnit = useAmPm ? TimeUnit.HOUR_12 : TimeUnit.HOUR_24;
287 /* tslint:disable:object-literal-sort-keys */
288 return {
289 hourText: formatTime(timeInRange.getHours(), hourUnit),
290 minuteText: formatTime(timeInRange.getMinutes(), TimeUnit.MINUTE),
291 secondText: formatTime(timeInRange.getSeconds(), TimeUnit.SECOND),
292 millisecondText: formatTime(timeInRange.getMilliseconds(), TimeUnit.MS),
293 value: timeInRange,
294 isPm: DateUtils.getIsPmFrom24Hour(timeInRange.getHours()),
295 };
296 /* tslint:enable:object-literal-sort-keys */
297 }
298
299 private incrementTime = (unit: TimeUnit) => this.shiftTime(unit, 1);
300
301 private decrementTime = (unit: TimeUnit) => this.shiftTime(unit, -1);
302
303 private shiftTime(unit: TimeUnit, amount: number) {
304 if (this.props.disabled) {
305 return;
306 }
307 const newTime = getTimeUnit(unit, this.state.value) + amount;
308 this.updateTime(wrapTimeAtUnit(unit, newTime), unit);
309 }
310
311 private updateTime(time: number, unit: TimeUnit) {
312 const newValue = DateUtils.clone(this.state.value);
313
314 if (isTimeUnitValid(unit, time)) {
315 setTimeUnit(unit, time, newValue, this.state.isPm);
316 if (DateUtils.isTimeInRange(newValue, this.props.minTime, this.props.maxTime)) {
317 this.updateState({ value: newValue });
318 } else {
319 this.updateState(this.getFullStateFromValue(this.state.value, this.props.useAmPm));
320 }
321 } else {
322 this.updateState(this.getFullStateFromValue(this.state.value, this.props.useAmPm));
323 }
324 }
325
326 private updateState(state: TimePickerState) {
327 let newState = state;
328 const hasNewValue = newState.value != null && !DateUtils.isSameTime(newState.value, this.state.value);
329
330 if (this.props.value == null) {
331 // component is uncontrolled
332 if (hasNewValue) {
333 newState = this.getFullStateFromValue(newState.value, this.props.useAmPm);
334 }
335 this.setState(newState);
336 } else {
337 // component is controlled, and there's a new value
338 // so set inputs' text based off of _old_ value and later fire onChange with new value
339 if (hasNewValue) {
340 this.setState(this.getFullStateFromValue(this.state.value, this.props.useAmPm));
341 } else {
342 // no new value, this means only text has changed (from user typing)
343 // we want inputs to change, so update state with new text for the inputs
344 // but don't change actual value
345 this.setState({ ...newState, value: DateUtils.clone(this.state.value) });
346 }
347 }
348
349 if (hasNewValue) {
350 this.props.onChange?.(newState.value);
351 }
352 }
353
354 private getInitialValue(): Date {
355 let value = this.props.minTime;
356 if (this.props.value != null) {
357 value = this.props.value;
358 } else if (this.props.defaultValue != null) {
359 value = this.props.defaultValue;
360 }
361
362 return value;
363 }
364}
365
366function formatTime(time: number, unit: TimeUnit) {
367 switch (unit) {
368 case TimeUnit.HOUR_24:
369 return time.toString();
370 case TimeUnit.HOUR_12:
371 return DateUtils.get12HourFrom24Hour(time).toString();
372 case TimeUnit.MINUTE:
373 case TimeUnit.SECOND:
374 return Utils.padWithZeroes(time.toString(), 2);
375 case TimeUnit.MS:
376 return Utils.padWithZeroes(time.toString(), 3);
377 default:
378 throw Error("Invalid TimeUnit");
379 }
380}
381
382function getStringValueFromInputEvent(e: React.SyntheticEvent<HTMLInputElement>) {
383 return (e.target as HTMLInputElement).value;
384}
385
386interface KeyEventMap {
387 [key: string]: () => void;
388}
389
390function handleKeyEvent(e: React.KeyboardEvent<HTMLInputElement>, actions: KeyEventMap, preventDefault = true) {
391 for (const key of Object.keys(actions)) {
392 if (e.key === key) {
393 if (preventDefault) {
394 e.preventDefault();
395 }
396 actions[key]();
397 }
398 }
399}