@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.
- package/dist/main.js +812 -82
- package/dist/main.js.map +1 -1
- package/dist/module.js +814 -84
- 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 +16 -15
- package/src/index.ts +1 -0
- package/src/useDateField.ts +12 -11
- package/src/useDatePicker.ts +11 -10
- package/src/useDatePickerGroup.ts +11 -9
- package/src/useDateRangePicker.ts +14 -19
- package/src/useDateSegment.ts +76 -31
- 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
|
/**
|
|
@@ -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
|
-
//
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
+
}
|
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 {
|