diff --git a/CHANGELOG.md b/CHANGELOG.md index 16b0e55..933a97a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.1] - 2023-12-18 - add option to enable title's tooltip + change up/down trend arrow colors to grey by [@shahramk](https://github.com/shahramk) diff --git a/README.md b/README.md index 0524cbf..6cf82ac 100644 --- a/README.md +++ b/README.md @@ -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/) diff --git a/src/components/index.js b/src/components/index.js index 312e90f..a1fc90a 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -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'; diff --git a/src/components/time-picker/README.md b/src/components/time-picker/README.md new file mode 100644 index 0000000..aaaa2b1 --- /dev/null +++ b/src/components/time-picker/README.md @@ -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 ( +
+ +
+ ); +} +``` +### 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 diff --git a/src/components/time-picker/index.js b/src/components/time-picker/index.js new file mode 100644 index 0000000..0777ff6 --- /dev/null +++ b/src/components/time-picker/index.js @@ -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 ( + + + + + +
+ + + {times.map(({ key, value }) => ( + + + + ))} + +
+
+
+ ); +}; + +TimePicker.propTypes = { + time: PropTypes.instanceOf(Date), + validFrom: PropTypes.instanceOf(Date), + validTill: PropTypes.instanceOf(Date), + onChange: PropTypes.func, +}; + +export default TimePicker; diff --git a/src/components/time-picker/styles.scss b/src/components/time-picker/styles.scss new file mode 100644 index 0000000..63d2578 --- /dev/null +++ b/src/components/time-picker/styles.scss @@ -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; + } +} diff --git a/src/components/time-picker/utils.js b/src/components/time-picker/utils.js new file mode 100644 index 0000000..6e83796 --- /dev/null +++ b/src/components/time-picker/utils.js @@ -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, +};