@react-aria/datepicker 3.0.0-rc.0 → 3.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.
@@ -10,18 +10,20 @@
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 {DOMAttributes} from '@react-types/shared';
16
+ import {getScrollParent, isIOS, isMac, mergeProps, scrollIntoView, useEvent, useId, useLabels, useLayoutEffect} from '@react-aria/utils';
15
17
  import {hookData} from './useDateField';
16
18
  import {NumberParser} from '@internationalized/number';
17
- import React, {HTMLAttributes, RefObject, useMemo, useRef} from 'react';
19
+ import React, {RefObject, useMemo, useRef} from 'react';
18
20
  import {useDateFormatter, useFilter, useLocale} from '@react-aria/i18n';
19
21
  import {useDisplayNames} from './useDisplayNames';
20
22
  import {useSpinButton} from '@react-aria/spinbutton';
21
23
 
22
24
  export interface DateSegmentAria {
23
25
  /** Props for the segment element. */
24
- segmentProps: HTMLAttributes<HTMLDivElement>
26
+ segmentProps: DOMAttributes
25
27
  }
26
28
 
27
29
  /**
@@ -31,11 +33,11 @@ export interface DateSegmentAria {
31
33
  */
32
34
  export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: RefObject<HTMLElement>): DateSegmentAria {
33
35
  let enteredKeys = useRef('');
34
- let {locale, direction} = useLocale();
36
+ let {locale} = useLocale();
35
37
  let displayNames = useDisplayNames();
36
38
  let {ariaLabel, ariaLabelledBy, ariaDescribedBy, focusManager} = hookData.get(state);
37
39
 
38
- let textValue = segment.text;
40
+ let textValue = segment.isPlaceholder ? '' : segment.text;
39
41
  let options = useMemo(() => state.dateFormatter.resolvedOptions(), [state.dateFormatter]);
40
42
  let monthDateFormatter = useDateFormatter({month: 'long', timeZone: options.timeZone});
41
43
  let hourDateFormatter = useDateFormatter({
@@ -44,14 +46,17 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
44
46
  timeZone: options.timeZone
45
47
  });
46
48
 
47
- if (segment.type === 'month') {
49
+ if (segment.type === 'month' && !segment.isPlaceholder) {
48
50
  let monthTextValue = monthDateFormatter.format(state.dateValue);
49
51
  textValue = monthTextValue !== textValue ? `${textValue} – ${monthTextValue}` : monthTextValue;
50
- } else if (segment.type === 'hour') {
52
+ } else if (segment.type === 'hour' && !segment.isPlaceholder) {
51
53
  textValue = hourDateFormatter.format(state.dateValue);
52
54
  }
53
55
 
54
56
  let {spinButtonProps} = useSpinButton({
57
+ // The ARIA spec says aria-valuenow is optional if there's no value, but aXe seems to require it.
58
+ // This doesn't seem to have any negative effects with real AT since we also use aria-valuetext.
59
+ // https://github.com/dequelabs/axe-core/issues/3505
55
60
  value: segment.value,
56
61
  textValue,
57
62
  minValue: segment.minValue,
@@ -114,34 +119,6 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
114
119
  }
115
120
 
116
121
  switch (e.key) {
117
- case 'ArrowLeft':
118
- e.preventDefault();
119
- e.stopPropagation();
120
- if (direction === 'rtl') {
121
- focusManager.focusNext({tabbable: true});
122
- } else {
123
- focusManager.focusPrevious({tabbable: true});
124
- }
125
- break;
126
- case 'ArrowRight':
127
- e.preventDefault();
128
- e.stopPropagation();
129
- if (direction === 'rtl') {
130
- focusManager.focusPrevious({tabbable: true});
131
- } else {
132
- focusManager.focusNext({tabbable: true});
133
- }
134
- break;
135
- case 'Enter':
136
- e.preventDefault();
137
- e.stopPropagation();
138
- if (segment.isPlaceholder && !state.isReadOnly) {
139
- state.confirmPlaceholder(segment.type);
140
- }
141
- focusManager.focusNext({tabbable: true});
142
- break;
143
- case 'Tab':
144
- break;
145
122
  case 'Backspace':
146
123
  case 'Delete': {
147
124
  // Safari on iOS does not fire beforeinput for the backspace key because the cursor is at the start.
@@ -168,6 +145,34 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
168
145
  return amPmFormatter.formatToParts(date).find(part => part.type === 'dayPeriod').value;
169
146
  }, [amPmFormatter]);
170
147
 
148
+ // Get a list of formatted era names so users can type the first character to choose one.
149
+ let eraFormatter = useDateFormatter({year: 'numeric', era: 'narrow', timeZone: 'UTC'});
150
+ let eras = useMemo(() => {
151
+ if (segment.type !== 'era') {
152
+ return [];
153
+ }
154
+
155
+ let date = toCalendar(new CalendarDate(1, 1, 1), state.calendar);
156
+ let eras = state.calendar.getEras().map(era => {
157
+ let eraDate = date.set({year: 1, month: 1, day: 1, era}).toDate('UTC');
158
+ let parts = eraFormatter.formatToParts(eraDate);
159
+ let formatted = parts.find(p => p.type === 'era').value;
160
+ return {era, formatted};
161
+ });
162
+
163
+ // Remove the common prefix from formatted values. This is so that in calendars with eras like
164
+ // ERA0 and ERA1 (e.g. Ethiopic), users can press "0" and "1" to select an era. In other cases,
165
+ // the first letter is used.
166
+ let prefixLength = commonPrefixLength(eras.map(era => era.formatted));
167
+ if (prefixLength) {
168
+ for (let era of eras) {
169
+ era.formatted = era.formatted.slice(prefixLength);
170
+ }
171
+ }
172
+
173
+ return eras;
174
+ }, [eraFormatter, state.calendar, segment.type]);
175
+
171
176
  let onInput = (key: string) => {
172
177
  if (state.isDisabled || state.isReadOnly) {
173
178
  return;
@@ -184,8 +189,16 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
184
189
  } else {
185
190
  break;
186
191
  }
187
- focusManager.focusNext({tabbable: true});
192
+ focusManager.focusNext();
188
193
  break;
194
+ case 'era': {
195
+ let matched = eras.find(e => startsWith(e.formatted, key));
196
+ if (matched) {
197
+ state.setSegment('era', matched.era);
198
+ focusManager.focusNext();
199
+ }
200
+ break;
201
+ }
189
202
  case 'day':
190
203
  case 'hour':
191
204
  case 'minute':
@@ -233,7 +246,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
233
246
  if (Number(numberValue + '0') > segment.maxValue || newValue.length >= String(segment.maxValue).length) {
234
247
  enteredKeys.current = '';
235
248
  if (shouldSetValue) {
236
- focusManager.focusNext({tabbable: true});
249
+ focusManager.focusNext();
237
250
  }
238
251
  } else {
239
252
  enteredKeys.current = newValue;
@@ -247,12 +260,9 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
247
260
  enteredKeys.current = '';
248
261
  scrollIntoView(getScrollParent(ref.current) as HTMLElement, ref.current);
249
262
 
250
- // Safari requires that a selection is set or it won't fire input events.
251
- // Since usePress disables text selection, this won't happen by default.
252
- ref.current.style.webkitUserSelect = 'text';
263
+ // Collapse selection to start or Chrome won't fire input events.
253
264
  let selection = window.getSelection();
254
265
  selection.collapse(ref.current);
255
- ref.current.style.webkitUserSelect = '';
256
266
  };
257
267
 
258
268
  let compositionRef = useRef('');
@@ -300,10 +310,18 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
300
310
  }
301
311
  });
302
312
 
303
- // For Android: prevent selection on long press.
304
- useEvent(ref, 'selectstart', e => {
305
- e.preventDefault();
306
- });
313
+ useLayoutEffect(() => {
314
+ let element = ref.current;
315
+ return () => {
316
+ // If the focused segment is removed, focus the previous one, or the next one if there was no previous one.
317
+ if (document.activeElement === element) {
318
+ let prev = focusManager.focusPrevious();
319
+ if (!prev) {
320
+ focusManager.focusNext();
321
+ }
322
+ }
323
+ };
324
+ }, [ref, focusManager]);
307
325
 
308
326
  // spinbuttons cannot be focused with VoiceOver on iOS.
309
327
  let touchPropOverrides = isIOS() || segment.type === 'timeZoneName' ? {
@@ -348,8 +366,8 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
348
366
  ...touchPropOverrides,
349
367
  'aria-invalid': state.validationState === 'invalid' ? 'true' : undefined,
350
368
  'aria-describedby': ariaDescribedBy,
351
- 'aria-placeholder': segment.isPlaceholder ? segment.text : undefined,
352
369
  'aria-readonly': state.isReadOnly || !segment.isEditable ? 'true' : undefined,
370
+ 'data-placeholder': segment.isPlaceholder || undefined,
353
371
  contentEditable: isEditable,
354
372
  suppressContentEditableWarning: isEditable,
355
373
  spellCheck: isEditable ? 'false' : undefined,
@@ -357,14 +375,12 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
357
375
  autoCorrect: isEditable ? 'off' : undefined,
358
376
  // Capitalization was changed in React 17...
359
377
  [parseInt(React.version, 10) >= 17 ? 'enterKeyHint' : 'enterkeyhint']: isEditable ? 'next' : undefined,
360
- inputMode: state.isDisabled || segment.type === 'dayPeriod' || !isEditable ? undefined : 'numeric',
378
+ inputMode: state.isDisabled || segment.type === 'dayPeriod' || segment.type === 'era' || !isEditable ? undefined : 'numeric',
361
379
  tabIndex: state.isDisabled ? undefined : 0,
362
380
  onKeyDown,
363
381
  onFocus,
364
382
  style: {
365
- caretColor: 'transparent',
366
- userSelect: 'none',
367
- WebkitUserSelect: 'none'
383
+ caretColor: 'transparent'
368
384
  },
369
385
  // Prevent pointer events from reaching useDatePickerGroup, and allow native browser behavior to focus the segment.
370
386
  onPointerDown(e) {
@@ -376,3 +392,16 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
376
392
  })
377
393
  };
