Skip to content

Commit 68a87ee

Browse files
authored
[react-interactions] Add FocusList component (#16875)
1 parent 18d2e0c commit 68a87ee

File tree

4 files changed

+284
-2
lines changed

4 files changed

+284
-2
lines changed
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
import type {ReactScopeMethods} from 'shared/ReactTypes';
11+
import type {KeyboardEvent} from 'react-interactions/events/keyboard';
12+
13+
import React from 'react';
14+
import {useKeyboard} from 'react-interactions/events/keyboard';
15+
16+
type FocusItemProps = {
17+
children?: React.Node,
18+
};
19+
20+
type FocusListProps = {|
21+
children: React.Node,
22+
portrait: boolean,
23+
|};
24+
25+
const {useRef} = React;
26+
27+
function focusListItem(cell: ReactScopeMethods): void {
28+
const tabbableNodes = cell.getScopedNodes();
29+
if (tabbableNodes !== null && tabbableNodes.length > 0) {
30+
tabbableNodes[0].focus();
31+
}
32+
}
33+
34+
function getPreviousListItem(
35+
list: ReactScopeMethods,
36+
currentItem: ReactScopeMethods,
37+
): null | ReactScopeMethods {
38+
const items = list.getChildren();
39+
if (items !== null) {
40+
const currentItemIndex = items.indexOf(currentItem);
41+
if (currentItemIndex > 0) {
42+
return items[currentItemIndex - 1] || null;
43+
}
44+
}
45+
return null;
46+
}
47+
48+
function getNextListItem(
49+
list: ReactScopeMethods,
50+
currentItem: ReactScopeMethods,
51+
): null | ReactScopeMethods {
52+
const items = list.getChildren();
53+
if (items !== null) {
54+
const currentItemIndex = items.indexOf(currentItem);
55+
if (currentItemIndex !== -1 && currentItemIndex !== items.length - 1) {
56+
return items[currentItemIndex + 1] || null;
57+
}
58+
}
59+
return null;
60+
}
61+
62+
export function createFocusList(
63+
scopeImpl: (type: string, props: Object) => boolean,
64+
): Array<React.Component> {
65+
const TableScope = React.unstable_createScope(scopeImpl);
66+
67+
function List({children, portrait}): FocusListProps {
68+
return (
69+
<TableScope type="list" portrait={portrait}>
70+
{children}
71+
</TableScope>
72+
);
73+
}
74+
75+
function Item({children}): FocusItemProps {
76+
const scopeRef = useRef(null);
77+
const keyboard = useKeyboard({
78+
onKeyDown(event: KeyboardEvent): void {
79+
const currentItem = scopeRef.current;
80+
if (currentItem !== null) {
81+
const list = currentItem.getParent();
82+
const listProps = list && list.getProps();
83+
if (list !== null && listProps.type === 'list') {
84+
const portrait = listProps.portrait;
85+
switch (event.key) {
86+
case 'ArrowUp': {
87+
if (portrait) {
88+
const previousListItem = getPreviousListItem(
89+
list,
90+
currentItem,
91+
);
92+
if (previousListItem) {
93+
event.preventDefault();
94+
focusListItem(previousListItem);
95+
return;
96+
}
97+
}
98+
break;
99+
}
100+
case 'ArrowDown': {
101+
if (portrait) {
102+
const nextListItem = getNextListItem(list, currentItem);
103+
if (nextListItem) {
104+
event.preventDefault();
105+
focusListItem(nextListItem);
106+
return;
107+
}
108+
}
109+
break;
110+
}
111+
case 'ArrowLeft': {
112+
if (!portrait) {
113+
const previousListItem = getPreviousListItem(
114+
list,
115+
currentItem,
116+
);
117+
if (previousListItem) {
118+
event.preventDefault();
119+
focusListItem(previousListItem);
120+
return;
121+
}
122+
}
123+
break;
124+
}
125+
case 'ArrowRight': {
126+
if (!portrait) {
127+
const nextListItem = getNextListItem(list, currentItem);
128+
if (nextListItem) {
129+
event.preventDefault();
130+
focusListItem(nextListItem);
131+
return;
132+
}
133+
}
134+
break;
135+
}
136+
}
137+
}
138+
}
139+
event.continuePropagation();
140+
},
141+
});
142+
return (
143+
<TableScope listeners={keyboard} ref={scopeRef} type="item">
144+
{children}
145+
</TableScope>
146+
);
147+
}
148+
149+
return [List, Item];
150+
}

packages/react-interactions/accessibility/src/FocusTable.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ export function createFocusTable(
149149
const keyboard = useKeyboard({
150150
onKeyDown(event: KeyboardEvent): void {
151151
const currentCell = scopeRef.current;
152+
if (currentCell === null) {
153+
event.continuePropagation();
154+
return;
155+
}
152156
switch (event.key) {
153157
case 'ArrowUp': {
154158
const [cells, cellIndex] = getRowCells(currentCell);
@@ -211,7 +215,6 @@ export function createFocusTable(
211215
return;
212216
}
213217
}
214-
event.continuePropagation();
215218
},
216219
});
217220
return (
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
import {createEventTarget} from 'react-interactions/events/src/dom/testing-library';
11+
12+
let React;
13+
let ReactFeatureFlags;
14+
let createFocusList;
15+
let tabFocusableImpl;
16+
17+
describe('FocusList', () => {
18+
beforeEach(() => {
19+
jest.resetModules();
20+
ReactFeatureFlags = require('shared/ReactFeatureFlags');
21+
ReactFeatureFlags.enableScopeAPI = true;
22+
ReactFeatureFlags.enableFlareAPI = true;
23+
createFocusList = require('../FocusList').createFocusList;
24+
tabFocusableImpl = require('../TabbableScope').tabFocusableImpl;
25+
React = require('react');
26+
});
27+
28+
describe('ReactDOM', () => {
29+
let ReactDOM;
30+
let container;
31+
32+
beforeEach(() => {
33+
ReactDOM = require('react-dom');
34+
container = document.createElement('div');
35+
document.body.appendChild(container);
36+
});
37+
38+
afterEach(() => {
39+
document.body.removeChild(container);
40+
container = null;
41+
});
42+
43+
function createFocusListComponent() {
44+
const [FocusList, FocusItem] = createFocusList(tabFocusableImpl);
45+
46+
return ({portrait}) => (
47+
<FocusList portrait={portrait}>
48+
<ul>
49+
<FocusItem>
50+
<li tabIndex={0}>Item 1</li>
51+
</FocusItem>
52+
<FocusItem>
53+
<li tabIndex={0}>Item 2</li>
54+
</FocusItem>
55+
<FocusItem>
56+
<li tabIndex={0}>Item 3</li>
57+
</FocusItem>
58+
</ul>
59+
</FocusList>
60+
);
61+
}
62+
63+
it('handles keyboard arrow operations (portrait)', () => {
64+
const Test = createFocusListComponent();
65+
66+
ReactDOM.render(<Test portrait={true} />, container);
67+
const listItems = document.querySelectorAll('li');
68+
const firstListItem = createEventTarget(listItems[0]);
69+
firstListItem.focus();
70+
firstListItem.keydown({
71+
key: 'ArrowDown',
72+
});
73+
expect(document.activeElement.textContent).toBe('Item 2');
74+
75+
const secondListItem = createEventTarget(document.activeElement);
76+
secondListItem.keydown({
77+
key: 'ArrowDown',
78+
});
79+
expect(document.activeElement.textContent).toBe('Item 3');
80+
81+
const thirdListItem = createEventTarget(document.activeElement);
82+
thirdListItem.keydown({
83+
key: 'ArrowDown',
84+
});
85+
expect(document.activeElement.textContent).toBe('Item 3');
86+
thirdListItem.keydown({
87+
key: 'ArrowRight',
88+
});
89+
expect(document.activeElement.textContent).toBe('Item 3');
90+
thirdListItem.keydown({
91+
key: 'ArrowLeft',
92+
});
93+
expect(document.activeElement.textContent).toBe('Item 3');
94+
});
95+
96+
it('handles keyboard arrow operations (landscape)', () => {
97+
const Test = createFocusListComponent();
98+
99+
ReactDOM.render(<Test portrait={false} />, container);
100+
const listItems = document.querySelectorAll('li');
101+
const firstListItem = createEventTarget(listItems[0]);
102+
firstListItem.focus();
103+
firstListItem.keydown({
104+
key: 'ArrowRight',
105+
});
106+
expect(document.activeElement.textContent).toBe('Item 2');
107+
108+
const secondListItem = createEventTarget(document.activeElement);
109+
secondListItem.keydown({
110+
key: 'ArrowRight',
111+
});
112+
expect(document.activeElement.textContent).toBe('Item 3');
113+
114+
const thirdListItem = createEventTarget(document.activeElement);
115+
thirdListItem.keydown({
116+
key: 'ArrowRight',
117+
});
118+
expect(document.activeElement.textContent).toBe('Item 3');
119+
thirdListItem.keydown({
120+
key: 'ArrowUp',
121+
});
122+
expect(document.activeElement.textContent).toBe('Item 3');
123+
thirdListItem.keydown({
124+
key: 'ArrowDown',
125+
});
126+
expect(document.activeElement.textContent).toBe('Item 3');
127+
});
128+
});
129+
});

packages/react-interactions/accessibility/src/__tests__/FocusTable-test.internal.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ let ReactFeatureFlags;
1414
let createFocusTable;
1515
let tabFocusableImpl;
1616

17-
describe('ReactFocusTable', () => {
17+
describe('FocusTable', () => {
1818
beforeEach(() => {
1919
jest.resetModules();
2020
ReactFeatureFlags = require('shared/ReactFeatureFlags');

0 commit comments

Comments
 (0)