[react-interactions] Add experimental FocusGrid API (#16766)
Dominic Gannaway committed
Sep 13, 2019 at 12:29 UTC
b4b8a349a3aa62e693e73cc16a231c3cc59b96de
5 files changed
+309
-41
packages/react-dom/src/client/focus/FocusGrid.js
new
+131
@@ -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
+1
-16
@@ -8,6 +8,7 @@
8
*/
9
10
import type {ReactScopeMethods} from 'shared/ReactTypes';
11
+import type {KeyboardEvent} from 'react-events/src/dom/Keyboard';
12
13
import React from 'react';
14
import {TabbableScope} from './TabbableScope';
@@ -17,22 +18,6 @@ type TabFocusControllerProps = {
18
children: React.Node,
19
contain?: boolean,
20
};
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
-
21
const {useRef} = React;
22
23
function getTabbableNodes(scope: ReactScopeMethods) {
packages/react-dom/src/client/focus/TabbableScope.js
+24
-24
@@ -9,27 +9,27 @@
9
10
import React from 'react';
11
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);
packages/react-dom/src/client/focus/__tests__/FocusGrid-test.internal.js
new
+152
@@ -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
+1
-1
@@ -25,7 +25,7 @@ type KeyboardProps = {
25
preventKeys?: PreventKeysArray,
26
};
27
28
-type KeyboardEvent = {|
28
+export type KeyboardEvent = {|
29
altKey: boolean,
30
ctrlKey: boolean,
31
isComposing: boolean,