@samitouri / QOS-React-2 / commits / b4b8a349a3

[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,