Skip to content

Commit b4b8a34

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

5 files changed

Lines changed: 309 additions & 41 deletions

File tree

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+
importtype{KeyboardEvent}from'react-events/src/dom/Keyboard';
11+
12+
importReactfrom'react';
13+
import{tabFocusableImpl}from'./TabbableScope';
14+
import{useKeyboard}from'react-events/keyboard';
15+
16+
typeGridComponentProps={
17+
children: React.Node,
18+
};
19+
20+
const{useRef}=React;
21+
22+
functionfocusCell(cell){
23+
consttabbableNodes=cell.getScopedNodes();
24+
if(tabbableNodes!==null&&tabbableNodes.length>0){
25+
tabbableNodes[0].focus();
26+
}
27+
}
28+
29+
functionfocusCellByRowIndex(row,rowIndex){
30+
constcells=row.getChildren();
31+
constcell=cells[rowIndex];
32+
if(cell){
33+
focusCell(cell);
34+
}
35+
}
36+
37+
functiongetRowCells(currentCell){
38+
constrow=currentCell.getParent();
39+
if(parent!==null){
40+
constcells=row.getChildren();
41+
constrowIndex=cells.indexOf(currentCell);
42+
return[cells,rowIndex];
43+
}
44+
return[null,0];
45+
}
46+
47+
functiongetColumns(currentCell){
48+
constrow=currentCell.getParent();
49+
if(parent!==null){
50+
constgrid=row.getParent();
51+
constcolumns=grid.getChildren();
52+
constcolumnIndex=columns.indexOf(row);
53+
return[columns,columnIndex];
54+
}
55+
return[null,0];
56+
}
57+
58+
exportfunctioncreateFocusGrid(): Array<React.Component>{
59+
constGridScope=React.unstable_createScope(tabFocusableImpl);
60+
61+
functionGridContainer({children}): GridComponentProps{
62+
return<GridScope>{children}</GridScope>;
63+
}
64+
65+
functionGridRow({children}): GridComponentProps{
66+
return<GridScope>{children}</GridScope>;
67+
}
68+
69+
functionGridCell({children}): GridComponentProps{
70+
constscopeRef=useRef(null);
71+
constkeyboard=useKeyboard({
72+
onKeyDown(event: KeyboardEvent): boolean{
73+
constcurrentCell=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+
constcolumn=columns[columnIndex-1];
82+
focusCellByRowIndex(column,rowIndex);
83+
}
84+
}
85+
}
86+
returnfalse;
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+
constcolumn=columns[columnIndex+1];
95+
focusCellByRowIndex(column,rowIndex);
96+
}
97+
}
98+
}
99+
returnfalse;
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+
returnfalse;
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+
returnfalse;
118+
}
119+
}
120+
returntrue;
121+
},
122+
});
123+
return(
124+
<GridScopelisteners={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
importtype{ReactScopeMethods}from'shared/ReactTypes';
11+
importtype{KeyboardEvent}from'react-events/src/dom/Keyboard';
1112

1213
importReactfrom'react';
1314
import{TabbableScope}from'./TabbableScope';
@@ -17,22 +18,6 @@ type TabFocusControllerProps = {
1718
children: React.Node,
1819
contain?: boolean,
1920
};
20-
21-
typeKeyboardEventType='keydown'|'keyup';
22-
23-
typeKeyboardEvent={|
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
functiongetTabbableNodes(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
importReactfrom'react';
1111

12-
exportconstTabbableScope=React.unstable_createScope(
13-
(type: string,props: Object): boolean=>{
14-
if(props.tabIndex===-1||props.disabled){
15-
returnfalse;
16-
}
17-
if(props.tabIndex===0||props.contentEditable===true){
18-
returntrue;
19-
}
20-
if(type==='a'||type==='area'){
21-
return!!props.href&&props.rel!=='ignore';
22-
}
23-
if(type==='input'){
24-
returnprops.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+
exportconsttabFocusableImpl=(type: string,props: Object): boolean=>{
13+
if(props.tabIndex===-1||props.disabled){
14+
returnfalse;
15+
}
16+
if(props.tabIndex===0||props.contentEditable===true){
17+
returntrue;
18+
}
19+
if(type==='a'||type==='area'){
20+
return!!props.href&&props.rel!=='ignore';
21+
}
22+
if(type==='input'){
23+
returnprops.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+
exportconstTabbableScope=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+
letReact;
13+
letReactFeatureFlags;
14+
letcreateFocusGrid;
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+
letReactDOM;
28+
letcontainer;
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+
constfirstButtonRef=React.createRef();
48+
49+
constTest=()=>(
50+
<FocusGridContainer>
51+
<table>
52+
<tbody>
53+
<FocusGridRow>
54+
<tr>
55+
<FocusGridCell>
56+
<td>
57+
<buttonref={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+
consta1=createEventTarget(firstButtonRef.current);
117+
a1.focus();
118+
a1.keydown({
119+
key: 'RightArrow',
120+
});
121+
expect(document.activeElement.textContent).toBe('A2');
122+
123+
consta2=createEventTarget(document.activeElement);
124+
a2.keydown({
125+
key: 'DownArrow',
126+
});
127+
expect(document.activeElement.textContent).toBe('B2');
128+
129+
constb2=createEventTarget(document.activeElement);
130+
b2.keydown({
131+
key: 'LeftArrow',
132+
});
133+
expect(document.activeElement.textContent).toBe('B1');
134+
135+
constb1=createEventTarget(document.activeElement);
136+
b1.keydown({
137+
key: 'DownArrow',
138+
});
139+
expect(document.activeElement.textContent).toBe('C1');
140+
141+
constc1=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-
typeKeyboardEvent={|
28+
exporttypeKeyboardEvent={|
2929
altKey: boolean,
3030
ctrlKey: boolean,
3131
isComposing: boolean,

0 commit comments

Comments
 (0)