[react-interactions] Add FocusList component (#16875)
Dominic Gannaway committed
Sep 24, 2019 at 17:14 UTC
68a87eee544b2bcdff66c751e07cb55939b9d8ca
4 files changed
+284
-2
packages/react-interactions/accessibility/src/FocusList.js
new
+150
@@ -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
+4
-1
@@ -149,6 +149,10 @@ export function createFocusTable(
149
const keyboard = useKeyboard({
150
onKeyDown(event: KeyboardEvent): void {
151
const currentCell = scopeRef.current;
152
+ if (currentCell === null) {
153
+ event.continuePropagation();
154
+ return;
155
+ }
156
switch (event.key) {
157
case 'ArrowUp': {
158
const [cells, cellIndex] = getRowCells(currentCell);
@@ -211,7 +215,6 @@ export function createFocusTable(
215
return;
216
}
217
}
214
- event.continuePropagation();
218
},
219
});
220
return (
packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js
new
+129
@@ -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
+1
-1
@@ -14,7 +14,7 @@ let ReactFeatureFlags;
14
let createFocusTable;
15
let tabFocusableImpl;
16
17
-describe('ReactFocusTable', () => {
17
+describe('FocusTable', () => {
18
beforeEach(() => {
19
jest.resetModules();
20
ReactFeatureFlags = require('shared/ReactFeatureFlags');