mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
[react-interactions] Add experimental FocusGrid API (#16766)
This commit is contained in:
+131
@@ -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];
|
||||
}
|
||||
+1
-16
@@ -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';
|
||||
@@ -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) {
|
||||
|
||||
+24
-24
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -25,7 +25,7 @@ type KeyboardProps = {
|
||||
preventKeys?: PreventKeysArray,
|
||||
};
|
||||
|
||||
type KeyboardEvent = {|
|
||||
export type KeyboardEvent = {|
|
||||
altKey: boolean,
|
||||
ctrlKey: boolean,
|
||||
isComposing: boolean,
|
||||
|
||||
Reference in New Issue
Block a user