@planningcenter/tapestry-react 2.7.0 → 2.8.1

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 (39) hide show
  1. package/dist/cjs/Button/Button.js +10 -13
  2. package/dist/cjs/Button/Button.test.js +53 -21
  3. package/dist/cjs/Calendar/Calendar.js +30 -25
  4. package/dist/cjs/Combobox/ComboboxInput.js +41 -37
  5. package/dist/cjs/DateField/DateField.js +78 -47
  6. package/dist/cjs/DateField/parse.js +106 -0
  7. package/dist/cjs/DateField/parse.test.js +46 -0
  8. package/dist/cjs/DateField/useArrowKeysToNavigateCalendar.js +44 -0
  9. package/dist/cjs/DateField/useEditableDate.js +72 -0
  10. package/dist/cjs/Select/Select.test.js +74 -0
  11. package/dist/esm/Button/Button.js +10 -13
  12. package/dist/esm/Button/Button.test.js +58 -26
  13. package/dist/esm/Calendar/Calendar.js +30 -25
  14. package/dist/esm/Combobox/ComboboxInput.js +40 -37
  15. package/dist/esm/DateField/DateField.js +79 -48
  16. package/dist/esm/DateField/parse.js +93 -0
  17. package/dist/esm/DateField/parse.test.js +42 -0
  18. package/dist/esm/DateField/useArrowKeysToNavigateCalendar.js +36 -0
  19. package/dist/esm/DateField/useEditableDate.js +62 -0
  20. package/dist/esm/Select/Select.test.js +59 -0
  21. package/dist/types/Button/Button.d.ts +1 -1
  22. package/dist/types/DateField/DateField.d.ts +48 -0
  23. package/dist/types/DateField/parse.d.ts +17 -0
  24. package/dist/types/DateField/parse.test.d.ts +1 -0
  25. package/dist/types/DateField/useArrowKeysToNavigateCalendar.d.ts +8 -0
  26. package/dist/types/DateField/useEditableDate.d.ts +25 -0
  27. package/dist/types/Select/Select.test.d.ts +1 -0
  28. package/package.json +3 -3
  29. package/src/Button/Button.test.tsx +32 -8
  30. package/src/Button/Button.tsx +8 -9
  31. package/src/Calendar/Calendar.js +22 -17
  32. package/src/Combobox/ComboboxInput.js +76 -62
  33. package/src/DateField/DateField.mdx +15 -0
  34. package/src/DateField/{DateField.js → DateField.tsx} +104 -52
  35. package/src/DateField/parse.test.ts +76 -0
  36. package/src/DateField/parse.ts +92 -0
  37. package/src/DateField/useArrowKeysToNavigateCalendar.ts +54 -0
  38. package/src/DateField/useEditableDate.ts +81 -0
  39. package/src/Select/Select.test.tsx +58 -0
