[react-interactions] Add FocusTable colSpan support (#17019)
Dominic Gannaway committed
Oct 7, 2019 at 12:04 UTC
fff5b1ca773c36d0aa221689d31fee79b0498495
3 files changed
+233
-91
packages/react-interactions/accessibility/src/FocusTable.js
+67
-45
@@ -17,6 +17,7 @@ import setElementCanTab from './shared/setElementCanTab';
17
type FocusCellProps = {
18
children?: React.Node,
19
onKeyDown?: KeyboardEvent => void,
20
+ colSpan?: number,
21
};
22
23
type FocusRowProps = {
@@ -25,12 +26,12 @@ type FocusRowProps = {
26
27
type FocusTableProps = {|
28
children: React.Node,
28
- id?: string,
29
onKeyboardOut?: (
30
direction: 'left' | 'right' | 'up' | 'down',
31
- focusTableByID: (id: string) => void,
31
+ event: KeyboardEvent,
32
) => void,
33
- wrap?: boolean,
33
+ wrapX?: boolean,
34
+ wrapY?: boolean,
35
tabScope?: ReactScope,
36
allowModifiers?: boolean,
37
|};
@@ -69,30 +70,59 @@ function focusScope(cell: ReactScopeMethods, event?: KeyboardEvent): void {
70
}
71
}
72
72
-function focusCellByIndex(
73
+// This takes into account colSpan
74
+function focusCellByColumnIndex(
75
row: ReactScopeMethods,
74
- cellIndex: number,
76
+ columnIndex: number,
77
event?: KeyboardEvent,
78
): void {
79
const cells = row.getChildren();
80
if (cells !== null) {
79
- const cell = cells[cellIndex];
80
- if (cell) {
81
- focusScope(cell, event);
81
+ let colSize = 0;
82
+ for (let i = 0; i < cells.length; i++) {
83
+ const cell = cells[i];
84
+ if (cell) {
85
+ colSize += cell.getProps().colSpan || 1;
86
+ if (colSize > columnIndex) {
87
+ focusScope(cell, event);
88
+ return;
89
+ }
90
+ }
91
}
92
}
93
}
94
95
+function getCellIndexes(
96
+ cells: Array<ReactScopeMethods>,
97
+ currentCell: ReactScopeMethods,
98
+): [number, number] {
99
+ let totalColSpan = 0;
100
+ for (let i = 0; i < cells.length; i++) {
101
+ const cell = cells[i];
102
+ if (cell === currentCell) {
103
+ return [i, i + totalColSpan];
104
+ }
105
+ const colSpan = cell.getProps().colSpan;
106
+ if (colSpan) {
107
+ totalColSpan += colSpan - 1;
108
+ }
109
+ }
110
+ return [-1, -1];
111
+}
112
+
113
function getRowCells(currentCell: ReactScopeMethods) {
114
const row = currentCell.getParent();
115
if (row !== null && row.getProps().type === 'row') {
116
const cells = row.getChildren();
117
if (cells !== null) {
91
- const rowIndex = cells.indexOf(currentCell);
92
- return [cells, rowIndex];
118
+ const [rowIndex, rowIndexWithColSpan] = getCellIndexes(
119
+ cells,
120
+ currentCell,
121
+ );
122
+ return [cells, rowIndex, rowIndexWithColSpan];
123
}
124
}
95
- return [null, 0];
125
+ return [null, -1, -1];
126
}
127
128
function getRows(currentCell: ReactScopeMethods) {
@@ -107,7 +137,7 @@ function getRows(currentCell: ReactScopeMethods) {
137
}
138
}
139
}
110
- return [null, 0];
140
+ return [null, -1, -1];
141
}
142
143
function triggerNavigateOut(
@@ -122,19 +152,7 @@ function triggerNavigateOut(
152
const props = table.getProps();
153
const onKeyboardOut = props.onKeyboardOut;
154
if (props.type === 'table' && typeof onKeyboardOut === 'function') {
125
- const focusTableByID = (id: string) => {
126
- const topLevelTables = table.getChildrenFromRoot();
127
- if (topLevelTables !== null) {
128
- for (let i = 0; i < topLevelTables.length; i++) {
129
- const topLevelTable = topLevelTables[i];
130
- if (topLevelTable.getProps().id === id) {
131
- focusFirstCellOnTable(topLevelTable);
132
- return;
133
- }
134
- }
135
- }
136
- };
137
- onKeyboardOut(direction, focusTableByID);
155
+ onKeyboardOut(direction, event);
156
return;
157
}
158
}
@@ -166,8 +184,8 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
184
function Table({
185
children,
186
onKeyboardOut,
169
- id,
170
- wrap,
187
+ wrapX,
188
+ wrapY,
189
tabScope: TabScope,
190
allowModifiers,
191
}): FocusTableProps {
@@ -176,8 +194,8 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
194
<TableScope
195
type="table"
196
onKeyboardOut={onKeyboardOut}
179
- id={id}
180
- wrap={wrap}
197
+ wrapX={wrapX}
198
+ wrapY={wrapY}
199
tabScopeRef={tabScopeRef}
200
allowModifiers={allowModifiers}>
201
{TabScope ? (
@@ -193,7 +211,7 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
211
return <TableScope type="row">{children}</TableScope>;
212
}
213
196
- function Cell({children, onKeyDown}): FocusCellProps {
214
+ function Cell({children, onKeyDown, colSpan}): FocusCellProps {
215
const scopeRef = useRef(null);
216
const keyboard = useKeyboard({
217
onKeyDown(event: KeyboardEvent): void {
@@ -232,18 +250,18 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
250
}
251
switch (key) {
252
case 'ArrowUp': {
235
- const [cells, cellIndex] = getRowCells(currentCell);
253
+ const [cells, , cellIndexWithColSpan] = getRowCells(currentCell);
254
if (cells !== null) {
255
const [rows, rowIndex] = getRows(currentCell);
256
if (rows !== null) {
257
if (rowIndex > 0) {
258
const row = rows[rowIndex - 1];
241
- focusCellByIndex(row, cellIndex, event);
259
+ focusCellByColumnIndex(row, cellIndexWithColSpan, event);
260
} else if (rowIndex === 0) {
243
- const wrap = getTableProps(currentCell).wrap;
244
- if (wrap) {
261
+ const wrapY = getTableProps(currentCell).wrapY;
262
+ if (wrapY) {
263
const row = rows[rows.length - 1];
246
- focusCellByIndex(row, cellIndex, event);
264
+ focusCellByColumnIndex(row, cellIndexWithColSpan, event);
265
} else {
266
triggerNavigateOut(currentCell, 'up', event);
267
}
@@ -253,22 +271,22 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
271
return;
272
}
273
case 'ArrowDown': {
256
- const [cells, cellIndex] = getRowCells(currentCell);
274
+ const [cells, , cellIndexWithColSpan] = getRowCells(currentCell);
275
if (cells !== null) {
276
const [rows, rowIndex] = getRows(currentCell);
277
if (rows !== null) {
278
if (rowIndex !== -1) {
279
if (rowIndex === rows.length - 1) {
262
- const wrap = getTableProps(currentCell).wrap;
263
- if (wrap) {
280
+ const wrapY = getTableProps(currentCell).wrapY;
281
+ if (wrapY) {
282
const row = rows[0];
265
- focusCellByIndex(row, cellIndex, event);
283
+ focusCellByColumnIndex(row, cellIndexWithColSpan, event);
284
} else {
285
triggerNavigateOut(currentCell, 'down', event);
286
}
287
} else {
288
const row = rows[rowIndex + 1];
271
- focusCellByIndex(row, cellIndex, event);
289
+ focusCellByColumnIndex(row, cellIndexWithColSpan, event);
290
}
291
}
292
}
@@ -282,8 +300,8 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
300
focusScope(cells[rowIndex - 1]);
301
event.preventDefault();
302
} else if (rowIndex === 0) {
285
- const wrap = getTableProps(currentCell).wrap;
286
- if (wrap) {
303
+ const wrapX = getTableProps(currentCell).wrapX;
304
+ if (wrapX) {
305
focusScope(cells[cells.length - 1], event);
306
} else {
307
triggerNavigateOut(currentCell, 'left', event);
@@ -297,8 +315,8 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
315
if (cells !== null) {
316
if (rowIndex !== -1) {
317
if (rowIndex === cells.length - 1) {
300
- const wrap = getTableProps(currentCell).wrap;
301
- if (wrap) {
318
+ const wrapX = getTableProps(currentCell).wrapX;
319
+ if (wrapX) {
320
focusScope(cells[0], event);
321
} else {
322
triggerNavigateOut(currentCell, 'right', event);
@@ -317,7 +335,11 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
335
},
336
});
337
return (
320
- <TableScope listeners={keyboard} ref={scopeRef} type="cell">
338
+ <TableScope
339
+ listeners={keyboard}
340
+ ref={scopeRef}
341
+ type="cell"
342
+ colSpan={colSpan}>
343
{children}
344
</TableScope>
345
);
packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js
+161
-45
@@ -46,11 +46,11 @@ describe('FocusTable', () => {
46
TabbableScope,
47
);
48
49
- return ({onKeyboardOut, id, wrap, allowModifiers}) => (
49
+ return ({onKeyboardOut, wrapX, wrapY, allowModifiers}) => (
50
<FocusTable
51
onKeyboardOut={onKeyboardOut}
52
- id={id}
53
- wrap={wrap}
52
+ wrapX={wrapX}
53
+ wrapY={wrapY}
54
allowModifiers={allowModifiers}>
55
<table>
56
<tbody>
@@ -180,50 +180,45 @@ describe('FocusTable', () => {
180
expect(document.activeElement.textContent).toBe('B1');
181
});
182
183
- it('handles keyboard arrow operations between tables', () => {
183
+ it('handles keyboard arrow operations between nested tables', () => {
184
const leftSidebarRef = React.createRef();
185
- const FocusTable = createFocusTableComponent();
185
+ const [
186
+ MainFocusTable,
187
+ MainFocusTableRow,
188
+ MainFocusTableCell,
189
+ ] = createFocusTable(TabbableScope);
190
+ const SubFocusTable = createFocusTableComponent();
191
+ const onKeyboardOut = jest.fn((direction, event) =>
192
+ event.continuePropagation(),
193
+ );
194
195
function Test() {
196
return (
189
- <div>
190
- <h1>Title</h1>
191
- <aside ref={leftSidebarRef}>
192
- <h2>Left Sidebar</h2>
193
- <FocusTable
194
- id="left-sidebar"
195
- onKeyboardOut={(direction, focusTableByID) => {
196
- if (direction === 'right') {
197
- focusTableByID('content');
198
- }
199
- }}
200
- />
201
- </aside>
202
- <section>
203
- <h2>Content</h2>
204
- <FocusTable
205
- id="content"
206
- onKeyboardOut={(direction, focusTableByID) => {
207
- if (direction === 'right') {
208
- focusTableByID('right-sidebar');
209
- } else if (direction === 'left') {
210
- focusTableByID('left-sidebar');
211
- }
212
- }}
213
- />
214
- </section>
215
- <aside>
216
- <h2>Right Sidebar</h2>
217
- <FocusTable
218
- id="right-sidebar"
219
- onKeyboardOut={(direction, focusTableByID) => {
220
- if (direction === 'left') {
221
- focusTableByID('content');
222
- }
223
- }}
224
- />
225
- </aside>
226
- </div>
197
+ <MainFocusTable>
198
+ <MainFocusTableRow>
199
+ <div>
200
+ <h1>Title</h1>
201
+ <aside ref={leftSidebarRef}>
202
+ <h2>Left Sidebar</h2>
203
+ <MainFocusTableCell>
204
+ <SubFocusTable onKeyboardOut={onKeyboardOut} />
205
+ </MainFocusTableCell>
206
+ </aside>
207
+ <section>
208
+ <h2>Content</h2>
209
+ <MainFocusTableCell>
210
+ <SubFocusTable onKeyboardOut={onKeyboardOut} />
211
+ </MainFocusTableCell>
212
+ </section>
213
+ <aside>
214
+ <h2>Right Sidebar</h2>
215
+ <MainFocusTableCell>
216
+ <SubFocusTable onKeyboardOut={onKeyboardOut} />
217
+ </MainFocusTableCell>
218
+ </aside>
219
+ </div>
220
+ </MainFocusTableRow>
221
+ </MainFocusTable>
222
);
223
}
224
@@ -246,6 +241,7 @@ describe('FocusTable', () => {
241
a3.keydown({
242
key: 'ArrowRight',
243
});
244
+ expect(onKeyboardOut).toHaveBeenCalledTimes(1);
245
expect(document.activeElement.textContent).toBe('A1');
246
247
a1 = createEventTarget(document.activeElement);
@@ -264,6 +260,7 @@ describe('FocusTable', () => {
260
a3.keydown({
261
key: 'ArrowRight',
262
});
263
+ expect(onKeyboardOut).toHaveBeenCalledTimes(2);
264
expect(document.activeElement.textContent).toBe('A1');
265
266
a1 = createEventTarget(document.activeElement);
@@ -354,10 +351,10 @@ describe('FocusTable', () => {
351
expect(document.activeElement.placeholder).toBe('B1');
352
});
353
357
- it('handles keyboard arrow operations with wrapping enabled', () => {
354
+ it('handles keyboard arrow operations with X wrapping enabled', () => {
355
const Test = createFocusTableComponent();
356
360
- ReactDOM.render(<Test wrap={true} />, container);
357
+ ReactDOM.render(<Test wrapX={true} />, container);
358
const buttons = document.querySelectorAll('button');
359
let a1 = createEventTarget(buttons[0]);
360
a1.focus();
@@ -385,6 +382,37 @@ describe('FocusTable', () => {
382
expect(document.activeElement.textContent).toBe('A3');
383
});
384
385
+ it('handles keyboard arrow operations with Y wrapping enabled', () => {
386
+ const Test = createFocusTableComponent();
387
+
388
+ ReactDOM.render(<Test wrapY={true} />, container);
389
+ const buttons = document.querySelectorAll('button');
390
+ let a1 = createEventTarget(buttons[0]);
391
+ a1.focus();
392
+ a1.keydown({
393
+ key: 'ArrowDown',
394
+ });
395
+ expect(document.activeElement.textContent).toBe('B1');
396
+
397
+ const a2 = createEventTarget(document.activeElement);
398
+ a2.keydown({
399
+ key: 'ArrowDown',
400
+ });
401
+ expect(document.activeElement.textContent).toBe('C1');
402
+
403
+ const a3 = createEventTarget(document.activeElement);
404
+ a3.keydown({
405
+ key: 'ArrowDown',
406
+ });
407
+ expect(document.activeElement.textContent).toBe('A1');
408
+
409
+ a1 = createEventTarget(document.activeElement);
410
+ a1.keydown({
411
+ key: 'ArrowUp',
412
+ });
413
+ expect(document.activeElement.textContent).toBe('C1');
414
+ });
415
+
416
it('handles keyboard arrow operations mixed with tabbing', () => {
417
const [FocusTable, FocusRow, FocusCell] = createFocusTable(TabbableScope);
418
const beforeRef = React.createRef();
@@ -447,5 +475,93 @@ describe('FocusTable', () => {
475
emulateBrowserTab(true);
476
expect(document.activeElement.placeholder).toBe('B1');
477
});
478
+
479
+ it('handles keyboard arrow operations with colSpan', () => {
480
+ const firstRef = React.createRef();
481
+ const [FocusTable, FocusRow, FocusCell] = createFocusTable(TabbableScope);
482
+
483
+ function Test() {
484
+ return (
485
+ <>
486
+ <FocusTable tabScope={TabbableScope}>
487
+ <div>
488
+ <FocusRow>
489
+ <FocusCell>
490
+ <input placeholder="A1" ref={firstRef} />
491
+ </FocusCell>
492
+ <FocusCell colSpan={2}>
493
+ <input placeholder="B1" />
494
+ </FocusCell>
495
+ <FocusCell>
496
+ <input placeholder="C1" />
497
+ </FocusCell>
498
+ </FocusRow>
499
+ </div>
500
+ <div>
501
+ <FocusRow>
502
+ <FocusCell>
503
+ <input placeholder="A2" />
504
+ </FocusCell>
505
+ <FocusCell>
506
+ <input placeholder="B2" />
507
+ </FocusCell>
508
+ <FocusCell>
509
+ <input placeholder="C2" />
510
+ </FocusCell>
511
+ <FocusCell>
512
+ <input placeholder="D2" />
513
+ </FocusCell>
514
+ </FocusRow>
515
+ </div>
516
+ </FocusTable>
517
+ </>
518
+ );
519
+ }
520
+
521
+ ReactDOM.render(<Test />, container);
522
+ firstRef.current.focus();
523
+
524
+ expect(document.activeElement.placeholder).toBe('A1');
525
+ const a1 = createEventTarget(document.activeElement);
526
+ a1.keydown({
527
+ key: 'ArrowRight',
528
+ });
529
+ expect(document.activeElement.placeholder).toBe('B1');
530
+ let b1 = createEventTarget(document.activeElement);
531
+ b1.keydown({
532
+ key: 'ArrowRight',
533
+ });
534
+ expect(document.activeElement.placeholder).toBe('C1');
535
+ let c1 = createEventTarget(document.activeElement);
536
+ c1.keydown({
537
+ key: 'ArrowDown',
538
+ });
539
+ expect(document.activeElement.placeholder).toBe('D2');
540
+ let d2 = createEventTarget(document.activeElement);
541
+ d2.keydown({
542
+ key: 'ArrowUp',
543
+ });
544
+ expect(document.activeElement.placeholder).toBe('C1');
545
+ c1 = createEventTarget(document.activeElement);
546
+ c1.keydown({
547
+ key: 'ArrowLeft',
548
+ });
549
+ expect(document.activeElement.placeholder).toBe('B1');
550
+ b1 = createEventTarget(document.activeElement);
551
+ b1.keydown({
552
+ key: 'ArrowDown',
553
+ });
554
+ expect(document.activeElement.placeholder).toBe('B2');
555
+ const b2 = createEventTarget(document.activeElement);
556
+ b2.keydown({
557
+ key: 'ArrowRight',
558
+ });
559
+ expect(document.activeElement.placeholder).toBe('C2');
560
+ const c2 = createEventTarget(document.activeElement);
561
+ c2.keydown({
562
+ key: 'ArrowUp',
563
+ });
564
+ expect(document.activeElement.placeholder).toBe('B1');
565
+ });
566
});
567
});
packages/react-reconciler/src/ReactFiberScope.js
+5
-1
@@ -98,7 +98,11 @@ function collectNearestChildScopeMethods(
98
}
99
100
function isValidScopeNode(node, scope) {
101
- return node.tag === ScopeComponent && node.type === scope;
101
+ return (
102
+ node.tag === ScopeComponent &&
103
+ node.type === scope &&
104
+ node.stateNode !== null
105
+ );
106
}
107
108
export function createScopeMethods(