@planningcenter/tapestry-react 2.7.0 → 2.8.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 (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 +74 -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 +75 -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} +96 -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,124 @@ 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 navigateCalendarWithArrowKeys = useArrowKeysToNavigateCalendar({
145
+ date: value,
146
+ calendarIsOpen: isPopoverOpen,
147
+ openCalendar: openPopover,
148
+ onChange: setDate,
114
149
  })
115
150
 
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
- }
151
+ const handleInputOnChange = useCallback(
152
+ (event: React.ChangeEvent<HTMLInputElement>) => {
153
+ setDate(event.currentTarget.value)
154
+ },
155
+ [setDate]
156
+ )
157
+
158
+ const handleOnBlur = useCallback(() => {
159
+ clearKeyBuffer()
124
160
  closePopover()
125
- })
161
+ }, [clearKeyBuffer, closePopover])
162
+
163
+ const inputColors = useMemo(() => {
164
+ if (invalidKeyBuffer) {
165
+ return { color: 'error-darker', backgroundColor: 'error-lighter' }
166
+ } else {
167
+ return {}
168
+ }
169
+ }, [invalidKeyBuffer])
126
170
 
127
171
  return (
128
- <FocusGroup onBlur={closePopover}>
172
+ <FocusGroup onBlur={handleOnBlur}>
129
173
  {({ requestBlur, setRef }) => (
130
174
  <Popover
131
175
  {...popoverProps}
132
176
  ref={(component) => {
133
177
  popover.current = component
134
178
  }}
179
+ as={Card}
180
+ elevation={2}
135
181
  innerRef={(node) => {
136
182
  popover.current = node
137
183
  setRef(`${id}-popover`)(node)
138
184
  }}
139
- as={Card}
140
- tabIndex={-1}
141
- elevation={2}
142
- onBlur={requestBlur}
143
185
  keepInView={keepInView}
144
186
  lockScrollWhileOpen={lockScrollWhileOpen}
145
- placement={placement}
146
- open={isPopoverOpen}
187
+ onBlur={requestBlur}
147
188
  onRequestClose={closePopover}
189
+ open={isPopoverOpen}
190
+ placement={placement}
191
+ tabIndex={-1}
148
192
  anchorElement={
149
193
  <Input
150
194
  innerRef={(node) => {
151
195
  inputWrapper.current = node
152
196
  setRef(`${id}-input`)(node)
153
197
  }}
154
- readOnly
155
- value={value ? format(value, formatValue) : ''}
198
+ onBlur={requestBlur}
199
+ onFocus={openPopover}
200
+ onChange={handleInputOnChange}
201
+ onKeyDown={navigateCalendarWithArrowKeys}
202
+ value={formattedDate}
156
203
  renderRight={
157
- <Icon name="general.calendar" color="foregroundTertiary" />
204
+ <Icon
205
+ name="general.calendar"
206
+ color="foregroundTertiary"
207
+ onClick={openPopover}
208
+ />
158
209
  }
159
- onClick={togglePopover}
160
- onBlur={requestBlur}
161
- onKeyDown={(event) => {
162
- if (event.key === ' ') {
163
- event.preventDefault()
164
- togglePopover()
165
- }
166
- }}
210
+ {...inputColors}
167
211
  {...restProps}
168
212
  />
169
213
  }
@@ -171,11 +215,11 @@ function DateField({
171
215
  <Calendar
172
216
  size="sm"
173
217
  {...calendarProps}
174
- initialDate={value}
218
+ date={value}
175
219
  selected={value}
176
220
  minDate={minDate}
177
221
  maxDate={maxDate}
178
- onDateSelect={handleDateSelect}
222
+ onDateSelect={handleDateSelectedFromCalendar}
179
223
  />
180
224
  </Popover>
181
225
  )}
@@ -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
+ })