Skip to content

Commit b4b8a34

Browse files
authored
[react-interactions] Add experimental FocusGrid API (#16766)
1 parent a7dabcb commit b4b8a34

File tree

5 files changed

+309
-41
lines changed

5 files changed

+309
-41
lines changed
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
import type {KeyboardEvent} from 'react-events/src/dom/Keyboard';
11+
12+
import React from 'react';
13+
import {tabFocusableImpl} from './TabbableScope';
14+
import {useKeyboard} from 'react-events/keyboard';
15+
16+
type GridComponentProps = {
17+
children: React.Node,
18+
};
19+
20+
const {useRef} = React;
21+
22+
function focusCell(cell) {
23+
const tabbableNodes = cell.getScopedNodes();
24+
if (tabbableNodes !== null && tabbableNodes.length > 0) {
25+
tabbableNodes[0].focus();
26+
}
27+
}
28+
29+
function focusCellByRowIndex(row, rowIndex) {
30+
const cells = row.getChildren();
31+
const cell = cells[rowIndex];
32+
if (cell) {
33+
focusCell(cell);
34+
}
35+
}
36+
37+
function getRowCells(currentCell) {
38+
const row = currentCell.getParent();
39+
if (parent !== null) {
40+
const cells = row.getChildren();
41+
const rowIndex = cells.indexOf(currentCell);
42+
return [cells, rowIndex];
43+
}
44+
return [null, 0];
45+
}
46+
47+
function getColumns(currentCell) {
48+
const row = currentCell.getParent();
49+
if (parent !== null) {
50+
const grid = row.getParent();
51+
const columns = grid.getChildren();
52+
const columnIndex = columns.indexOf(row);
53+
return [columns, columnIndex];
54+
}
55+
return [null, 0];
56+
}
57+
58+
export function createFocusGrid(): Array<React.Component> {
59+
const GridScope = React.unstable_createScope(tabFocusableImpl);
60+
61+
function GridContainer({children}): GridComponentProps {
62+
return <GridScope>{children}</GridScope>;
63+
}
64+
65+
function GridRow({children}): GridComponentProps {
66+
return <GridScope>{children}</GridScope>;
67+
}
68+
69+
function GridCell({children}): GridComponentProps {
70+
const scopeRef = useRef(null);
71+
const keyboard = useKeyboard({
72+
onKeyDown(event: KeyboardEvent): boolean {
73+
const currentCell = scopeRef.current;
74+
switch (event.key) {
75+
case 'UpArrow': {
76+
const [cells, rowIndex] = getRowCells(currentCell);
77+
if (cells !== null) {
78+
const [columns, columnIndex] = getColumns(currentCell);
79+
if (columns !== null) {
80+
if (columnIndex > 0) {
81+
const column = columns[columnIndex - 1];
82+
focusCellByRowIndex(column, rowIndex);
83+
}
84+
}
85+
}
86+
return false;
87+
}
88+
case 'DownArrow': {
89+
const [cells, rowIndex] = getRowCells(currentCell);
90+
if (cells !== null) {
91+
const [columns, columnIndex] = getColumns(currentCell);
92+
if (columns !== null) {
93+
if (columnIndex !== -1 && columnIndex !== columns.length - 1) {
94+
const column = columns[columnIndex + 1];
95+
focusCellByRowIndex(column, rowIndex);
96+
}
97+
}
98+
}
99+
return false;
100+
}
101+
case 'LeftArrow': {
102+
const [cells, rowIndex] = getRowCells(currentCell);
103+
if (cells !== null) {
104+
if (rowIndex > 0) {
105+
focusCell(cells[rowIndex - 1]);
106+
}
107+
}
108+
return false;
109+
}
110+
case 'RightArrow': {
111+
const [cells, rowIndex] = getRowCells(currentCell);
112+
if (cells !== null) {
113+
if (rowIndex !== -1 && rowIndex !== cells.length - 1) {
114+
focusCell(cells[rowIndex + 1]);
115+
}
116+
}
117+
return false;
118+
}
119+
}
120+
return true;
121+
},
122+
});
123+
return (
124+
<GridScope listeners={keyboard} ref={scopeRef}>
125+
{children}
126+
</GridScope>
127+
);
128+
}
129+
130+
return [GridContainer, GridRow, GridCell];
131+
}

packages/react-dom/src/client/focus/ReactTabFocus.js

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

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

1213
import React from 'react';
1314
import {TabbableScope} from './TabbableScope';
@@ -17,22 +18,6 @@ type TabFocusControllerProps = {
1718
children: React.Node,
1819
contain?: boolean,
1920
};
20-
21-
type KeyboardEventType = 'keydown' | 'keyup';
22-
23-
type KeyboardEvent = {|
24-
altKey: boolean,
25-
ctrlKey: boolean,
26-
isComposing: boolean,
27-
key: string,
28-
metaKey: boolean,
29-
shiftKey: boolean,
30-
target: Element | Document,
31-
type: KeyboardEventType,
32-
timeStamp: number,
33-
defaultPrevented: boolean,
34-
|};
35-
3621
const {useRef} = React;
3722

3823
function getTabbableNodes(scope: ReactScopeMethods) {

packages/react-dom/src/client/focus/TabbableScope.js

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,27 @@
99

1010
import React from 'react';
1111

12-
export const TabbableScope = React.unstable_createScope(
13-
(type: string, props: Object): boolean => {
14-
if (props.tabIndex === -1 || props.disabled) {
15-
return false;
16-
}
17-
if (props.tabIndex === 0 || props.contentEditable === true) {
18-
return true;
19-
}
20-
if (type === 'a' || type === 'area') {
21-
return !!props.href && props.rel !== 'ignore';
22-
}
23-
if (type === 'input') {
24-
return props.type !== 'hidden' && props.type !== 'file';
25-
}
26-
return (
27-
type === 'button' ||
28-
type === 'textarea' ||
29-
type === 'object' ||
30-
type === 'select' ||
31-
type === 'iframe' ||
32-
type === 'embed'
33-
);
34-
},
35-
);
12+
export const tabFocusableImpl = (type: string, props: Object): boolean => {
13+
if (props.tabIndex === -1 || props.disabled) {
14+
return false;
15+
}
16+
if (props.tabIndex === 0 || props.contentEditable === true) {
17+
return true;
18+
}
19+
if (type === 'a' || type === 'area') {
20+
return !!props.href && props.rel !== 'ignore';
21+
}
22+
if (type === 'input') {
23+
return props.type !== 'hidden' && props.type !== 'file';
24+
}
25+
return (
26+
type === 'button' ||
27+
type === 'textarea' ||
28+
type === 'object' ||
29+
type === 'select' ||
30+
type === 'iframe' ||
31+
type === 'embed'
32+
);
33+
};
34+
35+
export const TabbableScope = React.unstable_createScope(tabFocusableImpl);
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
import {createEventTarget} from 'react-events/src/dom/testing-library';
11+
12+
let React;
13+
let ReactFeatureFlags;
14+
let createFocusGrid;
15+
16+
describe('TabFocusController', () => {
17+
beforeEach(() => {
18+
jest.resetModules();
19+
ReactFeatureFlags = require('shared/ReactFeatureFlags');
20+
ReactFeatureFlags.enableScopeAPI = true;
21+
ReactFeatureFlags.enableFlareAPI = true;
22+
createFocusGrid = require('../FocusGrid').createFocusGrid;
23+
React = require('react');
24+
});
25+
26+
describe('ReactDOM', () => {
27+
let ReactDOM;
28+
let container;
29+
30+
beforeEach(() => {
31+
ReactDOM = require('react-dom');
32+
container = document.createElement('div');
33+
document.body.appendChild(container);
34+
});
35+
36+
afterEach(() => {
37+
document.body.removeChild(container);
38+
container = null;
39+
});
40+
41+
it('handles tab operations', () => {
42+
const [
43+
FocusGridContainer,
44+
FocusGridRow,
45+
FocusGridCell,
46+
] = createFocusGrid();
47+
const firstButtonRef = React.createRef();
48+
49+
const Test = () => (
50+
<FocusGridContainer>
51+
<table>
52+
<tbody>
53+
<FocusGridRow>
54+
<tr>
55+
<FocusGridCell>
56+
<td>
57+
<button ref={firstButtonRef}>A1</button>
58+
</td>
59+
</FocusGridCell>
60+
<FocusGridCell>
61+
<td>
62+
<button>A2</button>
63+
</td>
64+
</FocusGridCell>
65+
<FocusGridCell>
66+
<td>
67+
<button>A3</button>
68+
</td>
69+
</FocusGridCell>
70+
</tr>
71+
</FocusGridRow>
72+
<FocusGridRow>
73+
<tr>
74+
<FocusGridCell>
75+
<td>
76+
<button>B1</button>
77+
</td>
78+
</FocusGridCell>
79+
<FocusGridCell>
80+
<td>
81+
<button>B2</button>
82+
</td>
83+
</FocusGridCell>
84+
<FocusGridCell>
85+
<td>
86+
<button>B3</button>
87+
</td>
88+
</FocusGridCell>
89+
</tr>
90+
</FocusGridRow>
91+
<FocusGridRow>
92+
<tr>
93+
<FocusGridCell>
94+
<td>
95+
<button>C1</button>
96+
</td>
97+
</FocusGridCell>
98+
<FocusGridCell>
99+
<td>
100+
<button>C2</button>
101+
</td>
102+
</FocusGridCell>
103+
<FocusGridCell>
104+
<td>
105+
<button>C3</button>
106+
</td>
107+
</FocusGridCell>
108+
</tr>
109+
</FocusGridRow>
110+
</tbody>
111+
</table>
112+
</FocusGridContainer>
113+
);
114+
115+
ReactDOM.render(<Test />, container);
116+
const a1 = createEventTarget(firstButtonRef.current);
117+
a1.focus();
118+
a1.keydown({
119+
key: 'RightArrow',
120+
});
121+
expect(document.activeElement.textContent).toBe('A2');
122+
123+
const a2 = createEventTarget(document.activeElement);
124+
a2.keydown({
125+
key: 'DownArrow',
126+
});
127+
expect(document.activeElement.textContent).toBe('B2');
128+
129+
const b2 = createEventTarget(document.activeElement);
130+
b2.keydown({
131+
key: 'LeftArrow',
132+
});
133+
expect(document.activeElement.textContent).toBe('B1');
134+
135+
const b1 = createEventTarget(document.activeElement);
136+
b1.keydown({
137+
key: 'DownArrow',
138+
});
139+
expect(document.activeElement.textContent).toBe('C1');
140+
141+
const c1 = createEventTarget(document.activeElement);
142+
c1.keydown({
143+
key: 'DownArrow',
144+
});
145+
expect(document.activeElement.textContent).toBe('C1');
146+
c1.keydown({
147+
key: 'UpArrow',
148+
});
149+
expect(document.activeElement.textContent).toBe('B1');
150+
});
151+
});
152+
});

packages/react-events/src/dom/Keyboard.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ type KeyboardProps = {
2525
preventKeys?: PreventKeysArray,
2626
};
2727

28-
type KeyboardEvent = {|
28+
export type KeyboardEvent = {|
2929
altKey: boolean,
3030
ctrlKey: boolean,
3131
isComposing: boolean,

0 commit comments

Comments
 (0)