diff --git a/packages/react-events/docs/Focus.md b/packages/react-events/docs/Focus.md
index ea4151bf7302d..1fef78e370ff4 100644
--- a/packages/react-events/docs/Focus.md
+++ b/packages/react-events/docs/Focus.md
@@ -1,21 +1,31 @@
# Focus
The `Focus` module responds to focus and blur events on its child. Focus events
-are dispatched for `mouse`, `pen`, `touch`, and `keyboard`
-pointer types.
+are dispatched for all input types, with the exception of `onFocusVisibleChange`
+which is only dispatched when focusing with a keyboard.
Focus events do not propagate between `Focus` event responders.
```js
// Example
-const TextField = (props) => (
-
-
-
-);
+const Button = (props) => {
+ const [ focusVisible, setFocusVisible ] = useState(false);
+
+ return (
+
+
+ );
+};
```
## Types
@@ -23,7 +33,7 @@ const TextField = (props) => (
```js
type FocusEvent = {
target: Element,
- type: 'blur' | 'focus' | 'focuschange'
+ type: 'blur' | 'focus' | 'focuschange' | 'focusvisiblechange'
}
```
@@ -43,5 +53,10 @@ Called when the element gains focus.
### onFocusChange: boolean => void
-Called when the element changes hover state (i.e., after `onBlur` and
+Called when the element changes focus state (i.e., after `onBlur` and
`onFocus`).
+
+### onFocusVisibleChange: boolean => void
+
+Called when the element 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 052a6e0636903..0ee2908c0e96c 100644
--- a/packages/react-events/src/Focus.js
+++ b/packages/react-events/src/Focus.js
@@ -19,14 +19,16 @@ type FocusProps = {
onBlur: (e: FocusEvent) => void,
onFocus: (e: FocusEvent) => void,
onFocusChange: boolean => void,
+ onFocusVisibleChange: boolean => void,
};
type FocusState = {
- isFocused: boolean,
focusTarget: null | Element | Document,
+ isFocused: boolean,
+ isLocalFocusVisible: boolean,
};
-type FocusEventType = 'focus' | 'blur' | 'focuschange';
+type FocusEventType = 'focus' | 'blur' | 'focuschange' | 'focusvisiblechange';
type FocusEvent = {|
target: Element | Document,
@@ -38,6 +40,21 @@ const targetEventTypes = [
{name: 'blur', passive: true, capture: true},
];
+const rootEventTypes = [
+ 'keydown',
+ 'keypress',
+ 'keyup',
+ 'mousemove',
+ 'mousedown',
+ 'mouseup',
+ 'pointermove',
+ 'pointerdown',
+ 'pointerup',
+ 'touchmove',
+ 'touchstart',
+ 'touchend',
+];
+
function createFocusEvent(
type: FocusEventType,
target: Element | Document,
@@ -65,6 +82,13 @@ function dispatchFocusInEvents(
const syntheticEvent = createFocusEvent('focuschange', target);
context.dispatchEvent(syntheticEvent, listener, {discrete: true});
}
+ if (props.onFocusVisibleChange && state.isLocalFocusVisible) {
+ const listener = () => {
+ props.onFocusVisibleChange(true);
+ };
+ const syntheticEvent = createFocusEvent('focusvisiblechange', target);
+ context.dispatchEvent(syntheticEvent, listener, {discrete: true});
+ }
}
function dispatchFocusOutEvents(
@@ -84,6 +108,23 @@ function dispatchFocusOutEvents(
const syntheticEvent = createFocusEvent('focuschange', target);
context.dispatchEvent(syntheticEvent, listener, {discrete: true});
}
+ dispatchFocusVisibleOutEvent(context, props, state);
+}
+
+function dispatchFocusVisibleOutEvent(
+ context: ReactResponderContext,
+ props: FocusProps,
+ state: FocusState,
+) {
+ const target = ((state.focusTarget: any): Element | Document);
+ if (props.onFocusVisibleChange && state.isLocalFocusVisible) {
+ const listener = () => {
+ props.onFocusVisibleChange(false);
+ };
+ const syntheticEvent = createFocusEvent('focusvisiblechange', target);
+ context.dispatchEvent(syntheticEvent, listener, {discrete: true});
+ state.isLocalFocusVisible = false;
+ }
}
function unmountResponder(
@@ -96,12 +137,16 @@ function unmountResponder(
}
}
+let isGlobalFocusVisible = true;
+
const FocusResponder = {
targetEventTypes,
+ rootEventTypes,
createInitialState(): FocusState {
return {
- isFocused: false,
focusTarget: null,
+ isFocused: false,
+ isLocalFocusVisible: false,
};
},
stopLocalPropagation: true,
@@ -129,8 +174,9 @@ const FocusResponder = {
// Browser focus is not expected to bubble.
state.focusTarget = getEventCurrentTarget(event, context);
if (state.focusTarget === target) {
- dispatchFocusInEvents(context, props, state);
state.isFocused = true;
+ state.isLocalFocusVisible = isGlobalFocusVisible;
+ dispatchFocusInEvents(context, props, state);
}
}
break;
@@ -145,6 +191,59 @@ const FocusResponder = {
}
}
},
+ onRootEvent(
+ event: ReactResponderEvent,
+ context: ReactResponderContext,
+ props: FocusProps,
+ state: FocusState,
+ ): void {
+ const {type, target} = event;
+
+ switch (type) {
+ case 'mousemove':
+ case 'mousedown':
+ case 'mouseup':
+ case 'pointermove':
+ case 'pointerdown':
+ case 'pointerup':
+ case 'touchmove':
+ case 'touchstart':
+ case 'touchend': {
+ // Ignore a Safari quirks where 'mousemove' is dispatched on the 'html'
+ // element when the window blurs.
+ if (type === 'mousemove' && target.nodeName === 'HTML') {
+ return;
+ }
+
+ isGlobalFocusVisible = false;
+
+ // Focus should stop being visible if a pointer is used on the element
+ // after it was focused using a keyboard.
+ if (
+ state.focusTarget === getEventCurrentTarget(event, context) &&
+ (type === 'mousedown' ||
+ type === 'touchstart' ||
+ type === 'pointerdown')
+ ) {
+ dispatchFocusVisibleOutEvent(context, props, state);
+ }
+ break;
+ }
+
+ case 'keydown':
+ case 'keypress':
+ case 'keyup': {
+ const nativeEvent = event.nativeEvent;
+ if (
+ nativeEvent.key === 'Tab' &&
+ !(nativeEvent.metaKey || nativeEvent.altKey || nativeEvent.ctrlKey)
+ ) {
+ isGlobalFocusVisible = true;
+ }
+ break;
+ }
+ }
+ },
onUnmount(
context: ReactResponderContext,
props: FocusProps,
diff --git a/packages/react-events/src/__tests__/Focus-test.internal.js b/packages/react-events/src/__tests__/Focus-test.internal.js
index 5c8f6789470d1..e91c29a20d5d5 100644
--- a/packages/react-events/src/__tests__/Focus-test.internal.js
+++ b/packages/react-events/src/__tests__/Focus-test.internal.js
@@ -20,6 +20,25 @@ const createFocusEvent = type => {
return event;
};
+const createKeyboardEvent = (type, data) => {
+ return new KeyboardEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ ...data,
+ });
+};
+
+const createPointerEvent = (type, data) => {
+ const event = document.createEvent('CustomEvent');
+ event.initCustomEvent(type, true, true);
+ if (data != null) {
+ Object.entries(data).forEach(([key, value]) => {
+ event[key] = value;
+ });
+ }
+ return event;
+};
+
describe('Focus event responder', () => {
let container;
@@ -138,6 +157,55 @@ describe('Focus event responder', () => {
});
});
+ describe('onFocusVisibleChange', () => {
+ let onFocusVisibleChange, ref;
+
+ beforeEach(() => {
+ onFocusVisibleChange = jest.fn();
+ ref = React.createRef();
+ const element = (
+
+
+
+ );
+ ReactDOM.render(element, container);
+ });
+
+ it('is called after "focus" and "blur" if keyboard navigation is active', () => {
+ // use keyboard first
+ container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'}));
+ ref.current.dispatchEvent(createFocusEvent('focus'));
+ expect(onFocusVisibleChange).toHaveBeenCalledTimes(1);
+ expect(onFocusVisibleChange).toHaveBeenCalledWith(true);
+ ref.current.dispatchEvent(createFocusEvent('blur'));
+ expect(onFocusVisibleChange).toHaveBeenCalledTimes(2);
+ expect(onFocusVisibleChange).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'}));
+ ref.current.dispatchEvent(createFocusEvent('focus'));
+ expect(onFocusVisibleChange).toHaveBeenCalledTimes(1);
+ expect(onFocusVisibleChange).toHaveBeenCalledWith(true);
+ // then use pointer on the target, focus should no longer be visible
+ ref.current.dispatchEvent(createPointerEvent('pointerdown'));
+ expect(onFocusVisibleChange).toHaveBeenCalledTimes(2);
+ expect(onFocusVisibleChange).toHaveBeenCalledWith(false);
+ // onFocusVisibleChange should not be called again
+ ref.current.dispatchEvent(createFocusEvent('blur'));
+ expect(onFocusVisibleChange).toHaveBeenCalledTimes(2);
+ });
+
+ it('is not called after "focus" and "blur" events without keyboard', () => {
+ ref.current.dispatchEvent(createPointerEvent('pointerdown'));
+ ref.current.dispatchEvent(createFocusEvent('focus'));
+ container.dispatchEvent(createPointerEvent('pointerdown'));
+ ref.current.dispatchEvent(createFocusEvent('blur'));
+ expect(onFocusVisibleChange).toHaveBeenCalledTimes(0);
+ });
+ });
+
describe('nested Focus components', () => {
it('do not propagate events by default', () => {
const events = [];
diff --git a/packages/react-events/src/__tests__/Press-test.internal.js b/packages/react-events/src/__tests__/Press-test.internal.js
index cb770076aee46..f87b8968bc9aa 100644
--- a/packages/react-events/src/__tests__/Press-test.internal.js
+++ b/packages/react-events/src/__tests__/Press-test.internal.js
@@ -1090,10 +1090,10 @@ describe('Event responder: Press', () => {
ref.current.dispatchEvent(
createPointerEvent('pointermove', coordinatesInside),
);
- ref.current.dispatchEvent(
+ container.dispatchEvent(
createPointerEvent('pointermove', coordinatesOutside),
);
- ref.current.dispatchEvent(
+ container.dispatchEvent(
createPointerEvent('pointerup', coordinatesOutside),
);
jest.runAllTimers();
@@ -1135,13 +1135,13 @@ describe('Event responder: Press', () => {
ref.current.dispatchEvent(
createPointerEvent('pointermove', coordinatesInside),
);
- ref.current.dispatchEvent(
+ container.dispatchEvent(
createPointerEvent('pointermove', coordinatesOutside),
);
jest.runAllTimers();
expect(events).toEqual(['onPressMove']);
events = [];
- ref.current.dispatchEvent(
+ container.dispatchEvent(
createPointerEvent('pointerup', coordinatesOutside),
);
jest.runAllTimers();