@@ -16,7 +16,7 @@ type ButtonProps = {
16
16
  children?: any
17
17
 
18
18
  /**
19
- * Disables button by removing click handlers and making the button transparent.
19
+ * "Soft disables" button by adding an `aria-disabled` attribute and preventing `onClick` and `keyDown` events for "space" / "enter". This approach allows composing components (such as `Tooltip`) to still bubble up their events, while ensuring that clicking the button or submitting a form is prevented.
20
20
  */
21
21
  disabled?: boolean
22
22
 
@@ -256,14 +256,13 @@ export function Button({
256
256
 
257
257
  if (disabled) {
258
258
  buttonProps.opacity = 0.65
259
-
260
- if (buttonProps.type === "button" || buttonProps.type === "reset") {
261
- buttonProps.disabled = true
262
- } else if (buttonProps.type === "submit") {
263
- buttonProps['aria-disabled'] = true
264
- buttonProps.cursor = 'not-allowed'
265
- buttonProps.onClick = (event) => event.preventDefault()
266
- buttonProps.onKeyDown = (event) => event.preventDefault()
259
+ buttonProps['aria-disabled'] = true
260
+ buttonProps.cursor = 'not-allowed'
261
+ buttonProps.onClick = (event) => event.preventDefault()
262
+ buttonProps.onKeyDown = (event) => {
263
+ if (event.key === 'Enter' || event.key === ' ') {
264
+ event.preventDefault()
265
+ }
267
266
  }
268
267
  }
269
268
 
@@ -220,34 +220,36 @@ const Calendar = forwardRef(
220
220
  >
221
221
  <StackView axis="horizontal" spacing={0.5}>
222
222
  <Box
223
- as="button"
224
223
  aria-label={isMonthsView ? 'Choose dates' : 'Choose month'}
225
- padding={0.25}
226
- fontSize={headerFontSize}
227
- lineHeight={3}
228
- radius={3}
229
- cursor="pointer"
224
+ as="button"
230
225
  backgroundColor={hover ? 'highlight' : 'transparent'}
226
+ cursor="pointer"
227
+ fontSize={headerFontSize}
231
228
  hover={{ backgroundColor: 'highlightSecondary' }}
229
+ lineHeight={3}
232
230
  onClick={() => {
233
231
  setCurrentView(isMonthsView ? 'calendar' : 'months')
234
232
  }}
233
+ padding={0.25}
234
+ radius={3}
235
+ tabIndex={-1}
235
236
  >
236
237
  {format(currentDate, 'MMMM')}
237
238
  </Box>
238
239
  <Box
239
- as="button"
240
240
  aria-label={isYearsView ? 'Choose dates' : 'Choose year'}
241
- padding={0.25}
242
- fontSize={headerFontSize}
243
- lineHeight={3}
244
- radius={3}
245
- cursor="pointer"
241
+ as="button"
246
242
  backgroundColor={hover ? 'highlight' : 'transparent'}
243
+ cursor="pointer"
244
+ fontSize={headerFontSize}
247
245
  hover={{ backgroundColor: 'highlightSecondary' }}
246
+ lineHeight={3}
248
247
  onClick={() => {
249
248
  setCurrentView(isYearsView ? 'calendar' : 'years')
250
249
  }}
250
+ padding={0.25}
251
+ radius={3}
252
+ tabIndex={-1}
251
253
  >
252
254
  {currentYear}
253
255
  </Box>
@@ -260,29 +262,32 @@ const Calendar = forwardRef(
260
262
  }}
261
263
  >
262
264
  <Button
263
- title="Previous month"
264
- icon={{ name: 'general.leftChevron', size: "xxs" }}
265
265
  disabled={
266
266
  isYearsView
267
267
  ? currentYear <= minYear
268
268
  : isSameMonth(currentDate, minDate)
269
269
  }
270
+ icon={{ name: 'general.leftChevron', size: 'xxs' }}
270
271
  onClick={() => navigateCalendar(-1)}
272
+ tabIndex={-1}
273
+ title="Previous month"
271
274
  />
272
275
  <Button
273
- tooltip={{ title: 'Today' }}
274
276
  icon={{ name: 'tapestry.radio1' }}
275
277
  onClick={() => setDate(TODAY, true)}
278
+ tabIndex={-1}
279
+ tooltip={{ title: 'Today' }}
276
280
  />
277
281
  <Button
278
- title="Next month"
279
- icon={{ name: 'general.rightChevron', size: "xxs"}}
280
282
  disabled={
281
283
  isYearsView
282
284
  ? currentYear >= maxYear
283
285
  : isSameMonth(currentDate, maxDate)
284
286
  }
287
+ icon={{ name: 'general.rightChevron', size: 'xxs' }}
285
288
  onClick={() => navigateCalendar(1)}
289
+ tabIndex={-1}
290
+ title="Next month"
286
291
  />
287
292
  </Group>
288
293
  </StackView>
@@ -10,6 +10,7 @@ import React, {
10
10
 
11
11
  import Input from '../Input'
12
12
  import ItemListContext from '../ItemList/ItemListContext'
13
+ import { ItemListController } from '../ItemList'
13
14
  import { useThemeProps } from '../system'
14
15
 
15
16
  type Props = {
@@ -64,77 +65,90 @@ function ComboboxInput(props: Props, ref) {
64
65
  }))
65
66
 
66
67
  const itemList = useContext(ItemListContext)
67
- const { highlightedItemId, id } = itemList
68
68
 
69
69
  useEffect(() => {
70
70
  setTimeout(() => {
71
71
  itemList.setHighlightedIndex(0)
72
72
  })
73
- }, [itemList.items])
73
+ }, [itemList])
74
74
 
75
- const handleKeyDown = useCallback((e) => {
76
- if (e.key === 'ArrowUp') {
77
- e.preventDefault()
78
- itemList.moveHighlightedIndex(-1, { contain: false })
79
- }
80
- if (e.key === 'ArrowDown') {
81
- e.preventDefault()
82
- itemList.moveHighlightedIndex(1, { contain: false })
83
- }
84
- if (e.key === 'Enter') {
85
- e.preventDefault()
86
- itemList.selectHighlightedItem()
87
- }
88
- if (onKeyDown) {
89
- onKeyDown(e)
90
- }
91
- if (onClose && e.key === 'Tab') {
92
- onClose()
93
- }
94
- })
75
+ const handleKeyDown = useCallback(
76
+ (e) => {
77
+ if (e.key === 'ArrowUp') {
78
+ e.preventDefault()
79
+ itemList.moveHighlightedIndex(-1, { contain: false })
80
+ }
81
+ if (e.key === 'ArrowDown') {
82
+ e.preventDefault()
83
+ itemList.moveHighlightedIndex(1, { contain: false })
84
+ }
85
+ if (e.key === 'Enter') {
86
+ e.preventDefault()
87
+ itemList.selectHighlightedItem()
88
+ }
89
+ if (onKeyDown) {
90
+ onKeyDown(e)
91
+ }
92
+ if (onClose && e.key === 'Tab') {
93
+ onClose()
94
+ }
95
+ },
96
+ [itemList, onClose, onKeyDown]
97
+ )
95
98
 
96
- const handleDocumentKeyDown = useCallback((e) => {
97
- if (onClear && e.key === 'Escape') {
98
- onClear()
99
- }
100
- })
99
+ const handleDocumentKeyDown = useCallback(
100
+ (e) => {
101
+ if (onClear && e.key === 'Escape') {
102
+ onClear()
103
+ }
104
+ },
105
+ [onClear]
106
+ )
101
107
 
102
108
  return (
103
- <Input.InputBox
104
- innerRef={innerRef}
105
- size={size}
106
- disabled={disabled}
107
- isLoading={isLoading}
108
- onClear={onClear}
109
- {...restProps}
110
- grow={1}
111
- >
112
- <Input.InputField
113
- innerRef={(node) => {
114
- inputNode.current = node
115
- }}
116
- grow={1}
117
- role="combobox"
118
- autoComplete="off"
119
- aria-autocomplete="list"
120
- aria-controls={id}
121
- aria-expanded={isOpen}
122
- aria-activedescendant={highlightedItemId}
123
- autoFocus={autoFocus}
124
- type={type}
125
- name={name}
126
- disabled={disabled}
127
- readOnly={readOnly}
128
- placeholder={placeholder}
129
- defaultValue={defaultValue}
130
- value={value}
131
- onChange={onChange}
132
- onFocus={onFocus}
133
- onBlur={onBlur}
134
- onKeyDown={handleKeyDown}
135
- onKeyUp={onKeyUp}
136
- />
137
- </Input.InputBox>
109
+ <ItemListController>
110
+ {(itemList) => {
111
+ const { highlightedItemId, id } = itemList
112
+
113
+ return (
114
+ <Input.InputBox
115
+ innerRef={innerRef}
116
+ size={size}
117
+ disabled={disabled}
118
+ isLoading={isLoading}
119
+ onClear={onClear}
120
+ {...restProps}
121
+ grow={1}
122
+ >
123
+ <Input.InputField
124
+ innerRef={(node) => {
125
+ inputNode.current = node
126
+ }}
127
+ grow={1}
128
+ role="combobox"
129
+ autoComplete="off"
130
+ aria-autocomplete="list"
131
+ aria-controls={id}
132
+ aria-expanded={isOpen}
133
+ aria-activedescendant={highlightedItemId}
134
+ autoFocus={autoFocus}
135
+ type={type}
136
+ name={name}
137
+ disabled={disabled}
138
+ readOnly={readOnly}
139
+ placeholder={placeholder}
140
+ defaultValue={defaultValue}
141
+ value={value}
142
+ onChange={onChange}
143
+ onFocus={onFocus}
144
+ onBlur={onBlur}
145
+ onKeyDown={handleKeyDown}
146
+ onKeyUp={onKeyUp}
147
+ />
148
+ </Input.InputBox>
149
+ )
150
+ }}
151
+ </ItemListController>
138
152
  )
139
153
  }
140
154
  ComboboxInput = forwardRef(ComboboxInput)
@@ -17,3 +17,18 @@ render(() => {
17
17
  )
18
18
  })
