@react-aria/calendar 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.
@@ -11,22 +11,36 @@
11
11
  */
12
12
 
13
13
  import {announce} from '@react-aria/live-announcer';
14
- import {CalendarAria} from './types';
15
- import {calendarIds, useSelectedDateDescription, useVisibleRangeDescription} from './utils';
14
+ import {AriaButtonProps} from '@react-types/button';
16
15
  import {CalendarPropsBase} from '@react-types/calendar';
17
16
  import {CalendarState, RangeCalendarState} from '@react-stately/calendar';
18
17
  import {DOMProps} from '@react-types/shared';
18
+ import {filterDOMProps, mergeProps, useLabels, useSlotId, useUpdateEffect} from '@react-aria/utils';
19
+ import {hookData, useSelectedDateDescription, useVisibleRangeDescription} from './utils';
20
+ import {HTMLAttributes, useRef} from 'react';
19
21
  // @ts-ignore
20
22
  import intlMessages from '../intl/*.json';
21
- import {mergeProps, useDescription, useId, useUpdateEffect} from '@react-aria/utils';
22
23
  import {useMessageFormatter} from '@react-aria/i18n';
23
- import {useRef} from 'react';
24
+
25
+ export interface CalendarAria {
26
+ /** Props for the calendar grouping element. */
27
+ calendarProps: HTMLAttributes<HTMLElement>,
28
+ /** Props for the next button. */
29
+ nextButtonProps: AriaButtonProps,
30
+ /** Props for the previous button. */
31
+ prevButtonProps: AriaButtonProps,
32
+ /** Props for the error message element, if any. */
33
+ errorMessageProps: HTMLAttributes<HTMLElement>,
34
+ /** A description of the visible date range, for use in the calendar title. */
35
+ title: string
36
+ }
24
37
 
