@react-aria/datepicker 3.0.0-rc.1 → 3.1.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.
@@ -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
  /**
@@ -35,7 +37,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
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,16 +119,6 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
114
119
  }
115
120
 
116
121
  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
122
  case 'Backspace':
128
123
  case 'Delete': {
129
124
  // Safari on iOS does not fire beforeinput for the backspace key because the cursor is at the start.
@@ -150,6 +145,34 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
150
145
  return amPmFormatter.formatToParts(date).find(part => part.type === 'dayPeriod').value;
151
146
  }, [amPmFormatter]);
152
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
+
153
176
  let onInput = (key: string) => {
154
177
  if (state.isDisabled || state.isReadOnly) {
155
178
  return;
@@ -168,6 +191,14 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
168
191
  }
169
192
  focusManager.focusNext();
170
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
+ }
171
202
  case 'day':
172
203
  case 'hour':
173
204
  case 'minute':
@@ -229,14 +260,9 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
229
260
  enteredKeys.current = '';
230
261
  scrollIntoView(getScrollParent(ref.current) as HTMLElement, ref.current);
231
262
 
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';
263
+ // Collapse selection to start or Chrome won't fire input events.
236
264
  let selection = window.getSelection();
237
265
  selection.collapse(ref.current);
238
- ref.current.style.webkitUserSelect = 'none';
239
- ref.current.style.userSelect = 'none';
240
266
  };
241
267
 
242
268
  let compositionRef = useRef('');
@@ -284,10 +310,18 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
284
310
  }
285
311
  });
286
312
 
287
- // For Android: prevent selection on long press.
288
- useEvent(ref, 'selectstart', e => {
289
- e.preventDefault();
290
- });
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]);
291
325
 
292
326
  // spinbuttons cannot be focused with VoiceOver on iOS.
293
327
  let touchPropOverrides = isIOS() || segment.type === 'timeZoneName' ? {
@@ -332,8 +366,8 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
332
366
  ...touchPropOverrides,
333
367
  'aria-invalid': state.validationState === 'invalid' ? 'true' : undefined,
334
368
  'aria-describedby': ariaDescribedBy,
335
- 'aria-placeholder': segment.isPlaceholder ? segment.text : undefined,
336
369
  'aria-readonly': state.isReadOnly || !segment.isEditable ? 'true' : undefined,
370
+ 'data-placeholder': segment.isPlaceholder || undefined,
337
371
  contentEditable: isEditable,
338
372
  suppressContentEditableWarning: isEditable,
339
373
  spellCheck: isEditable ? 'false' : undefined,
@@ -341,14 +375,12 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
341
375
  autoCorrect: isEditable ? 'off' : undefined,
342
376
  // Capitalization was changed in React 17...
343
377
  [parseInt(React.version, 10) >= 17 ? 'enterKeyHint' : 'enterkeyhint']: isEditable ? 'next' : undefined,
344
- inputMode: state.isDisabled || segment.type === 'dayPeriod' || !isEditable ? undefined : 'numeric',
378
+ inputMode: state.isDisabled || segment.type === 'dayPeriod' || segment.type === 'era' || !isEditable ? undefined : 'numeric',
345
379
  tabIndex: state.isDisabled ? undefined : 0,
346
380
  onKeyDown,
347
381
  onFocus,
348
382
  style: {
349
- caretColor: 'transparent',
350
- userSelect: 'none',
351
- WebkitUserSelect: 'none'
383
+ caretColor: 'transparent'
352
384
  },
353
385
  // Prevent pointer events from reaching useDatePickerGroup, and allow native browser behavior to focus the segment.
354
386
  onPointerDown(e) {
@@ -360,3 +392,16 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
360
392
  })
361
393
  };
362
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 {