Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/react-interactions/accessibility/src/FocusControl.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,42 @@ export function getPreviousScope(
}
return allScopes[currentScopeIndex - 1];
}

const tabIndexDesc = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'tabIndex',
);
const tabIndexSetter = (tabIndexDesc: any).set;

export function setElementCanTab(elem: HTMLElement, canTab: boolean): void {
let tabIndexState = (elem: any)._tabIndexState;
if (!tabIndexState) {
tabIndexState = {
value: elem.tabIndex,
canTab,
};
(elem: any)._tabIndexState = tabIndexState;
if (!canTab) {
elem.tabIndex = -1;
}
// We track the tabIndex value so we can restore the correct
// tabIndex after we're done with it.
// $FlowFixMe: Flow comoplains that we are missing value?
Object.defineProperty(elem, 'tabIndex', {
enumerable: false,
configurable: true,
get() {
return tabIndexState.canTab ? tabIndexState.value : -1;
},
set(val) {
if (tabIndexState.canTab) {
tabIndexSetter.call(elem, val);
}
tabIndexState.value = val;
},
});
} else if (tabIndexState.canTab !== canTab) {
tabIndexSetter.call(elem, canTab ? tabIndexState.value : -1);
tabIndexState.canTab = canTab;
}
}
63 changes: 47 additions & 16 deletions packages/react-interactions/accessibility/src/FocusTable.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {KeyboardEvent} from 'react-interactions/events/keyboard';

import React from 'react';
import {useKeyboard} from 'react-interactions/events/keyboard';
import {setElementCanTab} from 'react-interactions/accessibility/focus-control';

type FocusCellProps = {
children?: React.Node,
Expand Down Expand Up @@ -56,7 +57,7 @@ export function focusFirstCellOnTable(table: ReactScopeMethods): void {
}
}

