diff --git a/packages/react-events/docs/Focus.md b/packages/react-events/docs/Focus.md
index 1fef78e370f..470232d1f35 100644
--- a/packages/react-events/docs/Focus.md
+++ b/packages/react-events/docs/Focus.md
@@ -33,7 +33,7 @@ const Button = (props) => {
```js
type FocusEvent = {
target: Element,
- type: 'blur' | 'focus' | 'focuschange' | 'focusvisiblechange'
+ type: 'blur' | 'focus' | 'focuschange' | 'focusvisiblechange' | 'focuswithinchange' | 'focusvisiblewithinchange'
}
```
@@ -60,3 +60,13 @@ Called when the element changes focus state (i.e., after `onBlur` and
Called when the element receives or loses focus following keyboard navigation.
This can be used to display focus styles only for keyboard interactions.
+
+### onFocusWithinChange: boolean => void
+
+Called when the element or a descendant changes focus state (i.e., after `onBlur` and
+`onFocus`).
+
+### onFocusVisibleWithinChange: boolean => void
+
+Called when the element or a descendant receives or loses focus following keyboard navigation.
+This can be used to display focus styles only for keyboard interactions.
diff --git a/packages/react-events/src/Focus.js b/packages/react-events/src/Focus.js
index 3992ded6a07..087ecd3119c 100644
--- a/packages/react-events/src/Focus.js
+++ b/packages/react-events/src/Focus.js
@@ -22,16 +22,25 @@ type FocusProps = {
onFocus: (e: FocusEvent) => void,
onFocusChange: boolean => void,
onFocusVisibleChange: boolean => void,
+ onFocusWithinChange: boolean => void,
+ onFocusVisibleWithinChange: boolean => void,
};
type FocusState = {
focusTarget: null | Element | Document,
isFocused: boolean,
isLocalFocusVisible: boolean,
+ isFocusWithin: boolean,
pointerType: PointerType,
};
-type FocusEventType = 'focus' | 'blur' | 'focuschange' | 'focusvisiblechange';
+type FocusEventType =
+ | 'focus'
+ | 'blur'
+ | 'focuschange'
+ | 'focusvisiblechange'
+ | 'focuswithinchange'
+ | 'focusvisiblewithinchange';
type FocusEvent = {|
target: Element | Document,
@@ -125,6 +134,38 @@ function dispatchFocusInEvents(
context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
}
}
+function dispatchFocusWithinEvents(
+ context: ReactDOMResponderContext,
+ props: FocusProps,
+ state: FocusState,
+) {
+ const pointerType = state.pointerType;
+ const target = ((state.focusTarget: any): Element | Document);
+ if (props.onFocusWithinChange) {
+ const listener = () => {
+ props.onFocusWithinChange(true);
+ };
+ const syntheticEvent = createFocusEvent(
+ context,
+ 'focuswithinchange',
+ target,
+ pointerType,
+ );
+ context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
+ }
+ if (props.onFocusVisibleWithinChange && state.isLocalFocusVisible) {
+ const listener = () => {
+ props.onFocusVisibleWithinChange(true);
+ };
+ const syntheticEvent = createFocusEvent(
+ context,
+ 'focusvisiblewithinchange',
+ target,
+ pointerType,
+ );
+ context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
+ }
+}
function dispatchFocusOutEvents(
context: ReactDOMResponderContext,
@@ -133,7 +174,7 @@ function dispatchFocusOutEvents(
) {
const pointerType = state.pointerType;
const target = ((state.focusTarget: any): Element | Document);
- if (props.onBlur) {
+ if (props.onBlur && state.isFocused) {
const syntheticEvent = createFocusEvent(
context,
'blur',
@@ -142,7 +183,7 @@ function dispatchFocusOutEvents(
);
context.dispatchEvent(syntheticEvent, props.onBlur, DiscreteEvent);
}
- if (props.onFocusChange) {
+ if (props.onFocusChange && state.isFocused) {
const listener = () => {
props.onFocusChange(false);
};
@@ -154,17 +195,33 @@ function dispatchFocusOutEvents(
);
context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
}
- dispatchFocusVisibleOutEvent(context, props, state);
+ if (props.onFocusWithinChange && state.isFocusWithin) {
+ const listener = () => {
+ props.onFocusWithinChange(false);
+ };
+ const syntheticEvent = createFocusEvent(
+ context,
+ 'focuswithinchange',
+ target,
+ pointerType,
+ );
+ context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
+ }
+ dispatchFocusVisibleOutEvents(context, props, state);
}
-function dispatchFocusVisibleOutEvent(
+function dispatchFocusVisibleOutEvents(
context: ReactDOMResponderContext,
props: FocusProps,
state: FocusState,
) {
+ if (!state.isLocalFocusVisible) {
+ return;
+ }
+
const pointerType = state.pointerType;
const target = ((state.focusTarget: any): Element | Document);
- if (props.onFocusVisibleChange && state.isLocalFocusVisible) {
+ if (props.onFocusVisibleChange && state.isFocused) {
const listener = () => {
props.onFocusVisibleChange(false);
};
@@ -175,8 +232,20 @@ function dispatchFocusVisibleOutEvent(
pointerType,
);
context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
- state.isLocalFocusVisible = false;
}
+ if (props.onFocusVisibleWithinChange && state.isFocusWithin) {
+ const listener = () => {
+ props.onFocusVisibleWithinChange(false);
+ };
+ const syntheticEvent = createFocusEvent(
+ context,
+ 'focusvisiblewithinchange',
+ target,
+ pointerType,
+ );
+ context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
+ }
+ state.isLocalFocusVisible = false;
}
function unmountResponder(
@@ -184,7 +253,7 @@ function unmountResponder(
props: FocusProps,
state: FocusState,
): void {
- if (state.isFocused) {
+ if (state.isFocused || state.isFocusWithin) {
dispatchFocusOutEvents(context, props, state);
}
}
@@ -210,7 +279,7 @@ function handleRootPointerEvent(
state.focusTarget === context.getEventCurrentTarget(event) &&
(type === 'mousedown' || type === 'touchstart' || type === 'pointerdown')
) {
- dispatchFocusVisibleOutEvent(context, props, state);
+ dispatchFocusVisibleOutEvents(context, props, state);
}
}
@@ -224,6 +293,7 @@ const FocusResponder = {
focusTarget: null,
isFocused: false,
isLocalFocusVisible: false,
+ isFocusWithin: false,
pointerType: '',
};
},
@@ -247,22 +317,35 @@ const FocusResponder = {
switch (type) {
case 'focus': {
- if (!state.isFocused) {
- // Limit focus events to the direct child of the event component.
- // Browser focus is not expected to bubble.
+ if (!state.isFocused || !state.isFocusWithin) {
state.focusTarget = context.getEventCurrentTarget(event);
- if (state.focusTarget === target) {
- state.isFocused = true;
- state.isLocalFocusVisible = isGlobalFocusVisible;
- dispatchFocusInEvents(context, props, state);
- }
+ }
+
+ state.isLocalFocusVisible = isGlobalFocusVisible;
+
+ // Limit focus events to the direct child of the event component
+ // Browser focus is not expected to bubble.
+ if (!state.isFocused && state.focusTarget === target) {
+ state.isFocused = true;
+ dispatchFocusInEvents(context, props, state);
+ }
+ if (!state.isFocusWithin) {
+ state.isFocusWithin = true;
+ dispatchFocusWithinEvents(context, props, state);
}
break;
}
case 'blur': {
- if (state.isFocused) {
+ // Ignore blur events when focus moves within the subtree
+ let relatedTarget = ((event.nativeEvent: any).relatedTarget: Element);
+ if (context.isTargetWithinEventComponent(relatedTarget)) {
+ break;
+ }
+
+ if (state.isFocused || state.isFocusWithin) {
dispatchFocusOutEvents(context, props, state);
state.isFocused = false;
+ state.isFocusWithin = false;
state.focusTarget = null;
}
break;
diff --git a/packages/react-events/src/__tests__/Focus-test.internal.js b/packages/react-events/src/__tests__/Focus-test.internal.js
index ef2cced2f55..fd50d6930e7 100644
--- a/packages/react-events/src/__tests__/Focus-test.internal.js
+++ b/packages/react-events/src/__tests__/Focus-test.internal.js
@@ -237,14 +237,17 @@ describe('Focus event responder', () => {
});
describe('onFocusChange', () => {
- let onFocusChange, ref;
+ let onFocusChange, ref, innerRef;
beforeEach(() => {
onFocusChange = jest.fn();
ref = React.createRef();
+ innerRef = React.createRef();
const element = (
-
+
);
ReactDOM.render(element, container);
@@ -258,17 +261,76 @@ describe('Focus event responder', () => {
expect(onFocusChange).toHaveBeenCalledTimes(2);
expect(onFocusChange).toHaveBeenCalledWith(false);
});
+
+ it('is not called after "blur" and "focus" events on nested descendants', () => {
+ innerRef.current.dispatchEvent(createFocusEvent('focus'));
+ expect(onFocusChange).toHaveBeenCalledTimes(0);
+ innerRef.current.dispatchEvent(createFocusEvent('blur'));
+ expect(onFocusChange).toHaveBeenCalledTimes(0);
+ });
+ });
+
+ describe('onFocusWithinChange', () => {
+ let onFocusWithinChange, ref, innerRef, innerRef2;
+
+ beforeEach(() => {
+ onFocusWithinChange = jest.fn();
+ ref = React.createRef();
+ innerRef = React.createRef();
+ innerRef2 = React.createRef();
+ const element = (
+
+
+
+ );
+ ReactDOM.render(element, container);
+ });
+
+ it('is called after "blur" and "focus" events on immediate child', () => {
+ ref.current.dispatchEvent(createFocusEvent('focus'));
+ expect(onFocusWithinChange).toHaveBeenCalledTimes(1);
+ expect(onFocusWithinChange).toHaveBeenCalledWith(true);
+ ref.current.dispatchEvent(createFocusEvent('blur'));
+ expect(onFocusWithinChange).toHaveBeenCalledTimes(2);
+ expect(onFocusWithinChange).toHaveBeenCalledWith(false);
+ });
+
+ it('is called after "blur" and "focus" events on descendants', () => {
+ innerRef.current.dispatchEvent(createFocusEvent('focus'));
+ expect(onFocusWithinChange).toHaveBeenCalledTimes(1);
+ expect(onFocusWithinChange).toHaveBeenCalledWith(true);
+ innerRef.current.dispatchEvent(createFocusEvent('blur'));
+ expect(onFocusWithinChange).toHaveBeenCalledTimes(2);
+ expect(onFocusWithinChange).toHaveBeenCalledWith(false);
+ });
+
+ it('is only called once when focus moves within the subtree', () => {
+ innerRef.current.dispatchEvent(createFocusEvent('focus'));
+ innerRef2.current.dispatchEvent(createFocusEvent('focus'));
+ ref.current.dispatchEvent(createFocusEvent('focus'));
+ expect(onFocusWithinChange).toHaveBeenCalledTimes(1);
+ expect(onFocusWithinChange).toHaveBeenCalledWith(true);
+ innerRef.current.dispatchEvent(createFocusEvent('blur'));
+ expect(onFocusWithinChange).toHaveBeenCalledTimes(2);
+ expect(onFocusWithinChange).toHaveBeenCalledWith(false);
+ });
});
describe('onFocusVisibleChange', () => {
- let onFocusVisibleChange, ref;
+ let onFocusVisibleChange, ref, innerRef;
beforeEach(() => {
onFocusVisibleChange = jest.fn();
ref = React.createRef();
+ innerRef = React.createRef();
const element = (
-
+
);
ReactDOM.render(element, container);
@@ -307,6 +369,92 @@ describe('Focus event responder', () => {
ref.current.dispatchEvent(createFocusEvent('blur'));
expect(onFocusVisibleChange).toHaveBeenCalledTimes(0);
});
+
+ it('is not called after "blur" and "focus" events on nested descendants', () => {
+ container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
+ innerRef.current.dispatchEvent(createFocusEvent('focus'));
+ expect(onFocusVisibleChange).toHaveBeenCalledTimes(0);
+ innerRef.current.dispatchEvent(createFocusEvent('blur'));
+ expect(onFocusVisibleChange).toHaveBeenCalledTimes(0);
+ });
+ });
+
+ describe('onFocusVisibleWithinChange', () => {
+ let onFocusVisibleWithinChange, ref, innerRef, innerRef2;
+
+ beforeEach(() => {
+ onFocusVisibleWithinChange = jest.fn();
+ ref = React.createRef();
+ innerRef = React.createRef();
+ innerRef2 = React.createRef();
+ const element = (
+
+
+
+
+
+
+ );
+ ReactDOM.render(element, container);
+ });
+
+ it('is called after "focus" and "blur" if keyboard navigation is active on immediate child', () => {
+ // use keyboard first
+ container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
+ ref.current.dispatchEvent(createFocusEvent('focus'));
+ expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(1);
+ expect(onFocusVisibleWithinChange).toHaveBeenCalledWith(true);
+ ref.current.dispatchEvent(createFocusEvent('blur'));
+ expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(2);
+ expect(onFocusVisibleWithinChange).toHaveBeenCalledWith(false);
+ });
+
+ it('is called after "focus" and "blur" if keyboard navigation is active on nested descendants', () => {
+ // use keyboard first
+ container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
+ innerRef.current.dispatchEvent(createFocusEvent('focus'));
+ expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(1);
+ expect(onFocusVisibleWithinChange).toHaveBeenCalledWith(true);
+ innerRef.current.dispatchEvent(createFocusEvent('blur'));
+ expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(2);
+ expect(onFocusVisibleWithinChange).toHaveBeenCalledWith(false);
+ });
+
+ it('is called if non-keyboard event is dispatched on target previously focused with keyboard', () => {
+ // use keyboard first
+ container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
+ innerRef.current.dispatchEvent(createFocusEvent('focus'));
+ expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(1);
+ expect(onFocusVisibleWithinChange).toHaveBeenCalledWith(true);
+ // then use pointer on the target, focus should no longer be visible
+ innerRef.current.dispatchEvent(createPointerEvent('pointerdown'));
+ expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(2);
+ expect(onFocusVisibleWithinChange).toHaveBeenCalledWith(false);
+ // onFocusVisibleChange should not be called again
+ innerRef.current.dispatchEvent(createFocusEvent('blur'));
+ expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(2);
+ });
+
+ it('is not called after "focus" and "blur" events without keyboard', () => {
+ innerRef.current.dispatchEvent(createPointerEvent('pointerdown'));
+ innerRef.current.dispatchEvent(createFocusEvent('focus'));
+ container.dispatchEvent(createPointerEvent('pointerdown'));
+ innerRef.current.dispatchEvent(createFocusEvent('blur'));
+ expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(0);
+ });
+
+ it('is only called once when focus moves within the subtree', () => {
+ // use keyboard first
+ container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
+ innerRef.current.focus();
+ innerRef2.current.focus();
+ ref.current.focus();
+ expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(1);
+ expect(onFocusVisibleWithinChange).toHaveBeenCalledWith(true);
+ innerRef.current.dispatchEvent(createFocusEvent('blur'));
+ expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(2);
+ expect(onFocusVisibleWithinChange).toHaveBeenCalledWith(false);
+ });
});
describe('nested Focus components', () => {