@react-aria/datepicker 3.0.0-nightly.3180 → 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.
- package/dist/main.js +197 -143
- package/dist/main.js.map +1 -1
- package/dist/module.js +197 -130
- package/dist/module.js.map +1 -1
- package/dist/types.d.ts +8 -6
- package/dist/types.d.ts.map +1 -1
- package/package.json +19 -18
- package/src/index.ts +7 -1
- package/src/useDateField.ts +15 -8
- package/src/useDatePicker.ts +12 -8
- package/src/useDatePickerGroup.ts +34 -5
- package/src/useDateRangePicker.ts +15 -13
- package/src/useDateSegment.ts +83 -63
- package/src/useDisplayNames.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@react-aria/datepicker",
|
|
3
|
-
"version": "3.0.0
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Spectrum UI components in React",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"main": "dist/main.js",
|
|
@@ -18,27 +18,28 @@
|
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@babel/runtime": "^7.6.2",
|
|
21
|
-
"@internationalized/
|
|
22
|
-
"@internationalized/
|
|
23
|
-
"@
|
|
24
|
-
"@react-aria/
|
|
25
|
-
"@react-aria/
|
|
26
|
-
"@react-aria/
|
|
27
|
-
"@react-aria/
|
|
28
|
-
"@react-aria/
|
|
29
|
-
"@react-
|
|
30
|
-
"@react-
|
|
31
|
-
"@react-types/
|
|
32
|
-
"@react-types/
|
|
33
|
-
"@react-types/
|
|
34
|
-
"@react-types/
|
|
21
|
+
"@internationalized/date": "^3.0.0",
|
|
22
|
+
"@internationalized/message": "^3.0.8",
|
|
23
|
+
"@internationalized/number": "^3.1.1",
|
|
24
|
+
"@react-aria/focus": "^3.6.1",
|
|
25
|
+
"@react-aria/i18n": "^3.4.1",
|
|
26
|
+
"@react-aria/interactions": "^3.9.1",
|
|
27
|
+
"@react-aria/label": "^3.3.1",
|
|
28
|
+
"@react-aria/spinbutton": "^3.1.1",
|
|
29
|
+
"@react-aria/utils": "^3.13.1",
|
|
30
|
+
"@react-stately/datepicker": "^3.0.0",
|
|
31
|
+
"@react-types/button": "^3.5.1",
|
|
32
|
+
"@react-types/calendar": "^3.0.0",
|
|
33
|
+
"@react-types/datepicker": "^3.0.0",
|
|
34
|
+
"@react-types/dialog": "^3.4.1",
|
|
35
|
+
"@react-types/shared": "^3.13.1"
|
|
35
36
|
},
|
|
36
37
|
"peerDependencies": {
|
|
37
|
-
"react": "^16.8.0 || ^17.0.0-rc.1",
|
|
38
|
-
"react-dom": "^16.8.0 || ^17.0.0-rc.1"
|
|
38
|
+
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0",
|
|
39
|
+
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
|
39
40
|
},
|
|
40
41
|
"publishConfig": {
|
|
41
42
|
"access": "public"
|
|
42
43
|
},
|
|
43
|
-
"gitHead": "
|
|
44
|
+
"gitHead": "715c3f563ccf8c2e0102d3e18403d9db21a05a71"
|
|
44
45
|
}
|
package/src/index.ts
CHANGED
|
@@ -14,4 +14,10 @@ export {useDatePicker} from './useDatePicker';
|
|
|
14
14
|
export {useDateSegment} from './useDateSegment';
|
|
15
15
|
export {useDateField, useTimeField} from './useDateField';
|
|
16
16
|
export {useDateRangePicker} from './useDateRangePicker';
|
|
17
|
-
export
|
|
17
|
+
export {useDisplayNames} from './useDisplayNames';
|
|
18
|
+
|
|
19
|
+
export type {AriaDatePickerProps, AriaDateRangePickerProps} from '@react-types/datepicker';
|
|
20
|
+
export type {AriaDateFieldProps, DateFieldAria} from './useDateField';
|
|
21
|
+
export type {DatePickerAria} from './useDatePicker';
|
|
22
|
+
export type {DateRangePickerAria} from './useDateRangePicker';
|
|
23
|
+
export type {DateSegmentAria} from './useDateSegment';
|
package/src/useDateField.ts
CHANGED
|
@@ -13,16 +13,19 @@
|
|
|
13
13
|
import {AriaDatePickerProps, AriaTimeFieldProps, DateValue, TimeValue} from '@react-types/datepicker';
|
|
14
14
|
import {createFocusManager, FocusManager} from '@react-aria/focus';
|
|
15
15
|
import {DateFieldState} from '@react-stately/datepicker';
|
|
16
|
+
import {filterDOMProps, mergeProps, useDescription} from '@react-aria/utils';
|
|
16
17
|
import {HTMLAttributes, RefObject, useEffect, useMemo, useRef} from 'react';
|
|
17
|
-
|
|
18
|
+
// @ts-ignore
|
|
19
|
+
import intlMessages from '../intl/*.json';
|
|
18
20
|
import {useDatePickerGroup} from './useDatePickerGroup';
|
|
19
21
|
import {useField} from '@react-aria/label';
|
|
20
22
|
import {useFocusWithin} from '@react-aria/interactions';
|
|
23
|
+
import {useMessageFormatter} from '@react-aria/i18n';
|
|
21
24
|
|
|
22
25
|
// Allows this hook to also be used with TimeField
|
|
23
|
-
interface
|
|
26
|
+
export interface AriaDateFieldProps<T extends DateValue> extends Omit<AriaDatePickerProps<T>, 'value' | 'defaultValue' | 'onChange' | 'minValue' | 'maxValue' | 'placeholderValue'> {}
|
|
24
27
|
|
|
25
|
-
interface DateFieldAria {
|
|
28
|
+
export interface DateFieldAria {
|
|
26
29
|
/** Props for the field's visible label element, if any. */
|
|
27
30
|
labelProps: HTMLAttributes<HTMLElement>,
|
|
28
31
|
/** Props for the field grouping element. */
|
|
@@ -53,21 +56,23 @@ export const focusManagerSymbol = '__focusManager_' + Date.now();
|
|
|
53
56
|
* A date field allows users to enter and edit date and time values using a keyboard.
|
|
54
57
|
* Each part of a date value is displayed in an individually editable segment.
|
|
55
58
|
*/
|
|
56
|
-
export function useDateField<T extends DateValue>(props:
|
|
59
|
+
export function useDateField<T extends DateValue>(props: AriaDateFieldProps<T>, state: DateFieldState, ref: RefObject<HTMLElement>): DateFieldAria {
|
|
57
60
|
let {labelProps, fieldProps, descriptionProps, errorMessageProps} = useField({
|
|
58
61
|
...props,
|
|
59
62
|
labelElementType: 'span'
|
|
60
63
|
});
|
|
61
64
|
|
|
62
|
-
let groupProps = useDatePickerGroup(state, ref);
|
|
63
|
-
|
|
64
65
|
let {focusWithinProps} = useFocusWithin({
|
|
65
66
|
onBlurWithin() {
|
|
66
67
|
state.confirmPlaceholder();
|
|
67
68
|
}
|
|
68
69
|
});
|
|
69
70
|
|
|
70
|
-
let
|
|
71
|
+
let formatMessage = useMessageFormatter(intlMessages);
|
|
72
|
+
let message = state.maxGranularity === 'hour' ? 'selectedTimeDescription' : 'selectedDateDescription';
|
|
73
|
+
let field = state.maxGranularity === 'hour' ? 'time' : 'date';
|
|
74
|
+
let description = state.value ? formatMessage(message, {[field]: state.formatValue({month: 'long'})}) : '';
|
|
75
|
+
let descProps = useDescription(description);
|
|
71
76
|
|
|
72
77
|
// If within a date picker or date range picker, the date field will have role="presentation" and an aria-describedby
|
|
73
78
|
// will be passed in that references the value (e.g. entire range). Otherwise, add the field's value description.
|
|
@@ -76,6 +81,7 @@ export function useDateField<T extends DateValue>(props: DateFieldProps<T>, stat
|
|
|
76
81
|
: [descProps['aria-describedby'], fieldProps['aria-describedby']].filter(Boolean).join(' ') || undefined;
|
|
77
82
|
let propsFocusManager = props[focusManagerSymbol];
|
|
78
83
|
let focusManager = useMemo(() => propsFocusManager || createFocusManager(ref), [propsFocusManager, ref]);
|
|
84
|
+
let groupProps = useDatePickerGroup(state, ref, props[roleSymbol] === 'presentation');
|
|
79
85
|
|
|
80
86
|
// Pass labels and other information to segments.
|
|
81
87
|
hookData.set(state, {
|
|
@@ -111,6 +117,7 @@ export function useDateField<T extends DateValue>(props: DateFieldProps<T>, stat
|
|
|
111
117
|
autoFocusRef.current = false;
|
|
112
118
|
}, [focusManager]);
|
|
113
119
|
|
|
120
|
+
let domProps = filterDOMProps(props);
|
|
114
121
|
return {
|
|
115
122
|
labelProps: {
|
|
116
123
|
...labelProps,
|
|
@@ -118,7 +125,7 @@ export function useDateField<T extends DateValue>(props: DateFieldProps<T>, stat
|
|
|
118
125
|
focusManager.focusFirst();
|
|
119
126
|
}
|
|
120
127
|
},
|
|
121
|
-
fieldProps: mergeProps(fieldDOMProps, groupProps, focusWithinProps),
|
|
128
|
+
fieldProps: mergeProps(domProps, fieldDOMProps, groupProps, focusWithinProps),
|
|
122
129
|
descriptionProps,
|
|
123
130
|
errorMessageProps
|
|
124
131
|
};
|
package/src/useDatePicker.ts
CHANGED
|
@@ -16,16 +16,16 @@ import {AriaDialogProps} from '@react-types/dialog';
|
|
|
16
16
|
import {CalendarProps} from '@react-types/calendar';
|
|
17
17
|
import {createFocusManager} from '@react-aria/focus';
|
|
18
18
|
import {DatePickerState} from '@react-stately/datepicker';
|
|
19
|
-
import {
|
|
19
|
+
import {filterDOMProps, mergeProps, useDescription, useId} from '@react-aria/utils';
|
|
20
|
+
import {HTMLAttributes, RefObject, useMemo} from 'react';
|
|
20
21
|
// @ts-ignore
|
|
21
22
|
import intlMessages from '../intl/*.json';
|
|
22
|
-
import {mergeProps, useDescription, useId} from '@react-aria/utils';
|
|
23
23
|
import {roleSymbol} from './useDateField';
|
|
24
24
|
import {useDatePickerGroup} from './useDatePickerGroup';
|
|
25
25
|
import {useField} from '@react-aria/label';
|
|
26
26
|
import {useLocale, useMessageFormatter} from '@react-aria/i18n';
|
|
27
27
|
|
|
28
|
-
interface DatePickerAria {
|
|
28
|
+
export interface DatePickerAria {
|
|
29
29
|
/** Props for the date picker's visible label element, if any. */
|
|
30
30
|
labelProps: HTMLAttributes<HTMLElement>,
|
|
31
31
|
/** Props for the grouping element containing the date field and button. */
|
|
@@ -63,11 +63,15 @@ export function useDatePicker<T extends DateValue>(props: AriaDatePickerProps<T>
|
|
|
63
63
|
let labelledBy = fieldProps['aria-labelledby'] || fieldProps.id;
|
|
64
64
|
|
|
65
65
|
let {locale} = useLocale();
|
|
66
|
-
let
|
|
66
|
+
let date = state.formatValue(locale, {month: 'long'});
|
|
67
|
+
let description = date ? formatMessage('selectedDateDescription', {date}) : '';
|
|
68
|
+
let descProps = useDescription(description);
|
|
67
69
|
let ariaDescribedBy = [descProps['aria-describedby'], fieldProps['aria-describedby']].filter(Boolean).join(' ') || undefined;
|
|
70
|
+
let domProps = filterDOMProps(props);
|
|
71
|
+
let focusManager = useMemo(() => createFocusManager(ref), [ref]);
|
|
68
72
|
|
|
69
73
|
return {
|
|
70
|
-
groupProps: mergeProps(groupProps, fieldProps, descProps, {
|
|
74
|
+
groupProps: mergeProps(domProps, groupProps, fieldProps, descProps, {
|
|
71
75
|
role: 'group',
|
|
72
76
|
'aria-disabled': props.isDisabled || null,
|
|
73
77
|
'aria-labelledby': labelledBy,
|
|
@@ -76,7 +80,6 @@ export function useDatePicker<T extends DateValue>(props: AriaDatePickerProps<T>
|
|
|
76
80
|
labelProps: {
|
|
77
81
|
...labelProps,
|
|
78
82
|
onClick: () => {
|
|
79
|
-
let focusManager = createFocusManager(ref);
|
|
80
83
|
focusManager.focusFirst();
|
|
81
84
|
}
|
|
82
85
|
},
|
|
@@ -103,7 +106,6 @@ export function useDatePicker<T extends DateValue>(props: AriaDatePickerProps<T>
|
|
|
103
106
|
buttonProps: {
|
|
104
107
|
...descProps,
|
|
105
108
|
id: buttonId,
|
|
106
|
-
excludeFromTabOrder: true,
|
|
107
109
|
'aria-haspopup': 'dialog',
|
|
108
110
|
'aria-label': formatMessage('calendar'),
|
|
109
111
|
'aria-labelledby': `${labelledBy} ${buttonId}`,
|
|
@@ -123,7 +125,9 @@ export function useDatePicker<T extends DateValue>(props: AriaDatePickerProps<T>
|
|
|
123
125
|
isDisabled: props.isDisabled,
|
|
124
126
|
isReadOnly: props.isReadOnly,
|
|
125
127
|
isDateUnavailable: props.isDateUnavailable,
|
|
126
|
-
defaultFocusedValue: state.dateValue ? undefined : props.placeholderValue
|
|
128
|
+
defaultFocusedValue: state.dateValue ? undefined : props.placeholderValue,
|
|
129
|
+
validationState: state.validationState,
|
|
130
|
+
errorMessage: props.errorMessage
|
|
127
131
|
}
|
|
128
132
|
};
|
|
129
133
|
}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import {createFocusManager, getFocusableTreeWalker} from '@react-aria/focus';
|
|
1
2
|
import {DateFieldState, DatePickerState, DateRangePickerState} from '@react-stately/datepicker';
|
|
2
|
-
import {getFocusableTreeWalker} from '@react-aria/focus';
|
|
3
3
|
import {KeyboardEvent} from '@react-types/shared';
|
|
4
4
|
import {mergeProps} from '@react-aria/utils';
|
|
5
|
-
import {RefObject} from 'react';
|
|
5
|
+
import {RefObject, useMemo} from 'react';
|
|
6
|
+
import {useLocale} from '@react-aria/i18n';
|
|
6
7
|
import {usePress} from '@react-aria/interactions';
|
|
7
8
|
|
|
8
|
-
export function useDatePickerGroup(state: DatePickerState | DateRangePickerState | DateFieldState, ref: RefObject<HTMLElement
|
|
9
|
+
export function useDatePickerGroup(state: DatePickerState | DateRangePickerState | DateFieldState, ref: RefObject<HTMLElement>, disableArrowNavigation?: boolean) {
|
|
10
|
+
let {direction} = useLocale();
|
|
11
|
+
let focusManager = useMemo(() => createFocusManager(ref), [ref]);
|
|
12
|
+
|
|
9
13
|
// Open the popover on alt + arrow down
|
|
10
14
|
let onKeyDown = (e: KeyboardEvent) => {
|
|
11
15
|
if (e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp') && 'setOpen' in state) {
|
|
@@ -13,6 +17,31 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState
|
|
|
13
17
|
e.stopPropagation();
|
|
14
18
|
state.setOpen(true);
|
|
15
19
|
}
|
|
20
|
+
|
|
21
|
+
if (disableArrowNavigation) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
switch (e.key) {
|
|
26
|
+
case 'ArrowLeft':
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
e.stopPropagation();
|
|
29
|
+
if (direction === 'rtl') {
|
|
30
|
+
focusManager.focusNext();
|
|
31
|
+
} else {
|
|
32
|
+
focusManager.focusPrevious();
|
|
33
|
+
}
|
|
34
|
+
break;
|
|
35
|
+
case 'ArrowRight':
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
e.stopPropagation();
|
|
38
|
+
if (direction === 'rtl') {
|
|
39
|
+
focusManager.focusPrevious();
|
|
40
|
+
} else {
|
|
41
|
+
focusManager.focusNext();
|
|
42
|
+
}
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
16
45
|
};
|
|
17
46
|
|
|
18
47
|
// Focus the first placeholder segment from the end on mouse down/touch up in the field.
|
|
@@ -37,9 +66,9 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState
|
|
|
37
66
|
}
|
|
38
67
|
|
|
39
68
|
// Now go backwards until we find an element that is not a placeholder.
|
|
40
|
-
while (target?.
|
|
69
|
+
while (target?.hasAttribute('data-placeholder')) {
|
|
41
70
|
let prev = walker.previousNode() as HTMLElement;
|
|
42
|
-
if (prev && prev.
|
|
71
|
+
if (prev && prev.hasAttribute('data-placeholder')) {
|
|
43
72
|
target = prev;
|
|
44
73
|
} else {
|
|
45
74
|
break;
|
|
@@ -15,18 +15,17 @@ import {AriaDatePickerProps, AriaDateRangePickerProps, DateValue} from '@react-t
|
|
|
15
15
|
import {AriaDialogProps} from '@react-types/dialog';
|
|
16
16
|
import {createFocusManager} from '@react-aria/focus';
|
|
17
17
|
import {DateRangePickerState} from '@react-stately/datepicker';
|
|
18
|
+
import {filterDOMProps, mergeProps, useDescription, useId} from '@react-aria/utils';
|
|
18
19
|
import {focusManagerSymbol, roleSymbol} from './useDateField';
|
|
19
20
|
import {HTMLAttributes, RefObject, useMemo} from 'react';
|
|
20
21
|
// @ts-ignore
|
|
21
22
|
import intlMessages from '../intl/*.json';
|
|
22
|
-
import {mergeProps, useDescription, useId} from '@react-aria/utils';
|
|
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
|
-
interface DateRangePickerAria {
|
|
28
|
+
export interface DateRangePickerAria {
|
|
30
29
|
/** Props for the date range picker's visible label element, if any. */
|
|
31
30
|
labelProps: HTMLAttributes<HTMLElement>,
|
|
32
31
|
/** Props for the grouping element containing the date fields and button. */
|
|
@@ -62,7 +61,8 @@ export function useDateRangePicker<T extends DateValue>(props: AriaDateRangePick
|
|
|
62
61
|
let labelledBy = fieldProps['aria-labelledby'] || fieldProps.id;
|
|
63
62
|
|
|
64
63
|
let {locale} = useLocale();
|
|
65
|
-
let
|
|
64
|
+
let range = state.formatValue(locale, {month: 'long'});
|
|
65
|
+
let description = range ? formatMessage('selectedRangeDescription', {startDate: range.start, endDate: range.end}) : '';
|
|
66
66
|
let descProps = useDescription(description);
|
|
67
67
|
|
|
68
68
|
let startFieldProps = {
|
|
@@ -79,14 +79,13 @@ export function useDateRangePicker<T extends DateValue>(props: AriaDateRangePick
|
|
|
79
79
|
let dialogId = useId();
|
|
80
80
|
|
|
81
81
|
let groupProps = useDatePickerGroup(state, ref);
|
|
82
|
-
let {focusWithinProps} = useFocusWithin({
|
|
83
|
-
onBlurWithin() {
|
|
84
|
-
state.confirmPlaceholder();
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
82
|
|
|
88
83
|
let ariaDescribedBy = [descProps['aria-describedby'], fieldProps['aria-describedby']].filter(Boolean).join(' ') || undefined;
|
|
89
|
-
let focusManager = useMemo(() => createFocusManager(ref
|
|
84
|
+
let focusManager = useMemo(() => createFocusManager(ref, {
|
|
85
|
+
// Exclude the button from the focus manager.
|
|
86
|
+
accept: element => element.id !== buttonId
|
|
87
|
+
}), [ref, buttonId]);
|
|
88
|
+
|
|
90
89
|
let commonFieldProps = {
|
|
91
90
|
[focusManagerSymbol]: focusManager,
|
|
92
91
|
[roleSymbol]: 'presentation',
|
|
@@ -103,8 +102,10 @@ export function useDateRangePicker<T extends DateValue>(props: AriaDateRangePick
|
|
|
103
102
|
validationState: state.validationState
|
|
104
103
|
};
|
|
105
104
|
|
|
105
|
+
let domProps = filterDOMProps(props);
|
|
106
|
+
|
|
106
107
|
return {
|
|
107
|
-
groupProps: mergeProps(groupProps, fieldProps, descProps,
|
|
108
|
+
groupProps: mergeProps(domProps, groupProps, fieldProps, descProps, {
|
|
108
109
|
role: 'group',
|
|
109
110
|
'aria-disabled': props.isDisabled || null,
|
|
110
111
|
'aria-describedby': ariaDescribedBy
|
|
@@ -118,7 +119,6 @@ export function useDateRangePicker<T extends DateValue>(props: AriaDateRangePick
|
|
|
118
119
|
buttonProps: {
|
|
119
120
|
...descProps,
|
|
120
121
|
id: buttonId,
|
|
121
|
-
excludeFromTabOrder: true,
|
|
122
122
|
'aria-haspopup': 'dialog',
|
|
123
123
|
'aria-label': formatMessage('calendar'),
|
|
124
124
|
'aria-labelledby': `${labelledBy} ${buttonId}`,
|
|
@@ -154,7 +154,9 @@ export function useDateRangePicker<T extends DateValue>(props: AriaDateRangePick
|
|
|
154
154
|
isReadOnly: props.isReadOnly,
|
|
155
155
|
isDateUnavailable: props.isDateUnavailable,
|
|
156
156
|
allowsNonContiguousRanges: props.allowsNonContiguousRanges,
|
|
157
|
-
defaultFocusedValue: state.dateRange ? undefined : props.placeholderValue
|
|
157
|
+
defaultFocusedValue: state.dateRange ? undefined : props.placeholderValue,
|
|
158
|
+
validationState: state.validationState,
|
|
159
|
+
errorMessage: props.errorMessage
|
|
158
160
|
}
|
|
159
161
|
};
|
|
160
162
|
}
|
package/src/useDateSegment.ts
CHANGED
|
@@ -10,17 +10,17 @@
|
|
|
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';
|
|
18
19
|
import {useDateFormatter, useFilter, useLocale} from '@react-aria/i18n';
|
|
19
20
|
import {useDisplayNames} from './useDisplayNames';
|
|
20
|
-
import {usePress} from '@react-aria/interactions';
|
|
21
21
|
import {useSpinButton} from '@react-aria/spinbutton';
|
|
22
22
|
|
|
23
|
-
interface DateSegmentAria {
|
|
23
|
+
export interface DateSegmentAria {
|
|
24
24
|
/** Props for the segment element. */
|
|
25
25
|
segmentProps: HTMLAttributes<HTMLDivElement>
|
|
26
26
|
}
|
|
@@ -32,11 +32,11 @@ interface DateSegmentAria {
|
|
|
32
32
|
*/
|
|
33
33
|
export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: RefObject<HTMLElement>): DateSegmentAria {
|
|
34
34
|
let enteredKeys = useRef('');
|
|
35
|
-
let {locale
|
|
35
|
+
let {locale} = useLocale();
|
|
36
36
|
let displayNames = useDisplayNames();
|
|
37
37
|
let {ariaLabel, ariaLabelledBy, ariaDescribedBy, focusManager} = hookData.get(state);
|
|
38
38
|
|
|
39
|
-
let textValue = segment.text;
|
|
39
|
+
let textValue = segment.isPlaceholder ? '' : segment.text;
|
|
40
40
|
let options = useMemo(() => state.dateFormatter.resolvedOptions(), [state.dateFormatter]);
|
|
41
41
|
let monthDateFormatter = useDateFormatter({month: 'long', timeZone: options.timeZone});
|
|
42
42
|
let hourDateFormatter = useDateFormatter({
|
|
@@ -45,14 +45,17 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
|
|
|
45
45
|
timeZone: options.timeZone
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
if (segment.type === 'month') {
|
|
48
|
+
if (segment.type === 'month' && !segment.isPlaceholder) {
|
|
49
49
|
let monthTextValue = monthDateFormatter.format(state.dateValue);
|
|
50
50
|
textValue = monthTextValue !== textValue ? `${textValue} – ${monthTextValue}` : monthTextValue;
|
|
51
|
-
} else if (segment.type === 'hour') {
|
|
51
|
+
} else if (segment.type === 'hour' && !segment.isPlaceholder) {
|
|
52
52
|
textValue = hourDateFormatter.format(state.dateValue);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
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
|
|
56
59
|
value: segment.value,
|
|
57
60
|
textValue,
|
|
58
61
|
minValue: segment.minValue,
|
|
@@ -115,34 +118,6 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
|
|
|
115
118
|
}
|
|
116
119
|
|
|
117
120
|
switch (e.key) {
|
|
118
|
-
case 'ArrowLeft':
|
|
119
|
-
e.preventDefault();
|
|
120
|
-
e.stopPropagation();
|
|
121
|
-
if (direction === 'rtl') {
|
|
122
|
-
focusManager.focusNext({tabbable: true});
|
|
123
|
-
} else {
|
|
124
|
-
focusManager.focusPrevious({tabbable: true});
|
|
125
|
-
}
|
|
126
|
-
break;
|
|
127
|
-
case 'ArrowRight':
|
|
128
|
-
e.preventDefault();
|
|
129
|
-
e.stopPropagation();
|
|
130
|
-
if (direction === 'rtl') {
|
|
131
|
-
focusManager.focusPrevious({tabbable: true});
|
|
132
|
-
} else {
|
|
133
|
-
focusManager.focusNext({tabbable: true});
|
|
134
|
-
}
|
|
135
|
-
break;
|
|
136
|
-
case 'Enter':
|
|
137
|
-
e.preventDefault();
|
|
138
|
-
e.stopPropagation();
|
|
139
|
-
if (segment.isPlaceholder && !state.isReadOnly) {
|
|
140
|
-
state.confirmPlaceholder(segment.type);
|
|
141
|
-
}
|
|
142
|
-
focusManager.focusNext({tabbable: true});
|
|
143
|
-
break;
|
|
144
|
-
case 'Tab':
|
|
145
|
-
break;
|
|
146
121
|
case 'Backspace':
|
|
147
122
|
case 'Delete': {
|
|
148
123
|
// Safari on iOS does not fire beforeinput for the backspace key because the cursor is at the start.
|
|
@@ -169,6 +144,34 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
|
|
|
169
144
|
return amPmFormatter.formatToParts(date).find(part => part.type === 'dayPeriod').value;
|
|
170
145
|
}, [amPmFormatter]);
|
|
171
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
|
+
|
|
172
175
|
let onInput = (key: string) => {
|
|
173
176
|
if (state.isDisabled || state.isReadOnly) {
|
|
174
177
|
return;
|
|
@@ -185,8 +188,16 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
|
|
|
185
188
|
} else {
|
|
186
189
|
break;
|
|
187
190
|
}
|
|
188
|
-
focusManager.focusNext(
|
|
191
|
+
focusManager.focusNext();
|
|
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
|
+
}
|
|
189
199
|
break;
|
|
200
|
+
}
|
|
190
201
|
case 'day':
|
|
191
202
|
case 'hour':
|
|
192
203
|
case 'minute':
|
|
@@ -234,7 +245,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
|
|
|
234
245
|
if (Number(numberValue + '0') > segment.maxValue || newValue.length >= String(segment.maxValue).length) {
|
|
235
246
|
enteredKeys.current = '';
|
|
236
247
|
if (shouldSetValue) {
|
|
237
|
-
focusManager.focusNext(
|
|
248
|
+
focusManager.focusNext();
|
|
238
249
|
}
|
|
239
250
|
} else {
|
|
240
251
|
enteredKeys.current = newValue;
|
|
@@ -248,12 +259,9 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
|
|
|
248
259
|
enteredKeys.current = '';
|
|
249
260
|
scrollIntoView(getScrollParent(ref.current) as HTMLElement, ref.current);
|
|
250
261
|
|
|
251
|
-
//
|
|
252
|
-
// Since usePress disables text selection, this won't happen by default.
|
|
253
|
-
ref.current.style.webkitUserSelect = 'text';
|
|
262
|
+
// Collapse selection to start or Chrome won't fire input events.
|
|
254
263
|
let selection = window.getSelection();
|
|
255
264
|
selection.collapse(ref.current);
|
|
256
|
-
ref.current.style.webkitUserSelect = '';
|
|
257
265
|
};
|
|
258
266
|
|
|
259
267
|
let compositionRef = useRef('');
|
|
@@ -301,26 +309,18 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
|
|
|
301
309
|
}
|
|
302
310
|
});
|
|
303
311
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
onPress(e) {
|
|
314
|
-
if (e.pointerType !== 'mouse') {
|
|
315
|
-
e.target.focus();
|
|
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
|
+
}
|
|
316
321
|
}
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
// For Android: prevent selection on long press.
|
|
321
|
-
useEvent(ref, 'selectstart', e => {
|
|
322
|
-
e.preventDefault();
|
|
323
|
-
});
|
|
322
|
+
};
|
|
323
|
+
}, [ref, focusManager]);
|
|
324
324
|
|
|
325
325
|
// spinbuttons cannot be focused with VoiceOver on iOS.
|
|
326
326
|
let touchPropOverrides = isIOS() || segment.type === 'timeZoneName' ? {
|
|
@@ -360,13 +360,13 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
|
|
|
360
360
|
}
|
|
361
361
|
|
|
362
362
|
return {
|
|
363
|
-
segmentProps: mergeProps(spinButtonProps,
|
|
363
|
+
segmentProps: mergeProps(spinButtonProps, labelProps, {
|
|
364
364
|
id,
|
|
365
365
|
...touchPropOverrides,
|
|
366
366
|
'aria-invalid': state.validationState === 'invalid' ? 'true' : undefined,
|
|
367
367
|
'aria-describedby': ariaDescribedBy,
|
|
368
|
-
'aria-placeholder': segment.isPlaceholder ? segment.text : undefined,
|
|
369
368
|
'aria-readonly': state.isReadOnly || !segment.isEditable ? 'true' : undefined,
|
|
369
|
+
'data-placeholder': segment.isPlaceholder || undefined,
|
|
370
370
|
contentEditable: isEditable,
|
|
371
371
|
suppressContentEditableWarning: isEditable,
|
|
372
372
|
spellCheck: isEditable ? 'false' : undefined,
|
|
@@ -374,13 +374,33 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
|
|
|
374
374
|
autoCorrect: isEditable ? 'off' : undefined,
|
|
375
375
|
// Capitalization was changed in React 17...
|
|
376
376
|
[parseInt(React.version, 10) >= 17 ? 'enterKeyHint' : 'enterkeyhint']: isEditable ? 'next' : undefined,
|
|
377
|
-
inputMode: state.isDisabled || segment.type === 'dayPeriod' || !isEditable ? undefined : 'numeric',
|
|
377
|
+
inputMode: state.isDisabled || segment.type === 'dayPeriod' || segment.type === 'era' || !isEditable ? undefined : 'numeric',
|
|
378
378
|
tabIndex: state.isDisabled ? undefined : 0,
|
|
379
379
|
onKeyDown,
|
|
380
380
|
onFocus,
|
|
381
381
|
style: {
|
|
382
382
|
caretColor: 'transparent'
|
|
383
|
+
},
|
|
384
|
+
// Prevent pointer events from reaching useDatePickerGroup, and allow native browser behavior to focus the segment.
|
|
385
|
+
onPointerDown(e) {
|
|
386
|
+
e.stopPropagation();
|
|
387
|
+
},
|
|
388
|
+
onMouseDown(e) {
|
|
389
|
+
e.stopPropagation();
|
|
383
390
|
}
|
|
384
391
|
})
|
|
385
392
|
};
|
|
386
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
|
+
}
|