From fd1bdbc6d06a934f1a871e547da01647417a9603 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Aug 2026 08:08:25 -0400 Subject: [PATCH] fix(headless): keep pointer interactions from painting a focus ring Safari never focuses a button it was clicked on, so a popup opened or dismissed with the mouse leaves the trigger or first control matching :focus-visible. Focus modality now decides both ends: the popup itself takes focus on a pointer open, and a pointer dismiss leaves focus where it is instead of restoring it. --- .changeset/headless-return-focus.md | 2 + .../src/hooks/use-return-focus.test.ts | 98 +++++++++++++++++++ .../headless/src/hooks/use-return-focus.ts | 49 ++++++++++ .../autocomplete/autocomplete.test.tsx | 3 + .../src/primitives/dialog/dialog-context.ts | 2 + .../src/primitives/dialog/dialog-popup.tsx | 15 ++- .../src/primitives/dialog/dialog-root.tsx | 5 + .../src/primitives/drawer/drawer-popup.tsx | 2 + .../src/primitives/drawer/drawer-root.tsx | 5 + .../src/primitives/menu/menu-context.ts | 2 + .../src/primitives/menu/menu-positioner.tsx | 7 +- .../src/primitives/menu/menu-root.tsx | 5 + .../src/primitives/menu/menu.test.tsx | 39 ++++++++ .../src/primitives/popover/popover-context.ts | 3 + .../primitives/popover/popover-positioner.tsx | 6 +- .../src/primitives/popover/popover-root.tsx | 20 +++- .../src/primitives/popover/popover.test.tsx | 32 +++++- .../src/primitives/select/select-context.ts | 2 + .../primitives/select/select-positioner.tsx | 2 + .../src/primitives/select/select-root.tsx | 5 + packages/headless/src/utils/index.ts | 1 + .../src/utils/interaction-modality.test.ts | 42 ++++++++ .../src/utils/interaction-modality.ts | 31 ++++++ 23 files changed, 369 insertions(+), 9 deletions(-) create mode 100644 .changeset/headless-return-focus.md create mode 100644 packages/headless/src/hooks/use-return-focus.test.ts create mode 100644 packages/headless/src/hooks/use-return-focus.ts create mode 100644 packages/headless/src/utils/interaction-modality.test.ts create mode 100644 packages/headless/src/utils/interaction-modality.ts diff --git a/.changeset/headless-return-focus.md b/.changeset/headless-return-focus.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/headless-return-focus.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/src/hooks/use-return-focus.test.ts b/packages/headless/src/hooks/use-return-focus.test.ts new file mode 100644 index 00000000000..7cca04b4c9d --- /dev/null +++ b/packages/headless/src/hooks/use-return-focus.test.ts @@ -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 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); + }); +}); diff --git a/packages/headless/src/hooks/use-return-focus.ts b/packages/headless/src/hooks/use-return-focus.ts new file mode 100644 index 00000000000..5e45a0ad510 --- /dev/null +++ b/packages/headless/src/hooks/use-return-focus.ts @@ -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, +): React.MutableRefObject { + const { open, events, elements } = context; + const returnFocusRef = useRef(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; +} diff --git a/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx b/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx index bbdad88c7ba..18066c37bd7 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx @@ -643,6 +643,7 @@ describe('Autocomplete', () => { return ( { setPopoverOpen(open); @@ -786,6 +787,7 @@ describe('Autocomplete', () => { return ( { setPopoverOpen(open); @@ -880,6 +882,7 @@ describe('Autocomplete', () => { return ( { setPopoverOpen(open); diff --git a/packages/headless/src/primitives/dialog/dialog-context.ts b/packages/headless/src/primitives/dialog/dialog-context.ts index 863d26af38e..b730f698d36 100644 --- a/packages/headless/src/primitives/dialog/dialog-context.ts +++ b/packages/headless/src/primitives/dialog/dialog-context.ts @@ -11,6 +11,8 @@ export interface DialogContextValue { getReferenceProps: UseInteractionsReturn['getReferenceProps']; getFloatingProps: UseInteractionsReturn['getFloatingProps']; popupRef: React.RefObject; + /** Where focus goes when the dialog closes, or `null` to leave focus alone. */ + returnFocusRef: React.MutableRefObject; modal: boolean; labelId: string; descriptionId: string; diff --git a/packages/headless/src/primitives/dialog/dialog-popup.tsx b/packages/headless/src/primitives/dialog/dialog-popup.tsx index 6e11828a734..98a1d706987 100644 --- a/packages/headless/src/primitives/dialog/dialog-popup.tsx +++ b/packages/headless/src/primitives/dialog/dialog-popup.tsx @@ -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(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, @@ -43,6 +53,7 @@ export const DialogPopup = React.forwardRef(fu context={floatingContext} modal={modal} outsideElementsInert={modal} + returnFocus={returnFocusRef} > {element} diff --git a/packages/headless/src/primitives/dialog/dialog-root.tsx b/packages/headless/src/primitives/dialog/dialog-root.tsx index ef61f56b1a0..471029c7956 100644 --- a/packages/headless/src/primitives/dialog/dialog-root.tsx +++ b/packages/headless/src/primitives/dialog/dialog-root.tsx @@ -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'; @@ -43,6 +44,8 @@ function DialogInner(props: DialogProps) { onOpenChange: setOpen, }); + const returnFocusRef = useReturnFocus(floatingContext); + const { mounted, transitionProps } = useTransition({ open, ref: popupRef, @@ -65,6 +68,7 @@ function DialogInner(props: DialogProps) { getReferenceProps, getFloatingProps, popupRef, + returnFocusRef, modal, labelId, descriptionId, @@ -78,6 +82,7 @@ function DialogInner(props: DialogProps) { refs, getReferenceProps, getFloatingProps, + returnFocusRef, modal, labelId, descriptionId, diff --git a/packages/headless/src/primitives/drawer/drawer-popup.tsx b/packages/headless/src/primitives/drawer/drawer-popup.tsx index 77df11eedd3..5388e23153f 100644 --- a/packages/headless/src/primitives/drawer/drawer-popup.tsx +++ b/packages/headless/src/primitives/drawer/drawer-popup.tsx @@ -24,6 +24,7 @@ export const DrawerPopup = React.forwardRef(fu getFloatingProps, floatingContext, modal, + returnFocusRef, labelId, descriptionId, mounted, @@ -107,6 +108,7 @@ export const DrawerPopup = React.forwardRef(fu modal={modal} outsideElementsInert={modal} initialFocus={autoFocus ? undefined : popupRef} + returnFocus={returnFocusRef} > {element} diff --git a/packages/headless/src/primitives/drawer/drawer-root.tsx b/packages/headless/src/primitives/drawer/drawer-root.tsx index b6b968b296f..34e400fdaeb 100644 --- a/packages/headless/src/primitives/drawer/drawer-root.tsx +++ b/packages/headless/src/primitives/drawer/drawer-root.tsx @@ -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 { @@ -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); @@ -223,6 +226,7 @@ function DrawerInner(props: DrawerProps) { getFloatingProps, popupRef, backdropRef, + returnFocusRef, modal, labelId, descriptionId, @@ -247,6 +251,7 @@ function DrawerInner(props: DrawerProps) { refs, getReferenceProps, getFloatingProps, + returnFocusRef, modal, labelId, descriptionId, diff --git a/packages/headless/src/primitives/menu/menu-context.ts b/packages/headless/src/primitives/menu/menu-context.ts index 1d3b464b08a..2f89b7b830b 100644 --- a/packages/headless/src/primitives/menu/menu-context.ts +++ b/packages/headless/src/primitives/menu/menu-context.ts @@ -24,6 +24,8 @@ export interface MenuContextValue { labelsRef: React.MutableRefObject>; arrowRef: React.MutableRefObject; popupRef: React.RefObject; + /** Where focus goes when the menu closes, or `null` to leave focus alone. */ + returnFocusRef: React.MutableRefObject; isNested: boolean; mounted: boolean; transitionProps: TransitionProps; diff --git a/packages/headless/src/primitives/menu/menu-positioner.tsx b/packages/headless/src/primitives/menu/menu-positioner.tsx index d64fec49d3a..69e9a0c0712 100644 --- a/packages/headless/src/primitives/menu/menu-positioner.tsx +++ b/packages/headless/src/primitives/menu/menu-positioner.tsx @@ -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'>; @@ -21,6 +21,7 @@ export const MenuPositioner = React.forwardRef { }); describe('focus management', () => { + it('focuses the menu itself, not an item, when opened with a pointer', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + + + , + ); + + await user.click(screen.getByText('Actions')); + await new Promise(r => requestAnimationFrame(r)); + + expect(document.activeElement).toBe(document.querySelector('[data-testid="menu-positioner"]')); + }); + + it('focuses the first item when opened with the keyboard', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + + + , + ); + + screen.getByText('Actions').focus(); + await user.keyboard('{Enter}'); + await new Promise(r => requestAnimationFrame(r)); + + expect(document.activeElement).toBe(screen.getByText('Cut')); + }); + it('returns focus to trigger on close via Escape', async () => { const user = userEvent.setup(); render( diff --git a/packages/headless/src/primitives/popover/popover-context.ts b/packages/headless/src/primitives/popover/popover-context.ts index de0bdf5f0b0..32e72f6187b 100644 --- a/packages/headless/src/primitives/popover/popover-context.ts +++ b/packages/headless/src/primitives/popover/popover-context.ts @@ -21,6 +21,9 @@ export interface PopoverContextValue { popupRef: React.RefObject; arrowRef: React.MutableRefObject; modal: boolean; + initialFocus: 'auto' | 'first'; + /** Where focus goes when the popup closes, or `null` to leave focus alone. */ + returnFocusRef: React.MutableRefObject; labelId: string; descriptionId: string; hasTitle: boolean; diff --git a/packages/headless/src/primitives/popover/popover-positioner.tsx b/packages/headless/src/primitives/popover/popover-positioner.tsx index 777168f0081..b9aff77ad6a 100644 --- a/packages/headless/src/primitives/popover/popover-positioner.tsx +++ b/packages/headless/src/primitives/popover/popover-positioner.tsx @@ -3,7 +3,7 @@ import { FloatingFocusManager } 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 { usePopoverContext } from './popover-context'; export type PopoverPositionerProps = ComponentProps<'div'>; @@ -19,6 +19,8 @@ export const PopoverPositioner = React.forwardRef {element} diff --git a/packages/headless/src/primitives/popover/popover-root.tsx b/packages/headless/src/primitives/popover/popover-root.tsx index 3edf3efabb4..d9831ea4e16 100644 --- a/packages/headless/src/primitives/popover/popover-root.tsx +++ b/packages/headless/src/primitives/popover/popover-root.tsx @@ -20,6 +20,7 @@ import { import { type ReactNode, useCallback, 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 { cssVars } from '../../utils/css-vars'; import { PopoverContext, type PopoverContextValue } from './popover-context'; @@ -31,12 +32,22 @@ export interface PopoverProps { placement?: Placement; sideOffset?: number; modal?: boolean; + /** + * Where focus lands when the popup opens. + * + * - `'auto'` (default): the first tabbable element when opened with the keyboard, + * the popup itself when opened with a pointer, so a mouse click never puts a + * focus ring on a control the user did not navigate to. + * - `'first'`: always the first tabbable element. Use it for popups whose content + * is meant to be typed into immediately, such as a combobox. + */ + initialFocus?: 'auto' | 'first'; children: ReactNode; } function PopoverInner(props: PopoverProps) { const nodeId = useFloatingNodeId(); - const { placement: placementProp = 'bottom', sideOffset = 4, modal = false, children } = props; + const { placement: placementProp = 'bottom', sideOffset = 4, modal = false, initialFocus = 'auto', children } = props; const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); @@ -49,7 +60,6 @@ function PopoverInner(props: PopoverProps) { const arrowRef = useRef(null); const popupRef = useRef(null); - const { refs, floatingStyles, @@ -74,6 +84,8 @@ function PopoverInner(props: PopoverProps) { whileElementsMounted: autoUpdate, }); + const returnFocusRef = useReturnFocus(floatingContext); + const { mounted, transitionProps } = useTransition({ open, ref: popupRef, @@ -98,6 +110,8 @@ function PopoverInner(props: PopoverProps) { popupRef, arrowRef, modal, + initialFocus, + returnFocusRef, labelId, descriptionId, hasTitle, @@ -117,6 +131,8 @@ function PopoverInner(props: PopoverProps) { getReferenceProps, getFloatingProps, modal, + initialFocus, + returnFocusRef, labelId, descriptionId, hasTitle, diff --git a/packages/headless/src/primitives/popover/popover.test.tsx b/packages/headless/src/primitives/popover/popover.test.tsx index 862de9cc0b6..ebc8a285a61 100644 --- a/packages/headless/src/primitives/popover/popover.test.tsx +++ b/packages/headless/src/primitives/popover/popover.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createRef } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -258,6 +258,36 @@ describe('Popover', () => { expect(positioner?.contains(document.activeElement)).toBe(true); }); + it('focuses the popup itself, not a control inside it, when opened with a pointer', async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + await new Promise(r => requestAnimationFrame(r)); + + expect(document.activeElement).toBe(document.querySelector('[data-testid="popover-positioner"]')); + }); + + it('focuses the first tabbable element when opened with the keyboard', async () => { + renderPopover(); + + // A button handles Enter/Space itself, so keyboard activation reaches the popover + // as a click with no pointer behind it. userEvent stamps its synthetic keyboard + // click with a pointerType, which browsers do not. + fireEvent.click(screen.getByRole('button', { name: 'Open popover' }), { detail: 0 }); + await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus()); + }); + + it('focuses the first tabbable element on pointer open when initialFocus is "first"', async () => { + const user = userEvent.setup(); + renderPopover({ initialFocus: 'first' }); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + await new Promise(r => requestAnimationFrame(r)); + + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close' })); + }); + it('returns focus to trigger on close via Escape', async () => { const user = userEvent.setup(); renderPopover(); diff --git a/packages/headless/src/primitives/select/select-context.ts b/packages/headless/src/primitives/select/select-context.ts index a9e16696e2f..7d56f607041 100644 --- a/packages/headless/src/primitives/select/select-context.ts +++ b/packages/headless/src/primitives/select/select-context.ts @@ -34,6 +34,8 @@ export interface SelectContextValue { labelsRef: React.MutableRefObject>; popupRef: RefObject; arrowRef: React.MutableRefObject; + /** Where focus goes when the listbox closes, or `null` to leave focus alone. */ + returnFocusRef: React.MutableRefObject; valueToLabelRef: React.MutableRefObject>; selectedItemRef: React.MutableRefObject; alignItemWithTrigger: boolean; diff --git a/packages/headless/src/primitives/select/select-positioner.tsx b/packages/headless/src/primitives/select/select-positioner.tsx index eceae876a31..32515127cb2 100644 --- a/packages/headless/src/primitives/select/select-positioner.tsx +++ b/packages/headless/src/primitives/select/select-positioner.tsx @@ -20,6 +20,7 @@ export const SelectPositioner = React.forwardRef { + it('is true for key events', () => { + expect(isKeyboardEvent(new KeyboardEvent('keydown', { key: 'Enter' }))).toBe(true); + expect(isKeyboardEvent(new KeyboardEvent('keyup', { key: ' ' }))).toBe(true); + }); + + it('is true for the click a button dispatches for Enter or Space', () => { + expect(isKeyboardEvent(keyboardClick())).toBe(true); + }); + + it('is false for a pointer click', () => { + expect(isKeyboardEvent(pointerClick())).toBe(false); + }); + + it('is false for a pointer press that dismisses the popup', () => { + expect(isKeyboardEvent(new MouseEvent('mousedown', { detail: 1 }))).toBe(false); + }); +}); + +describe('isKeyboardOpen', () => { + it('is false when nothing recorded an open event', () => { + expect(isKeyboardOpen({ dataRef: { current: {} } })).toBe(false); + }); + + it('follows the modality of the recorded open event', () => { + expect(isKeyboardOpen({ dataRef: { current: { openEvent: keyboardClick() } } })).toBe(true); + expect(isKeyboardOpen({ dataRef: { current: { openEvent: pointerClick() } } })).toBe(false); + }); +}); diff --git a/packages/headless/src/utils/interaction-modality.ts b/packages/headless/src/utils/interaction-modality.ts new file mode 100644 index 00000000000..99436bcc51f --- /dev/null +++ b/packages/headless/src/utils/interaction-modality.ts @@ -0,0 +1,31 @@ +import type { FloatingContext } from '@floating-ui/react'; +import { isVirtualClick } from '@floating-ui/react/utils'; + +/** + * Whether an event that opened or closed a floating element came from the keyboard. + * + * `useClick` lets native buttons handle Enter/Space themselves, so keyboard activation on a + * button arrives as a click with no pointer behind it, which is what `isVirtualClick` detects. + * Other triggers open and close on `keydown` (Enter) or `keyup` (Space). + */ +export function isKeyboardEvent(event: Event): boolean { + if (event.type.startsWith('key')) { + return true; + } + + // SAFETY: the remaining events come from `useClick`/`useHover`/`useDismiss`, which only ever + // hand us pointer or mouse events here. `isVirtualClick` reads optional MouseEvent fields and + // returns false for anything that lacks them. + return isVirtualClick(event as MouseEvent); +} + +/** + * Whether the floating element was opened by the keyboard rather than by a pointer. + * + * floating-ui records the event that caused the open on `dataRef.current.openEvent`. + */ +export function isKeyboardOpen(context: Pick): boolean { + const openEvent = context.dataRef.current.openEvent; + + return openEvent ? isKeyboardEvent(openEvent) : false; +}