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

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