Skip to content

Commit 11a8867

Browse files
committed
fix: prevent useGridCell from overriding child focus restoration loops
1 parent 64689e9 commit 11a8867

2 files changed

Lines changed: 77 additions & 44 deletions

File tree

packages/react-aria/src/grid/useGridCell.ts

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,28 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {DOMAttributes, FocusableElement, Key, RefObject} from '@react-types/shared';
14-
import {focusSafely} from '../interactions/focusSafely';
13+
import { DOMAttributes, FocusableElement, Key, RefObject } from '@react-types/shared';
14+
import { focusSafely } from '../interactions/focusSafely';
1515
import {
1616
getActiveElement,
1717
getEventTarget,
1818
isFocusWithin,
1919
nodeContains
2020
} from '../utils/shadowdom/DOMFunctions';
21-
import {getFocusableTreeWalker} from '../focus/FocusScope';
22-
import {getScrollParent} from '../utils/getScrollParent';
21+
import { getFocusableTreeWalker } from '../focus/FocusScope';
22+
import { getScrollParent } from '../utils/getScrollParent';
2323
import {
2424
IGridCollection as GridCollection,
2525
GridNode
2626
} from 'react-stately/private/grid/GridCollection';
27-
import {gridMap} from './utils';
28-
import {GridState} from 'react-stately/private/grid/useGridState';
29-
import {isFocusVisible} from '../interactions/useFocusVisible';
30-
import {mergeProps} from '../utils/mergeProps';
31-
import {KeyboardEvent as ReactKeyboardEvent, useRef} from 'react';
32-
import {scrollIntoViewport} from '../utils/scrollIntoView';
33-
import {useLocale} from '../i18n/I18nProvider';
34-
import {useSelectableItem} from '../selection/useSelectableItem';
27+
import { gridMap } from './utils';
28+
import { GridState } from 'react-stately/private/grid/useGridState';
29+
import { isFocusVisible } from '../interactions/useFocusVisible';
30+
import { mergeProps } from '../utils/mergeProps';
31+
import { KeyboardEvent as ReactKeyboardEvent, useRef } from 'react';
32+
import { scrollIntoViewport } from '../utils/scrollIntoView';
33+
import { useLocale } from '../i18n/I18nProvider';
34+
import { useSelectableItem } from '../selection/useSelectableItem';
3535

