Skip to content

Commit 10c7dfe

Browse files
authored
[react-interactins] FocusTable tabScope handling+tabIndex control (#16922)
1 parent d3622d0 commit 10c7dfe

File tree

4 files changed

+177
-16
lines changed

4 files changed

+177
-16
lines changed

packages/react-interactions/accessibility/src/FocusControl.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,42 @@ export function getPreviousScope(
137137
}
138138
return allScopes[currentScopeIndex - 1];
139139
}
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

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {KeyboardEvent} from 'react-interactions/events/keyboard';
1212

1313
import React from 'react';
1414
import {useKeyboard} from 'react-interactions/events/keyboard';
15+
import {setElementCanTab} from 'react-interactions/accessibility/focus-control';
1516

1617
type FocusCellProps = {
1718
children?: React.Node,
@@ -56,7 +57,7 @@ export function focusFirstCellOnTable(table: ReactScopeMethods): void {
5657
}
5758
}
5859

59-
function focusCell(cell: ReactScopeMethods, event?: KeyboardEvent): void {
60+
function focusScope(cell: ReactScopeMethods, event?: KeyboardEvent): void {
6061
const tabbableNodes = cell.getScopedNodes();
6162
if (tabbableNodes !== null && tabbableNodes.length > 0) {
6263
tabbableNodes[0].focus();
@@ -75,7 +76,7 @@ function focusCellByIndex(
7576
if (cells !== null) {
7677
const cell = cells[cellIndex];
7778
if (cell) {
78-
focusCell(cell, event);
79+
focusScope(cell, event);
7980
}
8081
}
8182
}
@@ -139,28 +140,40 @@ function triggerNavigateOut(
139140
event.continuePropagation();
140141
}
141142

142-
function getTableWrapProp(currentCell: ReactScopeMethods): boolean {
143+
function getTableProps(currentCell: ReactScopeMethods): Object {
143144
const row = currentCell.getParent();
144145
if (row !== null && row.getProps().type === 'row') {
145146
const table = row.getParent();
146147
if (table !== null) {
147-
return table.getProps().wrap || false;
148+
return table.getProps();
148149
}
149150
}
150-
return false;
151+
return {};
151152
}
152153

153154
export function createFocusTable(scope: ReactScope): Array<React.Component> {
154155
const TableScope = React.unstable_createScope(scope.fn);
155156

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);
157165
return (
158166
<TableScope
159167
type="table"
160168
onKeyboardOut={onKeyboardOut}
161169
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+
)}
164177
</TableScope>
165178
);
166179
}
@@ -179,6 +192,24 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
179192
return;
180193
}
181194
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+
}
182213
case 'ArrowUp': {
183214
const [cells, cellIndex] = getRowCells(currentCell);
184215
if (cells !== null) {
@@ -188,7 +219,7 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
188219
const row = rows[rowIndex - 1];
189220
focusCellByIndex(row, cellIndex, event);
190221
} else if (rowIndex === 0) {
191-
const wrap = getTableWrapProp(currentCell);
222+
const wrap = getTableProps(currentCell).wrap;
192223
if (wrap) {
193224
const row = rows[rows.length - 1];
194225
focusCellByIndex(row, cellIndex, event);
@@ -207,7 +238,7 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
207238
if (rows !== null) {
208239
if (rowIndex !== -1) {
209240
if (rowIndex === rows.length - 1) {
210-
const wrap = getTableWrapProp(currentCell);
241+
const wrap = getTableProps(currentCell).wrap;
211242
if (wrap) {
212243
const row = rows[0];
213244
focusCellByIndex(row, cellIndex, event);
@@ -227,12 +258,12 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
227258
const [cells, rowIndex] = getRowCells(currentCell);
228259
if (cells !== null) {
229260
if (rowIndex > 0) {
230-
focusCell(cells[rowIndex - 1]);
261+
focusScope(cells[rowIndex - 1]);
231262
event.preventDefault();
232263
} else if (rowIndex === 0) {
233-
const wrap = getTableWrapProp(currentCell);
264+
const wrap = getTableProps(currentCell).wrap;
234265
if (wrap) {
235-
focusCell(cells[cells.length - 1], event);
266+
focusScope(cells[cells.length - 1], event);
236267
} else {
237268
triggerNavigateOut(currentCell, 'left', event);
238269
}
@@ -245,14 +276,14 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
245276
if (cells !== null) {
246277
if (rowIndex !== -1) {
247278
if (rowIndex === cells.length - 1) {
248-
const wrap = getTableWrapProp(currentCell);
279+
const wrap = getTableProps(currentCell).wrap;
249280
if (wrap) {
250-
focusCell(cells[0], event);
281+
focusScope(cells[0], event);
251282
} else {
252283
triggerNavigateOut(currentCell, 'right', event);
253284
}
254285
} else {
255-
focusCell(cells[rowIndex + 1], event);
286+
focusScope(cells[rowIndex + 1], event);
256287
}
257288
}
258289
}

packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,33 @@ describe('FocusTable', () => {
2929
let ReactDOM;
3030
let container;
3131

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+
3259
beforeEach(() => {
3360
ReactDOM = require('react-dom');
3461
container = document.createElement('div');
@@ -357,5 +384,68 @@ describe('FocusTable', () => {
357384
});
358385
expect(document.activeElement.textContent).toBe('A3');
359386
});
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+
});
360450
});
361451
});

scripts/rollup/bundles.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,7 @@ const bundles = [
675675
'react',
676676
'react-interactions/events/keyboard',
677677
'react-interactions/accessibility/tabbable-scope',
678+
'react-interactions/accessibility/focus-control',
678679
],
679680
},
680681

0 commit comments

Comments
 (0)