diff --git a/packages/react-interactions/accessibility/tab-focus.js b/packages/react-interactions/accessibility/focus-manager.js
similarity index 81%
rename from packages/react-interactions/accessibility/tab-focus.js
rename to packages/react-interactions/accessibility/focus-manager.js
index 493333ccad3f6..56d396157c226 100644
--- a/packages/react-interactions/accessibility/tab-focus.js
+++ b/packages/react-interactions/accessibility/focus-manager.js
@@ -9,4 +9,4 @@
'use strict';
-module.exports = require('./src/TabFocus');
+module.exports = require('./src/FocusManager');
diff --git a/packages/react-interactions/accessibility/src/FocusManager.js b/packages/react-interactions/accessibility/src/FocusManager.js
new file mode 100644
index 0000000000000..34d4b9e44cfcc
--- /dev/null
+++ b/packages/react-interactions/accessibility/src/FocusManager.js
@@ -0,0 +1,114 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+import type {ReactScope} from 'shared/ReactTypes';
+import type {KeyboardEvent} from 'react-interactions/events/keyboard';
+
+import React from 'react';
+import {useKeyboard} from 'react-interactions/events/keyboard';
+import {useFocusWithin} from 'react-interactions/events/focus';
+import {
+ focusFirst,
+ focusPrevious,
+ focusNext,
+} from 'react-interactions/accessibility/focus-control';
+import TabbableScope from 'react-interactions/accessibility/tabbable-scope';
+
+type TabFocusProps = {|
+ autoFocus?: boolean,
+ children: React.Node,
+ containFocus?: boolean,
+ restoreFocus?: boolean,
+ scope: ReactScope,
+|};
+
+const {useLayoutEffect, useRef} = React;
+
+const FocusManager = React.forwardRef(
+ (
+ {
+ autoFocus,
+ children,
+ containFocus,
+ restoreFocus,
+ scope: CustomScope,
+ }: TabFocusProps,
+ ref,
+ ): React.Node => {
+ const ScopeToUse = CustomScope || TabbableScope;
+ const scopeRef = useRef(null);
+ // This ensures tabbing works through the React tree (including Portals and Suspense nodes)
+ const keyboard = useKeyboard({
+ onKeyDown(event: KeyboardEvent): void {
+ if (event.key !== 'Tab') {
+ event.continuePropagation();
+ return;
+ }
+ const scope = scopeRef.current;
+ if (scope !== null) {
+ if (event.shiftKey) {
+ focusPrevious(scope, event, containFocus);
+ } else {
+ focusNext(scope, event, containFocus);
+ }
+ }
+ },
+ });
+ const focusWithin = useFocusWithin({
+ onBlurWithin: function(event) {
+ if (!containFocus) {
+ event.continuePropagation();
+ }
+ const lastNode = event.target;
+ if (lastNode) {
+ requestAnimationFrame(() => {
+ (lastNode: any).focus();
+ });
+ }
+ },
+ });
+ useLayoutEffect(
+ () => {
+ const scope = scopeRef.current;
+ let restoreElem;
+ if (restoreFocus) {
+ restoreElem = document.activeElement;
+ }
+ if (autoFocus && scope !== null) {
+ focusFirst(scope);
+ }
+ if (restoreElem) {
+ return () => {
+ (restoreElem: any).focus();
+ };
+ }
+ },
+ [scopeRef],
+ );
+
+ return (
+ {
+ if (ref) {
+ if (typeof ref === 'function') {
+ ref(node);
+ } else {
+ ref.current = node;
+ }
+ }
+ scopeRef.current = node;
+ }}
+ listeners={[keyboard, focusWithin]}>
+ {children}
+
+ );
+ },
+);
+
+export default FocusManager;
diff --git a/packages/react-interactions/accessibility/src/TabFocus.js b/packages/react-interactions/accessibility/src/TabFocus.js
deleted file mode 100644
index 76e528116cd2e..0000000000000
--- a/packages/react-interactions/accessibility/src/TabFocus.js
+++ /dev/null
@@ -1,67 +0,0 @@
-/**
- * Copyright (c) Facebook, Inc. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- * @flow
- */
-
-import type {ReactScope} from 'shared/ReactTypes';
-import type {KeyboardEvent} from 'react-interactions/events/keyboard';
-
-import React from 'react';
-import {useKeyboard} from 'react-interactions/events/keyboard';
-import {
- focusPrevious,
- focusNext,
-} from 'react-interactions/accessibility/focus-control';
-
-type TabFocusProps = {
- children: React.Node,
- contain?: boolean,
- scope: ReactScope,
-};
-
-const {useRef} = React;
-
-const TabFocus = React.forwardRef(
- ({children, contain, scope: Scope}: TabFocusProps, ref): React.Node => {
- const scopeRef = useRef(null);
- const keyboard = useKeyboard({
- onKeyDown(event: KeyboardEvent): void {
- if (event.key !== 'Tab') {
- event.continuePropagation();
- return;
- }
- const scope = scopeRef.current;
- if (scope !== null) {
- if (event.shiftKey) {
- focusPrevious(scope, event, contain);
- } else {
- focusNext(scope, event, contain);
- }
- }
- },
- });
-
- return (
- {
- if (ref) {
- if (typeof ref === 'function') {
- ref(node);
- } else {
- ref.current = node;
- }
- }
- scopeRef.current = node;
- }}
- listeners={keyboard}>
- {children}
-
- );
- },
-);
-
-export default TabFocus;
diff --git a/packages/react-interactions/accessibility/src/__tests__/TabFocus-test.internal.js b/packages/react-interactions/accessibility/src/__tests__/FocusManager-test.internal.js
similarity index 75%
rename from packages/react-interactions/accessibility/src/__tests__/TabFocus-test.internal.js
rename to packages/react-interactions/accessibility/src/__tests__/FocusManager-test.internal.js
index 92a2833b9c43c..1cf07104c0f37 100644
--- a/packages/react-interactions/accessibility/src/__tests__/TabFocus-test.internal.js
+++ b/packages/react-interactions/accessibility/src/__tests__/FocusManager-test.internal.js
@@ -11,18 +11,16 @@ import {createEventTarget} from 'react-interactions/events/src/dom/testing-libra
let React;
let ReactFeatureFlags;
-let TabFocus;
-let TabbableScope;
+let FocusManager;
let FocusControl;
-describe('TabFocusController', () => {
+describe('FocusManager', () => {
beforeEach(() => {
jest.resetModules();
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableScopeAPI = true;
ReactFeatureFlags.enableFlareAPI = true;
- TabFocus = require('../TabFocus').default;
- TabbableScope = require('../TabbableScope').default;
+ FocusManager = require('../FocusManager').default;
FocusControl = require('../FocusControl');
React = require('react');
});
@@ -42,21 +40,21 @@ describe('TabFocusController', () => {
container = null;
});
- it('handles tab operations', () => {
+ it('handles tab operations by default', () => {
const inputRef = React.createRef();
const input2Ref = React.createRef();
const buttonRef = React.createRef();
- const butto2nRef = React.createRef();
+ const button2Ref = React.createRef();
const divRef = React.createRef();
const Test = () => (
-
+
-
-
+
+
);
ReactDOM.render(, container);
@@ -66,24 +64,67 @@ describe('TabFocusController', () => {
createEventTarget(document.activeElement).tabNext();
expect(document.activeElement).toBe(divRef.current);
createEventTarget(document.activeElement).tabNext();
- expect(document.activeElement).toBe(butto2nRef.current);
+ expect(document.activeElement).toBe(button2Ref.current);
createEventTarget(document.activeElement).tabPrevious();
expect(document.activeElement).toBe(divRef.current);
});
- it('handles tab operations with containment', () => {
+ it('handles autoFocus', () => {
+ const buttonRef = React.createRef();
+
+ const Test = () => (
+
+
+
+
+ );
+
+ ReactDOM.render(, container);
+ expect(document.activeElement).toBe(buttonRef.current);
+ });
+
+ it('handles restoreFocus', () => {
+ const difRef = React.createRef();
+ const buttonRef = React.createRef();
+
+ const Test = ({flag}) => {
+ return (
+
+ {flag ? (
+
+
+
+ ) : null}
+
+ );
+ };
+
+ ReactDOM.render(, container);
+ difRef.current.focus();
+ expect(document.activeElement).toBe(difRef.current);
+ ReactDOM.render(, container);
+ expect(document.activeElement).toBe(buttonRef.current);
+ ReactDOM.render(, container);
+ expect(document.activeElement).toBe(difRef.current);
+ });
+
+ it('handles containFocus', () => {
const inputRef = React.createRef();
const input2Ref = React.createRef();
+ const input3Ref = React.createRef();
const buttonRef = React.createRef();
const button2Ref = React.createRef();
const Test = () => (
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
ReactDOM.render(, container);
@@ -98,9 +139,16 @@ describe('TabFocusController', () => {
expect(document.activeElement).toBe(buttonRef.current);
createEventTarget(document.activeElement).tabPrevious();
expect(document.activeElement).toBe(button2Ref.current);
+ // Focus should be restored to the contained area
+ const rAF = window.requestAnimationFrame;
+ window.requestAnimationFrame = x => setTimeout(x);
+ input3Ref.current.focus();
+ jest.advanceTimersByTime(1);
+ window.requestAnimationFrame = rAF;
+ expect(document.activeElement).toBe(button2Ref.current);
});
- it('handles tab operations when controllers are nested', () => {
+ it('works with nested FocusManagers', () => {
const inputRef = React.createRef();
const input2Ref = React.createRef();
const buttonRef = React.createRef();
@@ -109,16 +157,16 @@ describe('TabFocusController', () => {
const button4Ref = React.createRef();
const Test = () => (
-
+
-
+
-
+
-
+
);
ReactDOM.render(, container);
@@ -136,7 +184,7 @@ describe('TabFocusController', () => {
expect(document.activeElement).toBe(button2Ref.current);
});
- it('handles tab operations when controllers are nested with containment', () => {
+ it('handles containFocus (nested FocusManagers)', () => {
const inputRef = React.createRef();
const input2Ref = React.createRef();
const buttonRef = React.createRef();
@@ -145,16 +193,16 @@ describe('TabFocusController', () => {
const button4Ref = React.createRef();
const Test = () => (
-
+
-
+
-
+
-
+
);
ReactDOM.render(, container);
@@ -195,14 +243,14 @@ describe('TabFocusController', () => {
}
const Test = () => (
-
+
}>
-
+
);
ReactDOM.render(, container);
@@ -220,7 +268,7 @@ describe('TabFocusController', () => {
expect(document.activeElement).toBe(button2Ref.current);
});
- it('allows for imperative tab focus control', () => {
+ it('allows for imperative tab focus control using FocusControl', () => {
const firstFocusControllerRef = React.createRef();
const secondFocusControllerRef = React.createRef();
const buttonRef = React.createRef();
@@ -229,16 +277,16 @@ describe('TabFocusController', () => {
const Test = () => (
);
diff --git a/packages/react-interactions/events/src/dom/Focus.js b/packages/react-interactions/events/src/dom/Focus.js
index d3d98e7ccb32a..8ae33233ad33e 100644
--- a/packages/react-interactions/events/src/dom/Focus.js
+++ b/packages/react-interactions/events/src/dom/Focus.js
@@ -26,6 +26,7 @@ type FocusEvent = {|
type: FocusEventType | FocusWithinEventType,
pointerType: PointerType,
timeStamp: number,
+ continuePropagation: () => void,
|};
type FocusState = {
@@ -47,12 +48,16 @@ type FocusProps = {
type FocusEventType = 'focus' | 'blur' | 'focuschange' | 'focusvisiblechange';
type FocusWithinProps = {
- disabled: boolean,
- onFocusWithinChange: boolean => void,
- onFocusWithinVisibleChange: boolean => void,
+ disabled?: boolean,
+ onBlurWithin?: (e: FocusEvent) => void,
+ onFocusWithinChange?: boolean => void,
+ onFocusWithinVisibleChange?: boolean => void,
};
-type FocusWithinEventType = 'focuswithinvisiblechange' | 'focuswithinchange';
+type FocusWithinEventType =
+ | 'focuswithinvisiblechange'
+ | 'focuswithinchange'
+ | 'blurwithin';
/**
* Shared between Focus and FocusWithin
@@ -89,6 +94,14 @@ function createFocusEvent(
type,
pointerType,
timeStamp: context.getTimeStamp(),
+ // We don't use stopPropagation, as the default behavior
+ // is to not propagate. Plus, there might be confusion
+ // using stopPropagation as we don't actually stop
+ // native propagation from working, but instead only
+ // allow propagation to the others keyboard responders.
+ continuePropagation() {
+ context.continuePropagation();
+ },
};
}
@@ -226,6 +239,26 @@ function dispatchBlurEvents(
}
}
+function dispatchBlurWithinEvents(
+ context: ReactDOMResponderContext,
+ event: ReactDOMResponderEvent,
+ props: FocusWithinProps,
+ state: FocusState,
+) {
+ const pointerType = state.pointerType;
+ const target = ((state.focusTarget: any): Element | Document) || event.target;
+ const onBlurWithin = (props.onBlurWithin: any);
+ if (isFunction(onBlurWithin)) {
+ const syntheticEvent = createFocusEvent(
+ context,
+ 'blurwithin',
+ target,
+ pointerType,
+ );
+ context.dispatchEvent(syntheticEvent, onBlurWithin, DiscreteEvent);
+ }
+}
+
function dispatchFocusChange(
context: ReactDOMResponderContext,
props: FocusProps,
@@ -364,7 +397,7 @@ function dispatchFocusWithinChangeEvent(
state: FocusState,
value: boolean,
) {
- const onFocusWithinChange = props.onFocusWithinChange;
+ const onFocusWithinChange = (props.onFocusWithinChange: any);
if (isFunction(onFocusWithinChange)) {
context.dispatchEvent(value, onFocusWithinChange, DiscreteEvent);
}
@@ -379,7 +412,7 @@ function dispatchFocusWithinVisibleChangeEvent(
state: FocusState,
value: boolean,
) {
- const onFocusWithinVisibleChange = props.onFocusWithinVisibleChange;
+ const onFocusWithinVisibleChange = (props.onFocusWithinVisibleChange: any);
if (isFunction(onFocusWithinVisibleChange)) {
context.dispatchEvent(value, onFocusWithinVisibleChange, DiscreteEvent);
}
@@ -447,6 +480,7 @@ const focusWithinResponderImpl = {
!context.isTargetWithinResponder(relatedTarget)
) {
dispatchFocusWithinChangeEvent(context, props, state, false);
+ dispatchBlurWithinEvents(context, event, props, state);
state.isFocused = false;
}
break;
diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js
index 14b6c12689738..4fef2cad1abde 100644
--- a/scripts/rollup/bundles.js
+++ b/scripts/rollup/bundles.js
@@ -681,11 +681,12 @@ const bundles = [
{
bundleTypes: [NODE_DEV, NODE_PROD, FB_WWW_DEV, FB_WWW_PROD],
moduleType: NON_FIBER_RENDERER,
- entry: 'react-interactions/accessibility/tab-focus',
- global: 'ReactTabFocus',
+ entry: 'react-interactions/accessibility/focus-manager',
+ global: 'ReactFocusManager',
externals: [
'react',
'react-interactions/events/keyboard',
+ 'react-interactions/events/focus',
'react-interactions/accessibility/tabbable-scope',
'react-interactions/accessibility/focus-control',
],
@@ -709,6 +710,7 @@ const bundles = [
];
const fbBundleExternalsMap = {
+ 'react-interactions/events/focus': 'ReactEventsFocus',
'react-interactions/events/keyboard': 'ReactEventsKeyboard',
'react-interactions/events/tap': 'ReactEventsTap',
'react-interactions/accessibility/tabbable-scope': 'ReactTabbableScope',