diff --git a/packages/react-reconciler/src/ReactFiberScope.js b/packages/react-reconciler/src/ReactFiberScope.js index 64c08df649c7d..5514a39341bc6 100644 --- a/packages/react-reconciler/src/ReactFiberScope.js +++ b/packages/react-reconciler/src/ReactFiberScope.js @@ -143,6 +143,10 @@ export function createScopeMethods( } return null; }, + getProps(): Object { + const currentFiber = ((instance.fiber: any): Fiber); + return currentFiber.memoizedProps; + }, getScopedNodes(): null | Array { const currentFiber = ((instance.fiber: any): Fiber); const child = currentFiber.child; diff --git a/packages/react-ui/accessibility/src/FocusGrid.js b/packages/react-ui/accessibility/src/FocusGrid.js deleted file mode 100644 index d7dcfe866fbf6..0000000000000 --- a/packages/react-ui/accessibility/src/FocusGrid.js +++ /dev/null @@ -1,131 +0,0 @@ -/** - * 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-ui/events/keyboard'; - -import React from 'react'; -import {tabFocusableImpl} from './TabbableScope'; -import {useKeyboard} from 'react-ui/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-ui/accessibility/src/ReactFocusTable.js b/packages/react-ui/accessibility/src/ReactFocusTable.js new file mode 100644 index 0000000000000..e0ab4e7640da1 --- /dev/null +++ b/packages/react-ui/accessibility/src/ReactFocusTable.js @@ -0,0 +1,220 @@ +/** + * 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-ui/events/keyboard'; + +import React from 'react'; +import {tabFocusableImpl} from './TabbableScope'; +import {useKeyboard} from 'react-ui/events/keyboard'; + +type FocusCellProps = { + children?: React.Node, +}; + +type FocusRowProps = { + children: React.Node, +}; + +type FocusTableProps = {| + children: React.Node, + id?: string, + onKeyboardOut?: ( + direction: 'left' | 'right' | 'up' | 'down', + focusTableByID: (id: string) => void, + ) => void, +|}; + +const {useRef} = React; + +export function focusFirstCellOnTable(table: ReactScopeMethods): void { + const rows = table.getChildren(); + if (rows !== null) { + const firstRow = rows[0]; + if (firstRow !== null) { + const cells = firstRow.getChildren(); + if (cells !== null) { + const firstCell = cells[0]; + if (firstCell !== null) { + const tabbableNodes = firstCell.getScopedNodes(); + if (tabbableNodes !== null) { + const firstElem = tabbableNodes[0]; + if (firstElem !== null) { + firstElem.focus(); + } + } + } + } + } + } +} + +function focusCell(cell: ReactScopeMethods): void { + const tabbableNodes = cell.getScopedNodes(); + if (tabbableNodes !== null && tabbableNodes.length > 0) { + tabbableNodes[0].focus(); + } +} + +function focusCellByRowIndex(row: ReactScopeMethods, rowIndex: number): void { + const cells = row.getChildren(); + if (cells !== null) { + const cell = cells[rowIndex]; + if (cell) { + focusCell(cell); + } + } +} + +function getRowCells(currentCell: ReactScopeMethods) { + const row = currentCell.getParent(); + if (row !== null && row.getProps().type === 'row') { + const cells = row.getChildren(); + if (cells !== null) { + const rowIndex = cells.indexOf(currentCell); + return [cells, rowIndex]; + } + } + return [null, 0]; +} + +function getRows(currentCell: ReactScopeMethods) { + const row = currentCell.getParent(); + if (row !== null && row.getProps().type === 'row') { + const table = row.getParent(); + if (table !== null && table.getProps().type === 'table') { + const rows = table.getChildren(); + if (rows !== null) { + const columnIndex = rows.indexOf(row); + return [rows, columnIndex]; + } + } + } + return [null, 0]; +} + +function triggerNavigateOut( + currentCell: ReactScopeMethods, + direction: 'left' | 'right' | 'up' | 'down', +): void { + const row = currentCell.getParent(); + if (row !== null && row.getProps().type === 'row') { + const table = row.getParent(); + if (table !== null) { + const props = table.getProps(); + const onKeyboardOut = props.onKeyboardOut; + if (props.type === 'table' && typeof onKeyboardOut === 'function') { + const focusTableByID = (id: string) => { + const topLevelTables = table.getChildrenFromRoot(); + if (topLevelTables !== null) { + for (let i = 0; i < topLevelTables.length; i++) { + const topLevelTable = topLevelTables[i]; + if (topLevelTable.getProps().id === id) { + focusFirstCellOnTable(topLevelTable); + return; + } + } + } + }; + onKeyboardOut(direction, focusTableByID); + } + } + } +} + +export function createFocusTable(): Array { + const TableScope = React.unstable_createScope(tabFocusableImpl); + + function Table({children, onKeyboardOut, id}): FocusTableProps { + return ( + + {children} + + ); + } + + function Row({children}): FocusRowProps { + return {children}; + } + + function Cell({children}): FocusCellProps { + 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] = getRows(currentCell); + if (columns !== null) { + if (columnIndex > 0) { + const column = columns[columnIndex - 1]; + focusCellByRowIndex(column, rowIndex); + } else if (columnIndex === 0) { + triggerNavigateOut(currentCell, 'up'); + } + } + } + return false; + } + case 'DownArrow': { + const [cells, rowIndex] = getRowCells(currentCell); + if (cells !== null) { + const [columns, columnIndex] = getRows(currentCell); + if (columns !== null) { + if (columnIndex !== -1) { + if (columnIndex === columns.length - 1) { + triggerNavigateOut(currentCell, 'down'); + } else { + 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]); + } else if (rowIndex === 0) { + triggerNavigateOut(currentCell, 'left'); + } + } + return false; + } + case 'RightArrow': { + const [cells, rowIndex] = getRowCells(currentCell); + if (cells !== null) { + if (rowIndex !== -1) { + if (rowIndex === cells.length - 1) { + triggerNavigateOut(currentCell, 'right'); + } else { + focusCell(cells[rowIndex + 1]); + } + } + } + return false; + } + } + return true; + }, + }); + return ( + + {children} + + ); + } + + return [Table, Row, Cell]; +} diff --git a/packages/react-ui/accessibility/src/__tests__/FocusGrid-test.internal.js b/packages/react-ui/accessibility/src/__tests__/FocusGrid-test.internal.js deleted file mode 100644 index abcbb8b134516..0000000000000 --- a/packages/react-ui/accessibility/src/__tests__/FocusGrid-test.internal.js +++ /dev/null @@ -1,152 +0,0 @@ -/** - * 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-ui/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-ui/accessibility/src/__tests__/ReactFocusTable-test.internal.js b/packages/react-ui/accessibility/src/__tests__/ReactFocusTable-test.internal.js new file mode 100644 index 0000000000000..f096366087f1e --- /dev/null +++ b/packages/react-ui/accessibility/src/__tests__/ReactFocusTable-test.internal.js @@ -0,0 +1,257 @@ +/** + * 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-ui/events/src/dom/testing-library'; + +let React; +let ReactFeatureFlags; +let createFocusTable; + +describe('ReactFocusTable', () => { + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableScopeAPI = true; + ReactFeatureFlags.enableFlareAPI = true; + createFocusTable = require('../ReactFocusTable').createFocusTable; + 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 createFocusTableComponent() { + const [FocusTable, FocusTableRow, FocusTableCell] = createFocusTable(); + + return ({onKeyboardOut, id}) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+
+ ); + } + + it('handles keyboard arrow operations', () => { + const Test = createFocusTableComponent(); + + ReactDOM.render(, container); + const buttons = document.querySelectorAll('button'); + const a1 = createEventTarget(buttons[0]); + 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'); + }); + + it('handles keyboard arrow operations between tables', () => { + const leftSidebarRef = React.createRef(); + const FocusTable = createFocusTableComponent(); + + function Test() { + return ( +
+

Title

+ +
+

Content

+ { + if (direction === 'right') { + focusTableByID('right-sidebar'); + } else if (direction === 'left') { + focusTableByID('left-sidebar'); + } + }} + /> +
+ +
+ ); + } + + ReactDOM.render(, container); + const buttons = document.querySelectorAll('button'); + let a1 = createEventTarget(buttons[0]); + a1.focus(); + a1.keydown({ + key: 'RightArrow', + }); + expect(document.activeElement.textContent).toBe('A2'); + + let a2 = createEventTarget(document.activeElement); + a2.keydown({ + key: 'RightArrow', + }); + expect(document.activeElement.textContent).toBe('A3'); + + let a3 = createEventTarget(document.activeElement); + a3.keydown({ + key: 'RightArrow', + }); + expect(document.activeElement.textContent).toBe('A1'); + + a1 = createEventTarget(document.activeElement); + a1.keydown({ + key: 'RightArrow', + }); + expect(document.activeElement.textContent).toBe('A2'); + + a2 = createEventTarget(document.activeElement); + a2.keydown({ + key: 'RightArrow', + }); + expect(document.activeElement.textContent).toBe('A3'); + + a3 = createEventTarget(document.activeElement); + a3.keydown({ + key: 'RightArrow', + }); + expect(document.activeElement.textContent).toBe('A1'); + + a1 = createEventTarget(document.activeElement); + a1.keydown({ + key: 'RightArrow', + }); + expect(document.activeElement.textContent).toBe('A2'); + + a2 = createEventTarget(document.activeElement); + a2.keydown({ + key: 'RightArrow', + }); + expect(document.activeElement.textContent).toBe('A3'); + + a3 = createEventTarget(document.activeElement); + a3.keydown({ + key: 'RightArrow', + }); + expect(document.activeElement.textContent).toBe('A3'); + }); + }); +}); diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index cd60a570c64f4..5e9a6b11df1d6 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -168,6 +168,7 @@ export type ReactScopeMethods = {| getChildren(): null | Array, getChildrenFromRoot(): null | Array, getParent(): null | ReactScopeMethods, + getProps(): Object, getScopedNodes(): null | Array, |};