25
38
  export function useCalendarBase(props: CalendarPropsBase & DOMProps, state: CalendarState | RangeCalendarState): CalendarAria {
26
39
  let formatMessage = useMessageFormatter(intlMessages);
27
- let calendarId = useId(props.id);
40
+ let domProps = filterDOMProps(props);
28
41
 
29
- let visibleRangeDescription = useVisibleRangeDescription(state.visibleRange.start, state.visibleRange.end, state.timeZone);
42
+ let title = useVisibleRangeDescription(state.visibleRange.start, state.visibleRange.end, state.timeZone, false);
43
+ let visibleRangeDescription = useVisibleRangeDescription(state.visibleRange.start, state.visibleRange.end, state.timeZone, true);
30
44
 
31
45
  // Announce when the visible date range changes
32
46
  useUpdateEffect(() => {
@@ -45,10 +59,15 @@ export function useCalendarBase(props: CalendarPropsBase & DOMProps, state: Cale
45
59
  // handle an update to the caption that describes the currently selected range, to announce the new value
46
60
  }, [selectedDateDescription]);
47
61
 
48
- let descriptionProps = useDescription(visibleRangeDescription);
62
+ let errorMessageId = useSlotId([Boolean(props.errorMessage), props.validationState]);
49
63
 
50
- // Label the child grid elements by the group element if it is labelled.
51
- calendarIds.set(state, props['aria-label'] || props['aria-labelledby'] ? calendarId : null);
64
+ // Pass the label to the child grid elements.
65
+ hookData.set(state, {
66
+ ariaLabel: props['aria-label'],
67
+ ariaLabelledBy: props['aria-labelledby'],
68
+ errorMessageId,
69
+ selectedDateDescription
70
+ });
52
71
 
53
72
  // If the next or previous buttons become disabled while they are focused, move focus to the calendar body.
54
73
  let nextFocused = useRef(false);
@@ -65,12 +84,16 @@ export function useCalendarBase(props: CalendarPropsBase & DOMProps, state: Cale
65
84
  state.setFocused(true);
66
85
  }
67
86
 
87
+ let labelProps = useLabels({
88
+ id: props['id'],
89
+ 'aria-label': [props['aria-label'], visibleRangeDescription].filter(Boolean).join(', '),
90
+ 'aria-labelledby': props['aria-labelledby']
91
+ });
92
+
68
93
  return {
69
- calendarProps: mergeProps(descriptionProps, {
94
+ calendarProps: mergeProps(domProps, labelProps, {
70
95
  role: 'group',
71
- id: calendarId,
72
- 'aria-label': props['aria-label'],
73
- 'aria-labelledby': props['aria-labelledby']
96
+ 'aria-describedby': props['aria-describedby'] || undefined
74
97
  }),
75
98
  nextButtonProps: {
76
99
  onPress: () => state.focusNextPage(),
@@ -86,6 +109,9 @@ export function useCalendarBase(props: CalendarPropsBase & DOMProps, state: Cale
86
109
  onFocus: () => previousFocused.current = true,
87
110
  onBlur: () => previousFocused.current = false
88
111
  },
89
- title: visibleRangeDescription
112
+ errorMessageProps: {
113
+ id: errorMessageId
114
+ },
115
+ title
90
116
  };
91
117
  }
@@ -12,13 +12,14 @@
12
12
 
13
13
  import {CalendarDate, isEqualDay, isSameDay, isToday} from '@internationalized/date';
14
14
  import {CalendarState, RangeCalendarState} from '@react-stately/calendar';
15
- import {focusWithoutScrolling} from '@react-aria/utils';
15
+ import {focusWithoutScrolling, getScrollParent, scrollIntoView, useDescription} from '@react-aria/utils';
16
+ import {getEraFormat, hookData} from './utils';
17
+ import {getInteractionModality, usePress} from '@react-aria/interactions';
16
18
  import {HTMLAttributes, RefObject, useEffect, useMemo, useRef} from 'react';
17
19
  // @ts-ignore
18
20
  import intlMessages from '../intl/*.json';
19
21
  import {mergeProps} from '@react-aria/utils';
20
22
  import {useDateFormatter, useMessageFormatter} from '@react-aria/i18n';
21
- import {usePress} from '@react-aria/interactions';
22
23
 
23
24
  export interface AriaCalendarCellProps {
24
25
  /** The date that this cell represents. */
@@ -30,7 +31,7 @@ export interface AriaCalendarCellProps {
30
31
  isDisabled?: boolean
31
32
  }
32
33
 
33
- interface CalendarCellAria {
34
+ export interface CalendarCellAria {
34
35
  /** Props for the grid cell element (e.g. `<td>`). */
35
36
  cellProps: HTMLAttributes<HTMLElement>,
36
37
  /** Props for the button element within the cell. */
@@ -61,6 +62,8 @@ interface CalendarCellAria {
61
62
  * For example, dates before the first day of a month in the same week.
62
63
  */
63
64
  isOutsideVisibleRange: boolean,
65
+ /** Whether the cell is part of an invalid selection. */
66
+ isInvalid: boolean,
64
67
  /** The day number formatted according to the current locale. */
65
68
  formattedDate: string
66
69
  }
@@ -71,13 +74,14 @@ interface CalendarCellAria {
71
74
  */
72
75
  export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarState | RangeCalendarState, ref: RefObject<HTMLElement>): CalendarCellAria {
73
76
  let {date, isDisabled} = props;
77
+ let {errorMessageId, selectedDateDescription} = hookData.get(state);
74
78
  let formatMessage = useMessageFormatter(intlMessages);
75
79
  let dateFormatter = useDateFormatter({
76
80
  weekday: 'long',
77
81
  day: 'numeric',
78
82
  month: 'long',
79
83
  year: 'numeric',
80
- era: date.calendar.identifier !== 'gregory' ? 'long' : undefined,
84
+ era: getEraFormat(date),
81
85
  timeZone: state.timeZone
82
86
  });
83
87
  let isSelected = state.isSelected(date);
@@ -85,6 +89,15 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
85
89
  isDisabled = isDisabled || state.isCellDisabled(date);
86
90
  let isUnavailable = state.isCellUnavailable(date);
87
91
  let isSelectable = !isDisabled && !isUnavailable;
92
+ let isInvalid = state.validationState === 'invalid' && (
93
+ 'highlightedRange' in state
94
+ ? !state.anchorDate && state.highlightedRange && date.compare(state.highlightedRange.start) >= 0 && date.compare(state.highlightedRange.end) <= 0
95
+ : state.value && isSameDay(state.value, date)
96
+ );
97
+
98
+ if (isInvalid) {
99
+ isSelected = true;
100
+ }
88
101
 
89
102
  // For performance, reuse the same date object as before if the new date prop is the same.
90
103
  // This allows subsequent useMemo results to be reused.
@@ -100,26 +113,45 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
100
113
  // aria-label should be localize Day of week, Month, Day and Year without Time.
101
114
  let isDateToday = isToday(date, state.timeZone);
102
115
  let label = useMemo(() => {
116
+ let label = '';
117
+
118
+ // If this is a range calendar, add a description of the full selected range
119
+ // to the first and last selected date.
120
+ if (
121
+ 'highlightedRange' in state &&
122
+ state.value &&
123
+ !state.anchorDate &&
124
+ (isSameDay(date, state.value.start) || isSameDay(date, state.value.end))
125
+ ) {
126
+ label = selectedDateDescription + ', ';
127
+ }
128
+
129
+ label += dateFormatter.format(nativeDate);
103
130
  if (isDateToday) {
104
131
  // If date is today, set appropriate string depending on selected state:
105
- return formatMessage(isSelected ? 'todayDateSelected' : 'todayDate', {
106
- date: nativeDate
132
+ label = formatMessage(isSelected ? 'todayDateSelected' : 'todayDate', {
133
+ date: label
107
134
  });
108
135
  } else if (isSelected) {
109
136
  // If date is selected but not today:
110
- return formatMessage('dateSelected', {
111
- date: nativeDate
137
+ label = formatMessage('dateSelected', {
138
+ date: label
112
139
  });
113
140
  }
114
141
 
115
- return dateFormatter.format(nativeDate);
116
- }, [dateFormatter, nativeDate, formatMessage, isSelected, isDateToday]);
142
+ if (state.minValue && isSameDay(date, state.minValue)) {
143
+ label += ', ' + formatMessage('minimumDate');
144
+ } else if (state.maxValue && isSameDay(date, state.maxValue)) {
145
+ label += ', ' + formatMessage('maximumDate');
146
+ }
147
+
148
+ return label;
149
+ }, [dateFormatter, nativeDate, formatMessage, isSelected, isDateToday, date, state, selectedDateDescription]);
117
150
 
118
151
  // When a cell is focused and this is a range calendar, add a prompt to help
119
152
  // screenreader users know that they are in a range selection mode.
153
+ let rangeSelectionPrompt = '';
120
154
  if ('anchorDate' in state && isFocused && !state.isReadOnly && isSelectable) {
121
- let rangeSelectionPrompt = '';
122
-
123
155
  // If selection has started add "click to finish selecting range"
124
156
  if (state.anchorDate) {
125
157
  rangeSelectionPrompt = formatMessage('finishRangeSelectionPrompt');
@@ -127,13 +159,10 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
127
159
  } else {
128
160
  rangeSelectionPrompt = formatMessage('startRangeSelectionPrompt');
129
161
  }
130
-
131
- // Append to aria-label
132
- if (rangeSelectionPrompt) {
133
- label = `${label} (${rangeSelectionPrompt})`;
134
- }
135
162
  }
136
163
 
164
+ let descriptionProps = useDescription(rangeSelectionPrompt);
165
+
137
166
  let isAnchorPressed = useRef(false);
138
167
  let isRangeBoundaryPressed = useRef(false);
139
168
  let touchDragTimerRef = useRef(null);
@@ -152,7 +181,9 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
152
181
  if ('highlightedRange' in state && !state.anchorDate && (e.pointerType === 'mouse' || e.pointerType === 'touch')) {
153
182
  // Allow dragging the start or end date of a range to modify it
154
183
  // rather than starting a new selection.
155
- if (state.highlightedRange) {
184
+ // Don't allow dragging when invalid, or weird jumping behavior may occur as date ranges
185
+ // are constrained to available dates. The user will need to select a new range in this case.
186
+ if (state.highlightedRange && !isInvalid) {
156
187
  if (isSameDay(date, state.highlightedRange.start)) {
157
188
  state.setAnchorDate(state.highlightedRange.end);
158
189
  state.setFocusedDate(date);
@@ -253,6 +284,14 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
253
284
  useEffect(() => {
254
285
  if (isFocused && ref.current) {
255
286
  focusWithoutScrolling(ref.current);
287
+
288
+ // Scroll into view if navigating with a keyboard, otherwise
289
+ // try not to shift the view under the user's mouse/finger.
290
+ // Only scroll the direct scroll parent, not the whole page, so
291
+ // we don't scroll to the bottom when opening date picker popover.
292
+ if (getInteractionModality() !== 'pointer') {
293
+ scrollIntoView(getScrollParent(ref.current) as HTMLElement, ref.current);
294
+ }
256
295
  }
257
296
  }, [isFocused, ref]);
258
297
 
@@ -268,7 +307,8 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
268
307
  cellProps: {
269
308
  role: 'gridcell',
270
309
  'aria-disabled': !isSelectable || null,
271
- 'aria-selected': isSelectable ? isSelected : null
310
+ 'aria-selected': isSelected || null,
311
+ 'aria-invalid': isInvalid || null
272
312
  },
273
313
  buttonProps: mergeProps(pressProps, {
274
314
  onFocus() {
@@ -280,6 +320,11 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
280
320
  role: 'button',
281
321
  'aria-disabled': !isSelectable || null,
282
322
  'aria-label': label,
323
+ 'aria-invalid': isInvalid || null,
324
+ 'aria-describedby': [
325
+ isInvalid ? errorMessageId : null,
326
+ descriptionProps['aria-describedby']
327
+ ].filter(Boolean).join(' ') || undefined,
283
328
  onPointerEnter(e) {
284
329
  // Highlight the date on hover or drag over a date when selecting a range.
285
330
  if ('highlightDate' in state && (e.pointerType !== 'touch' || state.isDragging) && isSelectable) {
@@ -305,6 +350,7 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
305
350
  isDisabled,
306
351
  isUnavailable,
307
352
  isOutsideVisibleRange: date.compare(state.visibleRange.start) < 0 || date.compare(state.visibleRange.end) > 0,
353
+ isInvalid,
308
354
  formattedDate
309
355
  };
310
356
  }
@@ -10,15 +10,14 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import {CalendarDate, startOfWeek} from '@internationalized/date';
14
- import {CalendarGridAria} from './types';
15
- import {calendarIds, useSelectedDateDescription, useVisibleRangeDescription} from './utils';
13
+ import {CalendarDate, startOfWeek, today} from '@internationalized/date';
16
14
  import {CalendarState, RangeCalendarState} from '@react-stately/calendar';
17
- import {KeyboardEvent} from 'react';
18
- import {mergeProps, useDescription, useLabels} from '@react-aria/utils';
15
+ import {hookData, useVisibleRangeDescription} from './utils';
16
+ import {HTMLAttributes, KeyboardEvent, useMemo} from 'react';
17
+ import {mergeProps, useLabels} from '@react-aria/utils';
19
18
  import {useDateFormatter, useLocale} from '@react-aria/i18n';
20
19
 
21
- interface CalendarGridProps {
20
+ export interface AriaCalendarGridProps {
22
21
  /**
23
22
  * The first date displayed in the calendar grid.
24
23
  * Defaults to the first visible date in the calendar.
@@ -33,12 +32,21 @@ interface CalendarGridProps {
33
32
  endDate?: CalendarDate
34
33
  }
35
34
 
35
+ export interface CalendarGridAria {
36
+ /** Props for the date grid element (e.g. `<table>`). */
37
+ gridProps: HTMLAttributes<HTMLElement>,
38
+ /** Props for the grid header element (e.g. `<thead>`). */
39
+ headerProps: HTMLAttributes<HTMLElement>,
40
+ /** A list of week day abbreviations formatted for the current locale, typically used in column headers. */
41
+ weekDays: string[]
42
+ }
43
+
36
44
  /**
37
45
  * Provides the behavior and accessibility implementation for a calendar grid component.
38
46
  * A calendar grid displays a single grid of days within a calendar or range calendar which
39
47
  * can be keyboard navigated and selected by the user.
40
48
  */
41
- export function useCalendarGrid(props: CalendarGridProps, state: CalendarState | RangeCalendarState): CalendarGridAria {
49
+ export function useCalendarGrid(props: AriaCalendarGridProps, state: CalendarState | RangeCalendarState): CalendarGridAria {
42
50
  let {
43
51
  startDate = state.visibleRange.start,
44
52
  endDate = state.visibleRange.end
@@ -55,30 +63,27 @@ export function useCalendarGrid(props: CalendarGridProps, state: CalendarState |
55
63
  break;
56
64
  case 'PageUp':
57
65
  e.preventDefault();
58
- if (e.shiftKey) {
59
- state.focusPreviousSection();
60
- } else {
61
- state.focusPreviousPage();
62
- }
66
+ e.stopPropagation();
67
+ state.focusPreviousSection(e.shiftKey);
63
68
  break;
64
69
  case 'PageDown':
65
70
  e.preventDefault();
66
- if (e.shiftKey) {
67
- state.focusNextSection();
68
- } else {
69
- state.focusNextPage();
70
- }
71
+ e.stopPropagation();
72
+ state.focusNextSection(e.shiftKey);
71
73
  break;
72
74
  case 'End':
73
75
  e.preventDefault();
74
- state.focusPageEnd();
76
+ e.stopPropagation();
77
+ state.focusSectionEnd();
75
78
  break;
76
79
  case 'Home':
77
80
  e.preventDefault();
78
- state.focusPageStart();
81
+ e.stopPropagation();
82
+ state.focusSectionStart();
79
83
  break;
80
84
  case 'ArrowLeft':
81
85
  e.preventDefault();
86
+ e.stopPropagation();
82
87
  if (direction === 'rtl') {
83
88
  state.focusNextDay();
84
89
  } else {
@@ -87,10 +92,12 @@ export function useCalendarGrid(props: CalendarGridProps, state: CalendarState |
87
92
  break;
88
93
  case 'ArrowUp':
89
94
  e.preventDefault();
95
+ e.stopPropagation();
90
96
  state.focusPreviousRow();
91
97
  break;
92
98
  case 'ArrowRight':
93
99
  e.preventDefault();
100
+ e.stopPropagation();
94
101
  if (direction === 'rtl') {
95
102
  state.focusPreviousDay();
96
103
  } else {
@@ -99,6 +106,7 @@ export function useCalendarGrid(props: CalendarGridProps, state: CalendarState |
99
106
  break;
100
107
  case 'ArrowDown':
101
108
  e.preventDefault();
109
+ e.stopPropagation();
102
110
  state.focusNextRow();
103
111
  break;
104
112
  case 'Escape':
@@ -111,32 +119,27 @@ export function useCalendarGrid(props: CalendarGridProps, state: CalendarState |
111
119
  }
112
120
  };
113
121
 
114
- let selectedDateDescription = useSelectedDateDescription(state);
115
- let descriptionProps = useDescription(selectedDateDescription);
116
- let visibleRangeDescription = useVisibleRangeDescription(startDate, endDate, state.timeZone);
122
+ let visibleRangeDescription = useVisibleRangeDescription(startDate, endDate, state.timeZone, true);
117
123
 
124
+ let {ariaLabel, ariaLabelledBy} = hookData.get(state);
118
125
  let labelProps = useLabels({
119
- 'aria-label': visibleRangeDescription,
120
- 'aria-labelledby': calendarIds.get(state)
126
+ 'aria-label': [ariaLabel, visibleRangeDescription].filter(Boolean).join(', '),
127
+ 'aria-labelledby': ariaLabelledBy
121
128
  });
122
129
 
123
130
  let dayFormatter = useDateFormatter({weekday: 'narrow', timeZone: state.timeZone});
124
- let dayFormatterLong = useDateFormatter({weekday: 'long', timeZone: state.timeZone});
125
131
  let {locale} = useLocale();
126
- let weekStart = startOfWeek(state.visibleRange.start, locale);
127
- let weekDays = [...new Array(7).keys()].map((index) => {
128
- let date = weekStart.add({days: index});
129
- let dateDay = date.toDate(state.timeZone);
130
- let narrow = dayFormatter.format(dateDay);
131
- let long = dayFormatterLong.format(dateDay);
132
- return {
133
- narrow,
134
- long
135
- };
136
- });
132
+ let weekDays = useMemo(() => {
133
+ let weekStart = startOfWeek(today(state.timeZone), locale);
134
+ return [...new Array(7).keys()].map((index) => {
135
+ let date = weekStart.add({days: index});
136
+ let dateDay = date.toDate(state.timeZone);
137
+ return dayFormatter.format(dateDay);
138
+ });
139
+ }, [locale, state.timeZone, dayFormatter]);
137
140
 
138
141
  return {
139
- gridProps: mergeProps(descriptionProps, labelProps, {
142
+ gridProps: mergeProps(labelProps, {
140
143
  role: 'grid',
141
144
  'aria-readonly': state.isReadOnly || null,
142
145
  'aria-disabled': state.isDisabled || null,
@@ -145,6 +148,11 @@ export function useCalendarGrid(props: CalendarGridProps, state: CalendarState |
145
148
  onFocus: () => state.setFocused(true),
146
149
  onBlur: () => state.setFocused(false)
147
150
  }),
151
+ headerProps: {
152
+ // Column headers are hidden to screen readers to make navigating with a touch screen reader easier.
153
+ // The day names are already included in the label of each cell, so there's no need to announce them twice.
154
+ 'aria-hidden': true
155
+ },
148
156
  weekDays
149
157
  };
150
158
  }
@@ -10,12 +10,11 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import {CalendarAria} from './types';
13
+ import {CalendarAria, useCalendarBase} from './useCalendarBase';
14
14
  import {DateValue, RangeCalendarProps} from '@react-types/calendar';
15
15
  import {RangeCalendarState} from '@react-stately/calendar';
16
16
  import {RefObject, useRef} from 'react';
17
- import {useCalendarBase} from './useCalendarBase';
18
- import {useEvent, useId} from '@react-aria/utils';
17
+ import {useEvent} from '@react-aria/utils';
19
18
 
20
19
  /**
21
20
  * Provides the behavior and accessibility implementation for a range calendar component.
@@ -23,8 +22,6 @@ import {useEvent, useId} from '@react-aria/utils';
23
22
  */
24
23
  export function useRangeCalendar<T extends DateValue>(props: RangeCalendarProps<T>, state: RangeCalendarState, ref: RefObject<HTMLElement>): CalendarAria {
25
24
  let res = useCalendarBase(props, state);
26
- res.nextButtonProps.id = useId();
27
- res.prevButtonProps.id = useId();
28
25
 
29
26
  // We need to ignore virtual pointer events from VoiceOver due to these bugs.
30
27
  // https://bugs.webkit.org/show_bug.cgi?id=222627
@@ -33,7 +30,8 @@ export function useRangeCalendar<T extends DateValue>(props: RangeCalendarProps<
33
30
  // We need to match that here otherwise this will fire before the press event in
34
31
  // useCalendarCell, causing range selection to not work properly.
35
32
  let isVirtualClick = useRef(false);
36
- useEvent(useRef(window), 'pointerdown', e => {
33
+ let windowRef = useRef(typeof window !== 'undefined' ? window : null);
34
+ useEvent(windowRef, 'pointerdown', e => {
37
35
  isVirtualClick.current = e.width === 0 && e.height === 0;
38
36
  });
39
37
 
@@ -53,16 +51,23 @@ export function useRangeCalendar<T extends DateValue>(props: RangeCalendarProps<
53
51
  let target = e.target as HTMLElement;
54
52
  let body = document.getElementById(res.calendarProps.id);
55
53
  if (
56
- (!body.contains(target) || !target.closest('[role="button"]')) &&
57
- !document.getElementById(res.nextButtonProps.id)?.contains(target) &&
58
- !document.getElementById(res.prevButtonProps.id)?.contains(target)
54
+ body &&
55
+ body.contains(document.activeElement) &&
56
+ (!body.contains(target) || !target.closest('button, [role="button"]'))
59
57
  ) {
60
58
  state.selectFocusedDate();
61
59
  }
62
60
  };
63
61
 
64
- useEvent(useRef(window), 'pointerup', endDragging);
65
- useEvent(useRef(window), 'pointercancel', endDragging);
62
+ useEvent(windowRef, 'pointerup', endDragging);
63
+ useEvent(windowRef, 'pointercancel', endDragging);
64
+
65
+ // Also stop range selection on blur, e.g. tabbing away from the calendar.
66
+ res.calendarProps.onBlur = e => {
67
+ if ((!e.relatedTarget || !ref.current.contains(e.relatedTarget)) && state.anchorDate) {
68
+ state.selectFocusedDate();
69
+ }
70
+ };
66
71
 
67
72
  // Prevent touch scrolling while dragging
68
73
  useEvent(ref, 'touchmove', e => {
package/src/utils.ts CHANGED
@@ -10,14 +10,25 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import {CalendarDate, endOfMonth, isSameDay, startOfMonth} from '@internationalized/date';
13
+ import {CalendarDate, DateFormatter, endOfMonth, isSameDay, startOfMonth} from '@internationalized/date';
14
14
  import {CalendarState, RangeCalendarState} from '@react-stately/calendar';
15
+ import {FormatMessage, useDateFormatter, useMessageFormatter} from '@react-aria/i18n';
15
16
  // @ts-ignore
16
17
  import intlMessages from '../intl/*.json';
17
- import {useDateFormatter, useMessageFormatter} from '@react-aria/i18n';
18
18
  import {useMemo} from 'react';
19
19
 
20
- export const calendarIds = new WeakMap<CalendarState | RangeCalendarState, string>();
20
+ interface HookData {
21
+ ariaLabel: string,
22
+ ariaLabelledBy: string,
23
+ errorMessageId: string,
24
+ selectedDateDescription: string
25
+ }
26
+
27
+ export const hookData = new WeakMap<CalendarState | RangeCalendarState, HookData>();
28
+
29
+ export function getEraFormat(date: CalendarDate): 'short' | undefined {
30
+ return date?.calendar.identifier === 'gregory' && date.era === 'BC' ? 'short' : undefined;
31
+ }
21
32
 
22
33
  export function useSelectedDateDescription(state: CalendarState | RangeCalendarState) {
23
34
  let formatMessage = useMessageFormatter(intlMessages);
@@ -29,6 +40,15 @@ export function useSelectedDateDescription(state: CalendarState | RangeCalendarS
29
40
  start = end = state.value;
30
41
  }
31
42
 
43
+ let dateFormatter = useDateFormatter({
44
+ weekday: 'long',
45
+ month: 'long',
46
+ year: 'numeric',
47
+ day: 'numeric',
48
+ era: getEraFormat(start) || getEraFormat(end),
49
+ timeZone: state.timeZone
50
+ });
51
+
32
52
  let anchorDate = 'anchorDate' in state ? state.anchorDate : null;
33
53
  return useMemo(() => {
34
54
  // No message if currently selecting a range, or there is nothing highlighted.
@@ -36,26 +56,34 @@ export function useSelectedDateDescription(state: CalendarState | RangeCalendarS
36
56
  // Use a single date message if the start and end dates are the same day,
37
57
  // otherwise include both dates.
38
58
  if (isSameDay(start, end)) {
39
- return formatMessage('selectedDateDescription', {date: start.toDate(state.timeZone)});
59
+ let date = dateFormatter.format(start.toDate(state.timeZone));
60
+ return formatMessage('selectedDateDescription', {date});
40
61
  } else {
41
- return formatMessage('selectedRangeDescription', {start: start.toDate(state.timeZone), end: end.toDate(state.timeZone)});
62
+ let dateRange = formatRange(dateFormatter, formatMessage, start, end, state.timeZone);
63
+
64
+ return formatMessage('selectedRangeDescription', {dateRange});
42
65
  }
43
66
  }
44
67
  return '';
45
- }, [start, end, anchorDate, state.timeZone, formatMessage]);
68
+ }, [start, end, anchorDate, state.timeZone, formatMessage, dateFormatter]);
46
69
  }
47
70
 
48
- export function useVisibleRangeDescription(startDate: CalendarDate, endDate: CalendarDate, timeZone: string) {
71
+ export function useVisibleRangeDescription(startDate: CalendarDate, endDate: CalendarDate, timeZone: string, isAria: boolean) {
72
+ let formatMessage = useMessageFormatter(intlMessages);
73
+ let era: any = getEraFormat(startDate) || getEraFormat(endDate);
49
74
  let monthFormatter = useDateFormatter({
50
75
  month: 'long',
51
76
  year: 'numeric',
52
- era: startDate.calendar.identifier !== 'gregory' ? 'long' : undefined,
77
+ era,
53
78
  calendar: startDate.calendar.identifier,
54
79
  timeZone
55
80
  });
56
81
 
57
82
  let dateFormatter = useDateFormatter({
58
- dateStyle: 'long',
83
+ month: 'long',
84
+ year: 'numeric',
85
+ day: 'numeric',
86
+ era,
59
87
  calendar: startDate.calendar.identifier,
60
88
  timeZone
61
89
  });
@@ -67,10 +95,43 @@ export function useVisibleRangeDescription(startDate: CalendarDate, endDate: Cal
67
95
  if (isSameDay(endDate, endOfMonth(startDate))) {
68
96
  return monthFormatter.format(startDate.toDate(timeZone));
69
97
  } else if (isSameDay(endDate, endOfMonth(endDate))) {
70
- return monthFormatter.formatRange(startDate.toDate(timeZone), endDate.toDate(timeZone));
98
+ return isAria
99
+ ? formatRange(monthFormatter, formatMessage, startDate, endDate, timeZone)
100
+ : monthFormatter.formatRange(startDate.toDate(timeZone), endDate.toDate(timeZone));
71
101
  }
72
102
  }
73
103
 
74
- return dateFormatter.formatRange(startDate.toDate(timeZone), endDate.toDate(timeZone));
75
- }, [startDate, endDate, monthFormatter, dateFormatter, timeZone]);
104
+ return isAria
105
+ ? formatRange(dateFormatter, formatMessage, startDate, endDate, timeZone)
106
+ : dateFormatter.formatRange(startDate.toDate(timeZone), endDate.toDate(timeZone));
107
+ }, [startDate, endDate, monthFormatter, dateFormatter, formatMessage, timeZone, isAria]);
108
+ }
109
+
110
+ function formatRange(dateFormatter: DateFormatter, formatMessage: FormatMessage, start: CalendarDate, end: CalendarDate, timeZone: string) {
111
+ let parts = dateFormatter.formatRangeToParts(start.toDate(timeZone), end.toDate(timeZone));
112
+
113
+ // Find the separator between the start and end date. This is determined
114
+ // by finding the last shared literal before the end range.
115
+ let separatorIndex = -1;
116
+ for (let i = 0; i < parts.length; i++) {
117
+ let part = parts[i];
118
+ if (part.source === 'shared' && part.type === 'literal') {
119
+ separatorIndex = i;
120
+ } else if (part.source === 'endRange') {
121
+ break;
122
+ }
123
+ }
124
+
125
+ // Now we can combine the parts into start and end strings.
126
+ let startValue = '';
127
+ let endValue = '';
128
+ for (let i = 0; i < parts.length; i++) {
129
+ if (i < separatorIndex) {
130
+ startValue += parts[i].value;
131
+ } else if (i > separatorIndex) {
132
+ endValue += parts[i].value;
133
+ }
134
+ }
135
+
136
+ return formatMessage('dateRange', {startDate: startValue, endDate: endValue});
76
137
  }