3636
export interface GridCellProps {
3737
/**
@@ -77,12 +77,12 @@ export function useGridCell<T, C extends GridCollection<T>>(
7777
state: GridState<T, C>,
7878
ref: RefObject<FocusableElement | null>
7979
): GridCellAria {
80-
let {node, isVirtualized, focusMode = 'child', shouldSelectOnPressUp, onAction} = props;
80+
let { node, isVirtualized, focusMode = 'child', shouldSelectOnPressUp, onAction } = props;
8181

82-
let {direction} = useLocale();
82+
let { direction } = useLocale();
8383
let {
8484
keyboardDelegate,
85-
actions: {onCellAction}
85+
actions: { onCellAction }
8686
} = gridMap.get(state)!;
8787

8888
// We need to track the key of the item at the time it was last focused so that we force
@@ -119,7 +119,7 @@ export function useGridCell<T, C extends GridCollection<T>>(
119119
}
120120
};
121121

122-
let {itemProps, isPressed} = useSelectableItem({
122+
let { itemProps, isPressed } = useSelectableItem({
123123
selectionManager: state.selectionManager,
124124
key: node.key,
125125
ref,
@@ -161,7 +161,7 @@ export function useGridCell<T, C extends GridCollection<T>>(
161161
e.stopPropagation();
162162
if (focusable) {
163163
focusSafely(focusable);
164-
scrollIntoViewport(focusable, {containingElement: getScrollParent(ref.current)});
164+
scrollIntoViewport(focusable, { containingElement: getScrollParent(ref.current) });
165165
} else {
166166
// If there is no next focusable child, then move to the next cell to the left of this one.
167167
// This will be handled by useSelectableCollection. However, if there is no cell to the left
@@ -181,14 +181,14 @@ export function useGridCell<T, C extends GridCollection<T>>(
181181

182182
if (focusMode === 'cell' && direction === 'rtl') {
183183
focusSafely(ref.current);
184-
scrollIntoViewport(ref.current, {containingElement: getScrollParent(ref.current)});
184+
scrollIntoViewport(ref.current, { containingElement: getScrollParent(ref.current) });
185185
} else {
186186
walker.currentNode = ref.current;
187187
focusable =
188188
direction === 'rtl' ? (walker.firstChild() as FocusableElement) : last(walker);
189189
if (focusable) {
190190
focusSafely(focusable);
191-
scrollIntoViewport(focusable, {containingElement: getScrollParent(ref.current)});
191+
scrollIntoViewport(focusable, { containingElement: getScrollParent(ref.current) });
192192
}
193193
}
194194
}
@@ -208,7 +208,7 @@ export function useGridCell<T, C extends GridCollection<T>>(
208208
e.stopPropagation();
209209
if (focusable) {
210210
focusSafely(focusable);
211-
scrollIntoViewport(focusable, {containingElement: getScrollParent(ref.current)});
211+
scrollIntoViewport(focusable, { containingElement: getScrollParent(ref.current) });
212212
} else {
213213
let next = keyboardDelegate.getKeyRightOf?.(node.key);
214214
if (next !== node.key) {
@@ -223,14 +223,14 @@ export function useGridCell<T, C extends GridCollection<T>>(
223223

224224
if (focusMode === 'cell' && direction === 'ltr') {
225225
focusSafely(ref.current);
226-
scrollIntoViewport(ref.current, {containingElement: getScrollParent(ref.current)});
226+
scrollIntoViewport(ref.current, { containingElement: getScrollParent(ref.current) });
227227
} else {
228228
walker.currentNode = ref.current;
229229
focusable =
230230
direction === 'rtl' ? last(walker) : (walker.firstChild() as FocusableElement);
231231
if (focusable) {
232232
focusSafely(focusable);
233-
scrollIntoViewport(focusable, {containingElement: getScrollParent(ref.current)});
233+
scrollIntoViewport(focusable, { containingElement: getScrollParent(ref.current) });
234234
}
235235
}
236236
}
@@ -254,28 +254,29 @@ export function useGridCell<T, C extends GridCollection<T>>(
254254

255255
// Grid cells can have focusable elements inside them. In this case, focus should
256256
// be marshalled to that element rather than focusing the cell itself.
257-
let onFocus = e => {
257+
let onFocus = (e: any) => {
258258
keyWhenFocused.current = node.key;
259+
260+
// FIX: If focus is moving directly to a specific inner child element
261+
// (like our restored Open Dialog button), update the selection state
262+
// and exit early. Do not call focus(), which resets to the first child.
259263
if (getEventTarget(e) !== ref.current) {
260-
// useSelectableItem only handles setting the focused key when
261-
// the focused element is the gridcell itself. We also want to
262-
// set the focused key when a child element receives focus.
263-
// If focus is currently visible (e.g. the user is navigating with the keyboard),
264-
// then skip this. We want to restore focus to the previously focused row/cell
265-
// in that case since the table should act like a single tab stop.
266264
if (!isFocusVisible()) {
267265
state.selectionManager.setFocusedKey(node.key);
268266
}
269267
return;
270268
}
271269

272-
// If the cell itself is focused, wait a frame so that focus finishes propagatating
273-
// up to the tree, and move focus to a focusable child if possible.
274-
requestAnimationFrame(() => {
275-
if (focusMode === 'child' && getActiveElement() === ref.current) {
276-
focus();
270+
if (focusMode === 'child') {
271+
// Safeguard: Check if the browser's active element has already shifted
272+
// into a nested child node during this event loop tick before forcing a reset.
273+
let activeElement = getActiveElement();
274+
if (ref.current && isFocusWithin(ref.current) && activeElement !== ref.current) {
275+
return;
277276
}
278-
});
277+
278+
focus();
279+
}
279280
};
280281

281282
let gridCellProps: DOMAttributes = mergeProps(itemProps, {

packages/react-aria/test/grid/useGrid.test.js

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {act, pointerMap, render} from '@react-spectrum/test-utils-internal';
14-
import {Grid} from '../../stories/grid/example';
15-
import {Item} from 'react-stately/Item';
13+
import { act, pointerMap, render } from '@react-spectrum/test-utils-internal';
14+
import { Grid } from '../../stories/grid/example';
15+
import { Item } from 'react-stately/Item';
1616
import React from 'react';
17-
import {Switch} from '@adobe/react-spectrum/Switch';
17+
import { Switch } from '@adobe/react-spectrum/Switch';
1818
import userEvent from '@testing-library/user-event';
1919

2020
function renderGrid(props = {}) {
@@ -41,7 +41,7 @@ describe('useGrid', () => {
4141
let user;
4242

4343
beforeAll(() => {
44-
user = userEvent.setup({delay: null, pointerMap});
44+
user = userEvent.setup({ delay: null, pointerMap });
4545
jest.useFakeTimers();
4646
});
4747
afterEach(() => {
@@ -51,7 +51,7 @@ describe('useGrid', () => {
5151
});
5252
});
5353
it('gridFocusMode = row, cellFocusMode = cell', async () => {
54-
let tree = renderGrid({gridFocusMode: 'row', cellFocusMode: 'cell'});
54+
let tree = renderGrid({ gridFocusMode: 'row', cellFocusMode: 'cell' });
5555

5656
await user.tab();
5757
expect(document.activeElement).toBe(tree.getAllByRole('row')[0]);
@@ -84,7 +84,7 @@ describe('useGrid', () => {
8484
});
8585

8686
it('gridFocusMode = row, cellFocusMode = child', async () => {
87-
let tree = renderGrid({gridFocusMode: 'row', cellFocusMode: 'child'});
87+
let tree = renderGrid({ gridFocusMode: 'row', cellFocusMode: 'child' });
8888

8989
await user.tab();
9090
expect(document.activeElement).toBe(tree.getAllByRole('row')[0]);
@@ -112,7 +112,7 @@ describe('useGrid', () => {
112112
});
113113

114114
it('gridFocusMode = cell, cellFocusMode = child', async () => {
115-
let tree = renderGrid({gridFocusMode: 'cell', cellFocusMode: 'child'});
115+
let tree = renderGrid({ gridFocusMode: 'cell', cellFocusMode: 'child' });
116116

117117
await user.tab();
118118
expect(document.activeElement).toBe(tree.getAllByRole('switch')[0]);
@@ -134,7 +134,7 @@ describe('useGrid', () => {
134134
});
135135

136136
it('gridFocusMode = cell, cellFocusMode = cell', async () => {
137-
let tree = renderGrid({gridFocusMode: 'cell', cellFocusMode: 'cell'});
137+
let tree = renderGrid({ gridFocusMode: 'cell', cellFocusMode: 'cell' });
138138

139139
await user.tab();
140140
expect(document.activeElement).toBe(tree.getAllByRole('gridcell')[0]);
@@ -157,4 +157,36 @@ describe('useGrid', () => {
157157
await user.keyboard('[ArrowLeft]');
158158
expect(document.activeElement).toBe(tree.getAllByRole('gridcell')[0]);
159159
});
160+
161+
it('should retain focus on a specific child element if focus is restored to it', async () => {
162+
let tree = renderGrid({ gridFocusMode: 'cell', cellFocusMode: 'child' });
163+
let switches = tree.getAllByRole('switch');
164+
let cells = tree.getAllByRole('gridcell');
165+
166+
// 1. Initially move focus onto the grid via tab flow (focuses Switch 1)
167+
await user.tab();
168+
expect(document.activeElement).toBe(switches[0]);
169+
170+
// 2. Simulate focus returning directly to the second target element (Switch 2)
171+
// exactly how the focus-restoration logic inside an overlay does it.
172+
act(() => {
173+
switches[1].focus();
174+
});
175+
expect(document.activeElement).toBe(switches[1]);
176+
177+
// 3. Fire a focus event directly on the gridcell container to trigger your
178+
// onFocus handler in useGridCell.ts and simulate event bubbling
179+
act(() => {
180+
cells[0].dispatchEvent(new FocusEvent('focus', { bubbles: true }));
181+
});
182+
183+
// 4. Force Jest's timer and requestAnimationFrame microtask cycles to execute completely
184+
act(() => {
185+
jest.runAllTimers();
186+
});
187+
188+
// 5. FINAL ASSERTION: Focus should accurately stay locked onto Switch 2
189+
// instead of resetting to the initial index element Switch 1!
190+
expect(document.activeElement).toBe(switches[1]);
191+
});
160192
});

0 commit comments

Comments
 (0)