diff --git a/packages/react-interactions/accessibility/docs/FocusContain.md b/packages/react-interactions/accessibility/docs/FocusContain.md index 2dea9f2cf155..724cbaacdafa 100644 --- a/packages/react-interactions/accessibility/docs/FocusContain.md +++ b/packages/react-interactions/accessibility/docs/FocusContain.md @@ -10,11 +10,11 @@ using the `tabFocus` prop. ```jsx import FocusContain from 'react-interactions/accessibility/focus-contain'; -import TabbableScope from 'react-interactions/accessibility/tabbable-scope'; +import tabbableScopeQuery from 'react-interactions/accessibility/tabbable-scope-query'; function MyDialog(props) { return ( - +

{props.title}

{props.text}

diff --git a/packages/react-interactions/accessibility/docs/FocusManager.md b/packages/react-interactions/accessibility/docs/FocusManager.md index 5cccd2676e55..f928ea13d6e9 100644 --- a/packages/react-interactions/accessibility/docs/FocusManager.md +++ b/packages/react-interactions/accessibility/docs/FocusManager.md @@ -14,6 +14,10 @@ import { getPreviousScope, } from 'react-interactions/accessibility/focus-manager'; +function scopeQuery(type) { + return type === 'div'; +} + function KeyboardFocusMover(props) { const scopeRef = useRef(null); @@ -22,9 +26,9 @@ function KeyboardFocusMover(props) { if (scope) { // Focus the first tabbable DOM node in my children - focusFirst(scope); + focusFirst(scopeQuery, scope); // Then focus the next chilkd - focusNext(scope); + focusNext(scopeQuery, scope); } }); diff --git a/packages/react-interactions/accessibility/docs/TabbableScope.md b/packages/react-interactions/accessibility/docs/TabbableScope.md deleted file mode 100644 index 2bafcad00db2..000000000000 --- a/packages/react-interactions/accessibility/docs/TabbableScope.md +++ /dev/null @@ -1,37 +0,0 @@ -# TabbableScope - -`TabbableScope` is a custom scope implementation that can be used with -`FocusContain`, `FocusGroup`, `FocusTable` and `FocusManager` modules. - -## Usage - -```jsx -import TabbableScope from 'react-interactions/accessibility/tabbable-scope'; - -function FocusableNodeCollector(props) { - const scopeRef = useRef(null); - - useEffect(() => { - const scope = scopeRef.current; - - if (scope) { - const tabFocusableNodes = scope.getAllNodes(); - if (tabFocusableNodes && props.onFocusableNodes) { - props.onFocusableNodes(tabFocusableNodes); - } - } - }); - - return ( - - {props.children} - - ); -} -``` - -## Implementation - -`TabbableScope` uses the experimental `React.unstable_createScope` API. The query -function used for the scope is designed to collect DOM nodes that are tab focusable -to the browser. See the [implementation](../src/TabbableScope.js#L12-L33) here. diff --git a/packages/react-interactions/accessibility/docs/TabbableScopeQuery.md b/packages/react-interactions/accessibility/docs/TabbableScopeQuery.md new file mode 100644 index 000000000000..0c942ec4281b --- /dev/null +++ b/packages/react-interactions/accessibility/docs/TabbableScopeQuery.md @@ -0,0 +1,31 @@ +# TabbableScopeQuery + +`TabbableScopeQuery` is a custom scope implementation that can be used with +`FocusContain`, `FocusGroup`, `FocusTable` and `FocusManager` modules. + +## Usage + +```jsx +import tabbableScopeQuery from 'react-interactions/accessibility/tabbable-scope-query'; + +function FocusableNodeCollector(props) { + const scopeRef = useRef(null); + + useEffect(() => { + const scope = scopeRef.current; + + if (scope) { + const tabFocusableNodes = scope.queryAllNodes(tabbableScopeQuery); + if (tabFocusableNodes && props.onFocusableNodes) { + props.onFocusableNodes(tabFocusableNodes); + } + } + }); + + return ( + + {props.children} + + ); +} +``` diff --git a/packages/react-interactions/accessibility/src/FocusContain.js b/packages/react-interactions/accessibility/src/FocusContain.js index 60bd3cd2929f..61304364f108 100644 --- a/packages/react-interactions/accessibility/src/FocusContain.js +++ b/packages/react-interactions/accessibility/src/FocusContain.js @@ -7,7 +7,6 @@ * @flow */ -import type {ReactScope} from 'shared/ReactTypes'; import type {KeyboardEvent} from 'react-interactions/events/keyboard'; import React from 'react'; @@ -21,15 +20,17 @@ import { type FocusContainProps = {| children: React.Node, disabled?: boolean, - tabScope: ReactScope, + scopeQuery: (type: string | Object, props: Object) => boolean, |}; const {useLayoutEffect, useRef} = React; +const FocusContainScope = React.unstable_createScope(); + export default function FocusContain({ children, disabled, - tabScope: TabScope, + scopeQuery, }: FocusContainProps): React.Node { const scopeRef = useRef(null); // This ensures tabbing works through the React tree (including Portals and Suspense nodes) @@ -42,9 +43,9 @@ export default function FocusContain({ const scope = scopeRef.current; if (scope !== null) { if (event.shiftKey) { - focusPrevious(scope, event, true); + focusPrevious(scopeQuery, scope, event, true); } else { - focusNext(scope, event, true); + focusNext(scopeQuery, scope, event, true); } } }, @@ -71,7 +72,7 @@ export default function FocusContain({ disabled !== true && !scope.containsNode(document.activeElement) ) { - const fistElem = scope.getFirstNode(); + const fistElem = scope.queryFirstNode(scopeQuery); if (fistElem !== null) { fistElem.focus(); } @@ -81,8 +82,8 @@ export default function FocusContain({ ); return ( - + {children} - + ); } diff --git a/packages/react-interactions/accessibility/src/FocusGroup.js b/packages/react-interactions/accessibility/src/FocusGroup.js index c3d8e4004edf..28fc9fe9f05b 100644 --- a/packages/react-interactions/accessibility/src/FocusGroup.js +++ b/packages/react-interactions/accessibility/src/FocusGroup.js @@ -7,7 +7,7 @@ * @flow */ -import type {ReactScope, ReactScopeMethods} from 'shared/ReactTypes'; +import type {ReactScopeMethods} from 'shared/ReactTypes'; import type {KeyboardEvent} from 'react-interactions/events/keyboard'; import React from 'react'; @@ -23,14 +23,18 @@ type FocusGroupProps = {| children: React.Node, portrait: boolean, wrap?: boolean, - tabScope?: ReactScope, + tabScopeQuery?: (type: string | Object, props: Object) => boolean, allowModifiers?: boolean, |}; const {useRef} = React; -function focusGroupItem(cell: ReactScopeMethods, event: KeyboardEvent): void { - const firstScopedNode = cell.getFirstNode(); +function focusGroupItem( + scopeQuery: (type: string | Object, props: Object) => boolean, + cell: ReactScopeMethods, + event: KeyboardEvent, +): void { + const firstScopedNode = cell.queryFirstNode(scopeQuery); if (firstScopedNode !== null) { firstScopedNode.focus(); event.preventDefault(); @@ -91,30 +95,25 @@ function hasModifierKey(event: KeyboardEvent): boolean { } export function createFocusGroup( - scope: ReactScope, + scopeQuery: (type: string | Object, props: Object) => boolean, ): [(FocusGroupProps) => React.Node, (FocusItemProps) => React.Node] { - const TableScope = React.unstable_createScope(scope.fn); + const TableScope = React.unstable_createScope(); function Group({ children, portrait, wrap, - tabScope: TabScope, + tabScopeQuery, allowModifiers, }: FocusGroupProps): React.Node { - const tabScopeRef = useRef(null); return ( - {TabScope ? ( - {children} - ) : ( - children - )} + {children} ); } @@ -132,19 +131,22 @@ export function createFocusGroup( const key = event.key; if (key === 'Tab') { - const tabScope = getGroupProps(currentItem).tabScopeRef.current; - if (tabScope) { - const activeNode = document.activeElement; - const nodes = tabScope.getAllNodes(); - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - if (node !== activeNode) { - setElementCanTab(node, false); - } else { - setElementCanTab(node, true); + const tabScopeQuery = getGroupProps(currentItem).tabScopeQuery; + if (tabScopeQuery) { + const groupScope = currentItem.getParent(); + if (groupScope) { + const activeNode = document.activeElement; + const nodes = groupScope.queryAllNodes(tabScopeQuery); + 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; @@ -166,7 +168,7 @@ export function createFocusGroup( currentItem, ); if (previousGroupItem) { - focusGroupItem(previousGroupItem, event); + focusGroupItem(scopeQuery, previousGroupItem, event); return; } } @@ -176,7 +178,7 @@ export function createFocusGroup( if (portrait) { const nextGroupItem = getNextGroupItem(group, currentItem); if (nextGroupItem) { - focusGroupItem(nextGroupItem, event); + focusGroupItem(scopeQuery, nextGroupItem, event); return; } } @@ -189,7 +191,7 @@ export function createFocusGroup( currentItem, ); if (previousGroupItem) { - focusGroupItem(previousGroupItem, event); + focusGroupItem(scopeQuery, previousGroupItem, event); return; } } @@ -199,7 +201,7 @@ export function createFocusGroup( if (!portrait) { const nextGroupItem = getNextGroupItem(group, currentItem); if (nextGroupItem) { - focusGroupItem(nextGroupItem, event); + focusGroupItem(scopeQuery, nextGroupItem, event); return; } } diff --git a/packages/react-interactions/accessibility/src/FocusManager.js b/packages/react-interactions/accessibility/src/FocusManager.js index d38434b2fd80..861f91b594bf 100644 --- a/packages/react-interactions/accessibility/src/FocusManager.js +++ b/packages/react-interactions/accessibility/src/FocusManager.js @@ -12,9 +12,14 @@ import type {KeyboardEvent} from 'react-interactions/events/keyboard'; import getTabbableNodes from './shared/getTabbableNodes'; -export function focusFirst(scope: ReactScopeMethods): void { - const [, firstTabbableElem] = getTabbableNodes(scope); - focusElem(firstTabbableElem); +export function focusFirst( + scopeQuery: (type: string | Object, props: Object) => boolean, + scope: ReactScopeMethods, +): void { + const firstNode = scope.queryFirstNode(scopeQuery); + if (firstNode) { + focusElem(firstNode); + } } function focusElem(elem: null | HTMLElement): void { @@ -24,6 +29,7 @@ function focusElem(elem: null | HTMLElement): void { } export function focusNext( + scopeQuery: (type: string | Object, props: Object) => boolean, scope: ReactScopeMethods, event?: KeyboardEvent, contain?: boolean, @@ -34,7 +40,7 @@ export function focusNext( lastTabbableElem, currentIndex, focusedElement, - ] = getTabbableNodes(scope); + ] = getTabbableNodes(scopeQuery, scope); if (focusedElement === null) { if (event) { @@ -58,6 +64,7 @@ export function focusNext( } export function focusPrevious( + scopeQuery: (type: string | Object, props: Object) => boolean, scope: ReactScopeMethods, event?: KeyboardEvent, contain?: boolean, @@ -68,7 +75,7 @@ export function focusPrevious( lastTabbableElem, currentIndex, focusedElement, - ] = getTabbableNodes(scope); + ] = getTabbableNodes(scopeQuery, scope); if (focusedElement === null) { if (event) { diff --git a/packages/react-interactions/accessibility/src/FocusTable.js b/packages/react-interactions/accessibility/src/FocusTable.js index 0f5970c1eb4f..d77c778af1ed 100644 --- a/packages/react-interactions/accessibility/src/FocusTable.js +++ b/packages/react-interactions/accessibility/src/FocusTable.js @@ -7,7 +7,7 @@ * @flow */ -import type {ReactScope, ReactScopeMethods} from 'shared/ReactTypes'; +import type {ReactScopeMethods} from 'shared/ReactTypes'; import type {KeyboardEvent} from 'react-interactions/events/keyboard'; import React from 'react'; @@ -32,14 +32,18 @@ type FocusTableProps = {| ) => void, wrapX?: boolean, wrapY?: boolean, - tabScope?: ReactScope, + tabScopeQuery?: (type: string | Object, props: Object) => boolean, allowModifiers?: boolean, |}; const {useRef} = React; -function focusScope(cell: ReactScopeMethods, event?: KeyboardEvent): void { - const firstScopedNode = cell.getFirstNode(); +function focusScope( + scopeQuery: (type: string | Object, props: Object) => boolean, + cell: ReactScopeMethods, + event?: KeyboardEvent, +): void { + const firstScopedNode = cell.queryFirstNode(scopeQuery); if (firstScopedNode !== null) { firstScopedNode.focus(); if (event) { @@ -50,6 +54,7 @@ function focusScope(cell: ReactScopeMethods, event?: KeyboardEvent): void { // This takes into account colSpan function focusCellByColumnIndex( + scopeQuery: (type: string | Object, props: Object) => boolean, row: ReactScopeMethods, columnIndex: number, event?: KeyboardEvent, @@ -62,7 +67,7 @@ function focusCellByColumnIndex( if (cell) { colSize += cell.getProps().colSpan || 1; if (colSize > columnIndex) { - focusScope(cell, event); + focusScope(scopeQuery, cell, event); return; } } @@ -157,36 +162,31 @@ function hasModifierKey(event: KeyboardEvent): boolean { } export function createFocusTable( - scope: ReactScope, + scopeQuery: (type: string | Object, props: Object) => boolean, ): [ (FocusTableProps) => React.Node, (FocusRowProps) => React.Node, (FocusCellProps) => React.Node, ] { - const TableScope = React.unstable_createScope(scope.fn); + const TableScope = React.unstable_createScope(); function Table({ children, onKeyboardOut, wrapX, wrapY, - tabScope: TabScope, + tabScopeQuery, allowModifiers, }: FocusTableProps): React.Node { - const tabScopeRef = useRef(null); return ( - {TabScope ? ( - {children} - ) : ( - children - )} + {children} ); } @@ -206,19 +206,25 @@ export function createFocusTable( } const key = event.key; if (key === 'Tab') { - const tabScope = getTableProps(currentCell).tabScopeRef.current; - if (tabScope) { - const activeNode = document.activeElement; - const nodes = tabScope.getAllNodes(); - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - if (node !== activeNode) { - setElementCanTab(node, false); - } else { - setElementCanTab(node, true); + const tabScopeQuery = getTableProps(currentCell).tabScopeQuery; + if (tabScopeQuery) { + const rowScope = currentCell.getParent(); + if (rowScope) { + const tableScope = rowScope.getParent(); + if (tableScope) { + const activeNode = document.activeElement; + const nodes = tableScope.queryAllNodes(tabScopeQuery); + 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; @@ -240,12 +246,22 @@ export function createFocusTable( if (rows !== null) { if (rowIndex > 0) { const row = rows[rowIndex - 1]; - focusCellByColumnIndex(row, cellIndexWithColSpan, event); + focusCellByColumnIndex( + scopeQuery, + row, + cellIndexWithColSpan, + event, + ); } else if (rowIndex === 0) { const wrapY = getTableProps(currentCell).wrapY; if (wrapY) { const row = rows[rows.length - 1]; - focusCellByColumnIndex(row, cellIndexWithColSpan, event); + focusCellByColumnIndex( + scopeQuery, + row, + cellIndexWithColSpan, + event, + ); } else { triggerNavigateOut(currentCell, 'up', event); } @@ -264,13 +280,23 @@ export function createFocusTable( const wrapY = getTableProps(currentCell).wrapY; if (wrapY) { const row = rows[0]; - focusCellByColumnIndex(row, cellIndexWithColSpan, event); + focusCellByColumnIndex( + scopeQuery, + row, + cellIndexWithColSpan, + event, + ); } else { triggerNavigateOut(currentCell, 'down', event); } } else { const row = rows[rowIndex + 1]; - focusCellByColumnIndex(row, cellIndexWithColSpan, event); + focusCellByColumnIndex( + scopeQuery, + row, + cellIndexWithColSpan, + event, + ); } } } @@ -281,12 +307,12 @@ export function createFocusTable( const [cells, rowIndex] = getRowCells(currentCell); if (cells !== null) { if (rowIndex > 0) { - focusScope(cells[rowIndex - 1]); + focusScope(scopeQuery, cells[rowIndex - 1]); event.preventDefault(); } else if (rowIndex === 0) { const wrapX = getTableProps(currentCell).wrapX; if (wrapX) { - focusScope(cells[cells.length - 1], event); + focusScope(scopeQuery, cells[cells.length - 1], event); } else { triggerNavigateOut(currentCell, 'left', event); } @@ -301,12 +327,12 @@ export function createFocusTable( if (rowIndex === cells.length - 1) { const wrapX = getTableProps(currentCell).wrapX; if (wrapX) { - focusScope(cells[0], event); + focusScope(scopeQuery, cells[0], event); } else { triggerNavigateOut(currentCell, 'right', event); } } else { - focusScope(cells[rowIndex + 1], event); + focusScope(scopeQuery, cells[rowIndex + 1], event); } } } diff --git a/packages/react-interactions/accessibility/src/TabbableScope.js b/packages/react-interactions/accessibility/src/TabbableScopeQuery.js similarity index 78% rename from packages/react-interactions/accessibility/src/TabbableScope.js rename to packages/react-interactions/accessibility/src/TabbableScopeQuery.js index 5cf27277add2..c6ab0d3e5446 100644 --- a/packages/react-interactions/accessibility/src/TabbableScope.js +++ b/packages/react-interactions/accessibility/src/TabbableScopeQuery.js @@ -7,9 +7,7 @@ * @flow */ -import React from 'react'; - -const tabFocusableImpl = (type: string, props: Object): boolean => { +const tabbableScopeQuery = (type: string, props: Object): boolean => { if (props.tabIndex === -1 || props.disabled) { return false; } @@ -32,6 +30,4 @@ const tabFocusableImpl = (type: string, props: Object): boolean => { ); }; -const TabbableScope = React.unstable_createScope(tabFocusableImpl); - -export default TabbableScope; +export default tabbableScopeQuery; diff --git a/packages/react-interactions/accessibility/src/__tests__/FocusContain-test.internal.js b/packages/react-interactions/accessibility/src/__tests__/FocusContain-test.internal.js index 4a53da46f994..387fc4883ec4 100644 --- a/packages/react-interactions/accessibility/src/__tests__/FocusContain-test.internal.js +++ b/packages/react-interactions/accessibility/src/__tests__/FocusContain-test.internal.js @@ -12,7 +12,7 @@ import {createEventTarget} from 'react-interactions/events/src/dom/testing-libra let React; let ReactFeatureFlags; let FocusContain; -let TabbableScope; +let tabbableScopeQuery; describe('FocusContain', () => { beforeEach(() => { @@ -21,7 +21,7 @@ describe('FocusContain', () => { ReactFeatureFlags.enableScopeAPI = true; ReactFeatureFlags.enableFlareAPI = true; FocusContain = require('../FocusContain').default; - TabbableScope = require('../TabbableScope').default; + tabbableScopeQuery = require('../TabbableScopeQuery').default; React = require('react'); }); @@ -48,7 +48,7 @@ describe('FocusContain', () => { const divRef = React.createRef(); const Test = () => ( - +