diff --git a/packages/@react-aria/calendar/src/useCalendarBase.ts b/packages/@react-aria/calendar/src/useCalendarBase.ts index 0930a191ff2..3fd8892d80a 100644 --- a/packages/@react-aria/calendar/src/useCalendarBase.ts +++ b/packages/@react-aria/calendar/src/useCalendarBase.ts @@ -20,6 +20,7 @@ import {DOMProps} from '@react-types/shared'; import intlMessages from '../intl/*.json'; import {mergeProps, useDescription, useId, useUpdateEffect} from '@react-aria/utils'; import {useMessageFormatter} from '@react-aria/i18n'; +import {useRef} from 'react'; export function useCalendarBase(props: CalendarPropsBase & DOMProps, state: CalendarState | RangeCalendarState): CalendarAria { let formatMessage = useMessageFormatter(intlMessages); @@ -49,6 +50,21 @@ export function useCalendarBase(props: CalendarPropsBase & DOMProps, state: Cale // Label the child grid elements by the group element if it is labelled. calendarIds.set(state, props['aria-label'] || props['aria-labelledby'] ? calendarId : null); + // 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); + } + return { calendarProps: mergeProps(descriptionProps, { role: 'group', @@ -59,12 +75,16 @@ export function useCalendarBase(props: CalendarPropsBase & DOMProps, state: Cale nextButtonProps: { onPress: () => state.focusNextPage(), 'aria-label': formatMessage('next'), - isDisabled: props.isDisabled || state.isNextVisibleRangeInvalid() + isDisabled: nextDisabled, + onFocus: () => nextFocused.current = true, + onBlur: () => nextFocused.current = false }, prevButtonProps: { onPress: () => state.focusPreviousPage(), 'aria-label': formatMessage('previous'), - isDisabled: props.isDisabled || state.isPreviousVisibleRangeInvalid() + isDisabled: previousDisabled, + onFocus: () => previousFocused.current = true, + onBlur: () => previousFocused.current = false }, title: visibleRangeDescription }; diff --git a/packages/@react-aria/interactions/src/useFocus.ts b/packages/@react-aria/interactions/src/useFocus.ts index 62bd9a29d5a..2242899c84c 100644 --- a/packages/@react-aria/interactions/src/useFocus.ts +++ b/packages/@react-aria/interactions/src/useFocus.ts @@ -17,6 +17,7 @@ import {FocusEvent, HTMLAttributes} from 'react'; import {FocusEvents} from '@react-types/shared'; +import {useSyntheticBlurEvent} from './utils'; interface FocusProps extends FocusEvents { /** Whether the focus events should be disabled. */ @@ -33,35 +34,40 @@ interface FocusResult { * Focus events on child elements will be ignored. */ export function useFocus(props: FocusProps): FocusResult { - if (props.isDisabled) { - return {focusProps: {}}; - } - - let onFocus, onBlur; - if (props.onFocus || props.onFocusChange) { - onFocus = (e: FocusEvent) => { + let onBlur: FocusProps['onBlur']; + if (!props.isDisabled && (props.onBlur || props.onFocusChange)) { + onBlur = (e: FocusEvent) => { if (e.target === e.currentTarget) { - if (props.onFocus) { - props.onFocus(e); + if (props.onBlur) { + props.onBlur(e); } if (props.onFocusChange) { - props.onFocusChange(true); + props.onFocusChange(false); } + + return true; } }; + } else { + onBlur = null; } - if (props.onBlur || props.onFocusChange) { - onBlur = (e: FocusEvent) => { + let onSyntheticFocus = useSyntheticBlurEvent(onBlur); + + let onFocus: FocusProps['onFocus']; + if (!props.isDisabled && (props.onFocus || props.onFocusChange || props.onBlur)) { + onFocus = (e: FocusEvent) => { if (e.target === e.currentTarget) { - if (props.onBlur) { - props.onBlur(e); + if (props.onFocus) { + props.onFocus(e); } if (props.onFocusChange) { - props.onFocusChange(false); + props.onFocusChange(true); } + + onSyntheticFocus(e); } }; } diff --git a/packages/@react-aria/interactions/src/useFocusWithin.ts b/packages/@react-aria/interactions/src/useFocusWithin.ts index 170bdf24a54..e0cf5e65f1a 100644 --- a/packages/@react-aria/interactions/src/useFocusWithin.ts +++ b/packages/@react-aria/interactions/src/useFocusWithin.ts @@ -16,6 +16,7 @@ // See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions import {FocusEvent, HTMLAttributes, useRef} from 'react'; +import {useSyntheticBlurEvent} from './utils'; interface FocusWithinProps { /** Whether the focus within events should be disabled. */ @@ -41,45 +42,43 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { isFocusWithin: false }).current; - if (props.isDisabled) { - return {focusWithinProps: {}}; - } + let onBlur = props.isDisabled ? null : (e: FocusEvent) => { + // We don't want to trigger onBlurWithin and then immediately onFocusWithin again + // when moving focus inside the element. Only trigger if the currentTarget doesn't + // include the relatedTarget (where focus is moving). + if (state.isFocusWithin && !(e.currentTarget as Element).contains(e.relatedTarget as Element)) { + state.isFocusWithin = false; - let onFocus = (e: FocusEvent) => { - if (!state.isFocusWithin) { - if (props.onFocusWithin) { - props.onFocusWithin(e); + if (props.onBlurWithin) { + props.onBlurWithin(e); } if (props.onFocusWithinChange) { - props.onFocusWithinChange(true); + props.onFocusWithinChange(false); } - - state.isFocusWithin = true; } }; - let onBlur = (e: FocusEvent) => { - // We don't want to trigger onBlurWithin and then immediately onFocusWithin again - // when moving focus inside the element. Only trigger if the currentTarget doesn't - // include the relatedTarget (where focus is moving). - if (state.isFocusWithin && !e.currentTarget.contains(e.relatedTarget as HTMLElement)) { - if (props.onBlurWithin) { - props.onBlurWithin(e); + let onSyntheticFocus = useSyntheticBlurEvent(onBlur); + let onFocus = props.isDisabled ? null : (e: FocusEvent) => { + if (!state.isFocusWithin) { + if (props.onFocusWithin) { + props.onFocusWithin(e); } if (props.onFocusWithinChange) { - props.onFocusWithinChange(false); + props.onFocusWithinChange(true); } - state.isFocusWithin = false; + state.isFocusWithin = true; + onSyntheticFocus(e); } }; return { focusWithinProps: { - onFocus: onFocus, - onBlur: onBlur + onFocus, + onBlur } }; } diff --git a/packages/@react-aria/interactions/src/utils.ts b/packages/@react-aria/interactions/src/utils.ts index d95fd43c6aa..4f04eb19ae9 100644 --- a/packages/@react-aria/interactions/src/utils.ts +++ b/packages/@react-aria/interactions/src/utils.ts @@ -10,6 +10,9 @@ * governing permissions and limitations under the License. */ +import {FocusEvent as ReactFocusEvent, useRef} from 'react'; +import {useLayoutEffect} from '@react-aria/utils'; + // Original licensing for the following method can be found in the // NOTICE file in the root directory of this source tree. // See https://github.com/facebook/react/blob/3c713d513195a53788b3f8bb4b70279d68b15bcc/packages/react-interactions/events/src/dom/shared/index.js#L74-L87 @@ -29,3 +32,117 @@ export function isVirtualClick(event: MouseEvent | PointerEvent): boolean { return event.detail === 0 && !(event as PointerEvent).pointerType; } + +export class SyntheticFocusEvent implements ReactFocusEvent { + nativeEvent: FocusEvent; + target: Element; + currentTarget: Element; + relatedTarget: Element; + bubbles: boolean; + cancelable: boolean; + defaultPrevented: boolean; + eventPhase: number; + isTrusted: boolean; + timeStamp: number; + type: string; + + constructor(type: string, nativeEvent: FocusEvent) { + this.nativeEvent = nativeEvent; + this.target = nativeEvent.target as Element; + this.currentTarget = nativeEvent.currentTarget as Element; + this.relatedTarget = nativeEvent.relatedTarget as Element; + this.bubbles = nativeEvent.bubbles; + this.cancelable = nativeEvent.cancelable; + this.defaultPrevented = nativeEvent.defaultPrevented; + this.eventPhase = nativeEvent.eventPhase; + this.isTrusted = nativeEvent.isTrusted; + this.timeStamp = nativeEvent.timeStamp; + this.type = type; + } + + isDefaultPrevented(): boolean { + return this.nativeEvent.defaultPrevented; + } + + preventDefault(): void { + this.defaultPrevented = true; + this.nativeEvent.preventDefault(); + } + + stopPropagation(): void { + this.nativeEvent.stopPropagation(); + this.isPropagationStopped = () => true; + } + + isPropagationStopped(): boolean { + return false; + } + + persist() {} +} + +export function useSyntheticBlurEvent(onBlur: (e: ReactFocusEvent) => void) { + let stateRef = useRef({ + isFocused: false, + onBlur, + observer: null as MutationObserver + }); + let state = stateRef.current; + state.onBlur = onBlur; + + // Clean up MutationObserver on unmount. See below. + // eslint-disable-next-line arrow-body-style + useLayoutEffect(() => { + return () => { + if (state.observer) { + state.observer.disconnect(); + state.observer = null; + } + }; + }, [state]); + + // This function is called during a React onFocus event. + return (e: ReactFocusEvent) => { + // React does not fire onBlur when an element is disabled. https://github.com/facebook/react/issues/9142 + // Most browsers fire a native focusout event in this case, except for Firefox. In that case, we use a + // MutationObserver to watch for the disabled attribute, and dispatch these events ourselves. + // For browsers that do, focusout fires before the MutationObserver, so onBlur should not fire twice. + if ( + e.target instanceof HTMLButtonElement || + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + e.target instanceof HTMLSelectElement + ) { + state.isFocused = true; + + let target = e.target; + let onBlurHandler = (e: FocusEvent) => { + let state = stateRef.current; + state.isFocused = false; + + if (target.disabled) { + // For backward compatibility, dispatch a (fake) React synthetic event. + state.onBlur?.(new SyntheticFocusEvent('blur', e)); + } + + // We no longer need the MutationObserver once the target is blurred. + if (state.observer) { + state.observer.disconnect(); + state.observer = null; + } + }; + + target.addEventListener('focusout', onBlurHandler, {once: true}); + + state.observer = new MutationObserver(() => { + if (state.isFocused && target.disabled) { + state.observer.disconnect(); + target.dispatchEvent(new FocusEvent('blur')); + target.dispatchEvent(new FocusEvent('focusout', {bubbles: true})); + } + }); + + state.observer.observe(target, {attributes: true, attributeFilter: ['disabled']}); + } + }; +} diff --git a/packages/@react-aria/interactions/test/useFocus.test.js b/packages/@react-aria/interactions/test/useFocus.test.js index a4e255d5d18..22a1a1c5d3f 100644 --- a/packages/@react-aria/interactions/test/useFocus.test.js +++ b/packages/@react-aria/interactions/test/useFocus.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, render} from '@testing-library/react'; +import {act, render, waitFor} from '@testing-library/react'; import React from 'react'; import {useFocus} from '../'; @@ -135,4 +135,22 @@ describe('useFocus', function () { expect(onWrapperFocus).toHaveBeenCalledTimes(1); expect(onWrapperBlur).toHaveBeenCalledTimes(1); }); + + it('should fire onBlur when a focused element is disabled', async function () { + function Example(props) { + let {focusProps} = useFocus(props); + return ; + } + + let onFocus = jest.fn(); + let onBlur = jest.fn(); + let tree = render(); + let button = tree.getByRole('button'); + + act(() => {button.focus();}); + expect(onFocus).toHaveBeenCalled(); + tree.rerender(); + // MutationObserver is async + await waitFor(() => expect(onBlur).toHaveBeenCalled()); + }); }); diff --git a/packages/@react-aria/interactions/test/useFocusWithin.test.js b/packages/@react-aria/interactions/test/useFocusWithin.test.js index 4b1bf9a9081..3d3e4c4b19a 100644 --- a/packages/@react-aria/interactions/test/useFocusWithin.test.js +++ b/packages/@react-aria/interactions/test/useFocusWithin.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, render} from '@testing-library/react'; +import {act, render, waitFor} from '@testing-library/react'; import React from 'react'; import {useFocusWithin} from '../'; @@ -138,4 +138,22 @@ describe('useFocusWithin', function () { expect(onWrapperFocus).toHaveBeenCalledTimes(1); expect(onWrapperBlur).toHaveBeenCalledTimes(1); }); + + it('should fire onBlur when a focused element is disabled', async function () { + function Example(props) { + let {focusWithinProps} = useFocusWithin(props); + return
; + } + + let onFocus = jest.fn(); + let onBlur = jest.fn(); + let tree = render(); + let button = tree.getByRole('button'); + + act(() => {button.focus();}); + expect(onFocus).toHaveBeenCalled(); + tree.rerender(); + // MutationObserver is async + await waitFor(() => expect(onBlur).toHaveBeenCalled()); + }); }); diff --git a/packages/@react-spectrum/calendar/package.json b/packages/@react-spectrum/calendar/package.json index dd0be355038..e375f173eb0 100644 --- a/packages/@react-spectrum/calendar/package.json +++ b/packages/@react-spectrum/calendar/package.json @@ -42,6 +42,7 @@ "@react-spectrum/button": "^3.7.1", "@react-spectrum/utils": "^3.6.5", "@react-stately/calendar": "3.0.0-alpha.3", + "@react-types/button": "^3.4.3", "@react-types/calendar": "3.0.0-alpha.3", "@react-types/shared": "^3.11.1", "@spectrum-icons/ui": "^3.2.3", diff --git a/packages/@react-spectrum/calendar/src/Calendar.tsx b/packages/@react-spectrum/calendar/src/Calendar.tsx index feb0e33c042..6f27ad696a5 100644 --- a/packages/@react-spectrum/calendar/src/Calendar.tsx +++ b/packages/@react-spectrum/calendar/src/Calendar.tsx @@ -13,7 +13,7 @@ import {CalendarBase} from './CalendarBase'; import {createCalendar} from '@internationalized/date'; import {DateValue, SpectrumCalendarProps} from '@react-types/calendar'; -import React, {useMemo} from 'react'; +import React, {useMemo, useRef} from 'react'; import {useCalendar} from '@react-aria/calendar'; import {useCalendarState} from '@react-stately/calendar'; import {useLocale} from '@react-aria/i18n'; @@ -32,10 +32,16 @@ export function Calendar(props: SpectrumCalendarProps) { createCalendar }); + let ref = useRef(); + let {calendarProps, prevButtonProps, nextButtonProps} = useCalendar(props, state); + return ( + calendarRef={ref} + calendarProps={calendarProps} + prevButtonProps={prevButtonProps} + nextButtonProps={nextButtonProps} /> ); } diff --git a/packages/@react-spectrum/calendar/src/CalendarBase.tsx b/packages/@react-spectrum/calendar/src/CalendarBase.tsx index f5060f9b260..3a0b81f1afa 100644 --- a/packages/@react-spectrum/calendar/src/CalendarBase.tsx +++ b/packages/@react-spectrum/calendar/src/CalendarBase.tsx @@ -11,7 +11,7 @@ */ import {ActionButton} from '@react-spectrum/button'; -import {CalendarAria} from '@react-aria/calendar'; +import {AriaButtonProps} from '@react-types/button'; import {CalendarMonth} from './CalendarMonth'; import {CalendarPropsBase} from '@react-types/calendar'; import {CalendarState, RangeCalendarState} from '@react-stately/calendar'; @@ -19,22 +19,28 @@ import ChevronLeft from '@spectrum-icons/ui/ChevronLeftLarge'; import ChevronRight from '@spectrum-icons/ui/ChevronRightLarge'; import {classNames} from '@react-spectrum/utils'; import {DOMProps, StyleProps} from '@react-types/shared'; -import React, {RefObject, useRef} from 'react'; +import React, {HTMLAttributes, RefObject} from 'react'; import styles from '@adobe/spectrum-css-temp/components/calendar/vars.css'; import {useDateFormatter, useLocale} from '@react-aria/i18n'; import {useProviderProps} from '@react-spectrum/provider'; interface CalendarBaseProps extends CalendarPropsBase, DOMProps, StyleProps { state: T, - useCalendar: (props: CalendarPropsBase, state: T, ref?: RefObject) => CalendarAria, - visibleMonths?: number + visibleMonths?: number, + calendarProps: HTMLAttributes, + nextButtonProps: AriaButtonProps, + prevButtonProps: AriaButtonProps, + calendarRef: RefObject } export function CalendarBase(props: CalendarBaseProps) { props = useProviderProps(props); let { state, - useCalendar, + calendarProps, + nextButtonProps, + prevButtonProps, + calendarRef: ref, visibleMonths = 1 } = props; let {direction} = useLocale(); @@ -45,8 +51,6 @@ export function CalendarBase(props era: currentMonth.calendar.identifier !== 'gregory' ? 'long' : undefined, calendar: currentMonth.calendar.identifier }); - let ref = useRef(null); - let {calendarProps, prevButtonProps, nextButtonProps} = useCalendar(props, state, ref); let titles = []; let calendars = []; diff --git a/packages/@react-spectrum/calendar/src/RangeCalendar.tsx b/packages/@react-spectrum/calendar/src/RangeCalendar.tsx index c6518c72557..be617138f0f 100644 --- a/packages/@react-spectrum/calendar/src/RangeCalendar.tsx +++ b/packages/@react-spectrum/calendar/src/RangeCalendar.tsx @@ -13,7 +13,7 @@ import {CalendarBase} from './CalendarBase'; import {createCalendar} from '@internationalized/date'; import {DateValue, SpectrumRangeCalendarProps} from '@react-types/calendar'; -import React, {useMemo} from 'react'; +import React, {useMemo, useRef} from 'react'; import {useLocale} from '@react-aria/i18n'; import {useRangeCalendar} from '@react-aria/calendar'; import {useRangeCalendarState} from '@react-stately/calendar'; @@ -32,10 +32,16 @@ export function RangeCalendar(props: SpectrumRangeCalendarP createCalendar }); + let ref = useRef(); + let {calendarProps, prevButtonProps, nextButtonProps} = useRangeCalendar(props, state, ref); + return ( + calendarRef={ref} + calendarProps={calendarProps} + prevButtonProps={prevButtonProps} + nextButtonProps={nextButtonProps} /> ); } diff --git a/packages/@react-spectrum/calendar/test/CalendarBase.test.js b/packages/@react-spectrum/calendar/test/CalendarBase.test.js index df5d554c560..ef52a8c6a1c 100644 --- a/packages/@react-spectrum/calendar/test/CalendarBase.test.js +++ b/packages/@react-spectrum/calendar/test/CalendarBase.test.js @@ -18,6 +18,7 @@ import {Provider} from '@react-spectrum/provider'; import React from 'react'; import {theme} from '@react-spectrum/theme-default'; import {triggerPress} from '@react-spectrum/test-utils'; +import userEvent from '@testing-library/user-event'; let cellFormatter = new Intl.DateTimeFormat('en-US', {weekday: 'long', day: 'numeric', month: 'long', year: 'numeric'}); let headingFormatter = new Intl.DateTimeFormat('en-US', {month: 'long', year: 'numeric'}); @@ -224,6 +225,36 @@ describe('CalendarBase', () => { expect(grids[2]).toHaveAttribute('aria-label', 'July 2019'); }); + 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}) => { + let {getByLabelText} = render(); + + let prevButton = getByLabelText('Previous'); + let nextButton = getByLabelText('Next'); + + expect(prevButton).not.toHaveAttribute('disabled'); + expect(nextButton).not.toHaveAttribute('disabled'); + + act(() => userEvent.click(prevButton)); + expect(prevButton).toHaveAttribute('disabled'); + expect(nextButton).not.toHaveAttribute('disabled'); + expect(document.activeElement.getAttribute('aria-label').startsWith('Sunday, February 10, 2019')).toBe(true); + + act(() => userEvent.click(nextButton)); + + expect(prevButton).not.toHaveAttribute('disabled'); + expect(nextButton).not.toHaveAttribute('disabled'); + expect(document.activeElement).toBe(nextButton); + + act(() => userEvent.click(nextButton)); + expect(prevButton).not.toHaveAttribute('disabled'); + expect(nextButton).toHaveAttribute('disabled'); + expect(document.activeElement.getAttribute('aria-label').startsWith('Wednesday, April 10, 2019')).toBe(true); + }); + it.each` Name | Calendar | props ${'v3 Calendar'} | ${Calendar} | ${{defaultValue: new CalendarDate(2019, 6, 5)}} diff --git a/packages/@react-spectrum/textfield/test/TextField.test.js b/packages/@react-spectrum/textfield/test/TextField.test.js index 787333ae195..7d4a73ebb76 100644 --- a/packages/@react-spectrum/textfield/test/TextField.test.js +++ b/packages/@react-spectrum/textfield/test/TextField.test.js @@ -10,8 +10,8 @@ * governing permissions and limitations under the License. */ +import {act, fireEvent, render, waitFor} from '@testing-library/react'; import Checkmark from '@spectrum-icons/workflow/Checkmark'; -import {fireEvent, render, waitFor} from '@testing-library/react'; import React from 'react'; import {SearchField} from '@react-spectrum/searchfield'; import {TextArea, TextField} from '../'; @@ -126,9 +126,9 @@ describe('Shared TextField behavior', () => { ${'v3 TextArea'} | ${TextArea} ${'v3 SearchField'} | ${SearchField} `('$Name calls onBlur when the input field loses focus', ({Name, Component}) => { - let tree = renderComponent(Component, {onBlur, 'aria-label': 'mandatory label'}); + let tree = renderComponent(Component, {onBlur, autoFocus: true, 'aria-label': 'mandatory label'}); let input = tree.getByTestId(testId); - fireEvent.blur(input); + act(() => input.blur()); expect(onBlur).toHaveBeenCalledTimes(1); }); diff --git a/packages/@react-stately/calendar/src/useCalendarState.ts b/packages/@react-stately/calendar/src/useCalendarState.ts index fcfbf093eb5..3367e553fa6 100644 --- a/packages/@react-stately/calendar/src/useCalendarState.ts +++ b/packages/@react-stately/calendar/src/useCalendarState.ts @@ -154,13 +154,13 @@ export function useCalendarState(props: CalendarStateOption }, focusNextPage() { let start = startDate.add(visibleDuration); - setStartDate(constrainStart(focusedDate, start, visibleDuration, locale, minValue, maxValue)); setFocusedDate(constrainValue(focusedDate.add(visibleDuration), minValue, maxValue)); + setStartDate(constrainStart(focusedDate, start, visibleDuration, locale, minValue, maxValue)); }, focusPreviousPage() { let start = startDate.subtract(visibleDuration); - setStartDate(constrainStart(focusedDate, start, visibleDuration, locale, minValue, maxValue)); setFocusedDate(constrainValue(focusedDate.subtract(visibleDuration), minValue, maxValue)); + setStartDate(constrainStart(focusedDate, start, visibleDuration, locale, minValue, maxValue)); }, focusPageStart() { focusCell(startDate);