From 68a87eee544b2bcdff66c751e07cb55939b9d8ca Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Tue, 24 Sep 2019 17:14:29 +0200 Subject: [PATCH] [react-interactions] Add FocusList component (#16875) --- .../accessibility/src/FocusList.js | 150 ++++++++++++++++++ .../accessibility/src/FocusTable.js | 5 +- .../src/__tests__/FocusList-test.internal.js | 129 +++++++++++++++ .../src/__tests__/FocusTable-test.internal.js | 2 +- 4 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 packages/react-interactions/accessibility/src/FocusList.js create mode 100644 packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js diff --git a/packages/react-interactions/accessibility/src/FocusList.js b/packages/react-interactions/accessibility/src/FocusList.js new file mode 100644 index 0000000000000..fa1479f25928a --- /dev/null +++ b/packages/react-interactions/accessibility/src/FocusList.js @@ -0,0 +1,150 @@ +/** + * 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. + * + * @flow + */ + +import type {ReactScopeMethods} from 'shared/ReactTypes'; +import type {KeyboardEvent} from 'react-interactions/events/keyboard'; + +import React from 'react'; +import {useKeyboard} from 'react-interactions/events/keyboard'; + +type FocusItemProps = { + children?: React.Node, +}; + +type FocusListProps = {| + children: React.Node, + portrait: boolean, +|}; + +const {useRef} = React; + +function focusListItem(cell: ReactScopeMethods): void { + const tabbableNodes = cell.getScopedNodes(); + if (tabbableNodes !== null && tabbableNodes.length > 0) { + tabbableNodes[0].focus(); + } +} + +function getPreviousListItem( + list: ReactScopeMethods, + currentItem: ReactScopeMethods, +): null | ReactScopeMethods { + const items = list.getChildren(); + if (items !== null) { + const currentItemIndex = items.indexOf(currentItem); + if (currentItemIndex > 0) { + return items[currentItemIndex - 1] || null; + } + } + return null; +} + +function getNextListItem( + list: ReactScopeMethods, + currentItem: ReactScopeMethods, +): null | ReactScopeMethods { + const items = list.getChildren(); + if (items !== null) { + const currentItemIndex = items.indexOf(currentItem); + if (currentItemIndex !== -1 && currentItemIndex !== items.length - 1) { + return items[currentItemIndex + 1] || null; + } + } + return null; +} + +export function createFocusList( + scopeImpl: (type: string, props: Object) => boolean, +): Array { + const TableScope = React.unstable_createScope(scopeImpl); + + function List({children, portrait}): FocusListProps { + return ( + + {children} + + ); + } + + function Item({children}): FocusItemProps { + const scopeRef = useRef(null); + const keyboard = useKeyboard({ + onKeyDown(event: KeyboardEvent): void { + const currentItem = scopeRef.current; + if (currentItem !== null) { + const list = currentItem.getParent(); + const listProps = list && list.getProps(); + if (list !== null && listProps.type === 'list') { + const portrait = listProps.portrait; + switch (event.key) { + case 'ArrowUp': { + if (portrait) { + const previousListItem = getPreviousListItem( + list, + currentItem, + ); + if (previousListItem) { + event.preventDefault(); + focusListItem(previousListItem); + return; + } + } + break; + } + case 'ArrowDown': { + if (portrait) { + const nextListItem = getNextListItem(list, currentItem); + if (nextListItem) { + event.preventDefault(); + focusListItem(nextListItem); + return; + } + } + break; + } + case 'ArrowLeft': { + if (!portrait) { + const previousListItem = getPreviousListItem( + list, + currentItem, + ); + if (previousListItem) { + event.preventDefault(); + focusListItem(previousListItem); + return; + } + } + break; + } + case 'ArrowRight': { + if (!portrait) { + const nextListItem = getNextListItem(list, currentItem); + if (nextListItem) { + event.preventDefault(); + focusListItem(nextListItem); + return; + } + } + break; + } + } + } + } + event.continuePropagation(); + }, + }); + return ( + + {children} + + ); + } + + return [List, Item]; +} diff --git a/packages/react-interactions/accessibility/src/FocusTable.js b/packages/react-interactions/accessibility/src/FocusTable.js index d405c0f929380..4e6f405a0d9aa 100644 --- a/packages/react-interactions/accessibility/src/FocusTable.js +++ b/packages/react-interactions/accessibility/src/FocusTable.js @@ -149,6 +149,10 @@ export function createFocusTable( const keyboard = useKeyboard({ onKeyDown(event: KeyboardEvent): void { const currentCell = scopeRef.current; + if (currentCell === null) { + event.continuePropagation(); + return; + } switch (event.key) { case 'ArrowUp': { const [cells, cellIndex] = getRowCells(currentCell); @@ -211,7 +215,6 @@ export function createFocusTable( return; } } - event.continuePropagation(); }, }); return ( diff --git a/packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js b/packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js new file mode 100644 index 0000000000000..9a0a7be6f2760 --- /dev/null +++ b/packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js @@ -0,0 +1,129 @@ +/** + * 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. + * + * @flow + */ + +import {createEventTarget} from 'react-interactions/events/src/dom/testing-library'; + +let React; +let ReactFeatureFlags; +let createFocusList; +let tabFocusableImpl; + +describe('FocusList', () => { + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableScopeAPI = true; + ReactFeatureFlags.enableFlareAPI = true; + createFocusList = require('../FocusList').createFocusList; + tabFocusableImpl = require('../TabbableScope').tabFocusableImpl; + React = require('react'); + }); + + describe('ReactDOM', () => { + let ReactDOM; + let container; + + beforeEach(() => { + ReactDOM = require('react-dom'); + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + container = null; + }); + + function createFocusListComponent() { + const [FocusList, FocusItem] = createFocusList(tabFocusableImpl); + + return ({portrait}) => ( + +
    + +
  • Item 1
  • +
    + +
  • Item 2
  • +
    + +
  • Item 3
  • +
    +
+
+ ); + } + + it('handles keyboard arrow operations (portrait)', () => { + const Test = createFocusListComponent(); + + ReactDOM.render(, container); + const listItems = document.querySelectorAll('li'); + const firstListItem = createEventTarget(listItems[0]); + firstListItem.focus(); + firstListItem.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.textContent).toBe('Item 2'); + + const secondListItem = createEventTarget(document.activeElement); + secondListItem.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + + const thirdListItem = createEventTarget(document.activeElement); + thirdListItem.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + thirdListItem.keydown({ + key: 'ArrowRight', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + thirdListItem.keydown({ + key: 'ArrowLeft', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + }); + + it('handles keyboard arrow operations (landscape)', () => { + const Test = createFocusListComponent(); + + ReactDOM.render(, container); + const listItems = document.querySelectorAll('li'); + const firstListItem = createEventTarget(listItems[0]); + firstListItem.focus(); + firstListItem.keydown({ + key: 'ArrowRight', + }); + expect(document.activeElement.textContent).toBe('Item 2'); + + const secondListItem = createEventTarget(document.activeElement); + secondListItem.keydown({ + key: 'ArrowRight', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + + const thirdListItem = createEventTarget(document.activeElement); + thirdListItem.keydown({ + key: 'ArrowRight', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + thirdListItem.keydown({ + key: 'ArrowUp', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + thirdListItem.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + }); + }); +}); 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 d05bec5ba8fb5..d261775bf4e20 100644 --- a/packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js +++ b/packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js @@ -14,7 +14,7 @@ let ReactFeatureFlags; let createFocusTable; let tabFocusableImpl; -describe('ReactFocusTable', () => { +describe('FocusTable', () => { beforeEach(() => { jest.resetModules(); ReactFeatureFlags = require('shared/ReactFeatureFlags');