@samitouri / QOS-React-2 / commits / 10c7dfe3b4

[react-interactins] FocusTable tabScope handling+tabIndex control (#16922)

Dominic Gannaway committed Sep 30, 2019 at 11:41 UTC 10c7dfe3b4acef516b657188e7e1fca72ed91922
4 files changed +177 -16
packages/react-interactions/accessibility/src/FocusControl.js
+39
@@ -137,3 +137,42 @@ export function getPreviousScope(
137 }
138 return allScopes[currentScopeIndex - 1];
139 }
140 +
141 +const tabIndexDesc = Object.getOwnPropertyDescriptor(
142 + HTMLElement.prototype,
143 + 'tabIndex',
144 +);
145 +const tabIndexSetter = (tabIndexDesc: any).set;
146 +
147 +export function setElementCanTab(elem: HTMLElement, canTab: boolean): void {
148 + let tabIndexState = (elem: any)._tabIndexState;
149 + if (!tabIndexState) {
150 + tabIndexState = {
151 + value: elem.tabIndex,
152 + canTab,
153 + };
154 + (elem: any)._tabIndexState = tabIndexState;
155 + if (!canTab) {
156 + elem.tabIndex = -1;
157 + }
158 + // We track the tabIndex value so we can restore the correct
159 + // tabIndex after we're done with it.
160 + // $FlowFixMe: Flow comoplains that we are missing value?
161 + Object.defineProperty(elem, 'tabIndex', {
162 + enumerable: false,
163 + configurable: true,
164 + get() {
165 + return tabIndexState.canTab ? tabIndexState.value : -1;
166 + },
167 + set(val) {
168 + if (tabIndexState.canTab) {
169 + tabIndexSetter.call(elem, val);
170 + }
171 + tabIndexState.value = val;
172 + },
173 + });
174 + } else if (tabIndexState.canTab !== canTab) {
175 + tabIndexSetter.call(elem, canTab ? tabIndexState.value : -1);
176 + tabIndexState.canTab = canTab;
177 + }
178 +}
packages/react-interactions/accessibility/src/FocusTable.js
+47 -16
@@ -12,6 +12,7 @@ import type {KeyboardEvent} from 'react-interactions/events/keyboard';
12
13 import React from 'react';
14 import {useKeyboard} from 'react-interactions/events/keyboard';
15 +import {setElementCanTab} from 'react-interactions/accessibility/focus-control';
16
17 type FocusCellProps = {
18 children?: React.Node,
@@ -56,7 +57,7 @@ export function focusFirstCellOnTable(table: ReactScopeMethods): void {
57 }
58 }
59
59 -function focusCell(cell: ReactScopeMethods, event?: KeyboardEvent): void {
60 +function focusScope(cell: ReactScopeMethods, event?: KeyboardEvent): void {
61 const tabbableNodes = cell.getScopedNodes();
62 if (tabbableNodes !== null && tabbableNodes.length > 0) {
63 tabbableNodes[0].focus();
@@ -75,7 +76,7 @@ function focusCellByIndex(
76 if (cells !== null) {
77 const cell = cells[cellIndex];
78 if (cell) {
78 - focusCell(cell, event);
79 + focusScope(cell, event);
80 }
81 }
82 }
@@ -139,28 +140,40 @@ function triggerNavigateOut(
140 event.continuePropagation();
141 }
142
142 -function getTableWrapProp(currentCell: ReactScopeMethods): boolean {
143 +function getTableProps(currentCell: ReactScopeMethods): Object {
144 const row = currentCell.getParent();
145 if (row !== null && row.getProps().type === 'row') {
146 const table = row.getParent();
147 if (table !== null) {
147 - return table.getProps().wrap || false;
148 + return table.getProps();
149 }
150 }
150 - return false;
151 + return {};
152 }
153
154 export function createFocusTable(scope: ReactScope): Array<React.Component> {
155 const TableScope = React.unstable_createScope(scope.fn);
156
156 - function Table({children, onKeyboardOut, id, wrap}): FocusTableProps {
157 + function Table({
158 + children,
159 + onKeyboardOut,
160 + id,
161 + wrap,
162 + tabScope: TabScope,
163 + }): FocusTableProps {
164 + const tabScopeRef = useRef(null);
165 return (
166 <TableScope
167 type="table"
168 onKeyboardOut={onKeyboardOut}
169 id={id}
162 - wrap={wrap}>
163 - {children}
170 + wrap={wrap}
171 + tabScopeRef={tabScopeRef}>
172 + {TabScope ? (
173 + <TabScope ref={tabScopeRef}>{children}</TabScope>
174 + ) : (
175 + children
176 + )}
177 </TableScope>
178 );
179 }
@@ -179,6 +192,24 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
192 return;
193 }
194 switch (event.key) {
195 + case 'Tab': {
196 + const tabScope = getTableProps(currentCell).tabScopeRef.current;
197 + if (tabScope) {
198 + const activeNode = document.activeElement;
199 + const nodes = tabScope.getScopedNodes();
200 + for (let i = 0; i < nodes.length; i++) {
201 + const node = nodes[i];
202 + if (node !== activeNode) {
203 + setElementCanTab(node, false);
204 + } else {
205 + setElementCanTab(node, true);
206 + }
207 + }
208 + return;
209 + }
210 + event.continuePropagation();
211 + return;
212 + }
213 case 'ArrowUp': {
214 const [cells, cellIndex] = getRowCells(currentCell);
215 if (cells !== null) {
@@ -188,7 +219,7 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
219 const row = rows[rowIndex - 1];
220 focusCellByIndex(row, cellIndex, event);
221 } else if (rowIndex === 0) {
191 - const wrap = getTableWrapProp(currentCell);
222 + const wrap = getTableProps(currentCell).wrap;
223 if (wrap) {
224 const row = rows[rows.length - 1];
225 focusCellByIndex(row, cellIndex, event);
@@ -207,7 +238,7 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
238 if (rows !== null) {
239 if (rowIndex !== -1) {
240 if (rowIndex === rows.length - 1) {
210 - const wrap = getTableWrapProp(currentCell);
241 + const wrap = getTableProps(currentCell).wrap;
242 if (wrap) {
243 const row = rows[0];
244 focusCellByIndex(row, cellIndex, event);
@@ -227,12 +258,12 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
258 const [cells, rowIndex] = getRowCells(currentCell);
259 if (cells !== null) {
260 if (rowIndex > 0) {
230 - focusCell(cells[rowIndex - 1]);
261 + focusScope(cells[rowIndex - 1]);
262 event.preventDefault();
263 } else if (rowIndex === 0) {
233 - const wrap = getTableWrapProp(currentCell);
264 + const wrap = getTableProps(currentCell).wrap;
265 if (wrap) {
235 - focusCell(cells[cells.length - 1], event);
266 + focusScope(cells[cells.length - 1], event);
267 } else {
268 triggerNavigateOut(currentCell, 'left', event);
269 }
@@ -245,14 +276,14 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
276 if (cells !== null) {
277 if (rowIndex !== -1) {
278 if (rowIndex === cells.length - 1) {
248 - const wrap = getTableWrapProp(currentCell);
279 + const wrap = getTableProps(currentCell).wrap;
280 if (wrap) {
250 - focusCell(cells[0], event);
281 + focusScope(cells[0], event);
282 } else {
283 triggerNavigateOut(currentCell, 'right', event);
284 }
285 } else {
255 - focusCell(cells[rowIndex + 1], event);
286 + focusScope(cells[rowIndex + 1], event);
287 }
288 }
289 }
packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js
+90
@@ -29,6 +29,33 @@ describe('FocusTable', () => {
29 let ReactDOM;
30 let container;
31
32 + function emulateBrowserTab(backwards) {
33 + const activeElement = document.activeElement;
34 + const focusedElem = createEventTarget(activeElement);
35 + let defaultPrevented = false;
36 + focusedElem.keydown({
37 + key: 'Tab',
38 + shiftKey: backwards,
39 + preventDefault() {
40 + defaultPrevented = true;
41 + },
42 + });
43 + if (!defaultPrevented) {
44 + // This is not a full spec compliant version, but should be suffice for this test
45 + const focusableElems = Array.from(
46 + document.querySelectorAll(
47 + 'input, button, select, textarea, a[href], [tabindex], [contenteditable], iframe, object, embed',
48 + ),
49 + ).filter(
50 + elem => elem.tabIndex > -1 && !elem.disabled && !elem.contentEditable,
51 + );
52 + const idx = focusableElems.indexOf(activeElement);
53 + if (idx !== -1) {
54 + focusableElems[backwards ? idx - 1 : idx + 1].focus();
55 + }
56 + }
57 + }
58 +
59 beforeEach(() => {
60 ReactDOM = require('react-dom');
61 container = document.createElement('div');
@@ -357,5 +384,68 @@ describe('FocusTable', () => {
384 });
385 expect(document.activeElement.textContent).toBe('A3');
386 });
387 +
388 + it('handles keyboard arrow operations mixed with tabbing', () => {
389 + const [FocusTable, FocusRow, FocusCell] = createFocusTable(TabbableScope);
390 + const beforeRef = React.createRef();
391 + const afterRef = React.createRef();
392 +
393 + function Test() {
394 + return (
395 + <>
396 + <input placeholder="Before" ref={beforeRef} />
397 + <FocusTable tabScope={TabbableScope}>
398 + <div>
399 + <FocusRow>
400 + <FocusCell>
401 + <input placeholder="A1" />
402 + </FocusCell>
403 + <FocusCell>
404 + <input placeholder="B1" />
405 + </FocusCell>
406 + <FocusCell>
407 + <input placeholder="C1" />
408 + </FocusCell>
409 + </FocusRow>
410 + </div>
411 + <div>
412 + <FocusRow>
413 + <FocusCell>
414 + <input placeholder="A2" />
415 + </FocusCell>
416 + <FocusCell>
417 + <input placeholder="B2" />
418 + </FocusCell>
419 + <FocusCell>
420 + <input placeholder="C1" />
421 + </FocusCell>
422 + </FocusRow>
423 + </div>
424 + </FocusTable>
425 + <input placeholder="After" ref={afterRef} />
426 + </>
427 + );
428 + }
429 +
430 + ReactDOM.render(<Test />, container);
431 + beforeRef.current.focus();
432 +
433 + expect(document.activeElement.placeholder).toBe('Before');
434 + emulateBrowserTab();
435 + expect(document.activeElement.placeholder).toBe('A1');
436 + emulateBrowserTab();
437 + expect(document.activeElement.placeholder).toBe('After');
438 + emulateBrowserTab(true);
439 + expect(document.activeElement.placeholder).toBe('A1');
440 + const a1 = createEventTarget(document.activeElement);
441 + a1.keydown({
442 + key: 'ArrowRight',
443 + });
444 + expect(document.activeElement.placeholder).toBe('B1');
445 + emulateBrowserTab();
446 + expect(document.activeElement.placeholder).toBe('After');
447 + emulateBrowserTab(true);
448 + expect(document.activeElement.placeholder).toBe('B1');
449 + });
450 });
451 });
scripts/rollup/bundles.js
+1
@@ -675,6 +675,7 @@ const bundles = [
675 'react',
676 'react-interactions/events/keyboard',
677 'react-interactions/accessibility/tabbable-scope',
678 + 'react-interactions/accessibility/focus-control',
679 ],
680 },
681