Skip to content
24 changes: 13 additions & 11 deletions packages/@react-aria/calendar/src/useCalendarBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {AriaLabelingProps, DOMAttributes} from '@react-types/shared';
import {CalendarPropsBase} from '@react-types/calendar';
import {CalendarState, RangeCalendarState} from '@react-stately/calendar';
import {DOMProps} from '@react-types/shared';
import {filterDOMProps, mergeProps, useLabels, useSlotId, useUpdateEffect} from '@react-aria/utils';
import {filterDOMProps, mergeProps, useLabels, useLayoutEffect, useSlotId, useUpdateEffect} from '@react-aria/utils';
import {hookData, useSelectedDateDescription, useVisibleRangeDescription} from './utils';
// @ts-ignore
import intlMessages from '../intl/*.json';
Expand Down Expand Up @@ -70,20 +70,22 @@ export function useCalendarBase(props: CalendarPropsBase & DOMProps & AriaLabeli
selectedDateDescription
});

// If the next or previous buttons become disabled while they are focused, move focus to the calendar body.
let nextFocused = useRef(false);
let nextDisabled = props.isDisabled || state.isNextVisibleRangeInvalid();
if (nextDisabled && nextFocused.current) {
nextFocused.current = false;
state.setFocused(true);
}

let previousFocused = useRef(false);
let previousDisabled = props.isDisabled || state.isPreviousVisibleRangeInvalid();
if (previousDisabled && previousFocused.current) {
previousFocused.current = false;
state.setFocused(true);
}
// If the next or previous buttons become disabled while they are focused, move focus to the calendar body.
useLayoutEffect(() => {
if (nextDisabled && nextFocused.current) {
nextFocused.current = false;
state.setFocused(true);
}

if (previousDisabled && previousFocused.current) {
previousFocused.current = false;
state.setFocused(true);
}
}, [nextDisabled, previousDisabled, state]);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this too late? If a browser were to fire onBlur when a button becomes disabled, that would happen before this layout effect, and thus nextFocused.current would already be false and we would never call setFocused.

I think the point of this was to avoid focus being lost when the button becomes disabled. The safest thing might be to change nextFocused into useState instead of useRef. But really, is what we currently have any less safe than something like this?

if (nextDisabled && document.activeElement === nextButtonRef.current) {
  // ...
}

Not suggesting we do that, just wondering if what we have is really bad or not.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, you can see this in chrome. Go here, go forward a month, and then tab to the previous button and press Enter. Focus gets lost rather than going back to the calendar, because the onBlur event runs first. Doesn't happen in JSDom or in other browsers.

I fixed this in #4564 by using state instead of a ref.


