Skip to content

Commit fe4148b

Browse files
authored
Improve handling when focused button becomes disabled (#2941)
1 parent 4937668 commit fe4148b

File tree

13 files changed

+282
-56
lines changed

13 files changed

+282
-56
lines changed

packages/@react-aria/calendar/src/useCalendarBase.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {DOMProps} from '@react-types/shared';
2020
import intlMessages from '../intl/*.json';
2121
import {mergeProps, useDescription, useId, useUpdateEffect} from '@react-aria/utils';
2222
import {useMessageFormatter} from '@react-aria/i18n';
23+
import {useRef} from 'react';
2324

2425
export function useCalendarBase(props: CalendarPropsBase & DOMProps, state: CalendarState | RangeCalendarState): CalendarAria {
2526
let formatMessage = useMessageFormatter(intlMessages);
@@ -49,6 +50,21 @@ export function useCalendarBase(props: CalendarPropsBase & DOMProps, state: Cale
4950
// Label the child grid elements by the group element if it is labelled.
5051
calendarIds.set(state, props['aria-label'] || props['aria-labelledby'] ? calendarId : null);
5152

53+
// If the next or previous buttons become disabled while they are focused, move focus to the calendar body.
54+
let nextFocused = useRef(false);
55+
let nextDisabled = props.isDisabled || state.isNextVisibleRangeInvalid();
56+
if (nextDisabled && nextFocused.current) {
57+
nextFocused.current = false;
58+
state.setFocused(true);
59+
}
60+
61+
let previousFocused = useRef(false);
62+
let previousDisabled = props.isDisabled || state.isPreviousVisibleRangeInvalid();
63+
if (previousDisabled && previousFocused.current) {
64+
previousFocused.current = false;
65+
state.setFocused(true);
66+
}
67+
5268
return {
5369
calendarProps: mergeProps(descriptionProps, {
5470
role: 'group',
@@ -59,12 +75,16 @@ export function useCalendarBase(props: CalendarPropsBase & DOMProps, state: Cale
5975
nextButtonProps: {
6076
onPress: () => state.focusNextPage(),
6177
'aria-label': formatMessage('next'),
62-
isDisabled: props.isDisabled || state.isNextVisibleRangeInvalid()
78+
isDisabled: nextDisabled,
79+
onFocus: () => nextFocused.current = true,
80+
onBlur: () => nextFocused.current = false
6381
},
6482
prevButtonProps: {
6583
onPress: () => state.focusPreviousPage(),
6684
'aria-label': formatMessage('previous'),
67-
isDisabled: props.isDisabled || state.isPreviousVisibleRangeInvalid()
85+
isDisabled: previousDisabled,
86+
onFocus: () => previousFocused.current = true,
87+
onBlur: () => previousFocused.current = false
6888
},
6989
title: visibleRangeDescription
7090
};

packages/@react-aria/interactions/src/useFocus.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import {FocusEvent, HTMLAttributes} from 'react';
1919
import {FocusEvents} from '@react-types/shared';
20+
import {useSyntheticBlurEvent} from './utils';
2021

2122
interface FocusProps extends FocusEvents {
2223
/** Whether the focus events should be disabled. */
@@ -33,35 +34,40 @@ interface FocusResult {
3334
* Focus events on child elements will be ignored.
3435
*/
3536
export function useFocus(props: FocusProps): FocusResult {
36-
if (props.isDisabled) {
37-
return {focusProps: {}};
38-
}
39-
40-
let onFocus, onBlur;
41-
if (props.onFocus || props.onFocusChange) {
42-
onFocus = (e: FocusEvent) => {
37+
let onBlur: FocusProps['onBlur'];
38+
if (!props.isDisabled && (props.onBlur || props.onFocusChange)) {
39+
onBlur = (e: FocusEvent) => {
4340
if (e.target === e.currentTarget) {
44-
if (props.onFocus) {
45-
props.onFocus(e);
41+
if (props.onBlur) {
42+
props.onBlur(e);
4643
}
4744

4845
if (props.onFocusChange) {
49-
props.onFocusChange(true);
46+
props.onFocusChange(false);
5047
}
48+
49+
return true;
5150
}
5251
};
52+
} else {
53+
onBlur = null;
5354
}
5455

55-
if (props.onBlur || props.onFocusChange) {
56-
onBlur = (e: FocusEvent) => {
56+
let onSyntheticFocus = useSyntheticBlurEvent(onBlur);
57+
58+
let onFocus: FocusProps['onFocus'];
59+
if (!props.isDisabled && (props.onFocus || props.onFocusChange || props.onBlur)) {
60+
onFocus = (e: FocusEvent) => {
5761
if (e.target === e.currentTarget) {
58-
if (props.onBlur) {
59-
props.onBlur(e);
62+
if (props.onFocus) {
63+
props.onFocus(e);
6064
}
6165

6266
if (props.onFocusChange) {
63-
props.onFocusChange(false);
67+
props.onFocusChange(true);
6468
}
69+
70+
onSyntheticFocus(e);
6571
}
6672
};
6773
}

packages/@react-aria/interactions/src/useFocusWithin.ts

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
// See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
1717

1818
import {FocusEvent, HTMLAttributes, useRef} from 'react';
19+
import {useSyntheticBlurEvent} from './utils';
1920

2021
interface FocusWithinProps {
2122
/** Whether the focus within events should be disabled. */
@@ -41,45 +42,43 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult {
4142
isFocusWithin: false
4243
}).current;
4344

44-
if (props.isDisabled) {
45-
return {focusWithinProps: {}};
46-
}
45+
let onBlur = props.isDisabled ? null : (e: FocusEvent) => {
46+
// We don't want to trigger onBlurWithin and then immediately onFocusWithin again
47+
// when moving focus inside the element. Only trigger if the currentTarget doesn't
48+
// include the relatedTarget (where focus is moving).
49+
if (state.isFocusWithin && !(e.currentTarget as Element).contains(e.relatedTarget as Element)) {
50+
state.isFocusWithin = false;
4751

48-
let onFocus = (e: FocusEvent) => {
49-
if (!state.isFocusWithin) {
50-
if (props.onFocusWithin) {
51-
props.onFocusWithin(e);
52+
if (props.onBlurWithin) {
53+
props.onBlurWithin(e);
5254
}
5355

5456
if (props.onFocusWithinChange) {
55-
props.onFocusWithinChange(true);
57+
props.onFocusWithinChange(false);
5658
}
57-
58-
state.isFocusWithin = true;
5959
}
6060
};
6161

62-
let onBlur = (e: FocusEvent) => {
63-
// We don't want to trigger onBlurWithin and then immediately onFocusWithin again
64-
// when moving focus inside the element. Only trigger if the currentTarget doesn't
65-
// include the relatedTarget (where focus is moving).
66-
if (state.isFocusWithin && !e.currentTarget.contains(e.relatedTarget as HTMLElement)) {
67-
if (props.onBlurWithin) {
68-
props.onBlurWithin(e);
62+
let onSyntheticFocus = useSyntheticBlurEvent(onBlur);
63+
let onFocus = props.isDisabled ? null : (e: FocusEvent) => {
64+
if (!state.isFocusWithin) {
65+
if (props.onFocusWithin) {
66+
props.onFocusWithin(e);
6967
}
7068

7169
if (props.onFocusWithinChange) {
72-
props.onFocusWithinChange(false);
70+
props.onFocusWithinChange(true);
7371
}
7472

75-
state.isFocusWithin = false;
73+
state.isFocusWithin = true;
74+
onSyntheticFocus(e);
7675
}
7776
};
7877

7978
return {
8079
focusWithinProps: {
81-
onFocus: onFocus,
82-
onBlur: onBlur
80+
onFocus,
81+
onBlur
8382
}
8483
};
8584
}

packages/@react-aria/interactions/src/utils.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {FocusEvent as ReactFocusEvent, useRef} from 'react';
14+
import {useLayoutEffect} from '@react-aria/utils';
15+
1316
// Original licensing for the following method can be found in the
1417
// NOTICE file in the root directory of this source tree.
1518
// 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 {
2932

3033
return event.detail === 0 && !(event as PointerEvent).pointerType;
3134
}
35+
36+
export class SyntheticFocusEvent implements ReactFocusEvent {
37+
nativeEvent: FocusEvent;
38+
target: Element;
39+
currentTarget: Element;
40+
relatedTarget: Element;
41+
bubbles: boolean;
42+
cancelable: boolean;
43+
defaultPrevented: boolean;
44+
eventPhase: number;
45+
isTrusted: boolean;
46+
timeStamp: number;
47+
type: string;
48+
49+
constructor(type: string, nativeEvent: FocusEvent) {
50+
this.nativeEvent = nativeEvent;
51+
this.target = nativeEvent.target as Element;
52+
this.currentTarget = nativeEvent.currentTarget as Element;
53+
this.relatedTarget = nativeEvent.relatedTarget as Element;
54+
this.bubbles = nativeEvent.bubbles;
55+
this.cancelable = nativeEvent.cancelable;
56+
this.defaultPrevented = nativeEvent.defaultPrevented;
57+
this.eventPhase = nativeEvent.eventPhase;
58+
this.isTrusted = nativeEvent.isTrusted;
59+
this.timeStamp = nativeEvent.timeStamp;
60+
this.type = type;
61+
}
62+
63+
isDefaultPrevented(): boolean {
64+
return this.nativeEvent.defaultPrevented;
65+
}
66+
67+
preventDefault(): void {
68+
this.defaultPrevented = true;
69+
this.nativeEvent.preventDefault();
70+
}
71+
72+
stopPropagation(): void {
73+
this.nativeEvent.stopPropagation();
74+
this.isPropagationStopped = () => true;
75+
}
76+
77+
isPropagationStopped(): boolean {
78+
return false;
79+
}
80+
81+
persist() {}
82+
}
83+
84+
export function useSyntheticBlurEvent(onBlur: (e: ReactFocusEvent) => void) {
85+
let stateRef = useRef({
86+
isFocused: false,
87+
onBlur,
88+
observer: null as MutationObserver
89+
});
90+
let state = stateRef.current;
91+
state.onBlur = onBlur;
92+
93+
// Clean up MutationObserver on unmount. See below.
94+
// eslint-disable-next-line arrow-body-style
95+
useLayoutEffect(() => {
96+
return () => {
97+
if (state.observer) {
98+
state.observer.disconnect();
99+
state.observer = null;
100+
}
101+
};
102+
}, [state]);
103+
104+
// This function is called during a React onFocus event.
105+
return (e: ReactFocusEvent) => {
106+
// React does not fire onBlur when an element is disabled. https://github.com/facebook/react/issues/9142
107+
// Most browsers fire a native focusout event in this case, except for Firefox. In that case, we use a
108+
// MutationObserver to watch for the disabled attribute, and dispatch these events ourselves.
109+
// For browsers that do, focusout fires before the MutationObserver, so onBlur should not fire twice.
110+
if (
111+
e.target instanceof HTMLButtonElement ||
112+
e.target instanceof HTMLInputElement ||
113+
e.target instanceof HTMLTextAreaElement ||
114+
e.target instanceof HTMLSelectElement
115+
) {
116+
state.isFocused = true;
117+
118+
let target = e.target;
119+
let onBlurHandler = (e: FocusEvent) => {
120+
let state = stateRef.current;
121+
state.isFocused = false;
122+
123+
if (target.disabled) {
124+
// For backward compatibility, dispatch a (fake) React synthetic event.
125+
state.onBlur?.(new SyntheticFocusEvent('blur', e));
126+
}
127+
128+
// We no longer need the MutationObserver once the target is blurred.
129+
if (state.observer) {
130+
state.observer.disconnect();
131+
state.observer = null;
132+
}
133+
};
134+
135+
target.addEventListener('focusout', onBlurHandler, {once: true});
136+
137+
state.observer = new MutationObserver(() => {
138+
if (state.isFocused && target.disabled) {
139+
state.observer.disconnect();
140+
target.dispatchEvent(new FocusEvent('blur'));
141+
target.dispatchEvent(new FocusEvent('focusout', {bubbles: true}));
142+
}
143+
});
144+
145+
state.observer.observe(target, {attributes: true, attributeFilter: ['disabled']});
146+
}
147+
};
148+
}

packages/@react-aria/interactions/test/useFocus.test.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {act, render} from '@testing-library/react';
13+
import {act, render, waitFor} from '@testing-library/react';
1414
import React from 'react';
1515
import {useFocus} from '../';
1616

@@ -135,4 +135,22 @@ describe('useFocus', function () {
135135
expect(onWrapperFocus).toHaveBeenCalledTimes(1);
136136
expect(onWrapperBlur).toHaveBeenCalledTimes(1);
137137
});
138+
139+
it('should fire onBlur when a focused element is disabled', async function () {
140+
function Example(props) {
141+
let {focusProps} = useFocus(props);
142+
return <button disabled={props.disabled} {...focusProps}>Button</button>;
143+
}
144+
145+
let onFocus = jest.fn();
146+
let onBlur = jest.fn();
147+
let tree = render(<Example onFocus={onFocus} onBlur={onBlur} />);
148+
let button = tree.getByRole('button');
149+
150+
act(() => {button.focus();});
151+
expect(onFocus).toHaveBeenCalled();
152+
tree.rerender(<Example disabled onFocus={onFocus} onBlur={onBlur} />);
153+
// MutationObserver is async
154+
await waitFor(() => expect(onBlur).toHaveBeenCalled());
155+
});
138156
});

packages/@react-aria/interactions/test/useFocusWithin.test.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {act, render} from '@testing-library/react';
13+
import {act, render, waitFor} from '@testing-library/react';
1414
import React from 'react';
1515
import {useFocusWithin} from '../';
1616

@@ -138,4 +138,22 @@ describe('useFocusWithin', function () {
138138
expect(onWrapperFocus).toHaveBeenCalledTimes(1);
139139
expect(onWrapperBlur).toHaveBeenCalledTimes(1);
140140
});
141+
142+
it('should fire onBlur when a focused element is disabled', async function () {
143+
function Example(props) {
144+
let {focusWithinProps} = useFocusWithin(props);
145+
return <div {...focusWithinProps}><button disabled={props.disabled}>Button</button></div>;
146+
}
147+
148+
let onFocus = jest.fn();
149+
let onBlur = jest.fn();
150+
let tree = render(<Example onFocusWithin={onFocus} onBlurWithin={onBlur} />);
151+
let button = tree.getByRole('button');
152+
153+
act(() => {button.focus();});
154+
expect(onFocus).toHaveBeenCalled();
155+
tree.rerender(<Example disabled onFocusWithin={onFocus} onBlurWithin={onBlur} />);
156+
// MutationObserver is async
157+
await waitFor(() => expect(onBlur).toHaveBeenCalled());
158+
});
141159
});

packages/@react-spectrum/calendar/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"@react-spectrum/button": "^3.7.1",
4343
"@react-spectrum/utils": "^3.6.5",
4444
"@react-stately/calendar": "3.0.0-alpha.3",
45+
"@react-types/button": "^3.4.3",
4546
"@react-types/calendar": "3.0.0-alpha.3",
4647
"@react-types/shared": "^3.11.1",
4748
"@spectrum-icons/ui": "^3.2.3",

0 commit comments

Comments
 (0)