@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,190 @@
|
|
|
1
|
+
import React, { useMemo, useRef, useState } from 'react'
|
|
2
|
+
import type { BaseProps } from '@toptal/picasso-shared'
|
|
3
|
+
import { Menu, MenuItem } from '@toptal/picasso-menu'
|
|
4
|
+
import { useIsomorphicLayoutEffect } from '@toptal/picasso-utils'
|
|
5
|
+
import { twMerge } from '@toptal/picasso-tailwind-merge'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
getCurrentTime,
|
|
9
|
+
getNearestOptionIndex,
|
|
10
|
+
getTimeOptions,
|
|
11
|
+
parseTime,
|
|
12
|
+
} from '../TimePicker/utils'
|
|
13
|
+
import type { HourCycle, TimeOption } from '../TimePicker/utils'
|
|
14
|
+
|
|
15
|
+
export interface Props extends BaseProps {
|
|
16
|
+
/** Selected time in 24-hour `HH:mm` format */
|
|
17
|
+
value?: string
|
|
18
|
+
/** Interval in minutes between the offered times */
|
|
19
|
+
minuteStep: number
|
|
20
|
+
/** Whether the times are labelled on a 12-hour clock with AM/PM or a 24-hour clock */
|
|
21
|
+
hourCycle: HourCycle
|
|
22
|
+
/** Moves focus to the selected or nearest time when the list appears */
|
|
23
|
+
autoFocus?: boolean
|
|
24
|
+
/** Called with the picked time in 24-hour `HH:mm` format */
|
|
25
|
+
onChange: (value: string) => void
|
|
26
|
+
/** Called when the keyboard asks to close the list */
|
|
27
|
+
onClose: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const OPTION = '[role="option"]'
|
|
31
|
+
|
|
32
|
+
const preventFocusLoss = (event: React.MouseEvent) => {
|
|
33
|
+
// Clicking a time must keep the caret in the field, like the browser picker does
|
|
34
|
+
event.preventDefault()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const getStartingIndex = (
|
|
38
|
+
options: TimeOption[],
|
|
39
|
+
minuteStep: number,
|
|
40
|
+
value?: string
|
|
41
|
+
) => {
|
|
42
|
+
const selectedIndex = options.findIndex(option => option.value === value)
|
|
43
|
+
|
|
44
|
+
return selectedIndex >= 0
|
|
45
|
+
? selectedIndex
|
|
46
|
+
: getNearestOptionIndex(parseTime(value) ?? getCurrentTime(), minuteStep)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const scrollToOption = (list: HTMLElement, option: HTMLElement) => {
|
|
50
|
+
const listRect = list.getBoundingClientRect()
|
|
51
|
+
const optionRect = option.getBoundingClientRect()
|
|
52
|
+
|
|
53
|
+
list.scrollTop +=
|
|
54
|
+
optionRect.top - listRect.top - (listRect.height - optionRect.height) / 2
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const getNextIndex = (key: string, index: number, count: number) => {
|
|
58
|
+
switch (key) {
|
|
59
|
+
case 'ArrowDown':
|
|
60
|
+
return Math.min(index + 1, count - 1)
|
|
61
|
+
case 'ArrowUp':
|
|
62
|
+
return Math.max(index - 1, 0)
|
|
63
|
+
case 'Home':
|
|
64
|
+
return 0
|
|
65
|
+
case 'End':
|
|
66
|
+
return count - 1
|
|
67
|
+
default:
|
|
68
|
+
return undefined
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const TimePickerList = ({
|
|
73
|
+
value,
|
|
74
|
+
minuteStep,
|
|
75
|
+
hourCycle,
|
|
76
|
+
autoFocus = false,
|
|
77
|
+
onChange,
|
|
78
|
+
onClose,
|
|
79
|
+
className,
|
|
80
|
+
style,
|
|
81
|
+
'data-testid': dataTestId,
|
|
82
|
+
}: Props): React.ReactElement => {
|
|
83
|
+
const listRef = useRef<HTMLUListElement>(null)
|
|
84
|
+
const options = useMemo(
|
|
85
|
+
() => getTimeOptions(minuteStep, hourCycle),
|
|
86
|
+
[minuteStep, hourCycle]
|
|
87
|
+
)
|
|
88
|
+
const [initialIndex] = useState(() =>
|
|
89
|
+
getStartingIndex(options, minuteStep, value)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const getOptionElements = () =>
|
|
93
|
+
Array.from(listRef.current?.querySelectorAll<HTMLElement>(OPTION) ?? [])
|
|
94
|
+
|
|
95
|
+
useIsomorphicLayoutEffect(() => {
|
|
96
|
+
const list = listRef.current
|
|
97
|
+
const option = getOptionElements()[initialIndex]
|
|
98
|
+
|
|
99
|
+
if (list && option) {
|
|
100
|
+
scrollToOption(list, option)
|
|
101
|
+
}
|
|
102
|
+
}, [initialIndex])
|
|
103
|
+
|
|
104
|
+
useIsomorphicLayoutEffect(() => {
|
|
105
|
+
if (!autoFocus) {
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The value may have been typed since the list opened, so the time to
|
|
110
|
+
// focus is looked up at the moment focus is requested
|
|
111
|
+
const list = listRef.current
|
|
112
|
+
const option =
|
|
113
|
+
getOptionElements()[getStartingIndex(options, minuteStep, value)]
|
|
114
|
+
|
|
115
|
+
if (list && option) {
|
|
116
|
+
scrollToOption(list, option)
|
|
117
|
+
option.focus({ preventScroll: true })
|
|
118
|
+
}
|
|
119
|
+
}, [autoFocus])
|
|
120
|
+
|
|
121
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
|
|
122
|
+
const optionElements = getOptionElements()
|
|
123
|
+
const index = optionElements.indexOf(
|
|
124
|
+
(event.target as HTMLElement).closest<HTMLElement>(OPTION) as HTMLElement
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
switch (event.key) {
|
|
128
|
+
case 'Enter':
|
|
129
|
+
case ' ':
|
|
130
|
+
event.preventDefault()
|
|
131
|
+
event.stopPropagation()
|
|
132
|
+
|
|
133
|
+
if (index >= 0) {
|
|
134
|
+
onChange(options[index].value)
|
|
135
|
+
}
|
|
136
|
+
break
|
|
137
|
+
case 'Escape':
|
|
138
|
+
// Keeps a surrounding Modal or Drawer open while the list closes
|
|
139
|
+
event.stopPropagation()
|
|
140
|
+
onClose()
|
|
141
|
+
break
|
|
142
|
+
case 'Tab':
|
|
143
|
+
onClose()
|
|
144
|
+
break
|
|
145
|
+
default: {
|
|
146
|
+
const nextIndex = getNextIndex(event.key, index, options.length)
|
|
147
|
+
|
|
148
|
+
if (nextIndex !== undefined) {
|
|
149
|
+
event.preventDefault()
|
|
150
|
+
optionElements[nextIndex]?.focus()
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<Menu
|
|
158
|
+
ref={listRef}
|
|
159
|
+
role='listbox'
|
|
160
|
+
aria-label='Choose time'
|
|
161
|
+
allowNestedNavigation={false}
|
|
162
|
+
onKeyDown={handleKeyDown}
|
|
163
|
+
onMouseDown={preventFocusLoss}
|
|
164
|
+
className={twMerge(
|
|
165
|
+
'bg-white shadow-5 max-h-[15rem] overflow-y-auto',
|
|
166
|
+
className
|
|
167
|
+
)}
|
|
168
|
+
style={style}
|
|
169
|
+
data-testid={dataTestId}
|
|
170
|
+
>
|
|
171
|
+
{options.map(option => (
|
|
172
|
+
<MenuItem
|
|
173
|
+
key={option.value}
|
|
174
|
+
role='option'
|
|
175
|
+
aria-selected={option.value === value}
|
|
176
|
+
checkmarked={option.value === value}
|
|
177
|
+
titleCase={false}
|
|
178
|
+
className='tabular-nums'
|
|
179
|
+
onClick={() => onChange(option.value)}
|
|
180
|
+
>
|
|
181
|
+
{option.label}
|
|
182
|
+
</MenuItem>
|
|
183
|
+
))}
|
|
184
|
+
</Menu>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
TimePickerList.displayName = 'TimePickerList'
|
|
189
|
+
|
|
190
|
+
export default TimePickerList
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import type { BaseProps } from '@toptal/picasso-shared'
|
|
3
|
+
import { InputAdornment } from '@toptal/picasso-input-adornment'
|
|
4
|
+
import { Time16 } from '@toptal/picasso-icons'
|
|
5
|
+
import { twMerge } from '@toptal/picasso-tailwind-merge'
|
|
6
|
+
|
|
7
|
+
export interface Props extends BaseProps {
|
|
8
|
+
/** Whether the time list is shown */
|
|
9
|
+
open: boolean
|
|
10
|
+
/** Whether the trigger ignores clicks */
|
|
11
|
+
disabled?: boolean
|
|
12
|
+
/** Called when the trigger is clicked */
|
|
13
|
+
onClick: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const preventFocusLoss = (event: React.MouseEvent) => event.preventDefault()
|
|
17
|
+
|
|
18
|
+
export const TimePickerTrigger = ({
|
|
19
|
+
open,
|
|
20
|
+
disabled,
|
|
21
|
+
onClick,
|
|
22
|
+
className,
|
|
23
|
+
style,
|
|
24
|
+
'data-testid': dataTestId,
|
|
25
|
+
}: Props): React.ReactElement => (
|
|
26
|
+
<InputAdornment position='end'>
|
|
27
|
+
{/* Touch devices keep the native picker: the button lets taps through to the browser's own picker indicator underneath */}
|
|
28
|
+
<button
|
|
29
|
+
type='button'
|
|
30
|
+
tabIndex={-1}
|
|
31
|
+
aria-label='Choose time'
|
|
32
|
+
aria-haspopup='listbox'
|
|
33
|
+
aria-expanded={open}
|
|
34
|
+
disabled={disabled}
|
|
35
|
+
onMouseDown={preventFocusLoss}
|
|
36
|
+
onClick={onClick}
|
|
37
|
+
className={twMerge(
|
|
38
|
+
'absolute right-[0.625rem] flex m-0 p-0 border-none bg-white text-inherit select-none',
|
|
39
|
+
'cursor-pointer disabled:cursor-default',
|
|
40
|
+
'pointer-events-none pointer-fine:pointer-events-auto',
|
|
41
|
+
className
|
|
42
|
+
)}
|
|
43
|
+
style={style}
|
|
44
|
+
data-testid={dataTestId}
|
|
45
|
+
>
|
|
46
|
+
<Time16 />
|
|
47
|
+
</button>
|
|
48
|
+
</InputAdornment>
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
TimePickerTrigger.displayName = 'TimePickerTrigger'
|
|
52
|
+
|
|
53
|
+
export default TimePickerTrigger
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as TimePickerTrigger } from './TimePickerTrigger'
|