378
394
  }
395
+
396
+ function commonPrefixLength(strings: string[]): number {
397
+ // Sort the strings, and compare the characters in the first and last to find the common prefix.
398
+ strings.sort();
399
+ let first = strings[0];
400
+ let last = strings[strings.length - 1];
401
+ for (let i = 0; i < first.length; i++) {
402
+ if (first[i] !== last[i]) {
403
+ return i;
404
+ }
405
+ }
406
+ return 0;
407
+ }
@@ -12,7 +12,7 @@
12
12
 
13
13
  // @ts-ignore
14
14
  import intlMessages from '../intl/*.json';
15
- import {MessageDictionary} from '@internationalized/message';
15
+ import {LocalizedStringDictionary} from '@internationalized/string';
16
16
  import {useLocale} from '@react-aria/i18n';
17
17
  import {useMemo} from 'react';
18
18
 
@@ -38,11 +38,11 @@ export function useDisplayNames(): DisplayNames {
38
38
 
39
39
  class DisplayNamesPolyfill implements DisplayNames {
40
40
  private locale: string;
41
- private dictionary: MessageDictionary;
41
+ private dictionary: LocalizedStringDictionary<Field, string>;
42
42
 
43
43
  constructor(locale: string) {
44
44
  this.locale = locale;
45
- this.dictionary = new MessageDictionary(intlMessages);
45
+ this.dictionary = new LocalizedStringDictionary<Field, string>(intlMessages);
46
46
  }
47
47
 
48
48
  of(field: Field): string {