[react-interactions] TabFocus -> FocusManager (#16874)

This commit is contained in:
Dominic Gannaway
2019-09-24 23:26:20 +02:00
committed by GitHub
parent 793f176dad
commit ebc299fc2f
6 changed files with 242 additions and 111 deletions

View File

@@ -9,4 +9,4 @@
'use strict';
module.exports = require('./src/TabFocus');
module.exports = require('./src/FocusManager');

View File

@@ -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 (
<ScopeToUse
ref={node => {
if (ref) {
if (typeof ref === 'function') {
ref(node);
} else {
ref.current = node;
}
}
scopeRef.current = node;
}}
listeners={[keyboard, focusWithin]}>
{children}
</ScopeToUse>
);
},
);
export default FocusManager;

View File

@@ -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 (
<Scope
ref={node => {
if (ref) {
if (typeof ref === 'function') {
ref(node);
} else {
ref.current = node;
}
}
scopeRef.current = node;
}}
listeners={keyboard}>
{children}
</Scope>
);
},
);
export default TabFocus;

View File

@@ -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 = () => (
<TabFocus scope={TabbableScope}>
<FocusManager>
<input ref={inputRef} />
<button ref={buttonRef} />
<div ref={divRef} tabIndex={0} />
<input ref={input2Ref} tabIndex={-1} />
<button ref={butto2nRef} />
</TabFocus>
<button ref={button2Ref} />
</FocusManager>
);
ReactDOM.render(<Test />, 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 = () => (
<FocusManager autoFocus={true}>
<input tabIndex={-1} />
<button ref={buttonRef} />
</FocusManager>
);
ReactDOM.render(<Test />, container);
expect(document.activeElement).toBe(buttonRef.current);
});
it('handles restoreFocus', () => {
const difRef = React.createRef();
const buttonRef = React.createRef();
const Test = ({flag}) => {
return (
<div ref={difRef} tabIndex={0}>
{flag ? (
<FocusManager autoFocus={true} restoreFocus={true}>
<button ref={buttonRef} />
</FocusManager>
) : null}
</div>
);
};
ReactDOM.render(<Test flag={false} />, container);
difRef.current.focus();
expect(document.activeElement).toBe(difRef.current);
ReactDOM.render(<Test flag={true} />, container);
expect(document.activeElement).toBe(buttonRef.current);
ReactDOM.render(<Test flag={false} />, 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 = () => (
<TabFocus scope={TabbableScope} contain={true}>
<input ref={inputRef} tabIndex={-1} />
<button ref={buttonRef} id={1} />
<button ref={button2Ref} id={2} />
<input ref={input2Ref} tabIndex={-1} />
</TabFocus>
<div>
<FocusManager containFocus={true}>
<input ref={inputRef} tabIndex={-1} />
<button ref={buttonRef} id={1} />
<button ref={button2Ref} id={2} />
<input ref={input2Ref} tabIndex={-1} />
</FocusManager>
<input ref={input3Ref} />
</div>
);
ReactDOM.render(<Test />, 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 = () => (
<TabFocus scope={TabbableScope}>
<FocusManager>
<input ref={inputRef} tabIndex={-1} />
<button ref={buttonRef} id={1} />
<TabFocus scope={TabbableScope}>
<FocusManager>
<button ref={button2Ref} id={2} />
<button ref={button3Ref} id={3} />
</TabFocus>
</FocusManager>
<input ref={input2Ref} tabIndex={-1} />
<button ref={button4Ref} id={4} />
</TabFocus>
</FocusManager>
);
ReactDOM.render(<Test />, 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 = () => (
<TabFocus scope={TabbableScope}>
<FocusManager>
<input ref={inputRef} tabIndex={-1} />
<button ref={buttonRef} id={1} />
<TabFocus contain={true} scope={TabbableScope}>
<FocusManager containFocus={true}>
<button ref={button2Ref} id={2} />
<button ref={button3Ref} id={3} />
</TabFocus>
</FocusManager>
<input ref={input2Ref} tabIndex={-1} />
<button ref={button4Ref} id={4} />
</TabFocus>
</FocusManager>
);
ReactDOM.render(<Test />, container);
@@ -195,14 +243,14 @@ describe('TabFocusController', () => {
}
const Test = () => (
<TabFocus scope={TabbableScope}>
<FocusManager>
<button ref={buttonRef} id={1} />
<button ref={button2Ref} id={2} />
<React.Suspense fallback={<button ref={button3Ref} id={3} />}>
<Component />
</React.Suspense>
<button ref={button4Ref} id={4} />
</TabFocus>
</FocusManager>
);
ReactDOM.render(<Test />, 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 = () => (
<div>
<TabFocus ref={firstFocusControllerRef} scope={TabbableScope}>
<FocusManager ref={firstFocusControllerRef}>
<input tabIndex={-1} />
<button ref={buttonRef} />
<button ref={button2Ref} />
<input tabIndex={-1} />
</TabFocus>
<TabFocus ref={secondFocusControllerRef} scope={TabbableScope}>
</FocusManager>
<FocusManager ref={secondFocusControllerRef}>
<input tabIndex={-1} />
<div ref={divRef} tabIndex={0} />
</TabFocus>
</FocusManager>
</div>
);

View File

@@ -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;

View File

@@ -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',
],
@@ -721,6 +722,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',