From 34457729a6b78bd319a8082cff032dd55e47c2e8 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Tue, 1 Oct 2019 17:59:52 +0200 Subject: [PATCH] [react-interactions] Add allowModifiers flag to FocusList + FocusTable (#16971) --- .../accessibility/src/FocusList.js | 51 +++++++++++++------ .../accessibility/src/FocusTable.js | 50 ++++++++++++------ .../src/__tests__/FocusList-test.internal.js | 36 ++++++++++--- .../src/__tests__/FocusTable-test.internal.js | 38 +++++++++++--- .../accessibility/src/emulateBrowserTab.js | 40 +++++++++++++++ .../events/src/dom/testing-library/index.js | 28 ---------- 6 files changed, 172 insertions(+), 71 deletions(-) create mode 100644 packages/react-interactions/accessibility/src/emulateBrowserTab.js diff --git a/packages/react-interactions/accessibility/src/FocusList.js b/packages/react-interactions/accessibility/src/FocusList.js index 5082b8ac6f333..f44db6330ed0d 100644 --- a/packages/react-interactions/accessibility/src/FocusList.js +++ b/packages/react-interactions/accessibility/src/FocusList.js @@ -24,6 +24,7 @@ type FocusListProps = {| portrait: boolean, wrap?: boolean, tabScope?: ReactScope, + allowModifiers?: boolean, |}; const {useRef} = React; @@ -82,6 +83,13 @@ function getListProps(currentCell: ReactScopeMethods): Object { return {}; } +function hasModifierKey(event: KeyboardEvent): boolean { + const {altKey, ctrlKey, metaKey, shiftKey} = event; + return ( + altKey === true || ctrlKey === true || metaKey === true || shiftKey === true + ); +} + export function createFocusList(scope: ReactScope): Array { const TableScope = React.unstable_createScope(scope.fn); @@ -90,6 +98,7 @@ export function createFocusList(scope: ReactScope): Array { portrait, wrap, tabScope: TabScope, + allowModifiers, }): FocusListProps { const tabScopeRef = useRef(null); return ( @@ -97,7 +106,8 @@ export function createFocusList(scope: ReactScope): Array { type="list" portrait={portrait} wrap={wrap} - tabScopeRef={tabScopeRef}> + tabScopeRef={tabScopeRef} + allowModifiers={allowModifiers}> {TabScope ? ( {children} ) : ( @@ -117,25 +127,36 @@ export function createFocusList(scope: ReactScope): Array { const listProps = list && list.getProps(); 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); - } + const key = event.key; + + if (key === '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; } + return; + } + event.continuePropagation(); + return; + } + // Using modifier keys with keyboard arrow events should be no-ops + // unless an explicit allowModifiers flag is set on the FocusList. + if (hasModifierKey(event)) { + const allowModifiers = getListProps(currentItem).allowModifiers; + if (!allowModifiers) { event.continuePropagation(); return; } + } + switch (key) { case 'ArrowUp': { if (portrait) { const previousListItem = getPreviousListItem( diff --git a/packages/react-interactions/accessibility/src/FocusTable.js b/packages/react-interactions/accessibility/src/FocusTable.js index 6e1ae6a75b6a2..0f7633c73dd47 100644 --- a/packages/react-interactions/accessibility/src/FocusTable.js +++ b/packages/react-interactions/accessibility/src/FocusTable.js @@ -32,6 +32,7 @@ type FocusTableProps = {| ) => void, wrap?: boolean, tabScope?: ReactScope, + allowModifiers?: boolean, |}; const {useRef} = React; @@ -152,6 +153,13 @@ function getTableProps(currentCell: ReactScopeMethods): Object { return {}; } +function hasModifierKey(event: KeyboardEvent): boolean { + const {altKey, ctrlKey, metaKey, shiftKey} = event; + return ( + altKey === true || ctrlKey === true || metaKey === true || shiftKey === true + ); +} + export function createFocusTable(scope: ReactScope): Array { const TableScope = React.unstable_createScope(scope.fn); @@ -161,6 +169,7 @@ export function createFocusTable(scope: ReactScope): Array { id, wrap, tabScope: TabScope, + allowModifiers, }): FocusTableProps { const tabScopeRef = useRef(null); return ( @@ -169,7 +178,8 @@ export function createFocusTable(scope: ReactScope): Array { onKeyboardOut={onKeyboardOut} id={id} wrap={wrap} - tabScopeRef={tabScopeRef}> + tabScopeRef={tabScopeRef} + allowModifiers={allowModifiers}> {TabScope ? ( {children} ) : ( @@ -192,25 +202,35 @@ export function createFocusTable(scope: ReactScope): Array { event.continuePropagation(); 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); - } + const key = event.key; + if (key === '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; } + return; + } + event.continuePropagation(); + return; + } + // Using modifier keys with keyboard arrow events should be no-ops + // unless an explicit allowModifiers flag is set on the FocusTable. + if (hasModifierKey(event)) { + const allowModifiers = getTableProps(currentCell).allowModifiers; + if (!allowModifiers) { event.continuePropagation(); return; } + } + switch (key) { case 'ArrowUp': { const [cells, cellIndex] = getRowCells(currentCell); if (cells !== null) { 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 6d164eb6bdee0..68a8cc83058b7 100644 --- a/packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js +++ b/packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js @@ -7,10 +7,8 @@ * @flow */ -import { - createEventTarget, - emulateBrowserTab, -} from 'react-interactions/events/src/dom/testing-library'; +import {createEventTarget} from 'react-interactions/events/src/dom/testing-library'; +import {emulateBrowserTab} from '../emulateBrowserTab'; let React; let ReactFeatureFlags; @@ -46,8 +44,11 @@ describe('FocusList', () => { function createFocusListComponent() { const [FocusList, FocusItem] = createFocusList(TabbableScope); - return ({portrait, wrap}) => ( - + return ({portrait, wrap, allowModifiers}) => ( +
  • Item 1
  • @@ -94,6 +95,12 @@ describe('FocusList', () => { key: 'ArrowLeft', }); expect(document.activeElement.textContent).toBe('Item 3'); + // Should be a no-op due to modifier + thirdListItem.keydown({ + key: 'ArrowUp', + altKey: true, + }); + expect(document.activeElement.textContent).toBe('Item 3'); }); it('handles keyboard arrow operations (landscape)', () => { @@ -160,6 +167,23 @@ describe('FocusList', () => { expect(document.activeElement.textContent).toBe('Item 3'); }); + it('handles keyboard arrow operations (portrait) with allowModifiers', () => { + const Test = createFocusListComponent(); + + ReactDOM.render( + , + container, + ); + const listItems = document.querySelectorAll('li'); + let firstListItem = createEventTarget(listItems[0]); + firstListItem.focus(); + firstListItem.keydown({ + key: 'ArrowDown', + altKey: true, + }); + expect(document.activeElement.textContent).toBe('Item 2'); + }); + it('handles keyboard arrow operations mixed with tabbing', () => { const [FocusList, FocusItem] = createFocusList(TabbableScope); const beforeRef = React.createRef(); 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 eea6216713ae8..5498231b2d82e 100644 --- a/packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js +++ b/packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js @@ -7,10 +7,8 @@ * @flow */ -import { - createEventTarget, - emulateBrowserTab, -} from 'react-interactions/events/src/dom/testing-library'; +import {createEventTarget} from 'react-interactions/events/src/dom/testing-library'; +import {emulateBrowserTab} from '../emulateBrowserTab'; let React; let ReactFeatureFlags; @@ -48,8 +46,12 @@ describe('FocusTable', () => { TabbableScope, ); - return ({onKeyboardOut, id, wrap}) => ( - + return ({onKeyboardOut, id, wrap, allowModifiers}) => ( + @@ -115,6 +117,20 @@ describe('FocusTable', () => { ); } + it('handles keyboard arrow operations with allowModifiers', () => { + const Test = createFocusTableComponent(); + + ReactDOM.render(, container); + const buttons = document.querySelectorAll('button'); + const a1 = createEventTarget(buttons[0]); + a1.focus(); + a1.keydown({ + key: 'ArrowRight', + altKey: true, + }); + expect(document.activeElement.textContent).toBe('A2'); + }); + it('handles keyboard arrow operations', () => { const Test = createFocusTableComponent(); @@ -133,7 +149,7 @@ describe('FocusTable', () => { }); expect(document.activeElement.textContent).toBe('B2'); - const b2 = createEventTarget(document.activeElement); + let b2 = createEventTarget(document.activeElement); b2.keydown({ key: 'ArrowLeft', }); @@ -154,6 +170,14 @@ describe('FocusTable', () => { key: 'ArrowUp', }); expect(document.activeElement.textContent).toBe('B1'); + // Should be a no-op due to modifier + b2 = createEventTarget(document.activeElement); + b2.keydown({ + key: 'ArrowUp', + altKey: true, + }); + b2 = createEventTarget(document.activeElement); + expect(document.activeElement.textContent).toBe('B1'); }); it('handles keyboard arrow operations between tables', () => { diff --git a/packages/react-interactions/accessibility/src/emulateBrowserTab.js b/packages/react-interactions/accessibility/src/emulateBrowserTab.js new file mode 100644 index 0000000000000..8cf2a49f68f52 --- /dev/null +++ b/packages/react-interactions/accessibility/src/emulateBrowserTab.js @@ -0,0 +1,40 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +import {createEventTarget} from 'react-interactions/events/src/dom/testing-library'; + +// This function is used by the a11y modules for testing +export 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(); + } + } +} 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 acc92235395ca..d46f96a5058ce 100644 --- a/packages/react-interactions/events/src/dom/testing-library/index.js +++ b/packages/react-interactions/events/src/dom/testing-library/index.js @@ -158,33 +158,6 @@ 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, @@ -193,5 +166,4 @@ export { hasPointerEvent, setPointerEvent, testWithPointerType, - emulateBrowserTab, };