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(