-
Notifications
You must be signed in to change notification settings - Fork 47.5k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[react-interactions] Add TabFocusContainer and TabbableScope UI compo…
…nents (#16732)
- Loading branch information
Showing
8 changed files
with
455 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<TabbableScope ref={scopeRef} listeners={keyboard}> | ||
{children} | ||
</TabbableScope> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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' | ||
); | ||
}, | ||
); |
225 changes: 225 additions & 0 deletions
225
packages/react-dom/src/client/focus/__tests__/TabFocusContainer-test.internal.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 = () => ( | ||
<TabFocusContainer> | ||
<input ref={inputRef} /> | ||
<button ref={buttonRef} /> | ||
<div ref={divRef} tabIndex={0} /> | ||
<input ref={input2Ref} tabIndex={-1} /> | ||
<button ref={butto2nRef} /> | ||
</TabFocusContainer> | ||
); | ||
|
||
ReactDOM.render(<Test />, 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 = () => ( | ||
<TabFocusContainer> | ||
<input ref={inputRef} tabIndex={-1} /> | ||
<button ref={buttonRef} id={1} /> | ||
<button ref={button2Ref} id={2} /> | ||
<input ref={input2Ref} tabIndex={-1} /> | ||
</TabFocusContainer> | ||
); | ||
|
||
ReactDOM.render(<Test />, 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 = () => ( | ||
<TabFocusContainer> | ||
<input ref={inputRef} tabIndex={-1} /> | ||
<button ref={buttonRef} id={1} /> | ||
<TabFocusContainer> | ||
<button ref={button2Ref} id={2} /> | ||
<button ref={button3Ref} id={3} /> | ||
</TabFocusContainer> | ||
<input ref={input2Ref} tabIndex={-1} /> | ||
<button ref={button4Ref} id={4} /> | ||
</TabFocusContainer> | ||
); | ||
|
||
ReactDOM.render(<Test />, 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 = () => ( | ||
<TabFocusContainer> | ||
<input ref={inputRef} tabIndex={-1} /> | ||
<button ref={buttonRef} id={1} /> | ||
<TabFocusContainer> | ||
<button ref={button2Ref} id={2} /> | ||
<button ref={button3Ref} id={3} /> | ||
</TabFocusContainer> | ||
<input ref={input2Ref} tabIndex={-1} /> | ||
<button ref={button4Ref} id={4} /> | ||
</TabFocusContainer> | ||
); | ||
|
||
ReactDOM.render(<Test />, 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 ( | ||
<React.Fragment> | ||
<button ref={button5Ref} id={5} /> | ||
<SuspendedComponent /> | ||
</React.Fragment> | ||
); | ||
} | ||
|
||
const Test = () => ( | ||
<TabFocusContainer> | ||
<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} /> | ||
</TabFocusContainer> | ||
); | ||
|
||
ReactDOM.render(<Test />, 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); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.