19
19
  ```
20
+
21
+ ### Example with day/month/year format
22
+
23
+ ```jsx live
24
+ render(() => {
25
+ const [date, setDate] = React.useState(Calendar.TODAY)
26
+ return (
27
+ <DateField
28
+ value={date}
29
+ formatValue="dd/MM/yyyy"
30
+ onChange={(date) => setDate(date)}
31
+ />
32
+ )
33
+ })
34
+ ```
@@ -1,6 +1,5 @@
1
1
  // @flow
2
- import React, { useState, useCallback, useRef } from 'react'
3
- import { format } from 'date-fns'
2
+ import React, { useState, useCallback, useMemo, useRef } from 'react'
4
3
 
5
4
  import Card from '../Card'
6
5
  import Calendar from '../Calendar'
@@ -10,62 +9,64 @@ import Input from '../Input/Input'
10
9
  import Popover from '../Popover'
11
10
  import { generateId } from '../utils'
12
11
  import { useThemeProps } from '../system'
12
+ import { useArrowKeysToNavigateCalendar } from './useArrowKeysToNavigateCalendar'
13
+ import { useEditableDate } from './useEditableDate'
13
14
 
14
15
  export type DateFieldProps = {
15
16
  /**
16
17
  * Format the displayed date using date-fns [format](https://date-fns.org/v2.0.0-alpha.9/docs/format) function.
17
18
  */
18
- formatValue: string,
19
+ formatValue: string
19
20
 
20
21
  /**
21
22
  * Controls the initial Popover state: open or closed (default).
22
23
  */
23
- defaultOpen: boolean,
24
+ defaultOpen: boolean
24
25
 
25
26
  /**
26
27
  * The minimum date that can be chosen.
27
28
  */
28
- minDate: Date,
29
+ minDate: Date
29
30
 
30
31
  /**
31
32
  * The maximum date that can be chosen.
32
33
  */
33
- maxDate: Date,
34
+ maxDate: Date
34
35
 
35
36
  /**
36
37
  * Called when a date has been selected.
37
38
  */
38
- onChange: (date: Date) => null,
39
+ onChange: (date: Date) => null
39
40
 
40
41
  /**
41
42
  * Determines where the popover is placed.
42
43
  */
43
- placement: string,
44
+ placement: string
44
45
 
45
46
  /**
46
47
  * The date that will be selected.
47
48
  */
48
- value: Date,
49
+ value: Date
49
50
 
50
51
  /**
51
52
  * Locks external scrollbars when open.
52
53
  */
53
- lockScrollWhileOpen?: boolean,
54
+ lockScrollWhileOpen?: boolean
54
55
 
55
56
  /**
56
57
  * Attempts to keep popover in view clipping edges if too large.
57
58
  */
58
- keepInView?: boolean,
59
+ keepInView?: boolean
59
60
 
60
61
  /**
61
62
  * Accepts any valid [Calendar](/calendar) props.
62
63
  */
63
- calendarProps?: object,
64
+ calendarProps?: object
64
65
 
65
66
  /**
66
67
  * Accepts any valid [Popover](/popover) props.
67
68
  */
68
- popoverProps?: object,
69
+ popoverProps?: object
69
70
  }
70
71
 
71
72
  function DateField({
@@ -89,81 +90,131 @@ function DateField({
89
90
  trackColor,
90
91
  ...restProps
91
92
  } = useThemeProps('dateField', props)
92
- let canClosePopover = true
93
93
  const [isPopoverOpen, setIsPopoverOpen] = useState(defaultOpen)
94
94
  const id = generateId('datefield')
95
95
  const popover = useRef(null)
96
96
  const inputWrapper = useRef(null)
97
97
 
98
+ const focusInput = useCallback(() => {
99
+ const input = inputWrapper.current.querySelector('input')
100
+ if (input.focus) {
101
+ input.focus()
102
+ }
103
+ }, [])
104
+
98
105
  const openPopover = useCallback(() => {
99
106
  setIsPopoverOpen(true)
100
- })
107
+ }, [])
101
108
 
102
109
  const closePopover = useCallback(() => {
103
- if (canClosePopover) {
104
- setIsPopoverOpen(false)
105
- }
110
+ setIsPopoverOpen(false)
111
+ }, [])
112
+
113
+ const dateValidator = useCallback(
114
+ (date: Date) => {
115
+ if (!date) return false
116
+ if (minDate && date < minDate) return false
117
+ if (maxDate && date > maxDate) return false
118
+ return true
119
+ },
120
+ [minDate, maxDate]
121
+ )
122
+
123
+ const {
124
+ formattedDate,
125
+ setDate,
126
+ clearKeyBuffer,
127
+ invalidKeyBuffer,
128
+ } = useEditableDate({
129
+ date: value,
130
+ dateFormat: formatValue,
131
+ dateValidator,
132
+ onChange,
106
133
  })
107
134
 
108
- const togglePopover = useCallback(() => {
109
- if (isPopoverOpen) {
135
+ const handleDateSelectedFromCalendar = useCallback(
136
+ (date) => {
137
+ focusInput()
138
+ setDate(date)
110
139
  closePopover()
111
- } else {
112
- openPopover()
113
- }
140
+ },
141
+ [focusInput, setDate, closePopover]
142
+ )
143
+
144
+ const handleNavigationChangeFromCalendar = useCallback(
145
+ (date) => {
146
+ setDate(date)
147
+ },
148
+ [setDate]
149
+ )
150
+
151
+ const navigateCalendarWithArrowKeys = useArrowKeysToNavigateCalendar({
152
+ date: value,
153
+ calendarIsOpen: isPopoverOpen,
154
+ openCalendar: openPopover,
155
+ onChange: setDate,
114
156
  })
115
157
 
116
- const handleDateSelect = useCallback((date) => {
117
- const input = inputWrapper.current.querySelector('input')
118
- if (input.focus) {
119
- input.focus()
120
- }
121
- if (onChange) {
122
- onChange(date)
123
- }
158
+ const handleInputOnChange = useCallback(
159
+ (event: React.ChangeEvent<HTMLInputElement>) => {
160
+ setDate(event.currentTarget.value)
161
+ },
162
+ [setDate]
163
+ )
164
+
165
+ const handleOnBlur = useCallback(() => {
166
+ clearKeyBuffer()
124
167
  closePopover()
125
- })
168
+ }, [clearKeyBuffer, closePopover])
169
+
170
+ const inputColors = useMemo(() => {
171
+ if (invalidKeyBuffer) {
172
+ return { color: 'error-darker', backgroundColor: 'error-lighter' }
173
+ } else {
174
+ return {}
175
+ }
176
+ }, [invalidKeyBuffer])
126
177
 
127
178
  return (
128
- <FocusGroup onBlur={closePopover}>
179
+ <FocusGroup onBlur={handleOnBlur}>
129
180
  {({ requestBlur, setRef }) => (
130
181
  <Popover
131
182
  {...popoverProps}
132
183
  ref={(component) => {
133
184
  popover.current = component
134
185
  }}
186
+ as={Card}
187
+ elevation={2}
135
188
  innerRef={(node) => {
136
189
  popover.current = node
137
190
  setRef(`${id}-popover`)(node)
138
191
  }}
139
- as={Card}
140
- tabIndex={-1}
141
- elevation={2}
142
- onBlur={requestBlur}
143
192
  keepInView={keepInView}
144
193
  lockScrollWhileOpen={lockScrollWhileOpen}
145
- placement={placement}
146
- open={isPopoverOpen}
194
+ onBlur={requestBlur}
147
195
  onRequestClose={closePopover}
196
+ open={isPopoverOpen}
197
+ placement={placement}
198
+ tabIndex={-1}
148
199
  anchorElement={
149
200
  <Input
150
201
  innerRef={(node) => {
151
202
  inputWrapper.current = node
152
203
  setRef(`${id}-input`)(node)
153
204
  }}
154
- readOnly
155
- value={value ? format(value, formatValue) : ''}
205
+ onBlur={requestBlur}
206
+ onFocus={openPopover}
207
+ onChange={handleInputOnChange}
208
+ onKeyDown={navigateCalendarWithArrowKeys}
209
+ value={formattedDate}
156
210
  renderRight={
157
- <Icon name="general.calendar" color="foregroundTertiary" />
211
+ <Icon
212
+ name="general.calendar"
213
+ color="foregroundTertiary"
214
+ onClick={openPopover}
215
+ />
158
216
  }
159
- onClick={togglePopover}
160
- onBlur={requestBlur}
161
- onKeyDown={(event) => {
162
- if (event.key === ' ') {
163
- event.preventDefault()
164
- togglePopover()
165
- }
166
- }}
217
+ {...inputColors}
167
218
  {...restProps}
168
219
  />
169
220
  }
@@ -171,11 +222,12 @@ function DateField({
171
222
  <Calendar
172
223
  size="sm"
173
224
  {...calendarProps}
174
- initialDate={value}
225
+ date={value}
175
226
  selected={value}
176
227
  minDate={minDate}
177
228
  maxDate={maxDate}
178
- onDateSelect={handleDateSelect}
229
+ onDateSelect={handleDateSelectedFromCalendar}
230
+ onDateChange={handleNavigationChangeFromCalendar}
179
231
  />
180
232
  </Popover>
181
233
  )}
@@ -0,0 +1,76 @@
1
+ import { format } from 'date-fns'
2
+ import { parseDate, isValidDate, parseMonth } from './parse'
3
+
4
+ describe('isValidDate', () => {
5
+ const validDates = [
6
+ 'jun 6, 2022',
7
+ 'June-06-2022',
8
+ '6-6-2022',
9
+ '6/6/2022',
10
+ '06-06-2022',
11
+ '2022-06-26', // year, month, day
12
+ '2022/6/1', // year, month, day
13
+ ]
14
+
15
+ validDates.forEach((date) => {
16
+ it(`returns true for "${date}"`, () => {
17
+ expect(isValidDate(date)).toBe(true)
18
+ })
19
+ })
20
+
21
+ const invalidDates = [
22
+ 'June-06-22',
23
+ '13-6-2022',
24
+ 'June 6 2022',
25
+ '2022/6/1/1',
26
+ '2022-15-06',
27
+ ]
28
+
29
+ invalidDates.forEach((date) => {
30
+ it(`returns false for "${date}"`, () => {
31
+ expect(isValidDate(date)).toBe(false)
32
+ })
33
+ })
34
+ })
35
+
36
+ describe('parseDate', () => {
37
+ const dates = [
38
+ ['January 31, 2022', 'January 31, 2022'],
39
+ ['jun 9, 2022', 'June 09, 2022'],
40
+ ['June-09-2022', 'June 09, 2022'],
41
+ ['6-9-2022', 'June 09, 2022'],
42
+ ['6/9/2022', 'June 09, 2022'],
43
+ ['06-09-2022', 'June 09, 2022'],
44
+ ['2022-06-26', 'June 26, 2022'],
45
+ ['2022/6/1', 'June 01, 2022', 'MMMM dd, yyyy'],
46
+ ['15/6/2022', 'June 15, 2022', 'dd/MM/YYYY'],
47
+ ['1/6/2022', 'June 01, 2022', 'dd MMMM, yyyy'],
48
+ ['15-aug-2023', 'August 15, 2023', 'dd MMMM, yyyy'],
49
+ ['15 August, 2023', 'August 15, 2023', 'dd MMMM, yyyy'],
50
+ ]
51
+
52
+ dates.forEach(([date, expected, dateFormat]) => {
53
+ it(`returns ${expected} for "${date}"`, () => {
54
+ expect(
55
+ format(parseDate({ date, format: dateFormat }), 'MMMM dd, yyyy')
56
+ ).toEqual(expected)
57
+ })
58
+ })
59
+ })
60
+
61
+ describe('parseMonth', () => {
62
+ const months: [string, number][] = [
63
+ ['jan', 1],
64
+ ['feb', 2],
65
+ ['December', 12],
66
+ ['2', 2],
67
+ ['12', 12],
68
+ ['05', 5],
69
+ ]
70
+
71
+ months.forEach(([month, expected]) => {
72
+ it(`returns ${expected} for "${month}"`, () => {
73
+ expect(parseMonth(month)).toBe(expected)
74
+ })
75
+ })
76
+ })