@toptal/picasso-timepicker 100.0.3 → 100.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-package/src/TimePicker/TimePicker.d.ts +4 -1
- package/dist-package/src/TimePicker/TimePicker.d.ts.map +1 -1
- package/dist-package/src/TimePicker/TimePicker.js +60 -21
- package/dist-package/src/TimePicker/TimePicker.js.map +1 -1
- package/dist-package/src/TimePicker/use-time-picker-popover.d.ts +21 -0
- package/dist-package/src/TimePicker/use-time-picker-popover.d.ts.map +1 -0
- package/dist-package/src/TimePicker/use-time-picker-popover.js +96 -0
- package/dist-package/src/TimePicker/use-time-picker-popover.js.map +1 -0
- package/dist-package/src/TimePicker/utils/get-hour-cycle.d.ts +3 -0
- package/dist-package/src/TimePicker/utils/get-hour-cycle.d.ts.map +1 -0
- package/dist-package/src/TimePicker/utils/get-hour-cycle.js +42 -0
- package/dist-package/src/TimePicker/utils/get-hour-cycle.js.map +1 -0
- package/dist-package/src/TimePicker/utils/index.d.ts +5 -0
- package/dist-package/src/TimePicker/utils/index.d.ts.map +1 -0
- package/dist-package/src/TimePicker/utils/index.js +3 -0
- package/dist-package/src/TimePicker/utils/index.js.map +1 -0
- package/dist-package/src/TimePicker/utils/time.d.ts +16 -0
- package/dist-package/src/TimePicker/utils/time.d.ts.map +1 -0
- package/dist-package/src/TimePicker/utils/time.js +41 -0
- package/dist-package/src/TimePicker/utils/time.js.map +1 -0
- package/dist-package/src/TimePickerDropdown/TimePickerDropdown.d.ts +13 -0
- package/dist-package/src/TimePickerDropdown/TimePickerDropdown.d.ts.map +1 -0
- package/dist-package/src/TimePickerDropdown/TimePickerDropdown.js +28 -0
- package/dist-package/src/TimePickerDropdown/TimePickerDropdown.js.map +1 -0
- package/dist-package/src/TimePickerDropdown/index.d.ts +2 -0
- package/dist-package/src/TimePickerDropdown/index.d.ts.map +1 -0
- package/dist-package/src/TimePickerDropdown/index.js +2 -0
- package/dist-package/src/TimePickerDropdown/index.js.map +1 -0
- package/dist-package/src/TimePickerList/TimePickerList.d.ts +23 -0
- package/dist-package/src/TimePickerList/TimePickerList.d.ts.map +1 -0
- package/dist-package/src/TimePickerList/TimePickerList.js +97 -0
- package/dist-package/src/TimePickerList/TimePickerList.js.map +1 -0
- package/dist-package/src/TimePickerList/index.d.ts +4 -0
- package/dist-package/src/TimePickerList/index.d.ts.map +1 -0
- package/dist-package/src/TimePickerList/index.js +2 -0
- package/dist-package/src/TimePickerList/index.js.map +1 -0
- package/dist-package/src/TimePickerTrigger/TimePickerTrigger.d.ts +16 -0
- package/dist-package/src/TimePickerTrigger/TimePickerTrigger.d.ts.map +1 -0
- package/dist-package/src/TimePickerTrigger/TimePickerTrigger.js +11 -0
- package/dist-package/src/TimePickerTrigger/TimePickerTrigger.js.map +1 -0
- package/dist-package/src/TimePickerTrigger/index.d.ts +2 -0
- package/dist-package/src/TimePickerTrigger/index.d.ts.map +1 -0
- package/dist-package/src/TimePickerTrigger/index.js +2 -0
- package/dist-package/src/TimePickerTrigger/index.js.map +1 -0
- package/package.json +4 -1
- package/src/TimePicker/TimePicker.tsx +161 -65
- package/src/TimePicker/story/MinuteStep.example.tsx +16 -0
- package/src/TimePicker/story/index.jsx +10 -0
- package/src/TimePicker/test.tsx +649 -1
- package/src/TimePicker/use-time-picker-popover.ts +120 -0
- package/src/TimePicker/utils/get-hour-cycle.test.ts +76 -0
- package/src/TimePicker/utils/get-hour-cycle.ts +63 -0
- package/src/TimePicker/utils/index.ts +10 -0
- package/src/TimePicker/utils/time.test.ts +143 -0
- package/src/TimePicker/utils/time.ts +76 -0
- package/src/TimePickerDropdown/TimePickerDropdown.tsx +45 -0
- package/src/TimePickerDropdown/index.ts +1 -0
- package/src/TimePickerList/TimePickerList.tsx +190 -0
- package/src/TimePickerList/index.ts +4 -0
- package/src/TimePickerTrigger/TimePickerTrigger.tsx +53 -0
- package/src/TimePickerTrigger/index.ts +1 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type React from 'react'
|
|
2
|
+
import { useEffect, useRef, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import type { HourCycle } from './utils'
|
|
5
|
+
|
|
6
|
+
interface Options {
|
|
7
|
+
enabled: boolean
|
|
8
|
+
getHourCycle: () => HourCycle
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// The shortcuts a browser uses to open the native time picker
|
|
12
|
+
const isOpenShortcut = (event: React.KeyboardEvent) =>
|
|
13
|
+
event.key === ' ' || (event.key === 'ArrowDown' && event.altKey)
|
|
14
|
+
|
|
15
|
+
export const useTimePickerPopover = ({ enabled, getHourCycle }: Options) => {
|
|
16
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
17
|
+
const [focusList, setFocusList] = useState(false)
|
|
18
|
+
const [hourCycle, setHourCycle] = useState<HourCycle>(24)
|
|
19
|
+
const anchorRef = useRef<HTMLElement>(null)
|
|
20
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
21
|
+
|
|
22
|
+
const focusField = () =>
|
|
23
|
+
anchorRef.current
|
|
24
|
+
?.querySelector<HTMLInputElement>('input:not([readonly])')
|
|
25
|
+
?.focus()
|
|
26
|
+
|
|
27
|
+
const open = ({ focusList: shouldFocusList }: { focusList: boolean }) => {
|
|
28
|
+
setFocusList(shouldFocusList)
|
|
29
|
+
setHourCycle(getHourCycle())
|
|
30
|
+
setIsOpen(true)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const close = () => setIsOpen(false)
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!enabled) {
|
|
37
|
+
close()
|
|
38
|
+
}
|
|
39
|
+
}, [enabled])
|
|
40
|
+
|
|
41
|
+
const closeAndFocusField = () => {
|
|
42
|
+
close()
|
|
43
|
+
focusField()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const handleTriggerClick = () => {
|
|
47
|
+
if (isOpen) {
|
|
48
|
+
closeAndFocusField()
|
|
49
|
+
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
open({ focusList: false })
|
|
54
|
+
focusField()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const handleClickAway = (event: React.MouseEvent) => {
|
|
58
|
+
if (!anchorRef.current?.contains(event.target as Node)) {
|
|
59
|
+
close()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const isFocusChangeWithinList = (event: React.FocusEvent) =>
|
|
64
|
+
event.relatedTarget instanceof Node &&
|
|
65
|
+
Boolean(listRef.current?.contains(event.relatedTarget))
|
|
66
|
+
|
|
67
|
+
const handleFieldFocus = () => {
|
|
68
|
+
// Focus is back in the field, so the next shortcut must be able to move it
|
|
69
|
+
// into the list again
|
|
70
|
+
setFocusList(false)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const handleFieldKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
74
|
+
if (!isOpen) {
|
|
75
|
+
if (enabled && isOpenShortcut(event)) {
|
|
76
|
+
event.preventDefault()
|
|
77
|
+
open({ focusList: true })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (isOpenShortcut(event)) {
|
|
84
|
+
// Keeps the browser from opening its own time picker over the list
|
|
85
|
+
event.preventDefault()
|
|
86
|
+
setFocusList(true)
|
|
87
|
+
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
switch (event.key) {
|
|
92
|
+
case 'Escape':
|
|
93
|
+
// Keeps a surrounding Modal or Drawer open while the list closes
|
|
94
|
+
event.stopPropagation()
|
|
95
|
+
close()
|
|
96
|
+
break
|
|
97
|
+
case 'Enter':
|
|
98
|
+
event.preventDefault()
|
|
99
|
+
close()
|
|
100
|
+
break
|
|
101
|
+
case 'Tab':
|
|
102
|
+
close()
|
|
103
|
+
break
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
isOpen,
|
|
109
|
+
hourCycle,
|
|
110
|
+
anchorRef,
|
|
111
|
+
listRef,
|
|
112
|
+
focusList,
|
|
113
|
+
isFocusChangeWithinList,
|
|
114
|
+
handleTriggerClick,
|
|
115
|
+
handleClickAway,
|
|
116
|
+
closeAndFocusField,
|
|
117
|
+
handleFieldFocus,
|
|
118
|
+
handleFieldKeyDown,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { getHourCycle } from './get-hour-cycle'
|
|
2
|
+
|
|
3
|
+
const PROBE_HEIGHT = 100
|
|
4
|
+
|
|
5
|
+
const mockNativeFieldLayout = (renderedSegments: string[]) => {
|
|
6
|
+
const appendChild = document.body.appendChild.bind(document.body)
|
|
7
|
+
|
|
8
|
+
jest.spyOn(document.body, 'appendChild').mockImplementation(node => {
|
|
9
|
+
if (node instanceof HTMLElement) {
|
|
10
|
+
const testId = node.getAttribute('data-testid')
|
|
11
|
+
const isStretched = renderedSegments.some(
|
|
12
|
+
segment => testId === `time-picker-probe-${segment}`
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
Object.defineProperty(node, 'offsetHeight', {
|
|
16
|
+
value: isStretched ? PROBE_HEIGHT : 17,
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return appendChild(node)
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('getHourCycle', () => {
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
jest.restoreAllMocks()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('when the native time field renders an AM/PM segment', () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
mockNativeFieldLayout(['hour', 'ampm'])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('leaves no probe elements in the document', () => {
|
|
35
|
+
getHourCycle('de-DE')
|
|
36
|
+
|
|
37
|
+
expect(document.querySelector('input')).not.toBeInTheDocument()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('when the locale uses a 24-hour clock', () => {
|
|
41
|
+
it('returns 12', () => {
|
|
42
|
+
expect(getHourCycle('de-DE')).toBe(12)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('when the native time field renders no AM/PM segment', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
mockNativeFieldLayout(['hour'])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('when the locale uses a 12-hour clock', () => {
|
|
53
|
+
it('returns 24', () => {
|
|
54
|
+
expect(getHourCycle('en-US')).toBe(24)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('when the browser does not expose the segments of the native field', () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
mockNativeFieldLayout([])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('when the locale uses a 12-hour clock', () => {
|
|
65
|
+
it('returns 12', () => {
|
|
66
|
+
expect(getHourCycle('en-US')).toBe(12)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('when the locale uses a 24-hour clock', () => {
|
|
71
|
+
it('returns 24', () => {
|
|
72
|
+
expect(getHourCycle('de-DE')).toBe(24)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type HourCycle = 12 | 24
|
|
2
|
+
|
|
3
|
+
type Segment = 'hour' | 'ampm'
|
|
4
|
+
|
|
5
|
+
const PROBE_CLASS_NAME =
|
|
6
|
+
'fixed top-0 left-0 invisible pointer-events-none h-auto p-0 border-0'
|
|
7
|
+
|
|
8
|
+
// Blink calls the AM/PM segment `ampm-field`, WebKit calls it `meridiem-field`
|
|
9
|
+
const SEGMENT_CLASS_NAMES: Record<Segment, string> = {
|
|
10
|
+
hour: '[&::-webkit-datetime-edit-hour-field]:inline-block [&::-webkit-datetime-edit-hour-field]:h-screen',
|
|
11
|
+
ampm: '[&::-webkit-datetime-edit-ampm-field]:inline-block [&::-webkit-datetime-edit-ampm-field]:h-screen [&::-webkit-datetime-edit-meridiem-field]:inline-block [&::-webkit-datetime-edit-meridiem-field]:h-screen',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const measureProbe = (segment?: Segment) => {
|
|
15
|
+
const probe = document.createElement('input')
|
|
16
|
+
|
|
17
|
+
probe.type = 'time'
|
|
18
|
+
probe.tabIndex = -1
|
|
19
|
+
probe.setAttribute('aria-hidden', 'true')
|
|
20
|
+
probe.className = PROBE_CLASS_NAME
|
|
21
|
+
|
|
22
|
+
if (segment) {
|
|
23
|
+
probe.className += ` ${SEGMENT_CLASS_NAMES[segment]}`
|
|
24
|
+
probe.setAttribute('data-testid', `time-picker-probe-${segment}`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
document.body.appendChild(probe)
|
|
28
|
+
|
|
29
|
+
const height = probe.offsetHeight
|
|
30
|
+
|
|
31
|
+
probe.remove()
|
|
32
|
+
|
|
33
|
+
return height
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The native field follows the operating system's 12/24-hour setting, which
|
|
37
|
+
// `Intl` does not see, so the field itself is the only reliable source
|
|
38
|
+
const getNativeFieldHourCycle = (): HourCycle | undefined => {
|
|
39
|
+
if (typeof document === 'undefined') {
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const stretchedHeight = measureProbe() * 2
|
|
44
|
+
const rendersSegment = (segment: Segment) =>
|
|
45
|
+
measureProbe(segment) > stretchedHeight
|
|
46
|
+
|
|
47
|
+
if (!rendersSegment('hour')) {
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return rendersSegment('ampm') ? 12 : 24
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const getLocaleHourCycle = (locale?: string): HourCycle => {
|
|
55
|
+
const { hour12 } = new Intl.DateTimeFormat(locale, {
|
|
56
|
+
hour: 'numeric',
|
|
57
|
+
}).resolvedOptions()
|
|
58
|
+
|
|
59
|
+
return hour12 ? 12 : 24
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const getHourCycle = (locale?: string): HourCycle =>
|
|
63
|
+
getNativeFieldHourCycle() ?? getLocaleHourCycle(locale)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { getHourCycle } from './get-hour-cycle'
|
|
2
|
+
export type { HourCycle } from './get-hour-cycle'
|
|
3
|
+
export {
|
|
4
|
+
VALID_TIME_REGEX,
|
|
5
|
+
getCurrentTime,
|
|
6
|
+
getNearestOptionIndex,
|
|
7
|
+
getTimeOptions,
|
|
8
|
+
parseTime,
|
|
9
|
+
} from './time'
|
|
10
|
+
export type { Time, TimeOption } from './time'
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import {
|
|
2
|
+
VALID_TIME_REGEX,
|
|
3
|
+
formatTime,
|
|
4
|
+
getCurrentTime,
|
|
5
|
+
getNearestOptionIndex,
|
|
6
|
+
getTimeOptions,
|
|
7
|
+
parseTime,
|
|
8
|
+
} from './time'
|
|
9
|
+
|
|
10
|
+
describe('VALID_TIME_REGEX', () => {
|
|
11
|
+
it.each(['00:00', '09:05', '12:30', '19:59', '23:59'])(
|
|
12
|
+
'accepts %s',
|
|
13
|
+
value => {
|
|
14
|
+
expect(VALID_TIME_REGEX.test(value)).toBe(true)
|
|
15
|
+
}
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
it.each([
|
|
19
|
+
['24:00', 'the hour is out of range'],
|
|
20
|
+
['12:60', 'the minute is out of range'],
|
|
21
|
+
['9:30', 'the hour is not padded'],
|
|
22
|
+
['12:3', 'the minute is not padded'],
|
|
23
|
+
['12:30:00', 'seconds are included'],
|
|
24
|
+
['12.30', 'the separator is not a colon'],
|
|
25
|
+
[' 12:30', 'there is surrounding whitespace'],
|
|
26
|
+
['', 'the value is empty'],
|
|
27
|
+
])('rejects "%s" because %s', value => {
|
|
28
|
+
expect(VALID_TIME_REGEX.test(value)).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('parseTime', () => {
|
|
33
|
+
describe('when the value is a valid time', () => {
|
|
34
|
+
it('returns the hours and minutes as numbers', () => {
|
|
35
|
+
expect(parseTime('09:05')).toEqual({ hours: 9, minutes: 5 })
|
|
36
|
+
expect(parseTime('23:59')).toEqual({ hours: 23, minutes: 59 })
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('when the value is not a valid time', () => {
|
|
41
|
+
it('returns undefined', () => {
|
|
42
|
+
expect(parseTime('24:00')).toBeUndefined()
|
|
43
|
+
expect(parseTime('9:05')).toBeUndefined()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('when the value is missing', () => {
|
|
48
|
+
it('returns undefined', () => {
|
|
49
|
+
expect(parseTime()).toBeUndefined()
|
|
50
|
+
expect(parseTime('')).toBeUndefined()
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('formatTime', () => {
|
|
56
|
+
it('pads hours and minutes to two digits', () => {
|
|
57
|
+
expect(formatTime({ hours: 0, minutes: 0 })).toBe('00:00')
|
|
58
|
+
expect(formatTime({ hours: 9, minutes: 5 })).toBe('09:05')
|
|
59
|
+
expect(formatTime({ hours: 23, minutes: 59 })).toBe('23:59')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('getCurrentTime', () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
jest.useFakeTimers().setSystemTime(new Date(2026, 0, 1, 14, 7))
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
jest.useRealTimers()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('returns the local hours and minutes of now', () => {
|
|
73
|
+
expect(getCurrentTime()).toEqual({ hours: 14, minutes: 7 })
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('getTimeOptions', () => {
|
|
78
|
+
it('offers one option per step across the whole day', () => {
|
|
79
|
+
expect(getTimeOptions(60, 24)).toHaveLength(24)
|
|
80
|
+
expect(getTimeOptions(30, 24)).toHaveLength(48)
|
|
81
|
+
expect(getTimeOptions(15, 24)).toHaveLength(96)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('keeps values in 24-hour HH:mm format regardless of the hour cycle', () => {
|
|
85
|
+
const values = (hourCycle: 12 | 24) =>
|
|
86
|
+
getTimeOptions(30, hourCycle).map(option => option.value)
|
|
87
|
+
|
|
88
|
+
expect(values(12)).toEqual(values(24))
|
|
89
|
+
expect(values(24).slice(0, 3)).toEqual(['00:00', '00:30', '01:00'])
|
|
90
|
+
expect(values(24).slice(-1)).toEqual(['23:30'])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('when the hour cycle is 24', () => {
|
|
94
|
+
it('labels the options with the 24-hour time', () => {
|
|
95
|
+
const labels = getTimeOptions(30, 24).map(option => option.label)
|
|
96
|
+
|
|
97
|
+
expect(labels).toContain('00:00')
|
|
98
|
+
expect(labels).toContain('12:30')
|
|
99
|
+
expect(labels).toContain('13:00')
|
|
100
|
+
expect(labels).toContain('23:30')
|
|
101
|
+
expect(labels.some(label => /AM|PM/.test(label))).toBe(false)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('when the hour cycle is 12', () => {
|
|
106
|
+
it('labels the options with a 12-hour time and a period', () => {
|
|
107
|
+
const labelOf = (value: string) =>
|
|
108
|
+
getTimeOptions(30, 12).find(option => option.value === value)?.label
|
|
109
|
+
|
|
110
|
+
expect(labelOf('00:00')).toBe('12:00 AM')
|
|
111
|
+
expect(labelOf('00:30')).toBe('12:30 AM')
|
|
112
|
+
expect(labelOf('01:00')).toBe('01:00 AM')
|
|
113
|
+
expect(labelOf('11:30')).toBe('11:30 AM')
|
|
114
|
+
expect(labelOf('12:00')).toBe('12:00 PM')
|
|
115
|
+
expect(labelOf('13:00')).toBe('01:00 PM')
|
|
116
|
+
expect(labelOf('23:30')).toBe('11:30 PM')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('getNearestOptionIndex', () => {
|
|
122
|
+
describe('when the time falls on an option', () => {
|
|
123
|
+
it('returns the index of that option', () => {
|
|
124
|
+
expect(getNearestOptionIndex({ hours: 0, minutes: 0 }, 15)).toBe(0)
|
|
125
|
+
expect(getNearestOptionIndex({ hours: 10, minutes: 0 }, 15)).toBe(40)
|
|
126
|
+
expect(getNearestOptionIndex({ hours: 23, minutes: 45 }, 15)).toBe(95)
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('when the time falls between options', () => {
|
|
131
|
+
it('returns the index of the closest option', () => {
|
|
132
|
+
expect(getNearestOptionIndex({ hours: 10, minutes: 7 }, 15)).toBe(40)
|
|
133
|
+
expect(getNearestOptionIndex({ hours: 10, minutes: 8 }, 15)).toBe(41)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('when the time is closer to midnight than to the last option', () => {
|
|
138
|
+
it('returns the index of the last option', () => {
|
|
139
|
+
expect(getNearestOptionIndex({ hours: 23, minutes: 59 }, 15)).toBe(95)
|
|
140
|
+
expect(getNearestOptionIndex({ hours: 23, minutes: 50 }, 60)).toBe(23)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { HourCycle } from './get-hour-cycle'
|
|
2
|
+
|
|
3
|
+
export interface Time {
|
|
4
|
+
hours: number
|
|
5
|
+
minutes: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TimeOption {
|
|
9
|
+
value: string
|
|
10
|
+
label: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const VALID_TIME_REGEX = /^([0-1][0-9]|2[0-3]):[0-5][0-9]$/
|
|
14
|
+
|
|
15
|
+
const MINUTES_IN_HOUR = 60
|
|
16
|
+
const MINUTES_IN_DAY = 24 * MINUTES_IN_HOUR
|
|
17
|
+
const HALF_DAY = 12
|
|
18
|
+
|
|
19
|
+
const padTimePart = (part: number) => String(part).padStart(2, '0')
|
|
20
|
+
|
|
21
|
+
export const parseTime = (value?: string): Time | undefined => {
|
|
22
|
+
if (!value || !VALID_TIME_REGEX.test(value)) {
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const [hours, minutes] = value.split(':').map(Number)
|
|
27
|
+
|
|
28
|
+
return { hours, minutes }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const formatTime = ({ hours, minutes }: Time) =>
|
|
32
|
+
`${padTimePart(hours)}:${padTimePart(minutes)}`
|
|
33
|
+
|
|
34
|
+
export const getCurrentTime = (): Time => {
|
|
35
|
+
const now = new Date()
|
|
36
|
+
|
|
37
|
+
return { hours: now.getHours(), minutes: now.getMinutes() }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const formatLabel = (time: Time, hourCycle: HourCycle) => {
|
|
41
|
+
if (hourCycle === 24) {
|
|
42
|
+
return formatTime(time)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const hours = time.hours % HALF_DAY || HALF_DAY
|
|
46
|
+
const period = time.hours < HALF_DAY ? 'AM' : 'PM'
|
|
47
|
+
|
|
48
|
+
return `${formatTime({ hours, minutes: time.minutes })} ${period}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const getTimeOptions = (
|
|
52
|
+
minuteStep: number,
|
|
53
|
+
hourCycle: HourCycle
|
|
54
|
+
): TimeOption[] => {
|
|
55
|
+
const options: TimeOption[] = []
|
|
56
|
+
|
|
57
|
+
for (let minutes = 0; minutes < MINUTES_IN_DAY; minutes += minuteStep) {
|
|
58
|
+
const time = {
|
|
59
|
+
hours: Math.floor(minutes / MINUTES_IN_HOUR),
|
|
60
|
+
minutes: minutes % MINUTES_IN_HOUR,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
options.push({
|
|
64
|
+
value: formatTime(time),
|
|
65
|
+
label: formatLabel(time, hourCycle),
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return options
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const getNearestOptionIndex = (time: Time, minuteStep: number) =>
|
|
73
|
+
Math.min(
|
|
74
|
+
Math.round((time.hours * MINUTES_IN_HOUR + time.minutes) / minuteStep),
|
|
75
|
+
Math.ceil(MINUTES_IN_DAY / minuteStep) - 1
|
|
76
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React, { forwardRef } from 'react'
|
|
2
|
+
import { Popper } from '@toptal/picasso-popper'
|
|
3
|
+
import { ClickAwayListener } from '@toptal/picasso-utils'
|
|
4
|
+
|
|
5
|
+
import { TimePickerList } from '../TimePickerList'
|
|
6
|
+
import type { TimePickerListProps } from '../TimePickerList'
|
|
7
|
+
|
|
8
|
+
export interface Props extends TimePickerListProps {
|
|
9
|
+
/** Whether the dropdown is shown */
|
|
10
|
+
open: boolean
|
|
11
|
+
/** Element the dropdown is positioned under */
|
|
12
|
+
anchorEl: HTMLElement | null
|
|
13
|
+
/** Called when a click lands outside of the dropdown */
|
|
14
|
+
onClickAway: (event: React.MouseEvent) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const TimePickerDropdown = forwardRef<HTMLDivElement, Props>(
|
|
18
|
+
function TimePickerDropdown(
|
|
19
|
+
{ open, anchorEl, onClickAway, ...rest }: Props,
|
|
20
|
+
ref
|
|
21
|
+
): React.ReactElement | null {
|
|
22
|
+
if (!open || !anchorEl) {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Popper
|
|
28
|
+
open
|
|
29
|
+
role='presentation'
|
|
30
|
+
anchorEl={anchorEl}
|
|
31
|
+
placement='bottom-start'
|
|
32
|
+
>
|
|
33
|
+
<ClickAwayListener onClickAway={onClickAway}>
|
|
34
|
+
<div ref={ref}>
|
|
35
|
+
<TimePickerList {...rest} />
|
|
36
|
+
</div>
|
|
37
|
+
</ClickAwayListener>
|
|
38
|
+
</Popper>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
TimePickerDropdown.displayName = 'TimePickerDropdown'
|
|
44
|
+
|
|
45
|
+
export default TimePickerDropdown
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as TimePickerDropdown } from './TimePickerDropdown'
|