diff --git a/packages/react-dom/src/client/focus/TabFocusContainer.js b/packages/react-dom/src/client/focus/TabFocusContainer.js
deleted file mode 100644
index b6d930f840487..0000000000000
--- a/packages/react-dom/src/client/focus/TabFocusContainer.js
+++ /dev/null
@@ -1,81 +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 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/TabFocusController.js b/packages/react-dom/src/client/focus/TabFocusController.js
new file mode 100644
index 0000000000000..fbf941fcefbf1
--- /dev/null
+++ b/packages/react-dom/src/client/focus/TabFocusController.js
@@ -0,0 +1,179 @@
+/**
+ * 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 TabFocusControllerProps = {
+ children: React.Node,
+ contain?: boolean,
+};
+
+type KeyboardEventType = 'keydown' | 'keyup';
+
+type KeyboardEvent = {|
+ altKey: boolean,
+ ctrlKey: boolean,
+ isComposing: boolean,
+ key: string,
+ metaKey: boolean,
+ shiftKey: boolean,
+ target: Element | Document,
+ type: KeyboardEventType,
+ timeStamp: number,
+ defaultPrevented: boolean,
+|};
+
+type ControllerHandle = {|
+ focusFirst: () => void,
+ focusNext: () => boolean,
+ focusPrevious: () => boolean,
+ getNextController: () => null | ControllerHandle,
+ getPreviousController: () => null | ControllerHandle,
+|};
+
+const {useImperativeHandle, useRef} = React;
+
+function getTabbableNodes(scopeRef) {
+ const tabbableScope = scopeRef.current;
+ const tabbableNodes = tabbableScope.getScopedNodes();
+ const firstTabbableElem = tabbableNodes[0];
+ const lastTabbableElem = tabbableNodes[tabbableNodes.length - 1];
+ const currentIndex = tabbableNodes.indexOf(document.activeElement);
+ let focusedElement = null;
+ if (currentIndex !== -1) {
+ focusedElement = tabbableNodes[currentIndex];
+ }
+ return [
+ tabbableNodes,
+ firstTabbableElem,
+ lastTabbableElem,
+ currentIndex,
+ focusedElement,
+ ];
+}
+
+export const TabFocusController = React.forwardRef(
+ ({children, contain}: TabFocusControllerProps, ref): React.Node => {
+ const scopeRef = useRef(null);
+ const keyboard = useKeyboard({
+ onKeyDown(event: KeyboardEvent): boolean {
+ if (event.key !== 'Tab') {
+ return true;
+ }
+ if (event.shiftKey) {
+ return focusPrevious();
+ } else {
+ return focusNext();
+ }
+ },
+ preventKeys: ['Tab', ['Tab', {shiftKey: true}]],
+ });
+
+ function focusFirst(): void {
+ const [, firstTabbableElem] = getTabbableNodes(scopeRef);
+ firstTabbableElem.focus();
+ }
+
+ function focusNext(): boolean {
+ const [
+ tabbableNodes,
+ firstTabbableElem,
+ lastTabbableElem,
+ currentIndex,
+ focusedElement,
+ ] = getTabbableNodes(scopeRef);
+
+ if (focusedElement === null) {
+ firstTabbableElem.focus();
+ } else if (focusedElement === lastTabbableElem) {
+ if (contain === true) {
+ firstTabbableElem.focus();
+ } else {
+ return true;
+ }
+ } else {
+ tabbableNodes[currentIndex + 1].focus();
+ }
+ return false;
+ }
+
+ function focusPrevious(): boolean {
+ const [
+ tabbableNodes,
+ firstTabbableElem,
+ lastTabbableElem,
+ currentIndex,
+ focusedElement,
+ ] = getTabbableNodes(scopeRef);
+
+ if (focusedElement === null) {
+ firstTabbableElem.focus();
+ } else if (focusedElement === firstTabbableElem) {
+ if (contain === true) {
+ lastTabbableElem.focus();
+ } else {
+ return true;
+ }
+ } else {
+ tabbableNodes[currentIndex - 1].focus();
+ }
+ return false;
+ }
+
+ function getPreviousController(): null | ControllerHandle {
+ const tabbableScope = scopeRef.current;
+ const allScopes = tabbableScope.getChildrenFromRoot();
+ if (allScopes === null) {
+ return null;
+ }
+ const currentScopeIndex = allScopes.indexOf(tabbableScope);
+ if (currentScopeIndex <= 0) {
+ return null;
+ }
+ return allScopes[currentScopeIndex - 1].getHandle();
+ }
+
+ function getNextController(): null | ControllerHandle {
+ const tabbableScope = scopeRef.current;
+ const allScopes = tabbableScope.getChildrenFromRoot();
+ if (allScopes === null) {
+ return null;
+ }
+ const currentScopeIndex = allScopes.indexOf(tabbableScope);
+ if (
+ currentScopeIndex === -1 ||
+ currentScopeIndex === allScopes.length - 1
+ ) {
+ return null;
+ }
+ return allScopes[currentScopeIndex + 1].getHandle();
+ }
+
+ const controllerHandle: ControllerHandle = {
+ focusFirst,
+ focusNext,
+ focusPrevious,
+ getNextController,
+ getPreviousController,
+ };
+
+ useImperativeHandle(ref, () => controllerHandle);
+
+ return (
+
+ {children}
+
+ );
+ },
+);
diff --git a/packages/react-dom/src/client/focus/__tests__/TabFocusContainer-test.internal.js b/packages/react-dom/src/client/focus/__tests__/TabFocusController-test.internal.js
similarity index 75%
rename from packages/react-dom/src/client/focus/__tests__/TabFocusContainer-test.internal.js
rename to packages/react-dom/src/client/focus/__tests__/TabFocusController-test.internal.js
index 4f1872d6e225b..71cc56301f5ee 100644
--- a/packages/react-dom/src/client/focus/__tests__/TabFocusContainer-test.internal.js
+++ b/packages/react-dom/src/client/focus/__tests__/TabFocusController-test.internal.js
@@ -11,15 +11,15 @@ import {createEventTarget} from 'react-events/src/dom/testing-library';
let React;
let ReactFeatureFlags;
-let TabFocusContainer;
+let TabFocusController;
-describe('TabFocusContainer', () => {
+describe('TabFocusController', () => {
beforeEach(() => {
jest.resetModules();
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableScopeAPI = true;
ReactFeatureFlags.enableFlareAPI = true;
- TabFocusContainer = require('../TabFocusContainer').TabFocusContainer;
+ TabFocusController = require('../TabFocusController').TabFocusController;
React = require('react');
});
@@ -38,7 +38,7 @@ describe('TabFocusContainer', () => {
container = null;
});
- it('should work as expected with simple tab operations', () => {
+ it('handles tab operations', () => {
const inputRef = React.createRef();
const input2Ref = React.createRef();
const buttonRef = React.createRef();
@@ -46,13 +46,13 @@ describe('TabFocusContainer', () => {
const divRef = React.createRef();
const Test = () => (
-
+
-
+
);
ReactDOM.render(, container);
@@ -67,19 +67,19 @@ describe('TabFocusContainer', () => {
expect(document.activeElement).toBe(divRef.current);
});
- it('should work as expected with wrapping tab operations', () => {
+ it('handles tab operations with containment', () => {
const inputRef = React.createRef();
const input2Ref = React.createRef();
const buttonRef = React.createRef();
const button2Ref = React.createRef();
const Test = () => (
-
+
-
+
);
ReactDOM.render(, container);
@@ -96,7 +96,7 @@ describe('TabFocusContainer', () => {
expect(document.activeElement).toBe(button2Ref.current);
});
- it('should work as expected when nested', () => {
+ it('handles tab operations when controllers are nested', () => {
const inputRef = React.createRef();
const input2Ref = React.createRef();
const buttonRef = React.createRef();
@@ -105,16 +105,16 @@ describe('TabFocusContainer', () => {
const button4Ref = React.createRef();
const Test = () => (
-
+
-
+
-
+
-
+
);
ReactDOM.render(, container);
@@ -125,12 +125,6 @@ describe('TabFocusContainer', () => {
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);
@@ -138,7 +132,7 @@ describe('TabFocusContainer', () => {
expect(document.activeElement).toBe(button2Ref.current);
});
- it('should work as expected when nested with scope that is contained', () => {
+ it('handles tab operations when controllers are nested with containment', () => {
const inputRef = React.createRef();
const input2Ref = React.createRef();
const buttonRef = React.createRef();
@@ -147,16 +141,16 @@ describe('TabFocusContainer', () => {
const button4Ref = React.createRef();
const Test = () => (
-
+
-
+
-
+
-
+
);
ReactDOM.render(, container);
@@ -197,14 +191,14 @@ describe('TabFocusContainer', () => {
}
const Test = () => (
-
+
}>
-
+
);
ReactDOM.render(, container);
@@ -221,5 +215,49 @@ describe('TabFocusContainer', () => {
createEventTarget(document.activeElement).tabPrevious();
expect(document.activeElement).toBe(button2Ref.current);
});
+
+ it('allows for imperative tab focus control', () => {
+ const firstFocusControllerRef = React.createRef();
+ const secondFocusControllerRef = React.createRef();
+ const buttonRef = React.createRef();
+ const button2Ref = React.createRef();
+ const divRef = React.createRef();
+
+ const Test = () => (
+
+ );
+
+ ReactDOM.render(, container);
+ const firstFocusController = firstFocusControllerRef.current;
+ const secondFocusController = secondFocusControllerRef.current;
+
+ firstFocusController.focusFirst();
+ expect(document.activeElement).toBe(buttonRef.current);
+ firstFocusController.focusNext();
+ expect(document.activeElement).toBe(button2Ref.current);
+ firstFocusController.focusPrevious();
+ expect(document.activeElement).toBe(buttonRef.current);
+
+ const nextController = firstFocusController.getNextController();
+ expect(nextController).toBe(secondFocusController);
+ nextController.focusNext();
+ expect(document.activeElement).toBe(divRef.current);
+
+ const previousController = nextController.getPreviousController();
+ expect(previousController).toBe(firstFocusController);
+ previousController.focusNext();
+ expect(document.activeElement).toBe(buttonRef.current);
+ });
});
});
diff --git a/packages/react-events/src/dom/Keyboard.js b/packages/react-events/src/dom/Keyboard.js
index a41dbe8886e29..1ef2062da355f 100644
--- a/packages/react-events/src/dom/Keyboard.js
+++ b/packages/react-events/src/dom/Keyboard.js
@@ -22,7 +22,7 @@ type KeyboardProps = {
disabled?: boolean,
onKeyDown?: (e: KeyboardEvent) => ?boolean,
onKeyUp?: (e: KeyboardEvent) => ?boolean,
- preventKeys?: Array,
+ preventKeys?: PreventKeysArray,
};
type KeyboardEvent = {|
@@ -30,9 +30,7 @@ type KeyboardEvent = {|
ctrlKey: boolean,
isComposing: boolean,
key: string,
- location: number,
metaKey: boolean,
- repeat: boolean,
shiftKey: boolean,
target: Element | Document,
type: KeyboardEventType,
@@ -40,6 +38,15 @@ type KeyboardEvent = {|
defaultPrevented: boolean,
|};
+type ModifiersObject = {|
+ altKey?: boolean,
+ ctrlKey?: boolean,
+ metaKey?: boolean,
+ shiftKey?: boolean,
+|};
+
+type PreventKeysArray = Array>;
+
const isArray = Array.isArray;
const targetEventTypes = ['keydown_active', 'keyup'];
const modifiers = ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'];
@@ -134,15 +141,7 @@ function createKeyboardEvent(
defaultPrevented: boolean,
): KeyboardEvent {
const nativeEvent = (event: any).nativeEvent;
- const {
- altKey,
- ctrlKey,
- isComposing,
- location,
- metaKey,
- repeat,
- shiftKey,
- } = nativeEvent;
+ const {altKey, ctrlKey, isComposing, metaKey, shiftKey} = nativeEvent;
return {
altKey,
@@ -150,9 +149,7 @@ function createKeyboardEvent(
defaultPrevented,
isComposing,
key: getEventKey(nativeEvent),
- location,
metaKey,
- repeat,
shiftKey,
target: event.target,
timeStamp: context.getTimeStamp(),
@@ -198,7 +195,7 @@ const keyboardResponderImpl = {
}
let defaultPrevented = nativeEvent.defaultPrevented === true;
if (type === 'keydown') {
- const preventKeys = ((props.preventKeys: any): Array);
+ const preventKeys = ((props.preventKeys: any): PreventKeysArray);
if (!defaultPrevented && isArray(preventKeys)) {
preventKeyLoop: for (let i = 0; i < preventKeys.length; i++) {
const preventKey = preventKeys[i];
diff --git a/packages/react-reconciler/src/ReactFiberScope.js b/packages/react-reconciler/src/ReactFiberScope.js
index 83c7f9abf61fe..bdbae5e697be4 100644
--- a/packages/react-reconciler/src/ReactFiberScope.js
+++ b/packages/react-reconciler/src/ReactFiberScope.js
@@ -71,7 +71,7 @@ function collectNearestScopeMethods(
scope: ReactScope,
childrenScopes: Array,
): void {
- if (node.tag === ScopeComponent && node.type === scope) {
+ if (isValidScopeNode(node, scope)) {
childrenScopes.push(node.stateNode.methods);
} else {
let child = node.child;
@@ -86,7 +86,7 @@ function collectNearestScopeMethods(
}
function collectNearestChildScopeMethods(
- startingChild: Fiber,
+ startingChild: Fiber | null,
scope: ReactScope,
childrenScopes: Array,
): void {
@@ -97,6 +97,10 @@ function collectNearestChildScopeMethods(
}
}
+function isValidScopeNode(node, scope) {
+ return node.tag === ScopeComponent && node.type === scope;
+}
+
export function createScopeMethods(
scope: ReactScope,
instance: ReactScopeInstance,
@@ -112,6 +116,27 @@ export function createScopeMethods(
}
return childrenScopes.length === 0 ? null : childrenScopes;
},
+ getChildrenFromRoot(): null | Array {
+ const currentFiber = ((instance.fiber: any): Fiber);
+ let node = currentFiber;
+ while (node !== null) {
+ const parent = node.return;
+ if (parent === null) {
+ break;
+ }
+ node = parent;
+ if (node.tag === ScopeComponent && node.type === scope) {
+ break;
+ }
+ }
+ const childrenScopes = [];
+ collectNearestChildScopeMethods(node.child, scope, childrenScopes);
+ return childrenScopes.length === 0 ? null : childrenScopes;
+ },
+ getHandle(): null | mixed {
+ const currentFiber = ((instance.fiber: any): Fiber);
+ return currentFiber.memoizedProps.handle || null;
+ },
getParent(): null | ReactScopeMethods {
let node = ((instance.fiber: any): Fiber).return;
while (node !== null) {
diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js
index 13866ba14aedf..ddc713980ca32 100644
--- a/packages/shared/ReactTypes.js
+++ b/packages/shared/ReactTypes.js
@@ -166,6 +166,8 @@ export type ReactScope = {|
export type ReactScopeMethods = {|
getChildren(): null | Array,
+ getChildrenFromRoot(): null | Array,
+ getHandle(): null | mixed,
getParent(): null | ReactScopeMethods,
getScopedNodes(): null | Array