Skip to content

Commit

Permalink
fix(FloatingFocusManager): return focus to last connected node (#2635)
Browse files Browse the repository at this point in the history
  • Loading branch information
atomiks committed Nov 27, 2023
1 parent 9170b9e commit 66efdaf
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 9 deletions.
5 changes: 5 additions & 0 deletions .changeset/four-lions-rhyme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@floating-ui/react': patch
---

fix(FloatingFocusManager): return focus to last connected element
42 changes: 33 additions & 9 deletions packages/react/src/components/FloatingFocusManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
isVirtualPointerEvent,
stopEvent,
} from '@floating-ui/react/utils';
import {isHTMLElement} from '@floating-ui/utils/dom';
import {getNodeName, isHTMLElement} from '@floating-ui/utils/dom';
import * as React from 'react';
import type {FocusableElement} from 'tabbable';
import {tabbable} from 'tabbable';
Expand All @@ -31,6 +31,29 @@ import {usePortalContext} from './FloatingPortal';
import {useFloatingTree} from './FloatingTree';
import {FocusGuard, HIDDEN_STYLES} from './FocusGuard';

const LIST_LIMIT = 20;
let previouslyFocusedElements: Element[] = [];

function addPreviouslyFocusedElement(element: Element | null) {
previouslyFocusedElements = previouslyFocusedElements.filter(
(el) => el.isConnected,
);

if (element && getNodeName(element) !== 'body') {
previouslyFocusedElements.push(element);
if (previouslyFocusedElements.length > LIST_LIMIT) {
previouslyFocusedElements = previouslyFocusedElements.slice(-LIST_LIMIT);
}
}
}

function getPreviouslyFocusedElement() {
return previouslyFocusedElements
.slice()
.reverse()
.find((el) => el.isConnected);
}

const VisuallyHiddenDismiss = React.forwardRef(function VisuallyHiddenDismiss(
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
ref: React.Ref<HTMLButtonElement>,
Expand Down Expand Up @@ -116,7 +139,6 @@ export function FloatingFocusManager<RT extends ReferenceType = ReferenceType>(
const startDismissButtonRef = React.useRef<HTMLButtonElement>(null);
const endDismissButtonRef = React.useRef<HTMLButtonElement>(null);
const preventReturnFocusRef = React.useRef(false);
const previouslyFocusedElementRef = React.useRef<Element | null>(null);
const isPointerDownRef = React.useRef(false);

const isInsidePortal = portalContext != null;
Expand Down Expand Up @@ -245,7 +267,7 @@ export function FloatingFocusManager<RT extends ReferenceType = ReferenceType>(
movedToUnrelatedNode &&
!isPointerDownRef.current &&
// Fix React 18 Strict Mode returnFocus due to double rendering.
relatedTarget !== previouslyFocusedElementRef.current
relatedTarget !== getPreviouslyFocusedElement()
) {
preventReturnFocusRef.current = true;
onOpenChange(false, event);
Expand Down Expand Up @@ -358,7 +380,7 @@ export function FloatingFocusManager<RT extends ReferenceType = ReferenceType>(
const previouslyFocusedElement = activeElement(doc);
const contextData = dataRef.current;

previouslyFocusedElementRef.current = previouslyFocusedElement;
addPreviouslyFocusedElement(previouslyFocusedElement);

// Dismissing via outside press should always ignore `returnFocus` to
// prevent unwanted scrolling.
Expand All @@ -372,7 +394,7 @@ export function FloatingFocusManager<RT extends ReferenceType = ReferenceType>(
nested: boolean;
}) {
if (reason === 'escape-key' && refs.domReference.current) {
previouslyFocusedElementRef.current = refs.domReference.current;
addPreviouslyFocusedElement(refs.domReference.current);
}

if (reason === 'hover' && event.type === 'mouseleave') {
Expand Down Expand Up @@ -410,22 +432,24 @@ export function FloatingFocusManager<RT extends ReferenceType = ReferenceType>(
['click', 'mousedown'].includes(contextData.openEvent.type));

if (shouldFocusReference && refs.domReference.current) {
previouslyFocusedElementRef.current = refs.domReference.current;
addPreviouslyFocusedElement(refs.domReference.current);
}

const returnElement = getPreviouslyFocusedElement();

if (
// eslint-disable-next-line react-hooks/exhaustive-deps
returnFocusRef.current &&
isHTMLElement(previouslyFocusedElementRef.current) &&
!preventReturnFocusRef.current &&
isHTMLElement(returnElement) &&
// If the focus moved somewhere else after mount, avoid returning focus
// since it likely entered a different element which should be
// respected: https://github.com/floating-ui/floating-ui/issues/2607
(previouslyFocusedElement !== activeEl && activeEl !== doc.body
(returnElement !== activeEl && activeEl !== doc.body
? isFocusInsideFloatingTree
: true)
) {
enqueueFocus(previouslyFocusedElementRef.current, {
enqueueFocus(returnElement, {
// When dismissing nested floating elements, by the time the rAF has
// executed, the menus will all have been unmounted. When they try
// to get focused, the calls get ignored — leaving the root
Expand Down
77 changes: 77 additions & 0 deletions packages/react/test/unit/FloatingFocusManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1227,3 +1227,80 @@ test('untrapped combobox creates non-modal focus management', async () => {
expect(screen.getByTestId('input')).toHaveFocus();
cleanup();
});

test('returns focus to last connected element', async () => {
function Drawer({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const {refs, context} = useFloating({open, onOpenChange});
const dismiss = useDismiss(context);
const {getFloatingProps} = useInteractions([dismiss]);

return (
<FloatingFocusManager context={context}>
<div ref={refs.setFloating} {...getFloatingProps()}>
<button data-testid="child-reference" />
</div>
</FloatingFocusManager>
);
}

function Parent() {
const [isOpen, setIsOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);

const {refs, context} = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
});

const dismiss = useDismiss(context);
const click = useClick(context);

const {getReferenceProps, getFloatingProps} = useInteractions([
click,
dismiss,
]);

return (
<>
<button
ref={refs.setReference}
data-testid="parent-reference"
{...getReferenceProps()}
/>
{isOpen && (
<FloatingFocusManager context={context}>
<div ref={refs.setFloating} {...getFloatingProps()}>
Parent Floating
<button
data-testid="parent-floating-reference"
onClick={() => {
setIsDrawerOpen(true);
setIsOpen(false);
}}
/>
</div>
</FloatingFocusManager>
)}
{isDrawerOpen && (
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen} />
)}
</>
);
}

render(<Parent />);
await userEvent.click(screen.getByTestId('parent-reference'));
await act(async () => {});
expect(screen.getByTestId('parent-floating-reference')).toHaveFocus();
await userEvent.click(screen.getByTestId('parent-floating-reference'));
await act(async () => {});
expect(screen.getByTestId('child-reference')).toHaveFocus();
await userEvent.keyboard('{Escape}');
expect(screen.getByTestId('parent-reference')).toHaveFocus();
});

0 comments on commit 66efdaf

Please sign in to comment.