Skip to content

Commit b9811ed

Browse files
authored
[react-interactions] Add wrapping support to FocusList/FocusTable (#16903)
1 parent 49b0cb6 commit b9811ed

File tree

4 files changed

+160
-34
lines changed

4 files changed

+160
-34
lines changed

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

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,22 @@ import {useKeyboard} from 'react-interactions/events/keyboard';
1515

1616
type FocusItemProps = {
1717
children?: React.Node,
18+
onKeyDown?: KeyboardEvent => void,
1819
};
1920

2021
type FocusListProps = {|
2122
children: React.Node,
2223
portrait: boolean,
24+
wrap?: boolean,
2325
|};
2426

2527
const {useRef} = React;
2628

27-
function focusListItem(cell: ReactScopeMethods): void {
29+
function focusListItem(cell: ReactScopeMethods, event: KeyboardEvent): void {
2830
const tabbableNodes = cell.getScopedNodes();
2931
if (tabbableNodes !== null && tabbableNodes.length > 0) {
3032
tabbableNodes[0].focus();
33+
event.preventDefault();
3134
}
3235
}
3336

@@ -38,7 +41,10 @@ function getPreviousListItem(
3841
const items = list.getChildren();
3942
if (items !== null) {
4043
const currentItemIndex = items.indexOf(currentItem);
41-
if (currentItemIndex > 0) {
44+
const wrap = getListWrapProp(currentItem);
45+
if (currentItemIndex === 0 && wrap) {
46+
return items[items.length - 1] || null;
47+
} else if (currentItemIndex > 0) {
4248
return items[currentItemIndex - 1] || null;
4349
}
4450
}
@@ -52,25 +58,38 @@ function getNextListItem(
5258
const items = list.getChildren();
5359
if (items !== null) {
5460
const currentItemIndex = items.indexOf(currentItem);
55-
if (currentItemIndex !== -1 && currentItemIndex !== items.length - 1) {
61+
const wrap = getListWrapProp(currentItem);
62+
const end = currentItemIndex === items.length - 1;
63+
if (end && wrap) {
64+
return items[0] || null;
65+
} else if (currentItemIndex !== -1 && !end) {
5666
return items[currentItemIndex + 1] || null;
5767
}
5868
}
5969
return null;
6070
}
6171

72+
function getListWrapProp(currentItem: ReactScopeMethods): boolean {
73+
const list = currentItem.getParent();
74+
if (list !== null) {
75+
const listProps = list.getProps();
76+
return (listProps.type === 'list' && listProps.wrap) || false;
77+
}
78+
return false;
79+
}
80+
6281
export function createFocusList(scope: ReactScope): Array<React.Component> {
6382
const TableScope = React.unstable_createScope(scope.fn);
6483

65-
function List({children, portrait}): FocusListProps {
84+
function List({children, portrait, wrap}): FocusListProps {
6685
return (
67-
<TableScope type="list" portrait={portrait}>
86+
<TableScope type="list" portrait={portrait} wrap={wrap}>
6887
{children}
6988
</TableScope>
7089
);
7190
}
7291

73-
function Item({children}): FocusItemProps {
92+
function Item({children, onKeyDown}): FocusItemProps {
7493
const scopeRef = useRef(null);
7594
const keyboard = useKeyboard({
7695
onKeyDown(event: KeyboardEvent): void {
@@ -88,8 +107,7 @@ export function createFocusList(scope: ReactScope): Array<React.Component> {
88107
currentItem,
89108
);
90109
if (previousListItem) {
91-
event.preventDefault();
92-
focusListItem(previousListItem);
110+
focusListItem(previousListItem, event);
93111
return;
94112
}
95113
}
@@ -99,8 +117,7 @@ export function createFocusList(scope: ReactScope): Array<React.Component> {
99117
if (portrait) {
100118
const nextListItem = getNextListItem(list, currentItem);
101119
if (nextListItem) {
102-
event.preventDefault();
103-
focusListItem(nextListItem);
120+
focusListItem(nextListItem, event);
104121
return;
105122
}
106123
}
@@ -113,8 +130,7 @@ export function createFocusList(scope: ReactScope): Array<React.Component> {
113130
currentItem,
114131
);
115132
if (previousListItem) {
116-
event.preventDefault();
117-
focusListItem(previousListItem);
133+
focusListItem(previousListItem, event);
118134
return;
119135
}
120136
}
@@ -124,8 +140,7 @@ export function createFocusList(scope: ReactScope): Array<React.Component> {
124140
if (!portrait) {
125141
const nextListItem = getNextListItem(list, currentItem);
126142
if (nextListItem) {
127-
event.preventDefault();
128-
focusListItem(nextListItem);
143+
focusListItem(nextListItem, event);
129144
return;
130145
}
131146
}
@@ -134,6 +149,9 @@ export function createFocusList(scope: ReactScope): Array<React.Component> {
134149
}
135150
}
136151
}
152+
if (onKeyDown) {
153+
onKeyDown(event);
154+
}
137155
event.continuePropagation();
138156
},
139157
});

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

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {useKeyboard} from 'react-interactions/events/keyboard';
1515

1616
type FocusCellProps = {
1717
children?: React.Node,
18+
onKeyDown?: KeyboardEvent => void,
1819
};
1920

2021
type FocusRowProps = {
@@ -28,6 +29,7 @@ type FocusTableProps = {|
2829
direction: 'left' | 'right' | 'up' | 'down',
2930
focusTableByID: (id: string) => void,
3031
) => void,
32+
wrap?: boolean,
3133
|};
3234

3335
const {useRef} = React;
@@ -54,19 +56,26 @@ export function focusFirstCellOnTable(table: ReactScopeMethods): void {
5456
}
5557
}
5658

57-
function focusCell(cell: ReactScopeMethods): void {
59+
function focusCell(cell: ReactScopeMethods, event?: KeyboardEvent): void {
5860
const tabbableNodes = cell.getScopedNodes();
5961
if (tabbableNodes !== null && tabbableNodes.length > 0) {
6062
tabbableNodes[0].focus();
63+
if (event) {
64+
event.preventDefault();
65+
}
6166
}
6267
}
6368

64-
function focusCellByIndex(row: ReactScopeMethods, cellIndex: number): void {
69+
function focusCellByIndex(
70+
row: ReactScopeMethods,
71+
cellIndex: number,
72+
event?: KeyboardEvent,
73+
): void {
6574
const cells = row.getChildren();
6675
if (cells !== null) {
6776
const cell = cells[cellIndex];
6877
if (cell) {
69-
focusCell(cell);
78+
focusCell(cell, event);
7079
}
7180
}
7281
}
@@ -130,12 +139,27 @@ function triggerNavigateOut(
130139
event.continuePropagation();
131140
}
132141

142+
function getTableWrapProp(currentCell: ReactScopeMethods): boolean {
143+
const row = currentCell.getParent();
144+
if (row !== null && row.getProps().type === 'row') {
145+
const table = row.getParent();
146+
if (table !== null) {
147+
return table.getProps().wrap || false;
148+
}
149+
}
150+
return false;
151+
}
152+
133153
export function createFocusTable(scope: ReactScope): Array<React.Component> {
134154
const TableScope = React.unstable_createScope(scope.fn);
135155

136-
function Table({children, onKeyboardOut, id}): FocusTableProps {
156+
function Table({children, onKeyboardOut, id, wrap}): FocusTableProps {
137157
return (
138-
<TableScope type="table" onKeyboardOut={onKeyboardOut} id={id}>
158+
<TableScope
159+
type="table"
160+
onKeyboardOut={onKeyboardOut}
161+
id={id}
162+
wrap={wrap}>
139163
{children}
140164
</TableScope>
141165
);
@@ -145,7 +169,7 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
145169
return <TableScope type="row">{children}</TableScope>;
146170
}
147171

148-
function Cell({children}): FocusCellProps {
172+
function Cell({children, onKeyDown}): FocusCellProps {
149173
const scopeRef = useRef(null);
150174
const keyboard = useKeyboard({
151175
onKeyDown(event: KeyboardEvent): void {
@@ -162,10 +186,15 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
162186
if (rows !== null) {
163187
if (rowIndex > 0) {
164188
const row = rows[rowIndex - 1];
165-
focusCellByIndex(row, cellIndex);
166-
event.preventDefault();
189+
focusCellByIndex(row, cellIndex, event);
167190
} else if (rowIndex === 0) {
168-
triggerNavigateOut(currentCell, 'up', event);
191+
const wrap = getTableWrapProp(currentCell);
192+
if (wrap) {
193+
const row = rows[rows.length - 1];
194+
focusCellByIndex(row, cellIndex, event);
195+
} else {
196+
triggerNavigateOut(currentCell, 'up', event);
197+
}
169198
}
170199
}
171200
}
@@ -178,11 +207,16 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
178207
if (rows !== null) {
179208
if (rowIndex !== -1) {
180209
if (rowIndex === rows.length - 1) {
181-
triggerNavigateOut(currentCell, 'down', event);
210+
const wrap = getTableWrapProp(currentCell);
211+
if (wrap) {
212+
const row = rows[0];
213+
focusCellByIndex(row, cellIndex, event);
214+
} else {
215+
triggerNavigateOut(currentCell, 'down', event);
216+
}
182217
} else {
183218
const row = rows[rowIndex + 1];
184-
focusCellByIndex(row, cellIndex);
185-
event.preventDefault();
219+
focusCellByIndex(row, cellIndex, event);
186220
}
187221
}
188222
}
@@ -196,7 +230,12 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
196230
focusCell(cells[rowIndex - 1]);
197231
event.preventDefault();
198232
} else if (rowIndex === 0) {
199-
triggerNavigateOut(currentCell, 'left', event);
233+
const wrap = getTableWrapProp(currentCell);
234+
if (wrap) {
235+
focusCell(cells[cells.length - 1], event);
236+
} else {
237+
triggerNavigateOut(currentCell, 'left', event);
238+
}
200239
}
201240
}
202241
return;
@@ -206,16 +245,23 @@ export function createFocusTable(scope: ReactScope): Array<React.Component> {
206245
if (cells !== null) {
207246
if (rowIndex !== -1) {
208247
if (rowIndex === cells.length - 1) {
209-
triggerNavigateOut(currentCell, 'right', event);
248+
const wrap = getTableWrapProp(currentCell);
249+
if (wrap) {
250+
focusCell(cells[0], event);
251+
} else {
252+
triggerNavigateOut(currentCell, 'right', event);
253+
}
210254
} else {
211-
focusCell(cells[rowIndex + 1]);
212-
event.preventDefault();
255+
focusCell(cells[rowIndex + 1], event);
213256
}
214257
}
215258
}
216259
return;
217260
}
218261
}
262+
if (onKeyDown) {
263+
onKeyDown(event);
264+
}
219265
},
220266
});
221267
return (

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

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ describe('FocusList', () => {
4343
function createFocusListComponent() {
4444
const [FocusList, FocusItem] = createFocusList(TabbableScope);
4545

46-
return ({portrait}) => (
47-
<FocusList portrait={portrait}>
46+
return ({portrait, wrap}) => (
47+
<FocusList portrait={portrait} wrap={wrap}>
4848
<ul>
4949
<FocusItem>
5050
<li tabIndex={0}>Item 1</li>
@@ -125,5 +125,36 @@ describe('FocusList', () => {
125125
});
126126
expect(document.activeElement.textContent).toBe('Item 3');
127127
});
128+
129+
it('handles keyboard arrow operations (portrait) with wrapping enabled', () => {
130+
const Test = createFocusListComponent();
131+
132+
ReactDOM.render(<Test portrait={true} wrap={true} />, container);
133+
const listItems = document.querySelectorAll('li');
134+
let firstListItem = createEventTarget(listItems[0]);
135+
firstListItem.focus();
136+
firstListItem.keydown({
137+
key: 'ArrowDown',
138+
});
139+
expect(document.activeElement.textContent).toBe('Item 2');
140+
141+
const secondListItem = createEventTarget(document.activeElement);
142+
secondListItem.keydown({
143+
key: 'ArrowDown',
144+
});
145+
expect(document.activeElement.textContent).toBe('Item 3');
146+
147+
const thirdListItem = createEventTarget(document.activeElement);
148+
thirdListItem.keydown({
149+
key: 'ArrowDown',
150+
});
151+
expect(document.activeElement.textContent).toBe('Item 1');
152+
153+
firstListItem = createEventTarget(document.activeElement);
154+
firstListItem.keydown({
155+
key: 'ArrowUp',
156+
});
157+
expect(document.activeElement.textContent).toBe('Item 3');
158+
});
128159
});
129160
});

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

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ describe('FocusTable', () => {
4545
TabbableScope,
4646
);
4747

48-
return ({onKeyboardOut, id}) => (
49-
<FocusTable onKeyboardOut={onKeyboardOut} id={id}>
48+
return ({onKeyboardOut, id, wrap}) => (
49+
<FocusTable onKeyboardOut={onKeyboardOut} id={id} wrap={wrap}>
5050
<table>
5151
<tbody>
5252
<FocusTableRow>
@@ -326,5 +326,36 @@ describe('FocusTable', () => {
326326
});
327327
expect(document.activeElement.placeholder).toBe('B1');
328328
});
329+
330+
it('handles keyboard arrow operations with wrapping enabled', () => {
331+
const Test = createFocusTableComponent();
332+
333+
ReactDOM.render(<Test wrap={true} />, container);
334+
const buttons = document.querySelectorAll('button');
335+
let a1 = createEventTarget(buttons[0]);
336+
a1.focus();
337+
a1.keydown({
338+
key: 'ArrowRight',
339+
});
340+
expect(document.activeElement.textContent).toBe('A2');
341+
342+
const a2 = createEventTarget(document.activeElement);
343+
a2.keydown({
344+
key: 'ArrowRight',
345+
});
346+
expect(document.activeElement.textContent).toBe('A3');
347+
348+
const a3 = createEventTarget(document.activeElement);
349+
a3.keydown({
350+
key: 'ArrowRight',
351+
});
352+
expect(document.activeElement.textContent).toBe('A1');
353+
354+
a1 = createEventTarget(document.activeElement);
355+
a1.keydown({
356+
key: 'ArrowLeft',
357+
});
358+
expect(document.activeElement.textContent).toBe('A3');
359+
});
329360
});
330361
});

0 commit comments

Comments
 (0)