@react-aria/datepicker 3.0.0-rc.1 → 3.0.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.
@@ -23,7 +23,6 @@ import intlMessages from '../intl/*.json';
23
23
  import {RangeCalendarProps} from '@react-types/calendar';
24
24
  import {useDatePickerGroup} from './useDatePickerGroup';
25
25
  import {useField} from '@react-aria/label';
26
- import {useFocusWithin} from '@react-aria/interactions';
27
26
  import {useLocale, useMessageFormatter} from '@react-aria/i18n';
28
27
 
29
28
  export interface DateRangePickerAria {
@@ -80,11 +79,6 @@ export function useDateRangePicker<T extends DateValue>(props: AriaDateRangePick
80
79
  let dialogId = useId();
81
80
 
82
81
  let groupProps = useDatePickerGroup(state, ref);
83
- let {focusWithinProps} = useFocusWithin({
84
- onBlurWithin() {
85
- state.confirmPlaceholder();
86
- }
87
- });
88
82
 
89
83
  let ariaDescribedBy = [descProps['aria-describedby'], fieldProps['aria-describedby']].filter(Boolean).join(' ') || undefined;
90
84
  let focusManager = useMemo(() => createFocusManager(ref, {
@@ -111,7 +105,7 @@ export function useDateRangePicker<T extends DateValue>(props: AriaDateRangePick
111
105
  let domProps = filterDOMProps(props);
112
106
 
113
107
  return {
114
- groupProps: mergeProps(domProps, groupProps, fieldProps, descProps, focusWithinProps, {
108
+ groupProps: mergeProps(domProps, groupProps, fieldProps, descProps, {
115
109
  role: 'group',
116
110
  'aria-disabled': props.isDisabled || null,
117
111
  'aria-describedby': ariaDescribedBy
@@ -10,8 +10,9 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ import {CalendarDate, toCalendar} from '@internationalized/date';
13
14
  import {DateFieldState, DateSegment} from '@react-stately/datepicker';
14
- import {getScrollParent, isIOS, isMac, mergeProps, scrollIntoView, useEvent, useId, useLabels} from '@react-aria/utils';
15
+ import {getScrollParent, isIOS, isMac, mergeProps, scrollIntoView, useEvent, useId, useLabels, useLayoutEffect} from '@react-aria/utils';
15
16
  import {hookData} from './useDateField';
16
17
  import {NumberParser} from '@internationalized/number';
17
18
  import React, {HTMLAttributes, RefObject, useMemo, useRef} from 'react';
@@ -35,7 +36,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
35
36
  let displayNames = useDisplayNames();
36
37
  let {ariaLabel, ariaLabelledBy, ariaDescribedBy, focusManager} = hookData.get(state);
37
38
 
38
- let textValue = segment.text;
39
+ let textValue = segment.isPlaceholder ? '' : segment.text;
39
40
  let options = useMemo(() => state.dateFormatter.resolvedOptions(), [state.dateFormatter]);
40
41
  let monthDateFormatter = useDateFormatter({month: 'long', timeZone: options.timeZone});
41
42
  let hourDateFormatter = useDateFormatter({
@@ -44,14 +45,17 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
44
45
  timeZone: options.timeZone
45
46
  });
46
47
 
47
- if (segment.type === 'month') {
48
+ if (segment.type === 'month' && !segment.isPlaceholder) {
48
49
  let monthTextValue = monthDateFormatter.format(state.dateValue);
49
50
  textValue = monthTextValue !== textValue ? `${textValue} – ${monthTextValue}` : monthTextValue;
50
- } else if (segment.type === 'hour') {
51
+ } else if (segment.type === 'hour' && !segment.isPlaceholder) {
51
52
  textValue = hourDateFormatter.format(state.dateValue);
52
53
  }
53
54
 
54
55
  let {spinButtonProps} = useSpinButton({
56
+ // The ARIA spec says aria-valuenow is optional if there's no value, but aXe seems to require it.
57
+ // This doesn't seem to have any negative effects with real AT since we also use aria-valuetext.
58
+ // https://github.com/dequelabs/axe-core/issues/3505
55
59
  value: segment.value,
56
60
  textValue,
57
61
  minValue: segment.minValue,
@@ -114,16 +118,6 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
114
118
  }
115
119
 
116
120
  switch (e.key) {
117
- case 'Enter':
118
- e.preventDefault();
119
- e.stopPropagation();
120
- if (segment.isPlaceholder && !state.isReadOnly) {
121
- state.confirmPlaceholder(segment.type);
122
- }
123
- focusManager.focusNext();
124
- break;
125
- case 'Tab':
126
- break;
127
121
  case 'Backspace':
128
122
  case 'Delete': {
129
123
  // Safari on iOS does not fire beforeinput for the backspace key because the cursor is at the start.
@@ -150,6 +144,34 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
150
144
  return amPmFormatter.formatToParts(date).find(part => part.type === 'dayPeriod').value;
151
145
  }, [amPmFormatter]);
152
146
 
147
+ // Get a list of formatted era names so users can type the first character to choose one.
148
+ let eraFormatter = useDateFormatter({year: 'numeric', era: 'narrow', timeZone: 'UTC'});
149
+ let eras = useMemo(() => {
150
+ if (segment.type !== 'era') {
151
+ return [];
152
+ }
153
+
154
+ let date = toCalendar(new CalendarDate(1, 1, 1), state.calendar);
155
+ let eras = state.calendar.getEras().map(era => {
156
+ let eraDate = date.set({year: 1, month: 1, day: 1, era}).toDate('UTC');
157
+ let parts = eraFormatter.formatToParts(eraDate);
158
+ let formatted = parts.find(p => p.type === 'era').value;
159
+ return {era, formatted};
160
+ });
161
+
162
+ // Remove the common prefix from formatted values. This is so that in calendars with eras like
163
+ // ERA0 and ERA1 (e.g. Ethiopic), users can press "0" and "1" to select an era. In other cases,
164
+ // the first letter is used.
165
+ let prefixLength = commonPrefixLength(eras.map(era => era.formatted));
166
+ if (prefixLength) {
167
+ for (let era of eras) {
168
+ era.formatted = era.formatted.slice(prefixLength);
169
+ }
170
+ }
171
+
172
+ return eras;
173
+ }, [eraFormatter, state.calendar, segment.type]);
174
+
153
175
  let onInput = (key: string) => {
154
176
  if (state.isDisabled || state.isReadOnly) {
155
177
  return;
@@ -168,6 +190,14 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
168
190
  }
169
191
  focusManager.focusNext();
170
192
  break;
193
+ case 'era': {
194
+ let matched = eras.find(e => startsWith(e.formatted, key));
195
+ if (matched) {
196
+ state.setSegment('era', matched.era);
197
+ focusManager.focusNext();
198
+ }
199
+ break;
200
+ }
171
201
  case 'day':
172
202
  case 'hour':
173
203
  case 'minute':
@@ -229,14 +259,9 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
229
259
  enteredKeys.current = '';
230
260
  scrollIntoView(getScrollParent(ref.current) as HTMLElement, ref.current);
231
261
 
232
- // Safari requires that a selection is set or it won't fire input events.
233
- // Since usePress disables text selection, this won't happen by default.
234
- ref.current.style.webkitUserSelect = 'text';
235
- ref.current.style.userSelect = 'text';
262
+ // Collapse selection to start or Chrome won't fire input events.
236
263
  let selection = window.getSelection();
237
264
  selection.collapse(ref.current);
238
- ref.current.style.webkitUserSelect = 'none';
239
- ref.current.style.userSelect = 'none';
240
265
  };
241
266
 
242
267
  let compositionRef = useRef('');
@@ -284,10 +309,18 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
284
309
  }
285
310
  });
286
311
 
287
- // For Android: prevent selection on long press.
288
- useEvent(ref, 'selectstart', e => {
289
- e.preventDefault();
290
- });
312
+ useLayoutEffect(() => {
313
+ let element = ref.current;
314
+ return () => {
315
+ // If the focused segment is removed, focus the previous one, or the next one if there was no previous one.
316
+ if (document.activeElement === element) {
317
+ let prev = focusManager.focusPrevious();
318
+ if (!prev) {
319
+ focusManager.focusNext();
320
+ }
321
+ }
322
+ };
323
+ }, [ref, focusManager]);
291
324
 
292
325
  // spinbuttons cannot be focused with VoiceOver on iOS.
293
326
  let touchPropOverrides = isIOS() || segment.type === 'timeZoneName' ? {
@@ -332,8 +365,8 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
332
365
  ...touchPropOverrides,
333
366
  'aria-invalid': state.validationState === 'invalid' ? 'true' : undefined,
334
367
  'aria-describedby': ariaDescribedBy,
335
- 'aria-placeholder': segment.isPlaceholder ? segment.text : undefined,
336
368
  'aria-readonly': state.isReadOnly || !segment.isEditable ? 'true' : undefined,
369
+ 'data-placeholder': segment.isPlaceholder || undefined,
337
370
  contentEditable: isEditable,
338
371
  suppressContentEditableWarning: isEditable,
339
372
  spellCheck: isEditable ? 'false' : undefined,
@@ -341,14 +374,12 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
341
374
  autoCorrect: isEditable ? 'off' : undefined,
342
375
  // Capitalization was changed in React 17...
343
376
  [parseInt(React.version, 10) >= 17 ? 'enterKeyHint' : 'enterkeyhint']: isEditable ? 'next' : undefined,
344
- inputMode: state.isDisabled || segment.type === 'dayPeriod' || !isEditable ? undefined : 'numeric',
377
+ inputMode: state.isDisabled || segment.type === 'dayPeriod' || segment.type === 'era' || !isEditable ? undefined : 'numeric',
345
378
  tabIndex: state.isDisabled ? undefined : 0,
346
379
  onKeyDown,
347
380
  onFocus,
348
381
  style: {
349
- caretColor: 'transparent',
350
- userSelect: 'none',
351
- WebkitUserSelect: 'none'
382
+ caretColor: 'transparent'
352
383
  },
353
384
  // Prevent pointer events from reaching useDatePickerGroup, and allow native browser behavior to focus the segment.
354
385
  onPointerDown(e) {
@@ -360,3 +391,16 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
360
391
  })
361
392
  };
362
393
  }
394
+
395
+ function commonPrefixLength(strings: string[]): number {
396
+ // Sort the strings, and compare the characters in the first and last to find the common prefix.
397
+ strings.sort();
398
+ let first = strings[0];
399
+ let last = strings[strings.length - 1];
400
+ for (let i = 0; i < first.length; i++) {
401
+ if (first[i] !== last[i]) {
402
+ return i;
403
+ }
404
+ }
405
+ return 0;
406
+ }