[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