From b4b8a349a3aa62e693e73cc16a231c3cc59b96de Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Fri, 13 Sep 2019 12:29:39 +0200 Subject: [PATCH] [react-interactions] Add experimental FocusGrid API (#16766) --- .../react-dom/src/client/focus/FocusGrid.js | 131 +++++++++++++++ .../src/client/focus/ReactTabFocus.js | 17 +- .../src/client/focus/TabbableScope.js | 48 +++--- .../__tests__/FocusGrid-test.internal.js | 152 ++++++++++++++++++ packages/react-events/src/dom/Keyboard.js | 2 +- 5 files changed, 309 insertions(+), 41 deletions(-) create mode 100644 packages/react-dom/src/client/focus/FocusGrid.js create mode 100644 packages/react-dom/src/client/focus/__tests__/FocusGrid-test.internal.js diff --git a/packages/react-dom/src/client/focus/FocusGrid.js b/packages/react-dom/src/client/focus/FocusGrid.js new file mode 100644 index 0000000000000..5d04c1054e971 --- /dev/null +++ b/packages/react-dom/src/client/focus/FocusGrid.js @@ -0,0 +1,131 @@ +/** + * 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 {KeyboardEvent} from 'react-events/src/dom/Keyboard'; + +import React from 'react'; +import {tabFocusableImpl} from './TabbableScope'; +import {useKeyboard} from 'react-events/keyboard'; + +type GridComponentProps = { + children: React.Node, +}; + +const {useRef} = React; + +function focusCell(cell) { + const tabbableNodes = cell.getScopedNodes(); + if (tabbableNodes !== null && tabbableNodes.length > 0) { + tabbableNodes[0].focus(); + } +} + +function focusCellByRowIndex(row, rowIndex) { + const cells = row.getChildren(); + const cell = cells[rowIndex]; + if (cell) { + focusCell(cell); + } +} + +function getRowCells(currentCell) { + const row = currentCell.getParent(); + if (parent !== null) { + const cells = row.getChildren(); + const rowIndex = cells.indexOf(currentCell); + return [cells, rowIndex]; + } + return [null, 0]; +} + +function getColumns(currentCell) { + const row = currentCell.getParent(); + if (parent !== null) { + const grid = row.getParent(); + const columns = grid.getChildren(); + const columnIndex = columns.indexOf(row); + return [columns, columnIndex]; + } + return [null, 0]; +} + +export function createFocusGrid(): Array { + const GridScope = React.unstable_createScope(tabFocusableImpl); + + function GridContainer({children}): GridComponentProps { + return {children}; + } + + function GridRow({children}): GridComponentProps { + return {children}; + } + + function GridCell({children}): GridComponentProps { + const scopeRef = useRef(null); + const keyboard = useKeyboard({ + onKeyDown(event: KeyboardEvent): boolean { + const currentCell = scopeRef.current; + switch (event.key) { + case 'UpArrow': { + const [cells, rowIndex] = getRowCells(currentCell); + if (cells !== null) { + const [columns, columnIndex] = getColumns(currentCell); + if (columns !== null) { + if (columnIndex > 0) { + const column = columns[columnIndex - 1]; + focusCellByRowIndex(column, rowIndex); + } + } + } + return false; + } + case 'DownArrow': { + const [cells, rowIndex] = getRowCells(currentCell); + if (cells !== null) { + const [columns, columnIndex] = getColumns(currentCell); + if (columns !== null) { + if (columnIndex !== -1 && columnIndex !== columns.length - 1) { + const column = columns[columnIndex + 1]; + focusCellByRowIndex(column, rowIndex); + } + } + } + return false; + } + case 'LeftArrow': { + const [cells, rowIndex] = getRowCells(currentCell); + if (cells !== null) { + if (rowIndex > 0) { + focusCell(cells[rowIndex - 1]); + } + } + return false; + } + case 'RightArrow': { + const [cells, rowIndex] = getRowCells(currentCell); + if (cells !== null) { + if (rowIndex !== -1 && rowIndex !== cells.length - 1) { + focusCell(cells[rowIndex + 1]); + } + } + return false; + } + } + return true; + }, + }); + return ( + + {children} + + ); + } + + return [GridContainer, GridRow, GridCell]; +} diff --git a/packages/react-dom/src/client/focus/ReactTabFocus.js b/packages/react-dom/src/client/focus/ReactTabFocus.js index 6840155261162..ecc5680ed7e4f 100644 --- a/packages/react-dom/src/client/focus/ReactTabFocus.js +++ b/packages/react-dom/src/client/focus/ReactTabFocus.js @@ -8,6 +8,7 @@ */ import type {ReactScopeMethods} from 'shared/ReactTypes'; +import type {KeyboardEvent} from 'react-events/src/dom/Keyboard'; import React from 'react'; import {TabbableScope} from './TabbableScope'; @@ -17,22 +18,6 @@ type TabFocusControllerProps = { children: React.Node, contain?: boolean, }; - -type KeyboardEventType = 'keydown' | 'keyup'; - -type KeyboardEvent = {| - altKey: boolean, - ctrlKey: boolean, - isComposing: boolean, - key: string, - metaKey: boolean, - shiftKey: boolean, - target: Element | Document, - type: KeyboardEventType, - timeStamp: number, - defaultPrevented: boolean, -|}; - const {useRef} = React; function getTabbableNodes(scope: ReactScopeMethods) { diff --git a/packages/react-dom/src/client/focus/TabbableScope.js b/packages/react-dom/src/client/focus/TabbableScope.js index 57b86d5286816..6a22a55b29883 100644 --- a/packages/react-dom/src/client/focus/TabbableScope.js +++ b/packages/react-dom/src/client/focus/TabbableScope.js @@ -9,27 +9,27 @@ import React from 'react'; -export const TabbableScope = React.unstable_createScope( - (type: string, props: Object): boolean => { - if (props.tabIndex === -1 || props.disabled) { - return false; - } - if (props.tabIndex === 0 || props.contentEditable === true) { - return true; - } - if (type === 'a' || type === 'area') { - return !!props.href && props.rel !== 'ignore'; - } - if (type === 'input') { - return props.type !== 'hidden' && props.type !== 'file'; - } - return ( - type === 'button' || - type === 'textarea' || - type === 'object' || - type === 'select' || - type === 'iframe' || - type === 'embed' - ); - }, -); +export const tabFocusableImpl = (type: string, props: Object): boolean => { + if (props.tabIndex === -1 || props.disabled) { + return false; + } + if (props.tabIndex === 0 || props.contentEditable === true) { + return true; + } + if (type === 'a' || type === 'area') { + return !!props.href && props.rel !== 'ignore'; + } + if (type === 'input') { + return props.type !== 'hidden' && props.type !== 'file'; + } + return ( + type === 'button' || + type === 'textarea' || + type === 'object' || + type === 'select' || + type === 'iframe' || + type === 'embed' + ); +}; + +export const TabbableScope = React.unstable_createScope(tabFocusableImpl); diff --git a/packages/react-dom/src/client/focus/__tests__/FocusGrid-test.internal.js b/packages/react-dom/src/client/focus/__tests__/FocusGrid-test.internal.js new file mode 100644 index 0000000000000..677c64b2d1b7d --- /dev/null +++ b/packages/react-dom/src/client/focus/__tests__/FocusGrid-test.internal.js @@ -0,0 +1,152 @@ +/** + * 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-events/src/dom/testing-library'; + +let React; +let ReactFeatureFlags; +let createFocusGrid; + +describe('TabFocusController', () => { + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableScopeAPI = true; + ReactFeatureFlags.enableFlareAPI = true; + createFocusGrid = require('../FocusGrid').createFocusGrid; + 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; + }); + + it('handles tab operations', () => { + const [ + FocusGridContainer, + FocusGridRow, + FocusGridCell, + ] = createFocusGrid(); + const firstButtonRef = React.createRef(); + + const Test = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+
+ ); + + ReactDOM.render(, container); + const a1 = createEventTarget(firstButtonRef.current); + a1.focus(); + a1.keydown({ + key: 'RightArrow', + }); + expect(document.activeElement.textContent).toBe('A2'); + + const a2 = createEventTarget(document.activeElement); + a2.keydown({ + key: 'DownArrow', + }); + expect(document.activeElement.textContent).toBe('B2'); + + const b2 = createEventTarget(document.activeElement); + b2.keydown({ + key: 'LeftArrow', + }); + expect(document.activeElement.textContent).toBe('B1'); + + const b1 = createEventTarget(document.activeElement); + b1.keydown({ + key: 'DownArrow', + }); + expect(document.activeElement.textContent).toBe('C1'); + + const c1 = createEventTarget(document.activeElement); + c1.keydown({ + key: 'DownArrow', + }); + expect(document.activeElement.textContent).toBe('C1'); + c1.keydown({ + key: 'UpArrow', + }); + expect(document.activeElement.textContent).toBe('B1'); + }); + }); +}); diff --git a/packages/react-events/src/dom/Keyboard.js b/packages/react-events/src/dom/Keyboard.js index 1ef2062da355f..b8f024cbd5809 100644 --- a/packages/react-events/src/dom/Keyboard.js +++ b/packages/react-events/src/dom/Keyboard.js @@ -25,7 +25,7 @@ type KeyboardProps = { preventKeys?: PreventKeysArray, }; -type KeyboardEvent = {| +export type KeyboardEvent = {| altKey: boolean, ctrlKey: boolean, isComposing: boolean,