Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 48 additions & 9 deletions packages/react-core/src/components/TimePicker/TimePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@ import { TimeOption } from './TimeOption';
import { KeyTypes, SelectDirection } from '../Select';
import { InputGroup } from '../InputGroup';
import { TextInput, TextInputProps } from '../TextInput';
import { parseTime, validateTime, makeTimeOptions, amSuffix, pmSuffix, getHours, getMinutes } from './TimePickerUtils';
import {
parseTime,
validateTime,
makeTimeOptions,
amSuffix,
pmSuffix,
getHours,
getMinutes,
isWithinMinMax
} from './TimePickerUtils';

export interface TimePickerProps
extends Omit<React.HTMLProps<HTMLDivElement>, 'onChange' | 'onFocus' | 'onBlur' | 'disabled' | 'ref'> {
Expand All @@ -27,6 +36,8 @@ export interface TimePickerProps
time?: string | Date;
/** Error message to display when the time is provided in an invalid format. */
invalidFormatErrorMessage?: string;
/** Error message to display when the time provided is not within the minTime/maxTime constriants */
invalidMinMaxErrorMessage?: string;
/** True if the time is 24 hour time. False if the time is 12 hour time */
is24Hour?: boolean;
/** Optional event handler called each time the value in the time picker input changes. */
Expand All @@ -50,6 +61,10 @@ export interface TimePickerProps
stepMinutes?: number;
/** Additional props for input field */
inputProps?: TextInputProps;
/** A time string indicating the minimum value allowed. The format could be an ISO 8601 formatted date string or in 'HH{delimiter}MM' format */
minTime?: string | Date;
/** A time string indicating the maximum value allowed. The format could be an ISO 8601 formatted date string or in 'HH{delimiter}MM' format */
maxTime?: string | Date;
}