let labelProps = useLabels({
id: props['id'],
Expand Down
7 changes: 6 additions & 1 deletion packages/@react-aria/calendar/src/useCalendarCell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
import {CalendarDate, isEqualDay, isSameDay, isToday} from '@internationalized/date';
import {CalendarState, RangeCalendarState} from '@react-stately/calendar';
import {DOMAttributes} from '@react-types/shared';
import {focusWithoutScrolling, getScrollParent, scrollIntoViewport, useDescription} from '@react-aria/utils';
import {
focusWithoutScrolling,
getScrollParent,
scrollIntoViewport,
useDescription
} from '@react-aria/utils';
import {getEraFormat, hookData} from './utils';
import {getInteractionModality, usePress} from '@react-aria/interactions';
// @ts-ignore
Expand Down
1 change: 1 addition & 0 deletions packages/@react-aria/calendar/src/useCalendarGrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export function useCalendarGrid(props: AriaCalendarGridProps, state: CalendarSta
let visibleRangeDescription = useVisibleRangeDescription(startDate, endDate, state.timeZone, true);

let {ariaLabel, ariaLabelledBy} = hookData.get(state);

let labelProps = useLabels({
'aria-label': [ariaLabel, visibleRangeDescription].filter(Boolean).join(', '),
'aria-labelledby': ariaLabelledBy
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/focus/src/useFocusRing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function useFocusRing(props: AriaFocusRingProps = {}): FocusRingAria {

return {
isFocused,
isFocusVisible: state.current.isFocused && isFocusVisibleState,
isFocusVisible: isFocusVisibleState,
focusProps: within ? focusWithinProps : focusProps
};
}
16 changes: 9 additions & 7 deletions packages/@react-aria/i18n/src/useDateFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import {DateFormatter} from '@internationalized/date';
import {useLocale} from './context';
import {useMemo, useRef} from 'react';
import {useMemo, useState} from 'react';

export interface DateFormatterOptions extends Intl.DateTimeFormatOptions {
calendar?: string
Expand All @@ -24,16 +24,18 @@ export interface DateFormatterOptions extends Intl.DateTimeFormatOptions {
* @param options - Formatting options.
*/
export function useDateFormatter(options?: DateFormatterOptions): DateFormatter {
let {locale} = useLocale();

// Reuse last options object if it is shallowly equal, which allows the useMemo result to also be reused.
let lastOptions = useRef(null);
if (options && lastOptions.current && isEqual(options, lastOptions.current)) {
options = lastOptions.current;
let [stabilizedOptions, setStabilizedOptions] = useState(options);
if (!stabilizedOptions || (options && !isEqual(stabilizedOptions, options))) {
// early render bail because setState in render
setStabilizedOptions(options);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A ref is actually safe here because the values are equivalent, this is just an optimization. So even if a render were to be thrown away, the behavior should be the same. Updated in #4564

}

lastOptions.current = options;
let formattedDate = useMemo(() => new DateFormatter(locale, stabilizedOptions), [locale, stabilizedOptions]);

let {locale} = useLocale();
return useMemo(() => new DateFormatter(locale, options), [locale, options]);
return formattedDate;
}

function isEqual(a: DateFormatterOptions, b: DateFormatterOptions) {
Expand Down
28 changes: 21 additions & 7 deletions packages/@react-aria/interactions/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,20 +64,25 @@ export class SyntheticFocusEvent<Target = Element> implements ReactFocusEvent<Ta
export function useSyntheticBlurEvent<Target = Element>(onBlur: (e: ReactFocusEvent<Target>) => void) {
let stateRef = useRef({
isFocused: false,
onBlur,
observer: null as MutationObserver
observer: null as MutationObserver,
target: null as Element,
listener: null as (e: FocusEvent) => void
});
stateRef.current.onBlur = onBlur;

// Clean up MutationObserver on unmount. See below.
// eslint-disable-next-line arrow-body-style
useLayoutEffect(() => {
const state = stateRef.current;
return () => {
if (state.observer) {
state.observer.disconnect();
state.observer = null;
}
if (state.target && state.listener) {
state.target.removeEventListener('focusout', state.listener);
}
state.target = null;
state.listener = null;
state.isFocused = false;
};
}, []);

Expand All @@ -101,7 +106,7 @@ export function useSyntheticBlurEvent<Target = Element>(onBlur: (e: ReactFocusEv

if (target.disabled) {
// For backward compatibility, dispatch a (fake) React synthetic event.
stateRef.current.onBlur?.(new SyntheticFocusEvent('blur', e));
onBlur?.(new SyntheticFocusEvent('blur', e));
}

// We no longer need the MutationObserver once the target is blurred.
Expand All @@ -111,10 +116,19 @@ export function useSyntheticBlurEvent<Target = Element>(onBlur: (e: ReactFocusEv
}
};

if (stateRef.current.target && stateRef.current.listener) {
stateRef.current.target.removeEventListener('focusout', stateRef.current.listener);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do some parts of this function use target directly and others use stateRef.current.target? Why does the target need to be in the ref at all?

}
target.addEventListener('focusout', onBlurHandler, {once: true});
stateRef.current.target = target;
stateRef.current.listener = onBlurHandler;

if (stateRef.current.observer) {
stateRef.current.observer.disconnect();
stateRef.current.observer = null;
}
stateRef.current.observer = new MutationObserver(() => {
if (stateRef.current.isFocused && target.disabled) {
if (stateRef.current.isFocused && target.disabled && document.contains(target)) {
stateRef.current.observer.disconnect();
target.dispatchEvent(new FocusEvent('blur'));
target.dispatchEvent(new FocusEvent('focusout', {bubbles: true}));
Expand All @@ -123,5 +137,5 @@ export function useSyntheticBlurEvent<Target = Element>(onBlur: (e: ReactFocusEv

stateRef.current.observer.observe(target, {attributes: true, attributeFilter: ['disabled']});
}
}, []);
}, [onBlur]);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There were a lot of changes in this file that I wasn't sure where they came from. In #4564, the only change I needed was to make onBlur use useEffectEvent. Where did the other stuff come from?

}
6 changes: 6 additions & 0 deletions packages/@react-aria/utils/src/useUpdateEffect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,10 @@ export function useUpdateEffect(effect: EffectCallback, dependencies: any[]) {
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);

// For strictmode, the above isn't sufficient for detecting 'initial mount', so we
// need to cleanup on unmount.
useEffect(() => () => {
isInitialMount.current = true;
}, []);
}
16 changes: 11 additions & 5 deletions packages/@react-spectrum/calendar/test/Calendar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* governing permissions and limitations under the License.
*/

import {act} from '@testing-library/react';

jest.mock('@react-aria/live-announcer');
import {announce} from '@react-aria/live-announcer';
import {Calendar} from '../';
Expand All @@ -21,12 +23,11 @@ import {useLocale} from '@react-aria/i18n';
let keyCodes = {'Enter': 13, ' ': 32, 'PageUp': 33, 'PageDown': 34, 'End': 35, 'Home': 36, 'ArrowLeft': 37, 'ArrowUp': 38, 'ArrowRight': 39, 'ArrowDown': 40};

describe('Calendar', () => {
beforeEach(() => {
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => cb());
beforeAll(() => {
jest.useFakeTimers();
});

afterEach(() => {
window.requestAnimationFrame.mockRestore();
act(() => {jest.runAllTimers();});
});

describe('basics', () => {
Expand Down Expand Up @@ -388,13 +389,13 @@ describe('Calendar', () => {
});
});

// These tests only work against v3
describe('announcing', () => {
it('announces when the current month changes', () => {
let {getAllByLabelText} = render(<Calendar defaultValue={new CalendarDate(2019, 6, 5)} />);

let nextButton = getAllByLabelText('Next')[0];
triggerPress(nextButton);
act(() => {jest.runAllTimers();});

expect(announce).toHaveBeenCalledTimes(1);
expect(announce).toHaveBeenCalledWith('July 2019');
Expand All @@ -405,6 +406,7 @@ describe('Calendar', () => {

let newDate = getByText('17');
triggerPress(newDate);
act(() => {jest.runAllTimers();});

expect(announce).toHaveBeenCalledTimes(1);
expect(announce).toHaveBeenCalledWith('Selected Date: Monday, June 17, 2019', 'polite', 4000);
Expand All @@ -418,6 +420,8 @@ describe('Calendar', () => {
expect(selectedDate).toHaveFocus();

fireEvent.keyDown(grid, {key: 'ArrowRight'});
fireEvent.keyUp(document.activeElement, {key: 'ArrowRight'});
Comment thread
ktabors marked this conversation as resolved.
act(() => {jest.runAllTimers();});
expect(getByLabelText('Thursday, June 6, 2019', {exact: false})).toHaveFocus();
});

Expand All @@ -426,13 +430,15 @@ describe('Calendar', () => {

let newDate = getByText('17');
triggerPress(newDate);
act(() => {jest.runAllTimers();});

expect(announce).toHaveBeenCalledTimes(1);
expect(announce).toHaveBeenCalledWith('Selected Date: Saturday, February 17, 5 BC', 'polite', 4000);

announce.mockReset();
let nextButton = getAllByLabelText('Next')[0];
triggerPress(nextButton);
act(() => {jest.runAllTimers();});

expect(announce).toHaveBeenCalledTimes(1);
expect(announce).toHaveBeenCalledWith('March 5 BC');
Expand Down
8 changes: 3 additions & 5 deletions packages/@react-spectrum/calendar/test/CalendarBase.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ let keyCodes = {'Enter': 13, ' ': 32, 'PageUp': 33, 'PageDown': 34, 'End': 35, '

describe('CalendarBase', () => {
beforeAll(() => {
jest.useFakeTimers('legacy');
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => cb());
jest.useFakeTimers();
});

afterEach(() => {
Expand Down Expand Up @@ -234,8 +233,7 @@ describe('CalendarBase', () => {
it.each`
Name | Calendar | props
${'v3 Calendar'} | ${Calendar} | ${{defaultValue: new CalendarDate(2019, 3, 10), minValue: new CalendarDate(2019, 2, 3), maxValue: new CalendarDate(2019, 4, 20)}}
${'v3 RangeCalendar'} | ${RangeCalendar} | ${{defaultValue: {start: new CalendarDate(2019, 3, 10), end: new CalendarDate(2019, 3, 15)}, minValue: new CalendarDate(2019, 2, 3), maxValue: new CalendarDate(2019, 4, 20)}}
`('$Name should move focus when the previous or next buttons become disabled', ({Calendar, props}) => {
`('$Name should move focus when the previous or next buttons become disabled', async ({Calendar, props}) => {
let {getByLabelText, getAllByLabelText} = render(<Calendar {...props} />);

let prevButton = getByLabelText('Previous');
Expand All @@ -244,7 +242,7 @@ describe('CalendarBase', () => {
expect(prevButton).not.toHaveAttribute('disabled');
expect(nextButton).not.toHaveAttribute('disabled');

act(() => userEvent.click(prevButton));
triggerPress(prevButton);
expect(prevButton).toHaveAttribute('disabled');
expect(nextButton).not.toHaveAttribute('disabled');
expect(document.activeElement.getAttribute('aria-label').startsWith('Sunday, February 10, 2019')).toBe(true);
Expand Down
9 changes: 3 additions & 6 deletions packages/@react-spectrum/calendar/test/RangeCalendar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,11 @@ function type(key) {
}

describe('RangeCalendar', () => {
beforeEach(() => {
jest.useFakeTimers('legacy');
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => cb());
beforeAll(() => {
jest.useFakeTimers();
});

afterEach(() => {
window.requestAnimationFrame.mockRestore();
act(() => {jest.runAllTimers();});
});

describe('basics', () => {
Expand Down Expand Up @@ -115,7 +113,6 @@ describe('RangeCalendar', () => {
expect(grid).not.toHaveAttribute('aria-activedescendant');
});

// v2 doesn't pass this test - it starts by showing the end date instead of the start date.
it('should show selected dates across multiple months', () => {
let {getByRole, getByLabelText, getAllByLabelText, getAllByRole} = render(<RangeCalendar value={{start: new CalendarDate(2019, 6, 20), end: new CalendarDate(2019, 7, 10)}} />);

Expand Down
17 changes: 10 additions & 7 deletions packages/@react-stately/calendar/src/useCalendarState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
import {CalendarProps, DateValue} from '@react-types/calendar';
import {CalendarState} from './types';
import {useControlledState} from '@react-stately/utils';
import {useMemo, useRef, useState} from 'react';
import {useEffect, useMemo, useRef, useState} from 'react';

export interface CalendarStateOptions<T extends DateValue = DateValue> extends CalendarProps<T> {
/** The locale to display and edit the value according to. */
Expand Down Expand Up @@ -113,12 +113,15 @@ export function useCalendarState<T extends DateValue = DateValue>(props: Calenda

// Reset focused date and visible range when calendar changes.
let lastCalendarIdentifier = useRef(calendar.identifier);
if (calendar.identifier !== lastCalendarIdentifier.current) {
let newFocusedDate = toCalendar(focusedDate, calendar);
setStartDate(alignCenter(newFocusedDate, visibleDuration, locale, minValue, maxValue));
setFocusedDate(newFocusedDate);
lastCalendarIdentifier.current = calendar.identifier;
}
let calIdentifier = calendar.identifier;
useEffect(() => {
if (calendar.identifier !== lastCalendarIdentifier.current) {
let newFocusedDate = toCalendar(focusedDate, calendar);
setStartDate(alignCenter(newFocusedDate, visibleDuration, locale, minValue, maxValue));
setFocusedDate(newFocusedDate);
lastCalendarIdentifier.current = calIdentifier;
}
}, [calendar, focusedDate, visibleDuration, locale, minValue, maxValue, setFocusedDate, calIdentifier]);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can do these in render still, we just need to make lastCalendarIdentifier also in state. Addressed in #4564.


if (isInvalid(focusedDate, minValue, maxValue)) {
// If the focused date was moved to an invalid value, it can't be focused, so constrain it.
Expand Down
12 changes: 7 additions & 5 deletions packages/@react-stately/calendar/src/useRangeCalendarState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {RangeCalendarProps} from '@react-types/calendar';
import {RangeValue} from '@react-types/shared';
import {useCalendarState} from './useCalendarState';
import {useControlledState} from '@react-stately/utils';
import {useMemo, useRef, useState} from 'react';
import {useEffect, useMemo, useRef, useState} from 'react';

export interface RangeCalendarStateOptions<T extends DateValue = DateValue> extends RangeCalendarProps<T> {
/** The locale to display and edit the value according to. */
Expand Down Expand Up @@ -92,10 +92,12 @@ export function useRangeCalendarState<T extends DateValue = DateValue>(props: Ra

// If the visible range changes, we need to update the available range.
let lastVisibleRange = useRef(calendar.visibleRange);
if (!isEqualDay(calendar.visibleRange.start, lastVisibleRange.current.start) || !isEqualDay(calendar.visibleRange.end, lastVisibleRange.current.end)) {
updateAvailableRange(anchorDate);
lastVisibleRange.current = calendar.visibleRange;
}
useEffect(() => {
if (!isEqualDay(calendar.visibleRange.start, lastVisibleRange.current.start) || !isEqualDay(calendar.visibleRange.end, lastVisibleRange.current.end)) {
updateAvailableRange(anchorDate);
lastVisibleRange.current = calendar.visibleRange;
}
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to avoid an extra render here too. This one seems like lastVisibleRange could be a useState also so we can update it in render and it would cause the same number of renders as before.


let setAnchorDate = (date: CalendarDate) => {
if (date) {
Expand Down
14 changes: 9 additions & 5 deletions packages/@react-stately/datepicker/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import {Calendar, now, Time, toCalendar, toCalendarDate, toCalendarDateTime} from '@internationalized/date';
import {DatePickerProps, DateValue, Granularity, TimeValue} from '@react-types/datepicker';
import {useRef} from 'react';
import {useEffect, useRef} from 'react';

export function isInvalid(value: DateValue, minValue: DateValue, maxValue: DateValue) {
return value != null && (
Expand Down Expand Up @@ -131,11 +131,15 @@ export function createPlaceholderDate(placeholderValue: DateValue, granularity:
export function useDefaultProps(v: DateValue, granularity: Granularity): [Granularity, string] {
// Compute default granularity and time zone from the value. If the value becomes null, keep the last values.
let lastValue = useRef(v);
if (v) {
lastValue.current = v;
useEffect(() => {
if (v) {
lastValue.current = v;
}
}, [v]);

if (!v) {
v = lastValue.current;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is still a read in render...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated this in #4564 to use state instead of a ref. The state should only update if the time zone or granularity change, which should be rare.

}

v = lastValue.current;
let defaultTimeZone = (v && 'timeZone' in v ? v.timeZone : undefined);
granularity = granularity || (v && 'minute' in v ? 'minute' : 'day');

Expand Down