diff --git a/packages/react-dom/src/client/focus/TabFocusContainer.js b/packages/react-dom/src/client/focus/TabFocusContainer.js
new file mode 100644
index 0000000000000..b6d930f840487
--- /dev/null
+++ b/packages/react-dom/src/client/focus/TabFocusContainer.js
@@ -0,0 +1,81 @@
+/**
+ * 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 React from 'react';
+import {TabbableScope} from './TabbableScope';
+import {useKeyboard} from 'react-events/keyboard';
+
+type TabFocusContainerProps = {
+ children: React.Node,
+};
+
+type KeyboardEventType = 'keydown' | 'keyup';
+
+type KeyboardEvent = {|
+ altKey: boolean,
+ ctrlKey: boolean,
+ isComposing: boolean,
+ key: string,
+ location: number,
+ metaKey: boolean,
+ repeat: boolean,
+ shiftKey: boolean,
+ target: Element | Document,
+ type: KeyboardEventType,
+ timeStamp: number,
+ defaultPrevented: boolean,
+|};
+
+const {useRef} = React;
+
+export function TabFocusContainer({
+ children,
+}: TabFocusContainerProps): React.Node {
+ const scopeRef = useRef(null);
+ const keyboard = useKeyboard({onKeyDown, preventKeys: ['tab']});
+
+ function onKeyDown(event: KeyboardEvent): boolean {
+ if (event.key !== 'Tab') {
+ return true;
+ }
+ const tabbableScope = scopeRef.current;
+ const tabbableNodes = tabbableScope.getScopedNodes();
+ const currentIndex = tabbableNodes.indexOf(document.activeElement);
+ const firstTabbableElem = tabbableNodes[0];
+ const lastTabbableElem = tabbableNodes[tabbableNodes.length - 1];
+
+ // We want to wrap focus back to start/end depending if
+ // shift is pressed when tabbing.
+ if (currentIndex === -1) {
+ firstTabbableElem.focus();
+ } else {
+ const focusedElement = tabbableNodes[currentIndex];
+ if (event.shiftKey) {
+ if (focusedElement === firstTabbableElem) {
+ lastTabbableElem.focus();
+ } else {
+ tabbableNodes[currentIndex - 1].focus();
+ }
+ } else {
+ if (focusedElement === lastTabbableElem) {
+ firstTabbableElem.focus();
+ } else {
+ tabbableNodes[currentIndex + 1].focus();
+ }
+ }
+ }
+ return false;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/react-dom/src/client/focus/TabbableScope.js b/packages/react-dom/src/client/focus/TabbableScope.js
new file mode 100644
index 0000000000000..57b86d5286816
--- /dev/null
+++ b/packages/react-dom/src/client/focus/TabbableScope.js
@@ -0,0 +1,35 @@
+/**
+ * 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 React from 'react';
+
+export const TabbableScope = React.unstable_createScope(
+ (type: string, props: Object): boolean => {
+ if (props.tabIndex === -1 || props.disabled) {
+ return false;
+ }
+ if (props.tabIndex === 0 || props.contentEditable === true) {
+ return true;
+ }
+ if (type === 'a' || type === 'area') {
+ return !!props.href && props.rel !== 'ignore';
+ }
+ if (type === 'input') {
+ return props.type !== 'hidden' && props.type !== 'file';
+ }
+ return (
+ type === 'button' ||
+ type === 'textarea' ||
+ type === 'object' ||
+ type === 'select' ||
+ type === 'iframe' ||
+ type === 'embed'
+ );
+ },
+);
diff --git a/packages/react-dom/src/client/focus/__tests__/TabFocusContainer-test.internal.js b/packages/react-dom/src/client/focus/__tests__/TabFocusContainer-test.internal.js
new file mode 100644
index 0000000000000..4f1872d6e225b
--- /dev/null
+++ b/packages/react-dom/src/client/focus/__tests__/TabFocusContainer-test.internal.js
@@ -0,0 +1,225 @@
+/**
+ * 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 {createEventTarget} from 'react-events/src/dom/testing-library';
+
+let React;
+let ReactFeatureFlags;
+let TabFocusContainer;
+
+describe('TabFocusContainer', () => {
+ beforeEach(() => {
+ jest.resetModules();
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
+ ReactFeatureFlags.enableScopeAPI = true;
+ ReactFeatureFlags.enableFlareAPI = true;
+ TabFocusContainer = require('../TabFocusContainer').TabFocusContainer;
+ React = require('react');
+ });
+
+ describe('ReactDOM', () => {
+ let ReactDOM;
+ let container;
+
+ beforeEach(() => {
+ ReactDOM = require('react-dom');
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(container);
+ container = null;
+ });
+
+ it('should work as expected with simple tab operations', () => {
+ const inputRef = React.createRef();
+ const input2Ref = React.createRef();
+ const buttonRef = React.createRef();
+ const butto2nRef = React.createRef();
+ const divRef = React.createRef();
+
+ const Test = () => (
+
+
+
+
+
+
+
+ );
+
+ ReactDOM.render(, container);
+ inputRef.current.focus();
+ createEventTarget(document.activeElement).tabNext();
+ expect(document.activeElement).toBe(buttonRef.current);
+ createEventTarget(document.activeElement).tabNext();
+ expect(document.activeElement).toBe(divRef.current);
+ createEventTarget(document.activeElement).tabNext();
+ expect(document.activeElement).toBe(butto2nRef.current);
+ createEventTarget(document.activeElement).tabPrevious();
+ expect(document.activeElement).toBe(divRef.current);
+ });
+
+ it('should work as expected with wrapping tab operations', () => {
+ const inputRef = React.createRef();
+ const input2Ref = React.createRef();
+ const buttonRef = React.createRef();
+ const button2Ref = React.createRef();
+
+ const Test = () => (
+
+
+
+
+
+
+ );
+
+ ReactDOM.render(, container);
+ buttonRef.current.focus();
+ createEventTarget(document.activeElement).tabNext();
+ expect(document.activeElement).toBe(button2Ref.current);
+ createEventTarget(document.activeElement).tabNext();
+ expect(document.activeElement).toBe(buttonRef.current);
+ createEventTarget(document.activeElement).tabNext();
+ expect(document.activeElement).toBe(button2Ref.current);
+ createEventTarget(document.activeElement).tabPrevious();
+ expect(document.activeElement).toBe(buttonRef.current);
+ createEventTarget(document.activeElement).tabPrevious();
+ expect(document.activeElement).toBe(button2Ref.current);
+ });
+
+ it('should work as expected when nested', () => {
+ const inputRef = React.createRef();
+ const input2Ref = React.createRef();
+ const buttonRef = React.createRef();
+ const button2Ref = React.createRef();
+ const button3Ref = React.createRef();
+ const button4Ref = React.createRef();
+
+ const Test = () => (
+
+
+
+
+
+
+
+
+
+
+ );
+
+ ReactDOM.render(, container);
+ buttonRef.current.focus();
+ expect(document.activeElement).toBe(buttonRef.current);
+ createEventTarget(document.activeElement).tabNext();
+ expect(document.activeElement).toBe(button2Ref.current);
+ createEventTarget(document.activeElement).tabNext();
+ expect(document.activeElement).toBe(button3Ref.current);
+ createEventTarget(document.activeElement).tabNext();
+ expect(document.activeElement).toBe(button2Ref.current);
+ // Focus is contained, so have to manually move it out
+ button4Ref.current.focus();
+ createEventTarget(document.activeElement).tabNext();
+ expect(document.activeElement).toBe(buttonRef.current);
+ createEventTarget(document.activeElement).tabPrevious();
+ expect(document.activeElement).toBe(button4Ref.current);
+ createEventTarget(document.activeElement).tabPrevious();
+ expect(document.activeElement).toBe(button3Ref.current);
+ createEventTarget(document.activeElement).tabPrevious();
+ expect(document.activeElement).toBe(button2Ref.current);
+ });
+
+ it('should work as expected when nested with scope that is contained', () => {
+ const inputRef = React.createRef();
+ const input2Ref = React.createRef();
+ const buttonRef = React.createRef();
+ const button2Ref = React.createRef();
+ const button3Ref = React.createRef();
+ const button4Ref = React.createRef();
+
+ const Test = () => (
+
+
+
+
+
+
+
+
+
+
+ );
+
+ ReactDOM.render(, container);
+ buttonRef.current.focus();
+ expect(document.activeElement).toBe(buttonRef.current);
+ createEventTarget(document.activeElement).tabNext();
+ expect(document.activeElement).toBe(button2Ref.current);
+ createEventTarget(document.activeElement).tabNext();
+ expect(document.activeElement).toBe(button3Ref.current);
+ createEventTarget(document.activeElement).tabNext();
+ expect(document.activeElement).toBe(button2Ref.current);
+ createEventTarget(document.activeElement).tabPrevious();
+ expect(document.activeElement).toBe(button3Ref.current);
+ createEventTarget(document.activeElement).tabPrevious();
+ expect(document.activeElement).toBe(button2Ref.current);
+ });
+
+ it('should work as expected with suspense fallbacks', () => {
+ const buttonRef = React.createRef();
+ const button2Ref = React.createRef();
+ const button3Ref = React.createRef();
+ const button4Ref = React.createRef();
+ const button5Ref = React.createRef();
+
+ function SuspendedComponent() {
+ throw new Promise(() => {
+ // Never resolve
+ });
+ }
+
+ function Component() {
+ return (
+
+
+
+
+ );
+ }
+
+ const Test = () => (
+
+
+
+ }>
+
+
+
+
+ );
+
+ ReactDOM.render(, container);
+ buttonRef.current.focus();
+ expect(document.activeElement).toBe(buttonRef.current);
+ createEventTarget(document.activeElement).tabNext();
+ expect(document.activeElement).toBe(button2Ref.current);
+ createEventTarget(document.activeElement).tabNext();
+ expect(document.activeElement).toBe(button3Ref.current);
+ createEventTarget(document.activeElement).tabNext();
+ expect(document.activeElement).toBe(button4Ref.current);
+ createEventTarget(document.activeElement).tabPrevious();
+ expect(document.activeElement).toBe(button3Ref.current);
+ createEventTarget(document.activeElement).tabPrevious();
+ expect(document.activeElement).toBe(button2Ref.current);
+ });
+ });
+});
diff --git a/packages/react-dom/src/client/focus/__tests__/TabbableScope-test.internal.js b/packages/react-dom/src/client/focus/__tests__/TabbableScope-test.internal.js
new file mode 100644
index 0000000000000..4f5aa09161603
--- /dev/null
+++ b/packages/react-dom/src/client/focus/__tests__/TabbableScope-test.internal.js
@@ -0,0 +1,73 @@
+/**
+ * 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
+ */
+
+let React;
+let ReactFeatureFlags;
+let TabbableScope;
+
+describe('TabbableScope', () => {
+ beforeEach(() => {
+ jest.resetModules();
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
+ ReactFeatureFlags.enableScopeAPI = true;
+ TabbableScope = require('../TabbableScope').TabbableScope;
+ React = require('react');
+ });
+
+ describe('ReactDOM', () => {
+ let ReactDOM;
+ let container;
+
+ beforeEach(() => {
+ ReactDOM = require('react-dom');
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(container);
+ container = null;
+ });
+
+ it('getScopedNodes() works as intended', () => {
+ const scopeRef = React.createRef();
+ const nodeRefA = React.createRef();
+ const nodeRefB = React.createRef();
+ const nodeRefC = React.createRef();
+ const nodeRefD = React.createRef();
+
+ function Test() {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ ReactDOM.render(, container);
+ let nodes = scopeRef.current.getScopedNodes();
+ expect(nodes).toEqual([
+ nodeRefA.current,
+ nodeRefB.current,
+ nodeRefC.current,
+ nodeRefD.current,
+ ]);
+ expect(nodes.length).toBe(4);
+ ReactDOM.render(null, container);
+ expect(scopeRef.current).toBe(null);
+ });
+ });
+});
diff --git a/packages/react-events/src/dom/Keyboard.js b/packages/react-events/src/dom/Keyboard.js
index ad9bbde916ab9..a41dbe8886e29 100644
--- a/packages/react-events/src/dom/Keyboard.js
+++ b/packages/react-events/src/dom/Keyboard.js
@@ -19,10 +19,10 @@ import type {ReactEventResponderListener} from 'shared/ReactTypes';
type KeyboardEventType = 'keydown' | 'keyup';
type KeyboardProps = {
- disabled: boolean,
- onKeyDown: (e: KeyboardEvent) => ?boolean,
- onKeyUp: (e: KeyboardEvent) => ?boolean,
- preventKeys: Array,
+ disabled?: boolean,
+ onKeyDown?: (e: KeyboardEvent) => ?boolean,
+ onKeyUp?: (e: KeyboardEvent) => ?boolean,
+ preventKeys?: Array,
};
type KeyboardEvent = {|
@@ -198,7 +198,7 @@ const keyboardResponderImpl = {
}
let defaultPrevented = nativeEvent.defaultPrevented === true;
if (type === 'keydown') {
- const preventKeys = props.preventKeys;
+ const preventKeys = ((props.preventKeys: any): Array);
if (!defaultPrevented && isArray(preventKeys)) {
preventKeyLoop: for (let i = 0; i < preventKeys.length; i++) {
const preventKey = preventKeys[i];
@@ -230,7 +230,7 @@ const keyboardResponderImpl = {
if (isFunction(onKeyDown)) {
dispatchKeyboardEvent(
event,
- onKeyDown,
+ ((onKeyDown: any): (e: KeyboardEvent) => ?boolean),
context,
'keydown',
defaultPrevented,
@@ -241,7 +241,7 @@ const keyboardResponderImpl = {
if (isFunction(onKeyUp)) {
dispatchKeyboardEvent(
event,
- onKeyUp,
+ ((onKeyUp: any): (e: KeyboardEvent) => ?boolean),
context,
'keyup',
defaultPrevented,
diff --git a/packages/react-events/src/dom/testing-library/index.js b/packages/react-events/src/dom/testing-library/index.js
index 40fb6beb80a02..d46f96a5058ce 100644
--- a/packages/react-events/src/dom/testing-library/index.js
+++ b/packages/react-events/src/dom/testing-library/index.js
@@ -50,6 +50,32 @@ const createEventTarget = node => ({
virtualclick(payload) {
node.dispatchEvent(domEvents.virtualclick(payload));
},
+ tabNext() {
+ node.dispatchEvent(
+ domEvents.keydown({
+ key: 'Tab',
+ }),
+ );
+ node.dispatchEvent(
+ domEvents.keyup({
+ key: 'Tab',
+ }),
+ );
+ },
+ tabPrevious() {
+ node.dispatchEvent(
+ domEvents.keydown({
+ key: 'Tab',
+ shiftKey: true,
+ }),
+ );
+ node.dispatchEvent(
+ domEvents.keyup({
+ key: 'Tab',
+ shiftKey: true,
+ }),
+ );
+ },
/**
* PointerEvent abstraction.
* Dispatches the expected sequence of PointerEvents, MouseEvents, and
diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js
index 6a59ce8607272..ffce3166eb40e 100644
--- a/packages/react-reconciler/src/ReactFiberCommitWork.js
+++ b/packages/react-reconciler/src/ReactFiberCommitWork.js
@@ -843,6 +843,12 @@ function commitUnmount(
}
}
}
+ return;
+ }
+ case ScopeComponent: {
+ if (enableScopeAPI) {
+ safelyDetachRef(current);
+ }
}
}
}
diff --git a/packages/react-reconciler/src/__tests__/ReactScope-test.internal.js b/packages/react-reconciler/src/__tests__/ReactScope-test.internal.js
index 9ceca7f77268e..b55422c3d531a 100644
--- a/packages/react-reconciler/src/__tests__/ReactScope-test.internal.js
+++ b/packages/react-reconciler/src/__tests__/ReactScope-test.internal.js
@@ -67,6 +67,8 @@ describe('ReactScope', () => {
ReactDOM.render(, container);
nodes = scopeRef.current.getScopedNodes();
expect(nodes).toEqual([aRef.current, divRef.current, spanRef.current]);
+ ReactDOM.render(null, container);
+ expect(scopeRef.current).toBe(null);
});
it('mixed getParent() and getScopedNodes() works as intended', () => {