mirror of
https://github.com/facebook/react.git
synced 2026-02-24 20:53:03 +00:00
[react-interactions] Add FocusList component (#16875)
This commit is contained in:
150
packages/react-interactions/accessibility/src/FocusList.js
vendored
Normal file
150
packages/react-interactions/accessibility/src/FocusList.js
vendored
Normal file
@@ -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<React.Component> {
|
||||
const TableScope = React.unstable_createScope(scopeImpl);
|
||||
|
||||
function List({children, portrait}): FocusListProps {
|
||||
return (
|
||||
<TableScope type="list" portrait={portrait}>
|
||||
{children}
|
||||
</TableScope>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<TableScope listeners={keyboard} ref={scopeRef} type="item">
|
||||
{children}
|
||||
</TableScope>
|
||||
);
|
||||
}
|
||||
|
||||
return [List, Item];
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}) => (
|
||||
<FocusList portrait={portrait}>
|
||||
<ul>
|
||||
<FocusItem>
|
||||
<li tabIndex={0}>Item 1</li>
|
||||
</FocusItem>
|
||||
<FocusItem>
|
||||
<li tabIndex={0}>Item 2</li>
|
||||
</FocusItem>
|
||||
<FocusItem>
|
||||
<li tabIndex={0}>Item 3</li>
|
||||
</FocusItem>
|
||||
</ul>
|
||||
</FocusList>
|
||||
);
|
||||
}
|
||||
|
||||
it('handles keyboard arrow operations (portrait)', () => {
|
||||
const Test = createFocusListComponent();
|
||||
|
||||
ReactDOM.render(<Test portrait={true} />, 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(<Test portrait={false} />, 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ let ReactFeatureFlags;
|
||||
let createFocusTable;
|
||||
let tabFocusableImpl;
|
||||
|
||||
describe('ReactFocusTable', () => {
|
||||
describe('FocusTable', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
ReactFeatureFlags = require('shared/ReactFeatureFlags');
|
||||
|
||||
Reference in New Issue
Block a user