Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/headless-return-focus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
98 changes: 98 additions & 0 deletions packages/headless/src/hooks/use-return-focus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { FloatingEvents } from '@floating-ui/react';
import { renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { useReturnFocus } from './use-return-focus';

function createEvents(): FloatingEvents & { close: (event?: Event) => void } {
const handlers = new Map<string, Array<(data: unknown) => void>>();

return {
emit(event, data) {
handlers.get(event)?.forEach(handler => handler(data));
},
on(event, handler) {
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
},
off(event, handler) {
handlers.set(
event,
(handlers.get(event) ?? []).filter(h => h !== handler),
);
},
close(event) {
this.emit('openchange', { open: false, event });
},
};
}

function renderReturnFocus(trigger: HTMLElement) {
const events = createEvents();

const { result, rerender } = renderHook(
({ open }: { open: boolean }) =>
useReturnFocus({ open, events, elements: { domReference: trigger, reference: trigger, floating: null } }),
{ initialProps: { open: false } },
);

return { events, result, open: (open: boolean) => rerender({ open }) };
}

let trigger: HTMLElement;

afterEach(() => trigger?.remove());

describe('useReturnFocus', () => {
beforeEach(() => {
trigger = document.createElement('button');
document.body.append(trigger);
});

it('resolves to the trigger while open', () => {
const { result, open } = renderReturnFocus(trigger);

open(true);

expect(result.current.current).toBe(trigger);
});

it('keeps the trigger when the close came from the keyboard', () => {
const { events, result, open } = renderReturnFocus(trigger);
open(true);

events.close(new KeyboardEvent('keydown', { key: 'Escape' }));

expect(result.current.current).toBe(trigger);
});

it('keeps the trigger when the close came from a control inside the popup', () => {
const { events, result, open } = renderReturnFocus(trigger);
open(true);

// A Close button or menu item routes through the consumer's own state setter,
// so floating-ui reports the change with no event behind it.
events.close();

expect(result.current.current).toBe(trigger);
});

it('leaves focus alone when the close came from a pointer', () => {
const { events, result, open } = renderReturnFocus(trigger);
open(true);

events.close(new MouseEvent('mousedown', { detail: 1 }));

expect(result.current.current).toBeNull();
});

it('restores the trigger on the next open', () => {
const { events, result, open } = renderReturnFocus(trigger);
open(true);
events.close(new MouseEvent('mousedown', { detail: 1 }));

open(false);
open(true);

expect(result.current.current).toBe(trigger);
});
});
49 changes: 49 additions & 0 deletions packages/headless/src/hooks/use-return-focus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use client';

import type { FloatingContext } from '@floating-ui/react';
import { useEffect, useRef } from 'react';

import { isKeyboardEvent } from '../utils/interaction-modality';

/**
* The element `FloatingFocusManager` restores focus to when the floating element closes.
*
* The trigger is the default, which is what a keyboard user needs. Safari never focuses a
* button it was clicked on, so after a pointer dismiss the popup is the only thing the page
* has focused: restoring focus to the trigger then matches `:focus-visible` and paints a ring
* the user never asked for. A pointer dismiss therefore resolves to `null`, which leaves focus
* where the pointer left it, the same choice Base UI makes from its close interaction type.
*
* Pass the result to `FloatingFocusManager`'s `returnFocus`. On `null` it falls back to the
* hidden guard element it keeps next to the trigger, so the tab position survives; verify that
* still holds when upgrading `@floating-ui/react`.
*/
export function useReturnFocus(
context: Pick<FloatingContext, 'open' | 'events' | 'elements'>,
): React.MutableRefObject<HTMLElement | null> {
const { open, events, elements } = context;
const returnFocusRef = useRef<HTMLElement | null>(null);
const trigger = elements.domReference;

useEffect(() => {
if (open) {
returnFocusRef.current = trigger instanceof HTMLElement ? trigger : null;
}
}, [open, trigger]);

useEffect(() => {
// Closes routed straight through the consumer's own state setter (a Close button, an
// item click) never reach floating-ui, so only what floating-ui itself drives can
// downgrade the default.
function onOpenChange({ open, event }: { open: boolean; event?: Event }) {
if (!open && event && !isKeyboardEvent(event)) {
returnFocusRef.current = null;
}
}

events.on('openchange', onOpenChange);
return () => events.off('openchange', onOpenChange);
}, [events]);

return returnFocusRef;
}
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,7 @@ describe('Autocomplete', () => {

return (
<Popover.Root
initialFocus='first'
open={popoverOpen}
onOpenChange={open => {
setPopoverOpen(open);
Expand Down Expand Up @@ -786,6 +787,7 @@ describe('Autocomplete', () => {

return (
<Popover.Root
initialFocus='first'
open={popoverOpen}
onOpenChange={open => {
setPopoverOpen(open);
Expand Down Expand Up @@ -880,6 +882,7 @@ describe('Autocomplete', () => {

return (
<Popover.Root
initialFocus='first'
open={popoverOpen}
onOpenChange={open => {
setPopoverOpen(open);
Expand Down
2 changes: 2 additions & 0 deletions packages/headless/src/primitives/dialog/dialog-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface DialogContextValue {
getReferenceProps: UseInteractionsReturn['getReferenceProps'];
getFloatingProps: UseInteractionsReturn['getFloatingProps'];
popupRef: React.RefObject<HTMLDivElement | null>;
/** Where focus goes when the dialog closes, or `null` to leave focus alone. */
returnFocusRef: React.MutableRefObject<HTMLElement | null>;
modal: boolean;
labelId: string;
descriptionId: string;
Expand Down
15 changes: 13 additions & 2 deletions packages/headless/src/primitives/dialog/dialog-popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,18 @@ export type DialogPopupProps = ComponentProps<'div'>;
/** The dialog content container. Manages focus trapping via `FloatingFocusManager` and wires ARIA attributes from `Dialog.Title` and `Dialog.Description`. */
export const DialogPopup = React.forwardRef<HTMLDivElement, DialogPopupProps>(function DialogPopup(props, ref) {
const { render, ...otherProps } = props;
const { popupRef, refs, getFloatingProps, floatingContext, modal, labelId, descriptionId, mounted, transitionProps } =
useDialogContext();
const {
popupRef,
refs,
getFloatingProps,
floatingContext,
modal,
returnFocusRef,
labelId,
descriptionId,
mounted,
transitionProps,
} = useDialogContext();

const ownProps = {
'aria-labelledby': labelId,
Expand Down Expand Up @@ -43,6 +53,7 @@ export const DialogPopup = React.forwardRef<HTMLDivElement, DialogPopupProps>(fu
context={floatingContext}
modal={modal}
outsideElementsInert={modal}
returnFocus={returnFocusRef}
>
{element}
</FloatingFocusManager>
Expand Down
5 changes: 5 additions & 0 deletions packages/headless/src/primitives/dialog/dialog-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { type ReactNode, useId, useMemo, useRef } from 'react';

import { useControllableState } from '../../hooks/use-controllable-state';
import { useReturnFocus } from '../../hooks/use-return-focus';
import { useTransition } from '../../hooks/use-transition';
import { DialogContext, type DialogContextValue } from './dialog-context';

Expand Down Expand Up @@ -43,6 +44,8 @@ function DialogInner(props: DialogProps) {
onOpenChange: setOpen,
});

const returnFocusRef = useReturnFocus(floatingContext);

const { mounted, transitionProps } = useTransition({
open,
ref: popupRef,
Expand All @@ -65,6 +68,7 @@ function DialogInner(props: DialogProps) {
getReferenceProps,
getFloatingProps,
popupRef,
returnFocusRef,
modal,
labelId,
descriptionId,
Expand All @@ -78,6 +82,7 @@ function DialogInner(props: DialogProps) {
refs,
getReferenceProps,
getFloatingProps,
returnFocusRef,
modal,
labelId,
descriptionId,
Expand Down
2 changes: 2 additions & 0 deletions packages/headless/src/primitives/drawer/drawer-popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const DrawerPopup = React.forwardRef<HTMLDivElement, DrawerPopupProps>(fu
getFloatingProps,
floatingContext,
modal,
returnFocusRef,
labelId,
descriptionId,
mounted,
Expand Down Expand Up @@ -107,6 +108,7 @@ export const DrawerPopup = React.forwardRef<HTMLDivElement, DrawerPopupProps>(fu
modal={modal}
outsideElementsInert={modal}
initialFocus={autoFocus ? undefined : popupRef}
returnFocus={returnFocusRef}
>
{element}
</FloatingFocusManager>
Expand Down
5 changes: 5 additions & 0 deletions packages/headless/src/primitives/drawer/drawer-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { type ReactNode, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';

import { useControllableState } from '../../hooks/use-controllable-state';
import { useReturnFocus } from '../../hooks/use-return-focus';
import { useTransition } from '../../hooks/use-transition';
import { DrawerAttrs, DrawerCssVars, registerDrawerCssVars } from './css-vars';
import {
Expand Down Expand Up @@ -119,6 +120,8 @@ function DrawerInner(props: DrawerProps) {
onOpenChange: setOpen,
});

const returnFocusRef = useReturnFocus(floatingContext);

const { mounted, transitionProps } = useTransition({ open, ref: popupRef });

const click = useClick(floatingContext);
Expand Down Expand Up @@ -223,6 +226,7 @@ function DrawerInner(props: DrawerProps) {
getFloatingProps,
popupRef,
backdropRef,
returnFocusRef,
modal,
labelId,
descriptionId,
Expand All @@ -247,6 +251,7 @@ function DrawerInner(props: DrawerProps) {
refs,
getReferenceProps,
getFloatingProps,
returnFocusRef,
modal,
labelId,
descriptionId,
Expand Down
2 changes: 2 additions & 0 deletions packages/headless/src/primitives/menu/menu-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface MenuContextValue {
labelsRef: React.MutableRefObject<Array<string | null>>;
arrowRef: React.MutableRefObject<SVGSVGElement | null>;
popupRef: React.RefObject<HTMLDivElement | null>;
/** Where focus goes when the menu closes, or `null` to leave focus alone. */
returnFocusRef: React.MutableRefObject<HTMLElement | null>;
isNested: boolean;
mounted: boolean;
transitionProps: TransitionProps;
Expand Down
7 changes: 4 additions & 3 deletions packages/headless/src/primitives/menu/menu-positioner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { FloatingFocusManager, FloatingList } from '@floating-ui/react';
import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { type ComponentProps, type DefaultProps, isKeyboardOpen, mergeProps, useRender } from '../../utils';
import { useMenuContext } from './menu-context';

export type MenuPositionerProps = ComponentProps<'div'>;
Expand All @@ -21,6 +21,7 @@ export const MenuPositioner = React.forwardRef<HTMLDivElement, MenuPositionerPro
elementsRef,
labelsRef,
isNested,
returnFocusRef,
setActiveIndex,
} = useMenuContext();

Expand Down Expand Up @@ -83,8 +84,8 @@ export const MenuPositioner = React.forwardRef<HTMLDivElement, MenuPositionerPro
<FloatingFocusManager
context={floatingContext}
modal={false}
initialFocus={isNested ? -1 : 0}
returnFocus={!isNested}
initialFocus={isNested ? -1 : isKeyboardOpen(floatingContext) ? 0 : refs.floating}
returnFocus={isNested ? false : returnFocusRef}
>
<FloatingList
elementsRef={elementsRef}
Expand Down
5 changes: 5 additions & 0 deletions packages/headless/src/primitives/menu/menu-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { type ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react';

import { useControllableState } from '../../hooks/use-controllable-state';
import { useReturnFocus } from '../../hooks/use-return-focus';
import { useTransition } from '../../hooks/use-transition';
import { cssVars } from '../../utils/css-vars';
import { MenuContext, type MenuContextValue } from './menu-context';
Expand Down Expand Up @@ -85,6 +86,8 @@ function MenuInner(props: MenuProps) {
whileElementsMounted: autoUpdate,
});

const returnFocusRef = useReturnFocus(floatingContext);

const { mounted, transitionProps } = useTransition({
open,
ref: popupRef,
Expand Down Expand Up @@ -179,6 +182,7 @@ function MenuInner(props: MenuProps) {
labelsRef,
arrowRef,
popupRef,
returnFocusRef,
isNested,
mounted,
transitionProps,
Expand All @@ -194,6 +198,7 @@ function MenuInner(props: MenuProps) {
getFloatingProps,
getItemProps,
activeIndex,
returnFocusRef,
isNested,
mounted,
transitionProps,
Expand Down
Loading
Loading