diff --git a/packages/react-aria/src/grid/useGridCell.ts b/packages/react-aria/src/grid/useGridCell.ts index a7f366eab2d..25f562aa4ac 100644 --- a/packages/react-aria/src/grid/useGridCell.ts +++ b/packages/react-aria/src/grid/useGridCell.ts @@ -254,28 +254,33 @@ export function useGridCell>( // Grid cells can have focusable elements inside them. In this case, focus should // be marshalled to that element rather than focusing the cell itself. - let onFocus = e => { + let onFocus = (e: FocusEvent) => { keyWhenFocused.current = node.key; + + // FIX: If focus is moving directly to a specific inner child element + // (like our restored Open Dialog button), update the selection state + // and exit early. Do not call focus(), which resets to the first child. + + // FIX: If focus is moving directly to a specific inner child element + // (like our restored Open Dialog button), update the selection state + // and exit early. Do not call focus(), which resets to the first child. if (getEventTarget(e) !== ref.current) { - // useSelectableItem only handles setting the focused key when - // the focused element is the gridcell itself. We also want to - // set the focused key when a child element receives focus. - // If focus is currently visible (e.g. the user is navigating with the keyboard), - // then skip this. We want to restore focus to the previously focused row/cell - // in that case since the table should act like a single tab stop. if (!isFocusVisible()) { state.selectionManager.setFocusedKey(node.key); } return; } - // If the cell itself is focused, wait a frame so that focus finishes propagatating - // up to the tree, and move focus to a focusable child if possible. - requestAnimationFrame(() => { - if (focusMode === 'child' && getActiveElement() === ref.current) { - focus(); + if (focusMode === 'child') { + // Safeguard: Check if the browser's active element has already shifted + // into a nested child node during this event loop tick before forcing a reset. + let activeElement = getActiveElement(); + if (ref.current && isFocusWithin(ref.current) && activeElement !== ref.current) { + return; } - }); + + focus(); + } }; let gridCellProps: DOMAttributes = mergeProps(itemProps, { diff --git a/packages/react-aria/test/grid/useGrid.test.js b/packages/react-aria/test/grid/useGrid.test.js old mode 100644 new mode 100755 index 20b1915ef35..24086ec8168 --- a/packages/react-aria/test/grid/useGrid.test.js +++ b/packages/react-aria/test/grid/useGrid.test.js @@ -157,4 +157,36 @@ describe('useGrid', () => { await user.keyboard('[ArrowLeft]'); expect(document.activeElement).toBe(tree.getAllByRole('gridcell')[0]); }); + + it('should retain focus on a specific child element if focus is restored to it', async () => { + let tree = renderGrid({gridFocusMode: 'cell', cellFocusMode: 'child'}); + let switches = tree.getAllByRole('switch'); + let cells = tree.getAllByRole('gridcell'); + + // 1. Initially move focus onto the grid via tab flow (focuses Switch 1) + await user.tab(); + expect(document.activeElement).toBe(switches[0]); + + // 2. Simulate focus returning directly to the second target element (Switch 2) + // exactly how the focus-restoration logic inside an overlay does it. + act(() => { + switches[1].focus(); + }); + expect(document.activeElement).toBe(switches[1]); + + // 3. Fire a focus event directly on the gridcell container to trigger your + // onFocus handler in useGridCell.ts and simulate event bubbling + act(() => { + cells[0].dispatchEvent(new FocusEvent('focus', {bubbles: true})); + }); + + // 4. Force Jest's timer and requestAnimationFrame microtask cycles to execute completely + act(() => { + jest.runAllTimers(); + }); + + // 5. FINAL ASSERTION: Focus should accurately stay locked onto Switch 2 + // instead of resetting to the initial index element Switch 1! + expect(document.activeElement).toBe(switches[1]); + }); });