interface TimePickerState {
Expand All @@ -59,6 +74,8 @@ interface TimePickerState {
focusedIndex: number;
scrollIndex: number;
timeRegex: RegExp;
minTimeState: string;
maxTimeState: string;
}

export class TimePicker extends React.Component<TimePickerProps, TimePickerState> {
Expand All @@ -74,29 +91,39 @@ export class TimePicker extends React.Component<TimePickerProps, TimePickerState
time: '',
is24Hour: false,
invalidFormatErrorMessage: 'Invalid time format',
invalidMinMaxErrorMessage: 'Invalid time entered',
placeholder: 'hh:mm',
delimiter: ':',
'aria-label': 'Time picker',
menuAppendTo: 'inline',
direction: 'down',
width: 150,
stepMinutes: 30,
inputProps: {}
inputProps: {},
minTime: '',
maxTime: ''
};

constructor(props: TimePickerProps) {
super(props);

const { is24Hour, delimiter, time } = this.props;
let { minTime, maxTime } = this.props;
if (minTime === '') {
minTime = is24Hour ? `00${delimiter}00` : `12${delimiter}00AM`;
}
if (maxTime === '') {
maxTime = is24Hour ? `23${delimiter}59` : `11${delimiter}59PM`;
}
const timeRegex = this.getRegExp();

this.state = {
isInvalid: false,
isOpen: false,
timeState: parseTime(time, timeRegex, delimiter, !is24Hour),
focusedIndex: null,
scrollIndex: 0,
timeRegex
timeRegex,
minTimeState: parseTime(minTime, timeRegex, delimiter, !is24Hour),
maxTimeState: parseTime(maxTime, timeRegex, delimiter, !is24Hour)
};
}

Expand Down Expand Up @@ -253,14 +280,22 @@ export class TimePicker extends React.Component<TimePickerProps, TimePickerState
getOptions = () =>
(this.menuRef && this.menuRef.current ? Array.from(this.menuRef.current.children) : []) as HTMLElement[];

isValid = (time: string) => {
isValidFormat = (time: string) => {
if (this.props.validateTime) {
return this.props.validateTime(time);
}
const { delimiter, is24Hour } = this.props;
return validateTime(time, this.state.timeRegex, delimiter, !is24Hour);
};

isValidTime = (time: string) => {
const { delimiter } = this.props;
const { minTimeState, maxTimeState } = this.state;
return isWithinMinMax(minTimeState, maxTimeState, time, delimiter);
};

isValid = (time: string) => this.isValidFormat(time) && this.isValidTime(time);

onToggle = (isOpen: boolean) => {
// on close, parse and validate input
this.setState(prevState => {
Expand Down Expand Up @@ -329,6 +364,7 @@ export class TimePicker extends React.Component<TimePickerProps, TimePickerState
menuAppendTo,
is24Hour,
invalidFormatErrorMessage,
invalidMinMaxErrorMessage,
direction,
stepMinutes,
width,
Expand All @@ -338,11 +374,14 @@ export class TimePicker extends React.Component<TimePickerProps, TimePickerState
time,
validateTime,
inputProps,
minTime,
maxTime,
...props
} = this.props;
const { timeState, isOpen, isInvalid, focusedIndex } = this.state;
const { timeState, isOpen, isInvalid, focusedIndex, minTimeState, maxTimeState } = this.state;
const style = { '--pf-c-date-picker__input--c-form-control--Width': width } as React.CSSProperties;
const options = makeTimeOptions(stepMinutes, !is24Hour, delimiter);
const options = makeTimeOptions(stepMinutes, !is24Hour, delimiter, minTimeState, maxTimeState);
const isValidFormat = this.isValidFormat(timeState);
const randomId = id || getUniqueId('time-picker');

const menuContainer = (
Expand Down Expand Up @@ -408,7 +447,7 @@ export class TimePicker extends React.Component<TimePickerProps, TimePickerState
</InputGroup>
{isInvalid && (
<div className={css(datePickerStyles.datePickerHelperText, datePickerStyles.modifiers.error)}>
{invalidFormatErrorMessage}
{!isValidFormat ? invalidFormatErrorMessage : invalidMinMaxErrorMessage}
</div>
)}
</div>
Expand Down
78 changes: 54 additions & 24 deletions packages/react-core/src/components/TimePicker/TimePickerUtils.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
export const amSuffix = ' AM';
export const pmSuffix = ' PM';

export const makeTimeOptions = (stepMinutes: number, hour12: boolean, delimiter: string) => {
export const makeTimeOptions = (
stepMinutes: number,
hour12: boolean,
delimiter: string,
minTime: string,
maxTime: string
) => {
const res = [];
const iter = new Date(new Date().setHours(0, 0, 0, 0));
const iterDay = iter.getDay();
while (iter.getDay() === iterDay) {
let hour = iter.getHours();
let hour: string | number = iter.getHours();
let suffix = amSuffix;
if (hour12) {
if (hour === 0) {
Expand All @@ -18,19 +24,18 @@ export const makeTimeOptions = (stepMinutes: number, hour12: boolean, delimiter:
hour %= 12;
}
}
res.push(
(hour12 ? hour.toString() : hour.toString().padStart(2, '0')) +
delimiter +
iter
.getMinutes()
.toString()
.padStart(2, '0') +
(hour12 ? suffix : '')
);

hour = hour12 ? hour.toString() : hour.toString().padStart(2, '0');
const minutes = iter
.getMinutes()
.toString()
.padStart(2, '0');
const timeOption = hour + delimiter + minutes + (hour12 ? suffix : '');
// time option is valid if within min/max constraints
if (isWithinMinMax(minTime, maxTime, timeOption, delimiter)) {
res.push(timeOption);
}
iter.setMinutes(iter.getMinutes() + stepMinutes);
}

return res;
};

Expand All @@ -53,26 +58,20 @@ export const parseTime = (time: string | Date, timeRegex: RegExp, delimiter: str
} else if (typeof time === 'string') {
time = time.trim();
if (is12Hour && time !== '' && validateTime(time, timeRegex, delimiter, is12Hour)) {
const [, hours, minutes, suffix = ''] = timeRegex.exec(time);
const uppercaseSuffix = suffix.toUpperCase();
// Format AM/PM according to design
let ampm = '';
if (time.toUpperCase().includes(amSuffix.toUpperCase().trim())) {
time = time
.toUpperCase()
.replace(amSuffix.toUpperCase().trim(), '')
.trim();
if (uppercaseSuffix === amSuffix.toUpperCase().trim()) {
ampm = amSuffix;
} else if (time.toUpperCase().includes(pmSuffix.toUpperCase().trim())) {
time = time
.toUpperCase()
.replace(pmSuffix.toUpperCase().trim(), '')
.trim();
} else if (uppercaseSuffix === pmSuffix.toUpperCase().trim()) {
ampm = pmSuffix;
} else {
// if this 12 hour time is missing am/pm but otherwise valid,
// append am/pm depending on time of day
ampm = new Date().getHours() > 11 ? pmSuffix : amSuffix;
}
return `${time}${ampm}`;
return `${hours}${delimiter}${minutes}${ampm}`;
}
}
return time.toString();
Expand Down Expand Up @@ -111,3 +110,34 @@ export const getMinutes = (time: string, timeRegex: RegExp) => {
const parts = time.match(timeRegex);
return parts && parts.length ? parseInt(parts[2]) : null;
};

export const isWithinMinMax = (minTime: string, maxTime: string, time: string, delimiter: string) => {
// do not throw error if empty string
if (time.trim() === '') {
return true;
}
const convertTo24Hour = (time: string): string => {
const timeReg = new RegExp(`^\\s*(\\d\\d?)${delimiter}([0-5]\\d)\\s*([AaPp][Mm])?\\s*$`);
const regMatches = timeReg.exec(time);
if (!regMatches || !regMatches.length) {
return;
}
let hours = regMatches[1].padStart(2, '0');
const minutes = regMatches[2];
const suffix = regMatches[3] || '';
if (suffix.toUpperCase() === 'PM' && hours !== '12') {
hours = `${parseInt(hours) + 12}`;
} else if (suffix.toUpperCase() === 'AM' && hours === '12') {
hours = '00';
}
return `${hours}${delimiter}${minutes}`;
};

// correctly format as 24hr times (12:30AM => 00:30, 1:15 => 01:15)
minTime = convertTo24Hour(minTime);
time = convertTo24Hour(time);
maxTime = convertTo24Hour(maxTime);

// simple string comparison for 24hr times
return minTime <= time && time <= maxTime;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In your example, it looks like the max time is not included in the list. Should it be?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nicolethoen it shouldn't be included as it's currently designed, but we could change that. I'm not touching the time options that are created from the stepMinutes other than to remove any that are earlier than the min or greater than the max.

In the example, the stepMinutes defaults to 30 minute intervals and because the maxTime is 17:15 the last time option shown is 17:00 (as the next option, 17:30, is above the maxTime).

@mcarrano would you expect that we should add an additional time option with their max time (or min time) if it doesn't align with one of the existing time options? In my example, where maxTime=17:15, rather than the last option being 17:00 there would be one more that would be 17:15?

};
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,14 @@ import { TimePicker } from '@patternfly/react-core';

<TimePicker is24Hour delimiter="h" placeholder=""/>
```

### Minimum/maximum times

The `minTime`/`maxTime` props restrict the options shown for the user to select from as well as trigger the `invalidMinMaxErrorMessage` if the user enters a time outside this range.

```js
import React from 'react';
import { TimePicker } from '@patternfly/react-core';

<TimePicker is24Hour minTime="9:30" maxTime="17:15" placeholder="14:00"/>
```