[react-interactions] Add FocusList component (#16875)

This commit is contained in:
Dominic Gannaway
2019-09-24 17:14:29 +02:00
committed by GitHub
parent 18d2e0c03e
commit 68a87eee54
4 changed files with 284 additions and 2 deletions

View File

@@ -0,0 +1,150 @@
/**
* 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 {ReactScopeMethods} from 'shared/ReactTypes';
import type {KeyboardEvent} from 'react-interactions/events/keyboard';
import React from 'react';
import {useKeyboard} from 'react-interactions/events/keyboard';
type FocusItemProps = {
children?: React.Node,
};
type FocusListProps = {|
children: React.Node,
portrait: boolean,
|};
const {useRef} = React;
function focusListItem(cell: ReactScopeMethods): void {
const tabbableNodes = cell.getScopedNodes();
if (tabbableNodes !== null && tabbableNodes.length > 0) {
tabbableNodes[0].focus();
}
}
function getPreviousListItem(
list: ReactScopeMethods,
currentItem: ReactScopeMethods,
): null | ReactScopeMethods {
const items = list.getChildren();
if (items !== null) {
const currentItemIndex = items.indexOf(currentItem);
if (currentItemIndex > 0) {
return items[currentItemIndex - 1] || null;
}
}
return null;
}
function getNextListItem(
list: ReactScopeMethods,
currentItem: ReactScopeMethods,
): null | ReactScopeMethods {
const items = list.getChildren();
if (items !== null) {
const currentItemIndex = items.indexOf(currentItem);
if (currentItemIndex !== -1 && currentItemIndex !== items.length - 1) {
return items[currentItemIndex + 1] || null;
}
}
return null;
}
export function createFocusList(
scopeImpl: (type: string, props: Object) => boolean,
): Array<React.Component> {
const TableScope = React.unstable_createScope(scopeImpl);
function List({children, portrait}): FocusListProps {
return (
<TableScope type="list" portrait={portrait}>
{children}
</TableScope>
);
}
function Item({children}): FocusItemProps {
const scopeRef = useRef(null);
const keyboard = useKeyboard({
onKeyDown(event: KeyboardEvent): void {
const currentItem = scopeRef.current;
if (currentItem !== null) {
const list = currentItem.getParent();
const listProps = list && list.getProps();
if (list !== null && listProps.type === 'list') {
const portrait = listProps.portrait;
switch (event.key) {
case 'ArrowUp': {
if (portrait) {
const previousListItem = getPreviousListItem(
list,
currentItem,
);
if (previousListItem) {
event.preventDefault();
focusListItem(previousListItem);
return;
}
}
break;
}
case 'ArrowDown': {
if (portrait) {
const nextListItem = getNextListItem(list, currentItem);
if (nextListItem) {
event.preventDefault();
focusListItem(nextListItem);
return;
}
}
break;
}
case 'ArrowLeft': {
if (!portrait) {
const previousListItem = getPreviousListItem(
list,
currentItem,
);
if (previousListItem) {
event.preventDefault();
focusListItem(previousListItem);
return;
}
}
break;
}
case 'ArrowRight': {
if (!portrait) {
const nextListItem = getNextListItem(list, currentItem);
if (nextListItem) {
event.preventDefault();
focusListItem(nextListItem);
return;
}
}
break;
}
}
}
}
event.continuePropagation();
},
});
return (
<TableScope listeners={keyboard} ref={scopeRef} type="item">
{children}
</TableScope>
);
}
return [List, Item];
}

View File

@@ -149,6 +149,10 @@ export function createFocusTable(
const keyboard = useKeyboard({
onKeyDown(event: KeyboardEvent): void {
const currentCell = scopeRef.current;
if (currentCell === null) {
event.continuePropagation();
return;
}
switch (event.key) {
case 'ArrowUp': {
const [cells, cellIndex] = getRowCells(currentCell);
@@ -211,7 +215,6 @@ export function createFocusTable(
return;
}
}
event.continuePropagation();
},
});
return (

View File

@@ -0,0 +1,129 @@
/**
* 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-interactions/events/src/dom/testing-library';
let React;
let ReactFeatureFlags;
let createFocusList;
let tabFocusableImpl;
describe('FocusList', () => {
beforeEach(() => {
jest.resetModules();
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableScopeAPI = true;
ReactFeatureFlags.enableFlareAPI = true;
createFocusList = require('../FocusList').createFocusList;
tabFocusableImpl = require('../TabbableScope').tabFocusableImpl;
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;
});
function createFocusListComponent() {
const [FocusList, FocusItem] = createFocusList(tabFocusableImpl);
return ({portrait}) => (
<FocusList portrait={portrait}>
<ul>
<FocusItem>
<li tabIndex={0}>Item 1</li>
</FocusItem>
<FocusItem>
<li tabIndex={0}>Item 2</li>
</FocusItem>
<FocusItem>
<li tabIndex={0}>Item 3</li>
</FocusItem>
</ul>
</FocusList>
);
}
it('handles keyboard arrow operations (portrait)', () => {
const Test = createFocusListComponent();
ReactDOM.render(<Test portrait={true} />, container);
const listItems = document.querySelectorAll('li');
const firstListItem = createEventTarget(listItems[0]);
firstListItem.focus();
firstListItem.keydown({
key: 'ArrowDown',
});
expect(document.activeElement.textContent).toBe('Item 2');
const secondListItem = createEventTarget(document.activeElement);
secondListItem.keydown({
key: 'ArrowDown',
});
expect(document.activeElement.textContent).toBe('Item 3');
const thirdListItem = createEventTarget(document.activeElement);
thirdListItem.keydown({
key: 'ArrowDown',
});
expect(document.activeElement.textContent).toBe('Item 3');
thirdListItem.keydown({
key: 'ArrowRight',
});
expect(document.activeElement.textContent).toBe('Item 3');
thirdListItem.keydown({
key: 'ArrowLeft',
});
expect(document.activeElement.textContent).toBe('Item 3');
});
it('handles keyboard arrow operations (landscape)', () => {
const Test = createFocusListComponent();
ReactDOM.render(<Test portrait={false} />, container);
const listItems = document.querySelectorAll('li');
const firstListItem = createEventTarget(listItems[0]);
firstListItem.focus();
firstListItem.keydown({
key: 'ArrowRight',
});
expect(document.activeElement.textContent).toBe('Item 2');
const secondListItem = createEventTarget(document.activeElement);
secondListItem.keydown({
key: 'ArrowRight',
});
expect(document.activeElement.textContent).toBe('Item 3');
const thirdListItem = createEventTarget(document.activeElement);
thirdListItem.keydown({
key: 'ArrowRight',
});
expect(document.activeElement.textContent).toBe('Item 3');
thirdListItem.keydown({
key: 'ArrowUp',
});
expect(document.activeElement.textContent).toBe('Item 3');
thirdListItem.keydown({
key: 'ArrowDown',
});
expect(document.activeElement.textContent).toBe('Item 3');
});
});
});

View File

@@ -14,7 +14,7 @@ let ReactFeatureFlags;
let createFocusTable;
let tabFocusableImpl;
describe('ReactFocusTable', () => {
describe('FocusTable', () => {
beforeEach(() => {
jest.resetModules();
ReactFeatureFlags = require('shared/ReactFeatureFlags');