Skip to content

Commit

Permalink
[react-interactions] Add experimental FocusGrid API (#16766)
Browse files Browse the repository at this point in the history
  • Loading branch information
trueadm authored Sep 13, 2019
1 parent a7dabcb commit b4b8a34
Show file tree
Hide file tree
Showing 5 changed files with 309 additions and 41 deletions.
131 changes: 131 additions & 0 deletions packages/react-dom/src/client/focus/FocusGrid.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* 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 {KeyboardEvent} from 'react-events/src/dom/Keyboard';

import React from 'react';
import {tabFocusableImpl} from './TabbableScope';
import {useKeyboard} from 'react-events/keyboard';

type GridComponentProps = {
children: React.Node,
};

const {useRef} = React;

function focusCell(cell) {
const tabbableNodes = cell.getScopedNodes();
if (tabbableNodes !== null && tabbableNodes.length > 0) {
tabbableNodes[0].focus();
}
}

function focusCellByRowIndex(row, rowIndex) {
const cells = row.getChildren();
const cell = cells[rowIndex];
if (cell) {
focusCell(cell);
}
}

function getRowCells(currentCell) {
const row = currentCell.getParent();
if (parent !== null) {
const cells = row.getChildren();
const rowIndex = cells.indexOf(currentCell);
return [cells, rowIndex];
}
return [null, 0];
}

function getColumns(currentCell) {
const row = currentCell.getParent();
if (parent !== null) {
const grid = row.getParent();
const columns = grid.getChildren();
const columnIndex = columns.indexOf(row);
return [columns, columnIndex];
}
return [null, 0];
}

export function createFocusGrid(): Array<React.Component> {
const GridScope = React.unstable_createScope(tabFocusableImpl);

function GridContainer({children}): GridComponentProps {
return <GridScope>{children}</GridScope>;
}

function GridRow({children}): GridComponentProps {
return <GridScope>{children}</GridScope>;
}

function GridCell({children}): GridComponentProps {
const scopeRef = useRef(null);
const keyboard = useKeyboard({
onKeyDown(event: KeyboardEvent): boolean {
const currentCell = scopeRef.current;
switch (event.key) {
case 'UpArrow': {
const [cells, rowIndex] = getRowCells(currentCell);
if (cells !== null) {
const [columns, columnIndex] = getColumns(currentCell);
if (columns !== null) {
if (columnIndex > 0) {
const column = columns[columnIndex - 1];
focusCellByRowIndex(column, rowIndex);
}
}
}
return false;
}
case 'DownArrow': {
const [cells, rowIndex] = getRowCells(currentCell);
if (cells !== null) {
const [columns, columnIndex] = getColumns(currentCell);
if (columns !== null) {
if (columnIndex !== -1 && columnIndex !== columns.length - 1) {
const column = columns[columnIndex + 1];
focusCellByRowIndex(column, rowIndex);
}
}
}
return false;
}
case 'LeftArrow': {
const [cells, rowIndex] = getRowCells(currentCell);
if (cells !== null) {
if (rowIndex > 0) {
focusCell(cells[rowIndex - 1]);
}
}
return false;
}
case 'RightArrow': {
const [cells, rowIndex] = getRowCells(currentCell);
if (cells !== null) {
if (rowIndex !== -1 && rowIndex !== cells.length - 1) {
focusCell(cells[rowIndex + 1]);
}
}
return false;
}
}
return true;
},
});
return (
<GridScope listeners={keyboard} ref={scopeRef}>
{children}
</GridScope>
);
}

return [GridContainer, GridRow, GridCell];
}
17 changes: 1 addition & 16 deletions packages/react-dom/src/client/focus/ReactTabFocus.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import type {ReactScopeMethods} from 'shared/ReactTypes';
import type {KeyboardEvent} from 'react-events/src/dom/Keyboard';

import React from 'react';
import {TabbableScope} from './TabbableScope';
Expand All @@ -17,22 +18,6 @@ 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,
|};

const {useRef} = React;

function getTabbableNodes(scope: ReactScopeMethods) {
Expand Down
48 changes: 24 additions & 24 deletions packages/react-dom/src/client/focus/TabbableScope.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,27 @@

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'
);
},
);
export const tabFocusableImpl = (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'
);
};

export const TabbableScope = React.unstable_createScope(tabFocusableImpl);
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* 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 createFocusGrid;

describe('TabFocusController', () => {
beforeEach(() => {
jest.resetModules();
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableScopeAPI = true;
ReactFeatureFlags.enableFlareAPI = true;
createFocusGrid = require('../FocusGrid').createFocusGrid;
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('handles tab operations', () => {
const [
FocusGridContainer,
FocusGridRow,
FocusGridCell,
] = createFocusGrid();
const firstButtonRef = React.createRef();

const Test = () => (
<FocusGridContainer>
<table>
<tbody>
<FocusGridRow>
<tr>
<FocusGridCell>
<td>
<button ref={firstButtonRef}>A1</button>
</td>
</FocusGridCell>
<FocusGridCell>
<td>
<button>A2</button>
</td>
</FocusGridCell>
<FocusGridCell>
<td>
<button>A3</button>
</td>
</FocusGridCell>
</tr>
</FocusGridRow>
<FocusGridRow>
<tr>
<FocusGridCell>
<td>
<button>B1</button>
</td>
</FocusGridCell>
<FocusGridCell>
<td>
<button>B2</button>
</td>
</FocusGridCell>
<FocusGridCell>
<td>
<button>B3</button>
</td>
</FocusGridCell>
</tr>
</FocusGridRow>
<FocusGridRow>
<tr>
<FocusGridCell>
<td>
<button>C1</button>
</td>
</FocusGridCell>
<FocusGridCell>
<td>
<button>C2</button>
</td>
</FocusGridCell>
<FocusGridCell>
<td>
<button>C3</button>
</td>
</FocusGridCell>
</tr>
</FocusGridRow>
</tbody>
</table>
</FocusGridContainer>
);

ReactDOM.render(<Test />, container);
const a1 = createEventTarget(firstButtonRef.current);
a1.focus();
a1.keydown({
key: 'RightArrow',
});
expect(document.activeElement.textContent).toBe('A2');

const a2 = createEventTarget(document.activeElement);
a2.keydown({
key: 'DownArrow',
});
expect(document.activeElement.textContent).toBe('B2');

const b2 = createEventTarget(document.activeElement);
b2.keydown({
key: 'LeftArrow',
});
expect(document.activeElement.textContent).toBe('B1');

const b1 = createEventTarget(document.activeElement);
b1.keydown({
key: 'DownArrow',
});
expect(document.activeElement.textContent).toBe('C1');

const c1 = createEventTarget(document.activeElement);
c1.keydown({
key: 'DownArrow',
});
expect(document.activeElement.textContent).toBe('C1');
c1.keydown({
key: 'UpArrow',
});
expect(document.activeElement.textContent).toBe('B1');
});
});
});
2 changes: 1 addition & 1 deletion packages/react-events/src/dom/Keyboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ type KeyboardProps = {
preventKeys?: PreventKeysArray,
};

type KeyboardEvent = {|
export type KeyboardEvent = {|
altKey: boolean,
ctrlKey: boolean,
isComposing: boolean,
Expand Down

0 comments on commit b4b8a34

Please sign in to comment.