@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.
Files changed (61) hide show
  1. package/dist-package/src/TimePicker/TimePicker.d.ts +4 -1
  2. package/dist-package/src/TimePicker/TimePicker.d.ts.map +1 -1
  3. package/dist-package/src/TimePicker/TimePicker.js +60 -21
  4. package/dist-package/src/TimePicker/TimePicker.js.map +1 -1
  5. package/dist-package/src/TimePicker/use-time-picker-popover.d.ts +21 -0
  6. package/dist-package/src/TimePicker/use-time-picker-popover.d.ts.map +1 -0
  7. package/dist-package/src/TimePicker/use-time-picker-popover.js +96 -0
  8. package/dist-package/src/TimePicker/use-time-picker-popover.js.map +1 -0
  9. package/dist-package/src/TimePicker/utils/get-hour-cycle.d.ts +3 -0
  10. package/dist-package/src/TimePicker/utils/get-hour-cycle.d.ts.map +1 -0
  11. package/dist-package/src/TimePicker/utils/get-hour-cycle.js +42 -0
  12. package/dist-package/src/TimePicker/utils/get-hour-cycle.js.map +1 -0
  13. package/dist-package/src/TimePicker/utils/index.d.ts +5 -0
  14. package/dist-package/src/TimePicker/utils/index.d.ts.map +1 -0
  15. package/dist-package/src/TimePicker/utils/index.js +3 -0
  16. package/dist-package/src/TimePicker/utils/index.js.map +1 -0
  17. package/dist-package/src/TimePicker/utils/time.d.ts +16 -0
  18. package/dist-package/src/TimePicker/utils/time.d.ts.map +1 -0
  19. package/dist-package/src/TimePicker/utils/time.js +41 -0
  20. package/dist-package/src/TimePicker/utils/time.js.map +1 -0
  21. package/dist-package/src/TimePickerDropdown/TimePickerDropdown.d.ts +13 -0
  22. package/dist-package/src/TimePickerDropdown/TimePickerDropdown.d.ts.map +1 -0
  23. package/dist-package/src/TimePickerDropdown/TimePickerDropdown.js +28 -0
  24. package/dist-package/src/TimePickerDropdown/TimePickerDropdown.js.map +1 -0
  25. package/dist-package/src/TimePickerDropdown/index.d.ts +2 -0
  26. package/dist-package/src/TimePickerDropdown/index.d.ts.map +1 -0
  27. package/dist-package/src/TimePickerDropdown/index.js +2 -0
  28. package/dist-package/src/TimePickerDropdown/index.js.map +1 -0
  29. package/dist-package/src/TimePickerList/TimePickerList.d.ts +23 -0
  30. package/dist-package/src/TimePickerList/TimePickerList.d.ts.map +1 -0
  31. package/dist-package/src/TimePickerList/TimePickerList.js +97 -0
  32. package/dist-package/src/TimePickerList/TimePickerList.js.map +1 -0
  33. package/dist-package/src/TimePickerList/index.d.ts +4 -0
  34. package/dist-package/src/TimePickerList/index.d.ts.map +1 -0
  35. package/dist-package/src/TimePickerList/index.js +2 -0
  36. package/dist-package/src/TimePickerList/index.js.map +1 -0
  37. package/dist-package/src/TimePickerTrigger/TimePickerTrigger.d.ts +16 -0
  38. package/dist-package/src/TimePickerTrigger/TimePickerTrigger.d.ts.map +1 -0
  39. package/dist-package/src/TimePickerTrigger/TimePickerTrigger.js +11 -0
  40. package/dist-package/src/TimePickerTrigger/TimePickerTrigger.js.map +1 -0
  41. package/dist-package/src/TimePickerTrigger/index.d.ts +2 -0
  42. package/dist-package/src/TimePickerTrigger/index.d.ts.map +1 -0
  43. package/dist-package/src/TimePickerTrigger/index.js +2 -0
  44. package/dist-package/src/TimePickerTrigger/index.js.map +1 -0
  45. package/package.json +4 -1
  46. package/src/TimePicker/TimePicker.tsx +161 -65
  47. package/src/TimePicker/story/MinuteStep.example.tsx +16 -0
  48. package/src/TimePicker/story/index.jsx +10 -0
  49. package/src/TimePicker/test.tsx +649 -1
  50. package/src/TimePicker/use-time-picker-popover.ts +120 -0
  51. package/src/TimePicker/utils/get-hour-cycle.test.ts +76 -0
  52. package/src/TimePicker/utils/get-hour-cycle.ts +63 -0
  53. package/src/TimePicker/utils/index.ts +10 -0
  54. package/src/TimePicker/utils/time.test.ts +143 -0
  55. package/src/TimePicker/utils/time.ts +76 -0
  56. package/src/TimePickerDropdown/TimePickerDropdown.tsx +45 -0
  57. package/src/TimePickerDropdown/index.ts +1 -0
  58. package/src/TimePickerList/TimePickerList.tsx +190 -0
  59. package/src/TimePickerList/index.ts +4 -0
  60. package/src/TimePickerTrigger/TimePickerTrigger.tsx +53 -0
  61. 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,4 @@
1
+ import type { Props } from './TimePickerList'
2
+
3
+ export { default as TimePickerList } from './TimePickerList'
4
+ export type TimePickerListProps = Props
@@ -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'