Skip to content

Commit

Permalink
feat: time picker component
Browse files Browse the repository at this point in the history
  • Loading branch information
amit-y committed Dec 18, 2023
1 parent 8613c1d commit 4dcb884
Show file tree
Hide file tree
Showing 7 changed files with 283 additions and 0 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- TimePicker component by [@amit-y](https://github.com/amit-y)

## [1.17.0] - 2023-12-14

### Added
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ Component that allows a user to filter options.
### [DatePicker](src/components/date-picker)
Component that displays a calendar to select a date

### [TimePicker](src/components/time-picker)
Component to select time values

## Utilities

### [timeRangeToNrql](src/utils/time-range-to-nrql/)
Expand Down
1 change: 1 addition & 0 deletions src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export { default as StatusIconsLayout } from './status-icons-layout';
export { default as ProgressBar } from './progress-bar';
export { default as FilterBar } from './filter-bar';
export { default as DatePicker } from './date-picker';
export { default as TimePicker } from './time-picker';
28 changes: 28 additions & 0 deletions src/components/time-picker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# TimePicker

The `TimePicker` component is used to select a time value from a list of valid times.

## Usage

To use the component, simply import and use:

```jsx
import React, { useState } from 'react';
import { TimePicker } from '@newrelic/nr-labs-components';

function MyComponent() {
const [selectedTime, setSelectedTime] = useState(new Date());

return (
<div>
<TimePicker time={selectedTime} onChange={setSelectedTime} />
</div>
);
}
```
### Props

- time (date): The default time
- onChange (function): A function that is called when the user selects a time
- validFrom (date): only allow times starting from the provided value
- validTill (date): only allow times ending till the provided value
162 changes: 162 additions & 0 deletions src/components/time-picker/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import React, { useCallback, useEffect, useState } from 'react';
import PropTypes from 'prop-types';

import {
Popover,
PopoverTrigger,
PopoverBody,
TextField,
List,
ListItem,
Button,
} from 'nr1';

import {
getHourMinuteFromTimeString,
isValidTime,
normalizedDateTime,
isHourMinuteNumbers,
} from './utils';

import styles from './styles.scss';

const TIME_FORMATTER = new Intl.DateTimeFormat('default', {
hour12: true,
hour: 'numeric',
minute: 'numeric',
});

const TIMES_LIST = Array.from({ length: 48 }).map((_, i) => ({
key: `time${i}`,
value:
`${Math.floor(i / 2) % 12 || 12}`.padStart(2, '0') +
`:${i % 2 ? '30' : '00'} ${i > 23 ? 'pm' : 'am'}`,
}));

const TimePicker = ({ time, validFrom, validTill, onChange }) => {
const [opened, setOpened] = useState(false);
const [filter, setFilter] = useState('');
const [times, setTimes] = useState([]);

useEffect(() => {
if (!filter) {
setTimes(
TIMES_LIST.filter(({ value }) =>
isValidTime(value, validFrom, validTill, time)
)
);
return;
}
const re = /^(1[0-2]|0?[1-9]):?([0-5]?[0-9])? ?([AaPp][Mm]?)?/;
const [, hr, mi, me] = filter.match(re) || [];

if (hr) {
let reStr = hr;
if (reStr.length === 1 && Number(hr) < 3) reStr += '[0-9]?';
reStr += mi ? `:${mi}` : ':[0-9][0-9]';
if (reStr && me && /^[Aa]/.test(me)) reStr += ' am';
if (reStr && me && /^[Pp]/.test(me)) reStr += ' pm';

const re2 = new RegExp(reStr);
const matches = TIMES_LIST.filter(
({ value }) =>
value.match(re2) && isValidTime(value, validFrom, validTill, time)
);
if (!matches.length && hr && mi && mi.length === 2) {
if (me && /^[AaPp]/.test(me)) {
const value = `${hr}:${mi} ${/^[Aa]/.test(me) ? 'am' : 'pm'}`;
if (isValidTime(value, validFrom, validTill, time))
matches.push({
key: 'time48',
value,
});
} else {
['am', 'pm'].forEach((m) => {
const value = `${hr}:${mi} ${m}`;
if (isValidTime(value, validFrom, validTill, time))
matches.push({
key: `time48${m}`,
value,
});
});
}
}
setTimes(matches);
}
}, [filter]);

const clickHandler = useCallback((e, t) => {
e.stopPropagation();
const { hr, mi } = getHourMinuteFromTimeString(t);
if (!isHourMinuteNumbers(hr, mi)) return;
if (onChange) onChange(normalizedDateTime(time, hr, mi));
setOpened(false);
setFilter('');
}, []);

const changeHandler = useCallback((_, o) => {
if (!o) setFilter('');
setOpened(o);
});

const filterChangeHandler = useCallback(
({ target: { value = '' } } = {}) => setFilter(value),
[]
);

const keyDownHandler = useCallback((e) => {
const re = /[0-9APMapm: ]+/g;
if (!re.test(e.key)) e.preventDefault();
}, []);

return (
<Popover opened={opened} onChange={changeHandler}>
<PopoverTrigger>
<TextField
className={styles['time-picker-text-field']}
value={
time instanceof Date
? TIME_FORMATTER.format(time).toLocaleLowerCase()
: ''
}
placeholder="Select time"
readOnly
/>
</PopoverTrigger>
<PopoverBody>
<div className={styles['time-picker']}>
<TextField
className={styles['time-list-search']}
type={TextField.TYPE.SEARCH}
placeholder="filter..."
value={filter}
onKeyDown={keyDownHandler}
onChange={filterChangeHandler}
/>
<List className={styles['time-list-items']}>
{times.map(({ key, value }) => (
<ListItem key={key}>
<Button
className={styles['time-list-item']}
type={Button.TYPE.PLAIN}
onClick={(e) => clickHandler(e, value)}
>
{value}
</Button>
</ListItem>
))}
</List>
</div>
</PopoverBody>
</Popover>
);
};

TimePicker.propTypes = {
time: PropTypes.instanceOf(Date),
validFrom: PropTypes.instanceOf(Date),
validTill: PropTypes.instanceOf(Date),
onChange: PropTypes.func,
};

export default TimePicker;
45 changes: 45 additions & 0 deletions src/components/time-picker/styles.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
.time-picker {
box-sizing: border-box;
direction: ltr;
max-height: 178px;
max-width: 98px;
will-change: transform;
display: flex;
flex-direction: column;
}

.time-list-search {
flex: 1 0 10%;
border-bottom: 1px solid #E7E9EA;
input {
background-color: transparent;
}
}

.time-list-items {
flex: auto;
overflow-y: auto;
}

.time-list-item {
height: 24px;
width: 100%;
appearance: none;
background: none;
border: none;
font-size: 12px;
font-family: inherit;
color: #293338;
text-align: left;
cursor: pointer;
padding: 0 12px;
white-space: nowrap;
font-weight: normal;
border-radius: 0;
min-height: 24px;


&:hover {
box-shadow: none;
}
}
40 changes: 40 additions & 0 deletions src/components/time-picker/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const getHourMinuteFromTimeString = (timeString) => {
const parts = timeString.split(/:| /);
return parts.length === 3
? {
hr: (Number(parts[0]) % 12) + (parts[2] === 'pm' ? 12 : 0),
mi: Number(parts[1]),
}
: {};
};

const isValidTime = (timeString, validFrom, validTill, date) => {
const { hr, mi } = getHourMinuteFromTimeString(timeString);
if (!isHourMinuteNumbers(hr, mi)) return false;
if (!(date instanceof Date)) return true;
let isValid = true;
const d = normalizedDateTime(date, hr, mi);
if (validFrom instanceof Date) isValid = d >= normalizedDateTime(validFrom);
if (validTill instanceof Date)
isValid = isValid && d <= normalizedDateTime(validTill);
return isValid;
};

const normalizedDateTime = (dt = new Date(), hr, mi) =>
new Date(
dt.getFullYear(),
dt.getMonth(),
dt.getDate(),
typeof hr === 'number' ? hr : dt.getHours(),
typeof mi === 'number' ? mi : dt.getMinutes()
);

const isHourMinuteNumbers = (hr, mi) =>
typeof hr === 'number' && typeof mi === 'number';

export {
getHourMinuteFromTimeString,
isValidTime,
normalizedDateTime,
isHourMinuteNumbers,
};

0 comments on commit 4dcb884

Please sign in to comment.