@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.
- package/dist/main.js +850 -116
- package/dist/main.js.map +1 -1
- package/dist/module.js +852 -118
- package/dist/module.js.map +1 -1
- package/dist/types.d.ts +20 -18
- package/dist/types.d.ts.map +1 -1
- package/package.json +19 -18
- package/src/index.ts +1 -0
- package/src/useDateField.ts +13 -13
- package/src/useDatePicker.ts +12 -12
- package/src/useDatePickerGroup.ts +42 -11
- package/src/useDateRangePicker.ts +19 -21
- package/src/useDateSegment.ts +79 -50
- package/src/useDisplayNames.ts +3 -3
package/src/useDateSegment.ts
CHANGED
|
@@ -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 {
|
|
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, {
|
|
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:
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
+
}
|
package/src/useDisplayNames.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
// @ts-ignore
|
|
14
14
|
import intlMessages from '../intl/*.json';
|
|
15
|
-
import {
|
|
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:
|
|
41
|
+
private dictionary: LocalizedStringDictionary<Field, string>;
|
|
42
42
|
|
|
43
43
|
constructor(locale: string) {
|
|
44
44
|
this.locale = locale;
|
|
45
|
-
this.dictionary = new
|
|
45
|
+
this.dictionary = new LocalizedStringDictionary<Field, string>(intlMessages);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
of(field: Field): string {
|