From ac8e8b3278c69ddb6267ca0658c49af34cc4e785 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Mon, 30 Sep 2019 17:13:35 +0200 Subject: [PATCH] [react-interactions] Add tab handling to FocusList (#16958) --- .../accessibility/src/FocusList.js | 54 +++++++++++--- .../accessibility/src/FocusManager.js | 1 + .../accessibility/src/FocusTable.js | 1 + .../src/__tests__/FocusList-test.internal.js | 74 ++++++++++++++++++- .../src/__tests__/FocusTable-test.internal.js | 32 +------- .../events/src/dom/testing-library/index.js | 28 +++++++ scripts/rollup/bundles.js | 1 + 7 files changed, 153 insertions(+), 38 deletions(-) diff --git a/packages/react-interactions/accessibility/src/FocusList.js b/packages/react-interactions/accessibility/src/FocusList.js index 2338436bd3d03..5082b8ac6f333 100644 --- a/packages/react-interactions/accessibility/src/FocusList.js +++ b/packages/react-interactions/accessibility/src/FocusList.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 FocusItemProps = { children?: React.Node, @@ -22,6 +23,7 @@ type FocusListProps = {| children: React.Node, portrait: boolean, wrap?: boolean, + tabScope?: ReactScope, |}; const {useRef} = React; @@ -41,7 +43,7 @@ function getPreviousListItem( const items = list.getChildren(); if (items !== null) { const currentItemIndex = items.indexOf(currentItem); - const wrap = getListWrapProp(currentItem); + const wrap = getListProps(currentItem).wrap; if (currentItemIndex === 0 && wrap) { return items[items.length - 1] || null; } else if (currentItemIndex > 0) { @@ -58,7 +60,7 @@ function getNextListItem( const items = list.getChildren(); if (items !== null) { const currentItemIndex = items.indexOf(currentItem); - const wrap = getListWrapProp(currentItem); + const wrap = getListProps(currentItem).wrap; const end = currentItemIndex === items.length - 1; if (end && wrap) { return items[0] || null; @@ -69,22 +71,38 @@ function getNextListItem( return null; } -function getListWrapProp(currentItem: ReactScopeMethods): boolean { - const list = currentItem.getParent(); +function getListProps(currentCell: ReactScopeMethods): Object { + const list = currentCell.getParent(); if (list !== null) { const listProps = list.getProps(); - return (listProps.type === 'list' && listProps.wrap) || false; + if (listProps && listProps.type === 'list') { + return listProps; + } } - return false; + return {}; } export function createFocusList(scope: ReactScope): Array { const TableScope = React.unstable_createScope(scope.fn); - function List({children, portrait, wrap}): FocusListProps { + function List({ + children, + portrait, + wrap, + tabScope: TabScope, + }): FocusListProps { + const tabScopeRef = useRef(null); return ( - - {children} + + {TabScope ? ( + {children} + ) : ( + children + )} ); } @@ -100,6 +118,24 @@ export function createFocusList(scope: ReactScope): Array { if (list !== null && listProps.type === 'list') { const portrait = listProps.portrait; switch (event.key) { + case 'Tab': { + const tabScope = getListProps(currentItem).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': { if (portrait) { const previousListItem = getPreviousListItem( diff --git a/packages/react-interactions/accessibility/src/FocusManager.js b/packages/react-interactions/accessibility/src/FocusManager.js index 34d4b9e44cfcc..12a37297b9a01 100644 --- a/packages/react-interactions/accessibility/src/FocusManager.js +++ b/packages/react-interactions/accessibility/src/FocusManager.js @@ -64,6 +64,7 @@ const FocusManager = React.forwardRef( onBlurWithin: function(event) { if (!containFocus) { event.continuePropagation(); + return; } const lastNode = event.target; if (lastNode) { diff --git a/packages/react-interactions/accessibility/src/FocusTable.js b/packages/react-interactions/accessibility/src/FocusTable.js index d658de2aa545b..6e1ae6a75b6a2 100644 --- a/packages/react-interactions/accessibility/src/FocusTable.js +++ b/packages/react-interactions/accessibility/src/FocusTable.js @@ -31,6 +31,7 @@ type FocusTableProps = {| focusTableByID: (id: string) => void, ) => void, wrap?: boolean, + tabScope?: ReactScope, |}; const {useRef} = React; diff --git a/packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js b/packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js index e880d06c0437c..6d164eb6bdee0 100644 --- a/packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js +++ b/packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js @@ -7,7 +7,10 @@ * @flow */ -import {createEventTarget} from 'react-interactions/events/src/dom/testing-library'; +import { + createEventTarget, + emulateBrowserTab, +} from 'react-interactions/events/src/dom/testing-library'; let React; let ReactFeatureFlags; @@ -156,5 +159,74 @@ describe('FocusList', () => { }); expect(document.activeElement.textContent).toBe('Item 3'); }); + + it('handles keyboard arrow operations mixed with tabbing', () => { + const [FocusList, FocusItem] = createFocusList(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('A'); + emulateBrowserTab(); + expect(document.activeElement.placeholder).toBe('After'); + emulateBrowserTab(true); + expect(document.activeElement.placeholder).toBe('A'); + const a = createEventTarget(document.activeElement); + a.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.placeholder).toBe('B'); + emulateBrowserTab(); + expect(document.activeElement.placeholder).toBe('After'); + emulateBrowserTab(true); + expect(document.activeElement.placeholder).toBe('B'); + }); }); }); 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 ce33fa1fe6482..eea6216713ae8 100644 --- a/packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js +++ b/packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js @@ -7,7 +7,10 @@ * @flow */ -import {createEventTarget} from 'react-interactions/events/src/dom/testing-library'; +import { + createEventTarget, + emulateBrowserTab, +} from 'react-interactions/events/src/dom/testing-library'; let React; let ReactFeatureFlags; @@ -29,33 +32,6 @@ 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'); diff --git a/packages/react-interactions/events/src/dom/testing-library/index.js b/packages/react-interactions/events/src/dom/testing-library/index.js index d46f96a5058ce..acc92235395ca 100644 --- a/packages/react-interactions/events/src/dom/testing-library/index.js +++ b/packages/react-interactions/events/src/dom/testing-library/index.js @@ -158,6 +158,33 @@ function testWithPointerType(message, testFn) { }); } +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(); + } + } +} + export { buttonsType, createEventTarget, @@ -166,4 +193,5 @@ export { hasPointerEvent, setPointerEvent, testWithPointerType, + emulateBrowserTab, }; diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 20069c4841797..34f146d8a2a54 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -718,6 +718,7 @@ const bundles = [ 'react', 'react-interactions/events/keyboard', 'react-interactions/accessibility/tabbable-scope', + 'react-interactions/accessibility/focus-control', ], }, ];