From 10c7dfe3b4acef516b657188e7e1fca72ed91922 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Mon, 30 Sep 2019 11:41:20 +0200 Subject: [PATCH] [react-interactins] FocusTable tabScope handling+tabIndex control (#16922) --- .../accessibility/src/FocusControl.js | 39 ++++++++ .../accessibility/src/FocusTable.js | 63 +++++++++---- .../src/__tests__/FocusTable-test.internal.js | 90 +++++++++++++++++++ scripts/rollup/bundles.js | 1 + 4 files changed, 177 insertions(+), 16 deletions(-) diff --git a/packages/react-interactions/accessibility/src/FocusControl.js b/packages/react-interactions/accessibility/src/FocusControl.js index be0be363a1127..27939d956d0ca 100644 --- a/packages/react-interactions/accessibility/src/FocusControl.js +++ b/packages/react-interactions/accessibility/src/FocusControl.js @@ -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; + } +} diff --git a/packages/react-interactions/accessibility/src/FocusTable.js b/packages/react-interactions/accessibility/src/FocusTable.js index df50e1ebca984..d658de2aa545b 100644 --- a/packages/react-interactions/accessibility/src/FocusTable.js +++ b/packages/react-interactions/accessibility/src/FocusTable.js @@ -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, @@ -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(); @@ -75,7 +76,7 @@ function focusCellByIndex( if (cells !== null) { const cell = cells[cellIndex]; if (cell) { - focusCell(cell, event); + focusScope(cell, event); } } } @@ -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 { 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 ( - {children} + wrap={wrap} + tabScopeRef={tabScopeRef}> + {TabScope ? ( + {children} + ) : ( + children + )} ); } @@ -179,6 +192,24 @@ export function createFocusTable(scope: ReactScope): Array { 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) { @@ -188,7 +219,7 @@ export function createFocusTable(scope: ReactScope): Array { 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); @@ -207,7 +238,7 @@ export function createFocusTable(scope: ReactScope): Array { 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); @@ -227,12 +258,12 @@ export function createFocusTable(scope: ReactScope): Array { 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); } @@ -245,14 +276,14 @@ export function createFocusTable(scope: ReactScope): Array { 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); } } } diff --git a/packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js b/packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js index ad3db8d8aa5bb..ce33fa1fe6482 100644 --- a/packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js +++ b/packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js @@ -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'); @@ -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 ( + <> + + +
+ + + + + + + + + + + +
+
+ + + + + + + + + + + +
+
+ + + ); + } + + ReactDOM.render(, 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'); + }); }); }); diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 72d753937811f..20069c4841797 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -675,6 +675,7 @@ const bundles = [ 'react', 'react-interactions/events/keyboard', 'react-interactions/accessibility/tabbable-scope', + 'react-interactions/accessibility/focus-control', ], },