function focusCell(cell: ReactScopeMethods, event?: KeyboardEvent): void {
function focusScope(cell: ReactScopeMethods, event?: KeyboardEvent): void {
const tabbableNodes = cell.getScopedNodes();
if (tabbableNodes !== null && tabbableNodes.length > 0) {
tabbableNodes[0].focus();
Expand All @@ -75,7 +76,7 @@ function focusCellByIndex(
if (cells !== null) {
const cell = cells[cellIndex];
if (cell) {
focusCell(cell, event);
focusScope(cell, event);
}
}
}
Expand Down Expand Up @@ -139,28 +140,40 @@ function triggerNavigateOut(
event.continuePropagation();
}

function getTableWrapProp(currentCell: ReactScopeMethods): boolean {
function getTableProps(currentCell: ReactScopeMethods): Object {
const row = currentCell.getParent();
if (row !== null && row.getProps().type === 'row') {
const table = row.getParent();
if (table !== null) {
return table.getProps().wrap || false;
return table.getProps();
}
}
return false;
return {};
}

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

function Table({children, onKeyboardOut, id, wrap}): FocusTableProps {
function Table({
children,
onKeyboardOut,
id,
wrap,
tabScope: TabScope,
}): FocusTableProps {
const tabScopeRef = useRef(null);
return (
<TableScope
type="table"
onKeyboardOut={onKeyboardOut}
id={id}
wrap={wrap}>
{children}
wrap={wrap}
tabScopeRef={tabScopeRef}>
{TabScope ? (
<TabScope ref={tabScopeRef}>{children}</TabScope>
) : (
children
)}
</TableScope>
);
}
Expand All @@ -179,6 +192,24 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
return;
}
switch (event.key) {
case 'Tab': {
const tabScope = getTableProps(currentCell).tabScopeRef.current;
if (tabScope) {
const activeNode = document.activeElement;
const nodes = tabScope.getScopedNodes();
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (node !== activeNode) {
setElementCanTab(node, false);
} else {
setElementCanTab(node, true);
}
}
return;
}
event.continuePropagation();
return;
}
case 'ArrowUp': {
const [cells, cellIndex] = getRowCells(currentCell);
if (cells !== null) {
Expand All @@ -188,7 +219,7 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
const row = rows[rowIndex - 1];
focusCellByIndex(row, cellIndex, event);
} else if (rowIndex === 0) {
const wrap = getTableWrapProp(currentCell);
const wrap = getTableProps(currentCell).wrap;
if (wrap) {
const row = rows[rows.length - 1];
focusCellByIndex(row, cellIndex, event);
Expand All @@ -207,7 +238,7 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
if (rows !== null) {
if (rowIndex !== -1) {
if (rowIndex === rows.length - 1) {
const wrap = getTableWrapProp(currentCell);
const wrap = getTableProps(currentCell).wrap;
if (wrap) {
const row = rows[0];
focusCellByIndex(row, cellIndex, event);
Expand All @@ -227,12 +258,12 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
const [cells, rowIndex] = getRowCells(currentCell);
if (cells !== null) {
if (rowIndex > 0) {
focusCell(cells[rowIndex - 1]);
focusScope(cells[rowIndex - 1]);
event.preventDefault();
} else if (rowIndex === 0) {
const wrap = getTableWrapProp(currentCell);
const wrap = getTableProps(currentCell).wrap;
if (wrap) {
focusCell(cells[cells.length - 1], event);
focusScope(cells[cells.length - 1], event);
} else {
triggerNavigateOut(currentCell, 'left', event);
}
Expand All @@ -245,14 +276,14 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
if (cells !== null) {
if (rowIndex !== -1) {
if (rowIndex === cells.length - 1) {
const wrap = getTableWrapProp(currentCell);
const wrap = getTableProps(currentCell).wrap;
if (wrap) {
focusCell(cells[0], event);
focusScope(cells[0], event);
} else {
triggerNavigateOut(currentCell, 'right', event);
}
} else {
focusCell(cells[rowIndex + 1], event);
focusScope(cells[rowIndex + 1], event);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,33 @@ describe('FocusTable', () => {
let ReactDOM;
let container;

function emulateBrowserTab(backwards) {
const activeElement = document.activeElement;
const focusedElem = createEventTarget(activeElement);
let defaultPrevented = false;
focusedElem.keydown({
key: 'Tab',
shiftKey: backwards,
preventDefault() {
defaultPrevented = true;
},
});
if (!defaultPrevented) {
// This is not a full spec compliant version, but should be suffice for this test
const focusableElems = Array.from(
document.querySelectorAll(
'input, button, select, textarea, a[href], [tabindex], [contenteditable], iframe, object, embed',
),
).filter(
elem => elem.tabIndex > -1 && !elem.disabled && !elem.contentEditable,
);
const idx = focusableElems.indexOf(activeElement);
if (idx !== -1) {
focusableElems[backwards ? idx - 1 : idx + 1].focus();
}
}
}

beforeEach(() => {
ReactDOM = require('react-dom');
container = document.createElement('div');
Expand Down Expand Up @@ -357,5 +384,68 @@ describe('FocusTable', () => {
});
expect(document.activeElement.textContent).toBe('A3');
});

it('handles keyboard arrow operations mixed with tabbing', () => {
const [FocusTable, FocusRow, FocusCell] = createFocusTable(TabbableScope);
const beforeRef = React.createRef();
const afterRef = React.createRef();

function Test() {
return (
<>
<input placeholder="Before" ref={beforeRef} />
<FocusTable tabScope={TabbableScope}>
<div>
<FocusRow>
<FocusCell>
<input placeholder="A1" />
</FocusCell>
<FocusCell>
<input placeholder="B1" />
</FocusCell>
<FocusCell>
<input placeholder="C1" />
</FocusCell>
</FocusRow>
</div>
<div>
<FocusRow>
<FocusCell>
<input placeholder="A2" />
</FocusCell>
<FocusCell>
<input placeholder="B2" />
</FocusCell>
<FocusCell>
<input placeholder="C1" />
</FocusCell>
</FocusRow>
</div>
</FocusTable>
<input placeholder="After" ref={afterRef} />
</>
);
}

ReactDOM.render(<Test />, container);
beforeRef.current.focus();

expect(document.activeElement.placeholder).toBe('Before');
emulateBrowserTab();
expect(document.activeElement.placeholder).toBe('A1');
emulateBrowserTab();
expect(document.activeElement.placeholder).toBe('After');
emulateBrowserTab(true);
expect(document.activeElement.placeholder).toBe('A1');
const a1 = createEventTarget(document.activeElement);
a1.keydown({
key: 'ArrowRight',
});
expect(document.activeElement.placeholder).toBe('B1');
emulateBrowserTab();
expect(document.activeElement.placeholder).toBe('After');
emulateBrowserTab(true);
expect(document.activeElement.placeholder).toBe('B1');
});
});
});
1 change: 1 addition & 0 deletions scripts/rollup/bundles.js
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,7 @@ const bundles = [
'react',
'react-interactions/events/keyboard',
'react-interactions/accessibility/tabbable-scope',
'react-interactions/accessibility/focus-control',
],
},

Expand Down