diff --git a/docs/reference/generated/toast-root.json b/docs/reference/generated/toast-root.json
index 59e00963b3..6655e3f5df 100644
--- a/docs/reference/generated/toast-root.json
+++ b/docs/reference/generated/toast-root.json
@@ -3,7 +3,7 @@
"description": "Groups all parts of an individual toast.\nRenders a `
` element.",
"props": {
"swipeDirection": {
- "type": "'left' | 'right' | 'up' | 'down' | ('left' | 'right' | 'up' | 'down')[]",
+ "type": "'right' | 'left' | 'up' | 'down' | ('right' | 'left' | 'up' | 'down')[]",
"default": "['down', 'right']",
"description": "Direction(s) in which the toast can be swiped to dismiss."
},
diff --git a/package.json b/package.json
index db379ed34f..476daa34c1 100644
--- a/package.json
+++ b/package.json
@@ -90,11 +90,11 @@
"@types/yargs": "^17.0.33",
"@typescript-eslint/eslint-plugin": "^8.33.0",
"@typescript-eslint/parser": "^8.33.0",
- "@vvago/vale": "^3.11.2",
"@vitejs/plugin-react": "^4.5.0",
"@vitest/browser": "^3.1.4",
"@vitest/coverage-istanbul": "3.1.4",
"@vitest/ui": "3.1.4",
+ "@vvago/vale": "^3.11.2",
"babel-loader": "^10.0.0",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-module-resolver": "^5.0.2",
diff --git a/packages/react/package.json b/packages/react/package.json
index e327927977..a3822cb5e0 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -86,8 +86,9 @@
},
"dependencies": {
"@babel/runtime": "^7.27.0",
- "@floating-ui/react": "^0.27.10",
+ "@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.9",
+ "tabbable": "^6.2.0",
"reselect": "^5.1.1",
"use-sync-external-store": "^1.5.0"
},
@@ -102,6 +103,7 @@
"@types/sinon": "^17.0.4",
"@types/use-sync-external-store": "^1.5.0",
"chai": "^4.5.0",
+ "clsx": "^2.1.1",
"fs-extra": "^11.3.0",
"lodash": "^4.17.21",
"react": "^19.1.0",
diff --git a/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx b/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx
index 9d345f6e54..09d92f5b8d 100644
--- a/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx
+++ b/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { FloatingFocusManager } from '@floating-ui/react';
+import { FloatingFocusManager } from '../../floating-ui-react';
import { useDialogPopup } from '../../dialog/popup/useDialogPopup';
import { useAlertDialogRootContext } from '../root/AlertDialogRootContext';
import { useRenderElement } from '../../utils/useRenderElement';
diff --git a/packages/react/src/alert-dialog/portal/AlertDialogPortal.tsx b/packages/react/src/alert-dialog/portal/AlertDialogPortal.tsx
index d673b75c9d..1867b6775f 100644
--- a/packages/react/src/alert-dialog/portal/AlertDialogPortal.tsx
+++ b/packages/react/src/alert-dialog/portal/AlertDialogPortal.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { FloatingPortal } from '@floating-ui/react';
+import { FloatingPortal } from '../../floating-ui-react';
import { useAlertDialogRootContext } from '../root/AlertDialogRootContext';
import { AlertDialogPortalContext } from './AlertDialogPortalContext';
diff --git a/packages/react/src/composite/composite.ts b/packages/react/src/composite/composite.ts
index ab45b61177..67e186d072 100644
--- a/packages/react/src/composite/composite.ts
+++ b/packages/react/src/composite/composite.ts
@@ -13,7 +13,7 @@ export {
getGridNavigatedIndex,
getMaxListIndex,
getMinListIndex,
-} from '@floating-ui/react/utils';
+} from '../floating-ui-react/utils';
export interface Dimensions {
width: number;
diff --git a/packages/react/src/context-menu/trigger/ContextMenuTrigger.tsx b/packages/react/src/context-menu/trigger/ContextMenuTrigger.tsx
index 95047458a0..f737ab56a8 100644
--- a/packages/react/src/context-menu/trigger/ContextMenuTrigger.tsx
+++ b/packages/react/src/context-menu/trigger/ContextMenuTrigger.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { contains, getTarget, stopEvent } from '@floating-ui/react/utils';
+import { contains, getTarget, stopEvent } from '../../floating-ui-react/utils';
import type { BaseUIComponentProps } from '../../utils/types';
import { useEventCallback } from '../../utils/useEventCallback';
import { useContextMenuRootContext } from '../root/ContextMenuRootContext';
diff --git a/packages/react/src/dialog/popup/DialogPopup.tsx b/packages/react/src/dialog/popup/DialogPopup.tsx
index 68fb36658b..bbc9f07dc4 100644
--- a/packages/react/src/dialog/popup/DialogPopup.tsx
+++ b/packages/react/src/dialog/popup/DialogPopup.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { FloatingFocusManager } from '@floating-ui/react';
+import { FloatingFocusManager } from '../../floating-ui-react';
import { useDialogPopup } from './useDialogPopup';
import { useDialogRootContext } from '../root/DialogRootContext';
import { useRenderElement } from '../../utils/useRenderElement';
diff --git a/packages/react/src/dialog/portal/DialogPortal.tsx b/packages/react/src/dialog/portal/DialogPortal.tsx
index 535c854b3e..cd3411fbe8 100644
--- a/packages/react/src/dialog/portal/DialogPortal.tsx
+++ b/packages/react/src/dialog/portal/DialogPortal.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { FloatingPortal } from '@floating-ui/react';
+import { FloatingPortal } from '../../floating-ui-react';
import { useDialogRootContext } from '../root/DialogRootContext';
import { DialogPortalContext } from './DialogPortalContext';
diff --git a/packages/react/src/dialog/root/useDialogRoot.ts b/packages/react/src/dialog/root/useDialogRoot.ts
index 9f3128d285..4030182d00 100644
--- a/packages/react/src/dialog/root/useDialogRoot.ts
+++ b/packages/react/src/dialog/root/useDialogRoot.ts
@@ -2,14 +2,14 @@
import * as React from 'react';
import {
FloatingRootContext,
+ useClick,
useDismiss,
useFloatingRootContext,
useInteractions,
useRole,
type OpenChangeReason as FloatingUIOpenChangeReason,
-} from '@floating-ui/react';
-import { getTarget } from '@floating-ui/react/utils';
-import { useClick } from '../../utils/floating-ui/useClick';
+} from '../../floating-ui-react';
+import { getTarget } from '../../floating-ui-react/utils';
import { useControlled } from '../../utils/useControlled';
import { useEventCallback } from '../../utils/useEventCallback';
import { useScrollLock } from '../../utils/useScrollLock';
diff --git a/packages/react/src/field/label/FieldLabel.tsx b/packages/react/src/field/label/FieldLabel.tsx
index 309ee12ef4..2e69b2dc7c 100644
--- a/packages/react/src/field/label/FieldLabel.tsx
+++ b/packages/react/src/field/label/FieldLabel.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { getTarget } from '@floating-ui/react/utils';
+import { getTarget } from '../../floating-ui-react/utils';
import { FieldRoot } from '../root/FieldRoot';
import { useFieldRootContext } from '../root/FieldRootContext';
import { fieldValidityMapping } from '../utils/constants';
diff --git a/packages/react/src/floating-ui-react/components/FloatingDelayGroup.tsx b/packages/react/src/floating-ui-react/components/FloatingDelayGroup.tsx
new file mode 100644
index 0000000000..7805ea7d32
--- /dev/null
+++ b/packages/react/src/floating-ui-react/components/FloatingDelayGroup.tsx
@@ -0,0 +1,229 @@
+import * as React from 'react';
+import { useTimeout, Timeout } from '../../utils/useTimeout';
+import { useModernLayoutEffect } from '../../utils/useModernLayoutEffect';
+
+import { getDelay } from '../hooks/useHover';
+import type { FloatingRootContext, Delay } from '../types';
+
+interface ContextValue {
+ hasProvider: boolean;
+ timeoutMs: number;
+ delayRef: React.MutableRefObject
;
+ initialDelayRef: React.MutableRefObject;
+ timeout: Timeout;
+ currentIdRef: React.MutableRefObject;
+ currentContextRef: React.MutableRefObject<{
+ onOpenChange: (open: boolean) => void;
+ setIsInstantPhase: (value: boolean) => void;
+ } | null>;
+}
+
+const FloatingDelayGroupContext = React.createContext({
+ hasProvider: false,
+ timeoutMs: 0,
+ delayRef: { current: 0 },
+ initialDelayRef: { current: 0 },
+ timeout: new Timeout(),
+ currentIdRef: { current: null },
+ currentContextRef: { current: null },
+});
+
+export interface FloatingDelayGroupProps {
+ children?: React.ReactNode;
+ /**
+ * The delay to use for the group when it's not in the instant phase.
+ */
+ delay: Delay;
+ /**
+ * An optional explicit timeout to use for the group, which represents when
+ * grouping logic will no longer be active after the close delay completes.
+ * This is useful if you want grouping to “last” longer than the close delay,
+ * for example if there is no close delay at all.
+ */
+ timeoutMs?: number;
+}
+
+/**
+ * Experimental next version of `FloatingDelayGroup` to become the default
+ * in the future. This component is not yet stable.
+ * Provides context for a group of floating elements that should share a
+ * `delay`. Unlike `FloatingDelayGroup`, `useDelayGroup` with this
+ * component does not cause a re-render of unrelated consumers of the
+ * context when the delay changes.
+ * @see https://floating-ui.com/docs/FloatingDelayGroup
+ * @internal
+ */
+export function FloatingDelayGroup(props: FloatingDelayGroupProps): React.JSX.Element {
+ const { children, delay, timeoutMs = 0 } = props;
+
+ const delayRef = React.useRef(delay);
+ const initialDelayRef = React.useRef(delay);
+ const currentIdRef = React.useRef(null);
+ const currentContextRef = React.useRef(null);
+ const timeout = useTimeout();
+
+ return (
+ ({
+ hasProvider: true,
+ delayRef,
+ initialDelayRef,
+ currentIdRef,
+ timeoutMs,
+ currentContextRef,
+ timeout,
+ }),
+ [timeoutMs, timeout],
+ )}
+ >
+ {children}
+
+ );
+}
+
+interface UseDelayGroupOptions {
+ /**
+ * Whether delay grouping should be enabled.
+ * @default true
+ */
+ enabled?: boolean;
+}
+
+interface UseDelayGroupReturn {
+ /**
+ * The delay reference object.
+ */
+ delayRef: React.MutableRefObject;
+ /**
+ * Whether animations should be removed.
+ */
+ isInstantPhase: boolean;
+ /**
+ * Whether a `` provider is present.
+ */
+ hasProvider: boolean;
+}
+
+/**
+ * Enables grouping when called inside a component that's a child of a
+ * `FloatingDelayGroup`.
+ * @see https://floating-ui.com/docs/FloatingDelayGroup
+ * @internal
+ */
+export function useDelayGroup(
+ context: FloatingRootContext,
+ options: UseDelayGroupOptions = {},
+): UseDelayGroupReturn {
+ const { open, onOpenChange, floatingId } = context;
+ const { enabled = true } = options;
+
+ const groupContext = React.useContext(FloatingDelayGroupContext);
+ const {
+ currentIdRef,
+ delayRef,
+ timeoutMs,
+ initialDelayRef,
+ currentContextRef,
+ hasProvider,
+ timeout,
+ } = groupContext;
+
+ const [isInstantPhase, setIsInstantPhase] = React.useState(false);
+
+ useModernLayoutEffect(() => {
+ function unset() {
+ setIsInstantPhase(false);
+ currentContextRef.current?.setIsInstantPhase(false);
+ currentIdRef.current = null;
+ currentContextRef.current = null;
+ delayRef.current = initialDelayRef.current;
+ }
+
+ if (!enabled) {
+ return undefined;
+ }
+ if (!currentIdRef.current) {
+ return undefined;
+ }
+
+ if (!open && currentIdRef.current === floatingId) {
+ setIsInstantPhase(false);
+
+ if (timeoutMs) {
+ timeout.start(timeoutMs, unset);
+ return () => {
+ timeout.clear();
+ };
+ }
+
+ unset();
+ }
+ return undefined;
+ }, [
+ enabled,
+ open,
+ floatingId,
+ currentIdRef,
+ delayRef,
+ timeoutMs,
+ initialDelayRef,
+ currentContextRef,
+ timeout,
+ ]);
+
+ useModernLayoutEffect(() => {
+ if (!enabled) {
+ return;
+ }
+ if (!open) {
+ return;
+ }
+
+ const prevContext = currentContextRef.current;
+ const prevId = currentIdRef.current;
+
+ currentContextRef.current = { onOpenChange, setIsInstantPhase };
+ currentIdRef.current = floatingId;
+ delayRef.current = {
+ open: 0,
+ close: getDelay(initialDelayRef.current, 'close'),
+ };
+
+ if (prevId !== null && prevId !== floatingId) {
+ timeout.clear();
+ setIsInstantPhase(true);
+ prevContext?.setIsInstantPhase(true);
+ prevContext?.onOpenChange(false);
+ } else {
+ setIsInstantPhase(false);
+ prevContext?.setIsInstantPhase(false);
+ }
+ }, [
+ enabled,
+ open,
+ floatingId,
+ onOpenChange,
+ currentIdRef,
+ delayRef,
+ timeoutMs,
+ initialDelayRef,
+ currentContextRef,
+ timeout,
+ ]);
+
+ useModernLayoutEffect(() => {
+ return () => {
+ currentContextRef.current = null;
+ };
+ }, [currentContextRef]);
+
+ return React.useMemo(
+ () => ({
+ hasProvider,
+ delayRef,
+ isInstantPhase,
+ }),
+ [hasProvider, delayRef, isInstantPhase],
+ );
+}
diff --git a/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx b/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx
new file mode 100644
index 0000000000..9f48f50720
--- /dev/null
+++ b/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx
@@ -0,0 +1,820 @@
+import * as React from 'react';
+import { tabbable, isTabbable, focusable, type FocusableElement } from 'tabbable';
+import { getNodeName, isHTMLElement } from '@floating-ui/utils/dom';
+import { useForkRef } from '../../utils/useForkRef';
+import { useLatestRef } from '../../utils/useLatestRef';
+import { useEventCallback } from '../../utils/useEventCallback';
+import { useModernLayoutEffect } from '../../utils/useModernLayoutEffect';
+import { FocusGuard } from '../../utils/FocusGuard';
+import { visuallyHidden } from '../../utils/visuallyHidden';
+import {
+ activeElement,
+ contains,
+ getDocument,
+ getTarget,
+ isTypeableCombobox,
+ isVirtualClick,
+ isVirtualPointerEvent,
+ stopEvent,
+ getNodeAncestors,
+ getNodeChildren,
+ getFloatingFocusElement,
+ getTabbableOptions,
+ isOutsideEvent,
+ getNextTabbable,
+ getPreviousTabbable,
+} from '../utils';
+
+import type { FloatingRootContext, OpenChangeReason } from '../types';
+import { createAttribute } from '../utils/createAttribute';
+import { enqueueFocus } from '../utils/enqueueFocus';
+import { markOthers, supportsInert } from '../utils/markOthers';
+import { usePortalContext } from './FloatingPortal';
+import { useFloatingTree } from './FloatingTree';
+
+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);
+}
+
+function getFirstTabbableElement(container: Element) {
+ const tabbableOptions = getTabbableOptions();
+ if (isTabbable(container, tabbableOptions)) {
+ return container;
+ }
+
+ return tabbable(container, tabbableOptions)[0] || container;
+}
+
+function handleTabIndex(
+ floatingFocusElement: HTMLElement,
+ orderRef: React.MutableRefObject>,
+) {
+ if (
+ !orderRef.current.includes('floating') &&
+ !floatingFocusElement.getAttribute('role')?.includes('dialog')
+ ) {
+ return;
+ }
+
+ const options = getTabbableOptions();
+ const focusableElements = focusable(floatingFocusElement, options);
+ const tabbableContent = focusableElements.filter((element) => {
+ const dataTabIndex = element.getAttribute('data-tabindex') || '';
+ return (
+ isTabbable(element, options) ||
+ (element.hasAttribute('data-tabindex') && !dataTabIndex.startsWith('-'))
+ );
+ });
+ const tabIndex = floatingFocusElement.getAttribute('tabindex');
+
+ if (orderRef.current.includes('floating') || tabbableContent.length === 0) {
+ if (tabIndex !== '0') {
+ floatingFocusElement.setAttribute('tabindex', '0');
+ }
+ } else if (
+ tabIndex !== '-1' ||
+ (floatingFocusElement.hasAttribute('data-tabindex') &&
+ floatingFocusElement.getAttribute('data-tabindex') !== '-1')
+ ) {
+ floatingFocusElement.setAttribute('tabindex', '-1');
+ floatingFocusElement.setAttribute('data-tabindex', '-1');
+ }
+}
+
+const VisuallyHiddenDismiss = React.forwardRef(function VisuallyHiddenDismiss(
+ props: React.ButtonHTMLAttributes,
+ ref: React.ForwardedRef,
+) {
+ return ;
+});
+
+export interface FloatingFocusManagerProps {
+ children: React.JSX.Element;
+ /**
+ * The floating context returned from `useFloatingRootContext`.
+ */
+ context: FloatingRootContext;
+ /**
+ * Whether or not the focus manager should be disabled. Useful to delay focus
+ * management until after a transition completes or some other conditional
+ * state.
+ * @default false
+ */
+ disabled?: boolean;
+ /**
+ * The order in which focus cycles.
+ * @default ['content']
+ */
+ order?: Array<'reference' | 'floating' | 'content'>;
+ /**
+ * Which element to initially focus. Can be either a number (tabbable index as
+ * specified by the `order`) or a ref.
+ * @default 0
+ */
+ initialFocus?: number | React.MutableRefObject;
+ /**
+ * Determines if the focus guards are rendered. If not, focus can escape into
+ * the address bar/console/browser UI, like in native dialogs.
+ * @default true
+ */
+ guards?: boolean;
+ /**
+ * Determines if focus should be returned to the reference element once the
+ * floating element closes/unmounts (or if that is not available, the
+ * previously focused element). This prop is ignored if the floating element
+ * lost focus.
+ * It can be also set to a ref to explicitly control the element to return focus to.
+ * @default true
+ */
+ returnFocus?: boolean | React.MutableRefObject;
+ /**
+ * Determines if focus should be restored to the nearest tabbable element if
+ * focus inside the floating element is lost (such as due to the removal of
+ * the currently focused element from the DOM).
+ * @default false
+ */
+ restoreFocus?: boolean;
+ /**
+ * Determines if focus is “modal”, meaning focus is fully trapped inside the
+ * floating element and outside content cannot be accessed. This includes
+ * screen reader virtual cursors.
+ * @default true
+ */
+ modal?: boolean;
+ /**
+ * If your focus management is modal and there is no explicit close button
+ * available, you can use this prop to render a visually-hidden dismiss
+ * button at the start and end of the floating element. This allows
+ * touch-based screen readers to escape the floating element due to lack of
+ * an `esc` key.
+ * @default undefined
+ */
+ visuallyHiddenDismiss?: boolean | string;
+ /**
+ * Determines whether `focusout` event listeners that control whether the
+ * floating element should be closed if the focus moves outside of it are
+ * attached to the reference and floating elements. This affects non-modal
+ * focus management.
+ * @default true
+ */
+ closeOnFocusOut?: boolean;
+ /**
+ * Determines whether outside elements are `inert` when `modal` is enabled.
+ * This enables pointer modality without a backdrop.
+ * @default false
+ */
+ outsideElementsInert?: boolean;
+ /**
+ * Returns a list of elements that should be considered part of the
+ * floating element.
+ */
+ getInsideElements?: () => Element[];
+}
+
+/**
+ * Provides focus management for the floating element.
+ * @see https://floating-ui.com/docs/FloatingFocusManager
+ * @internal
+ */
+export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JSX.Element {
+ const {
+ context,
+ children,
+ disabled = false,
+ order = ['content'],
+ guards: guardsProp = true,
+ initialFocus = 0,
+ returnFocus = true,
+ restoreFocus = false,
+ modal = true,
+ visuallyHiddenDismiss = false,
+ closeOnFocusOut = true,
+ outsideElementsInert = false,
+ getInsideElements: getInsideElementsProp = () => [],
+ } = props;
+ const {
+ open,
+ onOpenChange,
+ events,
+ dataRef,
+ elements: { domReference, floating },
+ } = context;
+
+ const getNodeId = useEventCallback(() => dataRef.current.floatingContext?.nodeId);
+ const getInsideElements = useEventCallback(getInsideElementsProp);
+
+ const ignoreInitialFocus = typeof initialFocus === 'number' && initialFocus < 0;
+ // If the reference is a combobox and is typeable (e.g. input/textarea),
+ // there are different focus semantics. The guards should not be rendered, but
+ // aria-hidden should be applied to all nodes still. Further, the visually
+ // hidden dismiss button should only appear at the end of the list, not the
+ // start.
+ const isUntrappedTypeableCombobox = isTypeableCombobox(domReference) && ignoreInitialFocus;
+
+ // Force the guards to be rendered if the `inert` attribute is not supported.
+ const inertSupported = supportsInert();
+ const guards = inertSupported ? guardsProp : true;
+ const useInert = !guards || (inertSupported && outsideElementsInert);
+
+ const orderRef = useLatestRef(order);
+ const initialFocusRef = useLatestRef(initialFocus);
+ const returnFocusRef = useLatestRef(returnFocus);
+
+ const tree = useFloatingTree();
+ const portalContext = usePortalContext();
+
+ const startDismissButtonRef = React.useRef(null);
+ const endDismissButtonRef = React.useRef(null);
+ const preventReturnFocusRef = React.useRef(false);
+ const isPointerDownRef = React.useRef(false);
+ const tabbableIndexRef = React.useRef(-1);
+
+ const isInsidePortal = portalContext != null;
+ const floatingFocusElement = getFloatingFocusElement(floating);
+
+ const getTabbableContent = useEventCallback(
+ (container: Element | null = floatingFocusElement) => {
+ return container ? tabbable(container, getTabbableOptions()) : [];
+ },
+ );
+
+ const getTabbableElements = useEventCallback((container?: Element) => {
+ const content = getTabbableContent(container);
+
+ return orderRef.current
+ .map((type) => {
+ if (domReference && type === 'reference') {
+ return domReference;
+ }
+
+ if (floatingFocusElement && type === 'floating') {
+ return floatingFocusElement;
+ }
+
+ return content;
+ })
+ .filter(Boolean)
+ .flat() as Array;
+ });
+
+ React.useEffect(() => {
+ if (disabled) {
+ return undefined;
+ }
+ if (!modal) {
+ return undefined;
+ }
+
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Tab') {
+ // The focus guards have nothing to focus, so we need to stop the event.
+ if (
+ contains(floatingFocusElement, activeElement(getDocument(floatingFocusElement))) &&
+ getTabbableContent().length === 0 &&
+ !isUntrappedTypeableCombobox
+ ) {
+ stopEvent(event);
+ }
+
+ const els = getTabbableElements();
+ const target = getTarget(event);
+
+ if (orderRef.current[0] === 'reference' && target === domReference) {
+ stopEvent(event);
+ if (event.shiftKey) {
+ enqueueFocus(els[els.length - 1]);
+ } else {
+ enqueueFocus(els[1]);
+ }
+ }
+
+ if (
+ orderRef.current[1] === 'floating' &&
+ target === floatingFocusElement &&
+ event.shiftKey
+ ) {
+ stopEvent(event);
+ enqueueFocus(els[0]);
+ }
+ }
+ }
+
+ const doc = getDocument(floatingFocusElement);
+ doc.addEventListener('keydown', onKeyDown);
+ return () => {
+ doc.removeEventListener('keydown', onKeyDown);
+ };
+ }, [
+ disabled,
+ domReference,
+ floatingFocusElement,
+ modal,
+ orderRef,
+ isUntrappedTypeableCombobox,
+ getTabbableContent,
+ getTabbableElements,
+ ]);
+
+ React.useEffect(() => {
+ if (disabled) {
+ return undefined;
+ }
+ if (!floating) {
+ return undefined;
+ }
+
+ function handleFocusIn(event: FocusEvent) {
+ const target = getTarget(event) as Element | null;
+ const tabbableContent = getTabbableContent() as Array;
+ const tabbableIndex = tabbableContent.indexOf(target);
+ if (tabbableIndex !== -1) {
+ tabbableIndexRef.current = tabbableIndex;
+ }
+ }
+
+ floating.addEventListener('focusin', handleFocusIn);
+
+ return () => {
+ floating.removeEventListener('focusin', handleFocusIn);
+ };
+ }, [disabled, floating, getTabbableContent]);
+
+ React.useEffect(() => {
+ if (disabled) {
+ return undefined;
+ }
+ if (!closeOnFocusOut) {
+ return undefined;
+ }
+
+ // In Safari, buttons lose focus when pressing them.
+ function handlePointerDown() {
+ isPointerDownRef.current = true;
+ setTimeout(() => {
+ isPointerDownRef.current = false;
+ });
+ }
+
+ function handleFocusOutside(event: FocusEvent) {
+ const relatedTarget = event.relatedTarget as HTMLElement | null;
+ const currentTarget = event.currentTarget;
+ const target = getTarget(event) as HTMLElement | null;
+
+ queueMicrotask(() => {
+ const nodeId = getNodeId();
+ const movedToUnrelatedNode = !(
+ contains(domReference, relatedTarget) ||
+ contains(floating, relatedTarget) ||
+ contains(relatedTarget, floating) ||
+ contains(portalContext?.portalNode, relatedTarget) ||
+ relatedTarget?.hasAttribute(createAttribute('focus-guard')) ||
+ (tree &&
+ (getNodeChildren(tree.nodesRef.current, nodeId).find(
+ (node) =>
+ contains(node.context?.elements.floating, relatedTarget) ||
+ contains(node.context?.elements.domReference, relatedTarget),
+ ) ||
+ getNodeAncestors(tree.nodesRef.current, nodeId).find(
+ (node) =>
+ [
+ node.context?.elements.floating,
+ getFloatingFocusElement(node.context?.elements.floating),
+ ].includes(relatedTarget) ||
+ node.context?.elements.domReference === relatedTarget,
+ )))
+ );
+
+ if (currentTarget === domReference && floatingFocusElement) {
+ handleTabIndex(floatingFocusElement, orderRef);
+ }
+
+ // Restore focus to the previous tabbable element index to prevent
+ // focus from being lost outside the floating tree.
+ if (
+ restoreFocus &&
+ currentTarget !== domReference &&
+ !target?.isConnected &&
+ activeElement(getDocument(floatingFocusElement)) ===
+ getDocument(floatingFocusElement).body
+ ) {
+ // Let `FloatingPortal` effect knows that focus is still inside the
+ // floating tree.
+ if (isHTMLElement(floatingFocusElement)) {
+ floatingFocusElement.focus();
+ }
+
+ const prevTabbableIndex = tabbableIndexRef.current;
+ const tabbableContent = getTabbableContent() as Array;
+ const nodeToFocus =
+ tabbableContent[prevTabbableIndex] ||
+ tabbableContent[tabbableContent.length - 1] ||
+ floatingFocusElement;
+
+ if (isHTMLElement(nodeToFocus)) {
+ nodeToFocus.focus();
+ }
+ }
+
+ // https://github.com/floating-ui/floating-ui/issues/3060
+ if (dataRef.current.insideReactTree) {
+ dataRef.current.insideReactTree = false;
+ return;
+ }
+
+ // Focus did not move inside the floating tree, and there are no tabbable
+ // portal guards to handle closing.
+ if (
+ (isUntrappedTypeableCombobox ? true : !modal) &&
+ relatedTarget &&
+ movedToUnrelatedNode &&
+ !isPointerDownRef.current &&
+ // Fix React 18 Strict Mode returnFocus due to double rendering.
+ relatedTarget !== getPreviouslyFocusedElement()
+ ) {
+ preventReturnFocusRef.current = true;
+ onOpenChange(false, event, 'focus-out');
+ }
+ });
+ }
+
+ if (floating && isHTMLElement(domReference)) {
+ domReference.addEventListener('focusout', handleFocusOutside);
+ domReference.addEventListener('pointerdown', handlePointerDown);
+ floating.addEventListener('focusout', handleFocusOutside);
+
+ return () => {
+ domReference.removeEventListener('focusout', handleFocusOutside);
+ domReference.removeEventListener('pointerdown', handlePointerDown);
+ floating.removeEventListener('focusout', handleFocusOutside);
+ };
+ }
+ return undefined;
+ }, [
+ disabled,
+ domReference,
+ floating,
+ floatingFocusElement,
+ modal,
+ tree,
+ portalContext,
+ onOpenChange,
+ closeOnFocusOut,
+ restoreFocus,
+ getTabbableContent,
+ isUntrappedTypeableCombobox,
+ getNodeId,
+ orderRef,
+ dataRef,
+ ]);
+
+ const beforeGuardRef = React.useRef(null);
+ const afterGuardRef = React.useRef(null);
+
+ const mergedBeforeGuardRef = useForkRef(beforeGuardRef, portalContext?.beforeInsideRef);
+ const mergedAfterGuardRef = useForkRef(afterGuardRef, portalContext?.afterInsideRef);
+
+ React.useEffect(() => {
+ if (disabled) {
+ return undefined;
+ }
+ if (!floating) {
+ return undefined;
+ }
+
+ // Don't hide portals nested within the parent portal.
+ const portalNodes = Array.from(
+ portalContext?.portalNode?.querySelectorAll(`[${createAttribute('portal')}]`) || [],
+ );
+
+ const ancestors = tree ? getNodeAncestors(tree.nodesRef.current, getNodeId()) : [];
+ const ancestorFloatingNodes =
+ tree && !modal ? ancestors.map((node) => node.context?.elements.floating) : [];
+ const rootAncestorComboboxDomReference = ancestors.find((node) =>
+ isTypeableCombobox(node.context?.elements.domReference || null),
+ )?.context?.elements.domReference;
+
+ const insideElements = [
+ floating,
+ rootAncestorComboboxDomReference,
+ ...portalNodes,
+ ...ancestorFloatingNodes,
+ ...getInsideElements(),
+ startDismissButtonRef.current,
+ endDismissButtonRef.current,
+ beforeGuardRef.current,
+ afterGuardRef.current,
+ portalContext?.beforeOutsideRef.current,
+ portalContext?.afterOutsideRef.current,
+ orderRef.current.includes('reference') || isUntrappedTypeableCombobox ? domReference : null,
+ ].filter((x): x is Element => x != null);
+
+ const cleanup =
+ modal || isUntrappedTypeableCombobox
+ ? markOthers(insideElements, !useInert, useInert)
+ : markOthers(insideElements);
+
+ return () => {
+ cleanup();
+ };
+ }, [
+ disabled,
+ domReference,
+ floating,
+ modal,
+ orderRef,
+ portalContext,
+ isUntrappedTypeableCombobox,
+ guards,
+ useInert,
+ tree,
+ getNodeId,
+ getInsideElements,
+ ]);
+
+ useModernLayoutEffect(() => {
+ if (disabled || !isHTMLElement(floatingFocusElement)) {
+ return;
+ }
+
+ const doc = getDocument(floatingFocusElement);
+ const previouslyFocusedElement = activeElement(doc);
+
+ // Wait for any layout effect state setters to execute to set `tabIndex`.
+ queueMicrotask(() => {
+ const focusableElements = getTabbableElements(floatingFocusElement);
+ const initialFocusValue = initialFocusRef.current;
+ const elToFocus =
+ (typeof initialFocusValue === 'number'
+ ? focusableElements[initialFocusValue]
+ : initialFocusValue.current) || floatingFocusElement;
+ const focusAlreadyInsideFloatingEl = contains(floatingFocusElement, previouslyFocusedElement);
+
+ if (!ignoreInitialFocus && !focusAlreadyInsideFloatingEl && open) {
+ enqueueFocus(elToFocus, {
+ preventScroll: elToFocus === floatingFocusElement,
+ });
+ }
+ });
+ }, [
+ disabled,
+ open,
+ floatingFocusElement,
+ ignoreInitialFocus,
+ getTabbableElements,
+ initialFocusRef,
+ ]);
+
+ useModernLayoutEffect(() => {
+ if (disabled || !floatingFocusElement) {
+ return undefined;
+ }
+
+ const doc = getDocument(floatingFocusElement);
+ const previouslyFocusedElement = activeElement(doc);
+
+ addPreviouslyFocusedElement(previouslyFocusedElement);
+
+ // Dismissing via outside press should always ignore `returnFocus` to
+ // prevent unwanted scrolling.
+ function onOpenChangeLocal({
+ reason,
+ event,
+ nested,
+ }: {
+ open: boolean;
+ reason: OpenChangeReason;
+ event: Event;
+ nested: boolean;
+ }) {
+ if (['hover', 'safe-polygon'].includes(reason) && event.type === 'mouseleave') {
+ preventReturnFocusRef.current = true;
+ }
+
+ if (reason !== 'outside-press') {
+ return;
+ }
+
+ if (nested) {
+ preventReturnFocusRef.current = false;
+ } else if (
+ isVirtualClick(event as MouseEvent) ||
+ isVirtualPointerEvent(event as PointerEvent)
+ ) {
+ preventReturnFocusRef.current = false;
+ } else {
+ let isPreventScrollSupported = false;
+ document.createElement('div').focus({
+ get preventScroll() {
+ isPreventScrollSupported = true;
+ return false;
+ },
+ });
+
+ if (isPreventScrollSupported) {
+ preventReturnFocusRef.current = false;
+ } else {
+ preventReturnFocusRef.current = true;
+ }
+ }
+ }
+
+ events.on('openchange', onOpenChangeLocal);
+
+ const fallbackEl = doc.createElement('span');
+ fallbackEl.setAttribute('tabindex', '-1');
+ fallbackEl.setAttribute('aria-hidden', 'true');
+ Object.assign(fallbackEl.style, visuallyHidden);
+
+ if (isInsidePortal && domReference) {
+ domReference.insertAdjacentElement('afterend', fallbackEl);
+ }
+
+ function getReturnElement() {
+ if (typeof returnFocusRef.current === 'boolean') {
+ const el = domReference || getPreviouslyFocusedElement();
+ return el && el.isConnected ? el : fallbackEl;
+ }
+
+ return returnFocusRef.current.current || fallbackEl;
+ }
+
+ return () => {
+ events.off('openchange', onOpenChangeLocal);
+
+ const activeEl = activeElement(doc);
+ const isFocusInsideFloatingTree =
+ contains(floating, activeEl) ||
+ (tree &&
+ getNodeChildren(tree.nodesRef.current, getNodeId()).some((node) =>
+ contains(node.context?.elements.floating, activeEl),
+ ));
+
+ const returnElement = getReturnElement();
+
+ queueMicrotask(() => {
+ // This is `returnElement`, if it's tabbable, or its first tabbable child.
+ const tabbableReturnElement = getFirstTabbableElement(returnElement);
+ if (
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ returnFocusRef.current &&
+ !preventReturnFocusRef.current &&
+ isHTMLElement(tabbableReturnElement) &&
+ // 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
+ (tabbableReturnElement !== activeEl && activeEl !== doc.body
+ ? isFocusInsideFloatingTree
+ : true)
+ ) {
+ tabbableReturnElement.focus({ preventScroll: true });
+ }
+
+ fallbackEl.remove();
+ });
+ };
+ }, [
+ disabled,
+ floating,
+ floatingFocusElement,
+ returnFocusRef,
+ dataRef,
+ events,
+ tree,
+ isInsidePortal,
+ domReference,
+ getNodeId,
+ ]);
+
+ React.useEffect(() => {
+ // The `returnFocus` cleanup behavior is inside a microtask; ensure we
+ // wait for it to complete before resetting the flag.
+ queueMicrotask(() => {
+ preventReturnFocusRef.current = false;
+ });
+ }, [disabled]);
+
+ // Synchronize the `context` & `modal` value to the FloatingPortal context.
+ // It will decide whether or not it needs to render its own guards.
+ useModernLayoutEffect(() => {
+ if (disabled) {
+ return undefined;
+ }
+ if (!portalContext) {
+ return undefined;
+ }
+
+ portalContext.setFocusManagerState({
+ modal,
+ closeOnFocusOut,
+ open,
+ onOpenChange,
+ domReference,
+ });
+
+ return () => {
+ portalContext.setFocusManagerState(null);
+ };
+ }, [disabled, portalContext, modal, open, onOpenChange, closeOnFocusOut, domReference]);
+
+ useModernLayoutEffect(() => {
+ if (disabled) {
+ return;
+ }
+ if (!floatingFocusElement) {
+ return;
+ }
+ handleTabIndex(floatingFocusElement, orderRef);
+ }, [disabled, floatingFocusElement, orderRef]);
+
+ function renderDismissButton(location: 'start' | 'end') {
+ if (disabled || !visuallyHiddenDismiss || !modal) {
+ return null;
+ }
+
+ return (
+ onOpenChange(false, event.nativeEvent)}
+ >
+ {typeof visuallyHiddenDismiss === 'string' ? visuallyHiddenDismiss : 'Dismiss'}
+
+ );
+ }
+
+ const shouldRenderGuards =
+ !disabled &&
+ guards &&
+ (modal ? !isUntrappedTypeableCombobox : true) &&
+ (isInsidePortal || modal);
+
+ return (
+
+ {shouldRenderGuards && (
+ {
+ if (modal) {
+ const els = getTabbableElements();
+ enqueueFocus(order[0] === 'reference' ? els[0] : els[els.length - 1]);
+ } else if (portalContext?.preserveTabOrder && portalContext.portalNode) {
+ preventReturnFocusRef.current = false;
+ if (isOutsideEvent(event, portalContext.portalNode)) {
+ const nextTabbable = getNextTabbable(domReference);
+ nextTabbable?.focus();
+ } else {
+ portalContext.beforeOutsideRef.current?.focus();
+ }
+ }
+ }}
+ />
+ )}
+ {/*
+ Ensure the first swipe is the list item. The end of the listbox popup
+ will have a dismiss button.
+ */}
+ {!isUntrappedTypeableCombobox && renderDismissButton('start')}
+ {children}
+ {renderDismissButton('end')}
+ {shouldRenderGuards && (
+ {
+ if (modal) {
+ enqueueFocus(getTabbableElements()[0]);
+ } else if (portalContext?.preserveTabOrder && portalContext.portalNode) {
+ if (closeOnFocusOut) {
+ preventReturnFocusRef.current = true;
+ }
+
+ if (isOutsideEvent(event, portalContext.portalNode)) {
+ const prevTabbable = getPreviousTabbable(domReference);
+ prevTabbable?.focus();
+ } else {
+ portalContext.afterOutsideRef.current?.focus();
+ }
+ }
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/packages/react/src/floating-ui-react/components/FloatingPortal.tsx b/packages/react/src/floating-ui-react/components/FloatingPortal.tsx
new file mode 100644
index 0000000000..bf70a0bfcd
--- /dev/null
+++ b/packages/react/src/floating-ui-react/components/FloatingPortal.tsx
@@ -0,0 +1,279 @@
+import * as React from 'react';
+import * as ReactDOM from 'react-dom';
+import { isElement } from '@floating-ui/utils/dom';
+import { useId } from '../../utils/useId';
+import { useModernLayoutEffect } from '../../utils/useModernLayoutEffect';
+import { FocusGuard } from '../../utils/FocusGuard';
+import { visuallyHidden } from '../../utils/visuallyHidden';
+import {
+ enableFocusInside,
+ disableFocusInside,
+ getPreviousTabbable,
+ getNextTabbable,
+ isOutsideEvent,
+} from '../utils';
+
+import type { OpenChangeReason } from '../types';
+import { createAttribute } from '../utils/createAttribute';
+
+type FocusManagerState = {
+ modal: boolean;
+ open: boolean;
+ onOpenChange(open: boolean, event?: Event, reason?: OpenChangeReason): void;
+ domReference: Element | null;
+ closeOnFocusOut: boolean;
+} | null;
+
+const PortalContext = React.createContext>;
+ beforeInsideRef: React.RefObject;
+ afterInsideRef: React.RefObject;
+ beforeOutsideRef: React.RefObject;
+ afterOutsideRef: React.RefObject;
+}>(null);
+
+export const usePortalContext = () => React.useContext(PortalContext);
+
+const attr = createAttribute('portal');
+
+export interface UseFloatingPortalNodeProps {
+ id?: string;
+ root?: HTMLElement | null | React.MutableRefObject;
+}
+
+/**
+ * @see https://floating-ui.com/docs/FloatingPortal#usefloatingportalnode
+ */
+export function useFloatingPortalNode(props: UseFloatingPortalNodeProps = {}) {
+ const { id, root } = props;
+
+ const uniqueId = useId();
+ const portalContext = usePortalContext();
+
+ const [portalNode, setPortalNode] = React.useState(null);
+
+ const portalNodeRef = React.useRef(null);
+
+ useModernLayoutEffect(() => {
+ return () => {
+ portalNode?.remove();
+ // Allow the subsequent layout effects to create a new node on updates.
+ // The portal node will still be cleaned up on unmount.
+ // https://github.com/floating-ui/floating-ui/issues/2454
+ queueMicrotask(() => {
+ portalNodeRef.current = null;
+ });
+ };
+ }, [portalNode]);
+
+ useModernLayoutEffect(() => {
+ // Wait for the uniqueId to be generated before creating the portal node in
+ // React <18 (using `useFloatingId` instead of the native `useId`).
+ // https://github.com/floating-ui/floating-ui/issues/2778
+ if (!uniqueId) {
+ return;
+ }
+ if (portalNodeRef.current) {
+ return;
+ }
+ const existingIdRoot = id ? document.getElementById(id) : null;
+ if (!existingIdRoot) {
+ return;
+ }
+
+ const subRoot = document.createElement('div');
+ subRoot.id = uniqueId;
+ subRoot.setAttribute(attr, '');
+ existingIdRoot.appendChild(subRoot);
+ portalNodeRef.current = subRoot;
+ setPortalNode(subRoot);
+ }, [id, uniqueId]);
+
+ useModernLayoutEffect(() => {
+ // Wait for the root to exist before creating the portal node. The root must
+ // be stored in state, not a ref, for this to work reactively.
+ if (root === null) {
+ return;
+ }
+ if (!uniqueId) {
+ return;
+ }
+ if (portalNodeRef.current) {
+ return;
+ }
+
+ let container = root || portalContext?.portalNode;
+ if (container && !isElement(container)) {
+ container = container.current;
+ }
+ container = container || document.body;
+
+ let idWrapper: HTMLDivElement | null = null;
+ if (id) {
+ idWrapper = document.createElement('div');
+ idWrapper.id = id;
+ container.appendChild(idWrapper);
+ }
+
+ const subRoot = document.createElement('div');
+
+ subRoot.id = uniqueId;
+ subRoot.setAttribute(attr, '');
+
+ container = idWrapper || container;
+ container.appendChild(subRoot);
+
+ portalNodeRef.current = subRoot;
+ setPortalNode(subRoot);
+ }, [id, root, uniqueId, portalContext]);
+
+ return portalNode;
+}
+
+export interface FloatingPortalProps {
+ children?: React.ReactNode;
+ /**
+ * Optionally selects the node with the id if it exists, or create it and
+ * append it to the specified `root` (by default `document.body`).
+ */
+ id?: string;
+ /**
+ * Specifies the root node the portal container will be appended to.
+ */
+ root?: HTMLElement | null | React.MutableRefObject;
+ /**
+ * When using non-modal focus management using `FloatingFocusManager`, this
+ * will preserve the tab order context based on the React tree instead of the
+ * DOM tree.
+ */
+ preserveTabOrder?: boolean;
+}
+
+/**
+ * Portals the floating element into a given container element — by default,
+ * outside of the app root and into the body.
+ * This is necessary to ensure the floating element can appear outside any
+ * potential parent containers that cause clipping (such as `overflow: hidden`),
+ * while retaining its location in the React tree.
+ * @see https://floating-ui.com/docs/FloatingPortal
+ * @internal
+ */
+export function FloatingPortal(props: FloatingPortalProps): React.JSX.Element {
+ const { children, id, root, preserveTabOrder = true } = props;
+
+ const portalNode = useFloatingPortalNode({ id, root });
+ const [focusManagerState, setFocusManagerState] = React.useState(null);
+
+ const beforeOutsideRef = React.useRef(null);
+ const afterOutsideRef = React.useRef(null);
+ const beforeInsideRef = React.useRef(null);
+ const afterInsideRef = React.useRef(null);
+
+ const modal = focusManagerState?.modal;
+ const open = focusManagerState?.open;
+
+ const shouldRenderGuards =
+ // The FocusManager and therefore floating element are currently open/
+ // rendered.
+ !!focusManagerState &&
+ // Guards are only for non-modal focus management.
+ !focusManagerState.modal &&
+ // Don't render if unmount is transitioning.
+ focusManagerState.open &&
+ preserveTabOrder &&
+ !!(root || portalNode);
+
+ // https://codesandbox.io/s/tabbable-portal-f4tng?file=/src/TabbablePortal.tsx
+ React.useEffect(() => {
+ if (!portalNode || !preserveTabOrder || modal) {
+ return undefined;
+ }
+
+ // Make sure elements inside the portal element are tabbable only when the
+ // portal has already been focused, either by tabbing into a focus trap
+ // element outside or using the mouse.
+ function onFocus(event: FocusEvent) {
+ if (portalNode && isOutsideEvent(event)) {
+ const focusing = event.type === 'focusin';
+ const manageFocus = focusing ? enableFocusInside : disableFocusInside;
+ manageFocus(portalNode);
+ }
+ }
+ // Listen to the event on the capture phase so they run before the focus
+ // trap elements onFocus prop is called.
+ portalNode.addEventListener('focusin', onFocus, true);
+ portalNode.addEventListener('focusout', onFocus, true);
+ return () => {
+ portalNode.removeEventListener('focusin', onFocus, true);
+ portalNode.removeEventListener('focusout', onFocus, true);
+ };
+ }, [portalNode, preserveTabOrder, modal]);
+
+ React.useEffect(() => {
+ if (!portalNode) {
+ return;
+ }
+ if (open) {
+ return;
+ }
+ enableFocusInside(portalNode);
+ }, [open, portalNode]);
+
+ return (
+ ({
+ preserveTabOrder,
+ beforeOutsideRef,
+ afterOutsideRef,
+ beforeInsideRef,
+ afterInsideRef,
+ portalNode,
+ setFocusManagerState,
+ }),
+ [preserveTabOrder, portalNode],
+ )}
+ >
+ {shouldRenderGuards && portalNode && (
+ {
+ if (isOutsideEvent(event, portalNode)) {
+ beforeInsideRef.current?.focus();
+ } else {
+ const domReference = focusManagerState ? focusManagerState.domReference : null;
+ const prevTabbable = getPreviousTabbable(domReference);
+ prevTabbable?.focus();
+ }
+ }}
+ />
+ )}
+ {shouldRenderGuards && portalNode && (
+
+ )}
+ {portalNode && ReactDOM.createPortal(children, portalNode)}
+ {shouldRenderGuards && portalNode && (
+ {
+ if (isOutsideEvent(event, portalNode)) {
+ afterInsideRef.current?.focus();
+ } else {
+ const domReference = focusManagerState ? focusManagerState.domReference : null;
+ const nextTabbable = getNextTabbable(domReference);
+ nextTabbable?.focus();
+
+ if (focusManagerState?.closeOnFocusOut) {
+ focusManagerState?.onOpenChange(false, event.nativeEvent, 'focus-out');
+ }
+ }
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/packages/react/src/floating-ui-react/components/FloatingTree.tsx b/packages/react/src/floating-ui-react/components/FloatingTree.tsx
new file mode 100644
index 0000000000..0601c0a322
--- /dev/null
+++ b/packages/react/src/floating-ui-react/components/FloatingTree.tsx
@@ -0,0 +1,117 @@
+import * as React from 'react';
+
+import { useId } from '../../utils/useId';
+import { useModernLayoutEffect } from '../../utils/useModernLayoutEffect';
+import type { FloatingNodeType, FloatingTreeType, ReferenceType } from '../types';
+import { createEventEmitter } from '../utils/createEventEmitter';
+
+const FloatingNodeContext = React.createContext(null);
+const FloatingTreeContext = React.createContext(null);
+
+/**
+ * Returns the parent node id for nested floating elements, if available.
+ * Returns `null` for top-level floating elements.
+ */
+export const useFloatingParentNodeId = (): string | null =>
+ React.useContext(FloatingNodeContext)?.id || null;
+
+/**
+ * Returns the nearest floating tree context, if available.
+ */
+export const useFloatingTree = <
+ RT extends ReferenceType = ReferenceType,
+>(): FloatingTreeType | null =>
+ React.useContext(FloatingTreeContext) as FloatingTreeType | null;
+
+/**
+ * Registers a node into the `FloatingTree`, returning its id.
+ * @see https://floating-ui.com/docs/FloatingTree
+ */
+export function useFloatingNodeId(customParentId?: string): string | undefined {
+ const id = useId();
+ const tree = useFloatingTree();
+ const reactParentId = useFloatingParentNodeId();
+ const parentId = customParentId || reactParentId;
+
+ useModernLayoutEffect(() => {
+ if (!id) {
+ return undefined;
+ }
+ const node = { id, parentId };
+ tree?.addNode(node);
+ return () => {
+ tree?.removeNode(node);
+ };
+ }, [tree, id, parentId]);
+
+ return id;
+}
+
+export interface FloatingNodeProps {
+ children?: React.ReactNode;
+ id: string | undefined;
+}
+
+/**
+ * Provides parent node context for nested floating elements.
+ * @see https://floating-ui.com/docs/FloatingTree
+ * @internal
+ */
+export function FloatingNode(props: FloatingNodeProps): React.JSX.Element {
+ const { children, id } = props;
+
+ const parentId = useFloatingParentNodeId();
+
+ return (
+ ({ id, parentId }), [id, parentId])}>
+ {children}
+
+ );
+}
+
+export interface FloatingTreeProps {
+ children?: React.ReactNode;
+}
+
+/**
+ * Provides context for nested floating elements when they are not children of
+ * each other on the DOM.
+ * This is not necessary in all cases, except when there must be explicit communication between parent and child floating elements. It is necessary for:
+ * - The `bubbles` option in the `useDismiss()` Hook
+ * - Nested virtual list navigation
+ * - Nested floating elements that each open on hover
+ * - Custom communication between parent and child floating elements
+ * @see https://floating-ui.com/docs/FloatingTree
+ * @internal
+ */
+export function FloatingTree(props: FloatingTreeProps): React.JSX.Element {
+ const { children } = props;
+
+ const nodesRef = React.useRef>([]);
+
+ const addNode = React.useCallback((node: FloatingNodeType) => {
+ nodesRef.current = [...nodesRef.current, node];
+ }, []);
+
+ const removeNode = React.useCallback((node: FloatingNodeType) => {
+ nodesRef.current = nodesRef.current.filter((n) => n !== node);
+ }, []);
+
+ const [events] = React.useState(() => createEventEmitter());
+
+ return (
+ ({
+ nodesRef,
+ addNode,
+ removeNode,
+ events,
+ }),
+ [addNode, removeNode, events],
+ )}
+ >
+ {children}
+
+ );
+}
diff --git a/packages/react/src/utils/floating-ui/useClick.ts b/packages/react/src/floating-ui-react/hooks/useClick.ts
similarity index 93%
rename from packages/react/src/utils/floating-ui/useClick.ts
rename to packages/react/src/floating-ui-react/hooks/useClick.ts
index 3b21289562..a5631b3565 100644
--- a/packages/react/src/utils/floating-ui/useClick.ts
+++ b/packages/react/src/floating-ui-react/hooks/useClick.ts
@@ -1,9 +1,9 @@
'use client';
import * as React from 'react';
-import type { ElementProps, FloatingRootContext } from '@floating-ui/react';
-import { isMouseLikePointerType } from '@floating-ui/react/utils';
-import { useAnimationFrame } from '../useAnimationFrame';
-import { EMPTY_OBJECT } from '../constants';
+import { EMPTY_OBJECT } from '../../utils/constants';
+import type { ElementProps, FloatingRootContext } from '../types';
+import { isMouseLikePointerType } from '../utils';
+import { useAnimationFrame } from '../../utils/useAnimationFrame';
export interface UseClickProps {
/**
diff --git a/packages/react/src/floating-ui-react/hooks/useClientPoint.test.tsx b/packages/react/src/floating-ui-react/hooks/useClientPoint.test.tsx
new file mode 100644
index 0000000000..3ca3f92371
--- /dev/null
+++ b/packages/react/src/floating-ui-react/hooks/useClientPoint.test.tsx
@@ -0,0 +1,293 @@
+import * as React from 'react';
+import type { Coords } from '@floating-ui/react-dom';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { test } from 'vitest';
+
+/* eslint-disable testing-library/no-unnecessary-act */
+
+import { useClientPoint, useFloating, useInteractions } from '../index';
+
+function expectLocation({ x, y }: Coords) {
+ expect(Number(screen.getByTestId('x')?.textContent)).toBe(x);
+ expect(Number(screen.getByTestId('y')?.textContent)).toBe(y);
+ expect(Number(screen.getByTestId('width')?.textContent)).toBe(0);
+ expect(Number(screen.getByTestId('height')?.textContent)).toBe(0);
+}
+
+function App({
+ enabled = true,
+ point,
+ axis,
+}: {
+ enabled?: boolean;
+ point?: Coords;
+ axis?: 'both' | 'x' | 'y';
+}) {
+ const [isOpen, setIsOpen] = React.useState(false);
+ const { refs, elements, context } = useFloating({
+ open: isOpen,
+ onOpenChange: setIsOpen,
+ });
+ const clientPoint = useClientPoint(context, {
+ enabled,
+ ...point,
+ axis,
+ });
+ const { getReferenceProps, getFloatingProps } = useInteractions([clientPoint]);
+
+ const rect = elements.reference?.getBoundingClientRect();
+
+ return (
+
+
+ Reference
+
+ {isOpen && (
+
+ Floating
+
+ )}
+ setIsOpen((v) => !v)} />
+ {rect?.x}
+ {rect?.y}
+ {rect?.width}
+ {rect?.height}
+
+ );
+}
+
+test('renders at explicit client point and can be updated', async () => {
+ const { rerender } = render( );
+
+ fireEvent.click(screen.getByRole('button'));
+
+ await act(async () => {});
+
+ expectLocation({ x: 0, y: 0 });
+
+ rerender( );
+ await act(async () => {});
+
+ expectLocation({ x: 1000, y: 1000 });
+});
+
+test('renders at mouse event coords', async () => {
+ render( );
+
+ await act(async () => {});
+
+ fireEvent(
+ screen.getByTestId('reference'),
+ new MouseEvent('mousemove', {
+ bubbles: true,
+ clientX: 500,
+ clientY: 500,
+ }),
+ );
+ await act(async () => {});
+
+ expectLocation({ x: 500, y: 500 });
+
+ fireEvent(
+ screen.getByTestId('reference'),
+ new MouseEvent('mousemove', {
+ bubbles: true,
+ clientX: 1000,
+ clientY: 1000,
+ }),
+ );
+ await act(async () => {});
+
+ expectLocation({ x: 1000, y: 1000 });
+
+ // Window listener isn't registered unless the floating element is open.
+ fireEvent(
+ window,
+ new MouseEvent('mousemove', {
+ bubbles: true,
+ clientX: 700,
+ clientY: 700,
+ }),
+ );
+ await act(async () => {});
+
+ expectLocation({ x: 1000, y: 1000 });
+
+ fireEvent.click(screen.getByRole('button'));
+ await act(async () => {});
+
+ fireEvent(
+ screen.getByTestId('reference'),
+ new MouseEvent('mousemove', {
+ bubbles: true,
+ clientX: 700,
+ clientY: 700,
+ }),
+ );
+ await act(async () => {});
+
+ expectLocation({ x: 700, y: 700 });
+
+ fireEvent(
+ document.body,
+ new MouseEvent('mousemove', {
+ bubbles: true,
+ clientX: 0,
+ clientY: 0,
+ }),
+ );
+ await act(async () => {});
+
+ expectLocation({ x: 0, y: 0 });
+});
+
+test('ignores mouse events when explicit coords are specified', async () => {
+ render( );
+
+ fireEvent(
+ screen.getByTestId('reference'),
+ new MouseEvent('mousemove', {
+ bubbles: true,
+ clientX: 500,
+ clientY: 500,
+ }),
+ );
+ await act(async () => {});
+
+ expectLocation({ x: 0, y: 0 });
+});
+
+test('cleans up window listener when closing or disabling', async () => {
+ const { rerender } = render( );
+
+ fireEvent.click(screen.getByRole('button'));
+
+ fireEvent(
+ screen.getByTestId('reference'),
+ new MouseEvent('mousemove', {
+ bubbles: true,
+ clientX: 500,
+ clientY: 500,
+ }),
+ );
+ await act(async () => {});
+
+ fireEvent.click(screen.getByRole('button'));
+
+ fireEvent(
+ document.body,
+ new MouseEvent('mousemove', {
+ bubbles: true,
+ clientX: 0,
+ clientY: 0,
+ }),
+ );
+ await act(async () => {});
+
+ expectLocation({ x: 500, y: 500 });
+
+ fireEvent.click(screen.getByRole('button'));
+
+ fireEvent(
+ document.body,
+ new MouseEvent('mousemove', {
+ bubbles: true,
+ clientX: 500,
+ clientY: 500,
+ }),
+ );
+ await act(async () => {});
+
+ expectLocation({ x: 500, y: 500 });
+
+ rerender( );
+
+ fireEvent(
+ document.body,
+ new MouseEvent('mousemove', {
+ bubbles: true,
+ clientX: 0,
+ clientY: 0,
+ }),
+ );
+ await act(async () => {});
+
+ expectLocation({ x: 500, y: 500 });
+});
+
+test('axis x', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button'));
+
+ fireEvent(
+ screen.getByTestId('reference'),
+ new MouseEvent('mousemove', {
+ bubbles: true,
+ clientX: 500,
+ clientY: 500,
+ }),
+ );
+ await act(async () => {});
+
+ expectLocation({ x: 500, y: 0 });
+});
+
+test('axis y', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button'));
+
+ fireEvent(
+ screen.getByTestId('reference'),
+ new MouseEvent('mousemove', {
+ bubbles: true,
+ clientX: 500,
+ clientY: 500,
+ }),
+ );
+ await act(async () => {});
+
+ expectLocation({ x: 0, y: 500 });
+});
+
+test('removes window listener when cursor lands on floating element', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button'));
+
+ fireEvent(
+ screen.getByTestId('reference'),
+ new MouseEvent('mousemove', {
+ bubbles: true,
+ clientX: 500,
+ clientY: 500,
+ }),
+ );
+
+ fireEvent(
+ screen.getByTestId('floating'),
+ new MouseEvent('mousemove', {
+ bubbles: true,
+ clientX: 500,
+ clientY: 500,
+ }),
+ );
+
+ fireEvent(
+ document.body,
+ new MouseEvent('mousemove', {
+ bubbles: true,
+ clientX: 0,
+ clientY: 0,
+ }),
+ );
+ await act(async () => {});
+
+ expectLocation({ x: 500, y: 500 });
+});
diff --git a/packages/react/src/floating-ui-react/hooks/useClientPoint.ts b/packages/react/src/floating-ui-react/hooks/useClientPoint.ts
new file mode 100644
index 0000000000..5a635a85a6
--- /dev/null
+++ b/packages/react/src/floating-ui-react/hooks/useClientPoint.ts
@@ -0,0 +1,250 @@
+import * as React from 'react';
+import { getWindow } from '@floating-ui/utils/dom';
+import { useEventCallback } from '../../utils/useEventCallback';
+import { useModernLayoutEffect } from '../../utils/useModernLayoutEffect';
+import { contains, getTarget, isMouseLikePointerType } from '../utils';
+
+import type { ContextData, ElementProps, FloatingRootContext } from '../types';
+
+function createVirtualElement(
+ domElement: Element | null | undefined,
+ data: {
+ axis: 'x' | 'y' | 'both';
+ dataRef: React.MutableRefObject;
+ pointerType: string | undefined;
+ x: number | null;
+ y: number | null;
+ },
+) {
+ let offsetX: number | null = null;
+ let offsetY: number | null = null;
+ let isAutoUpdateEvent = false;
+
+ return {
+ contextElement: domElement || undefined,
+ getBoundingClientRect() {
+ const domRect = domElement?.getBoundingClientRect() || {
+ width: 0,
+ height: 0,
+ x: 0,
+ y: 0,
+ };
+
+ const isXAxis = data.axis === 'x' || data.axis === 'both';
+ const isYAxis = data.axis === 'y' || data.axis === 'both';
+ const canTrackCursorOnAutoUpdate =
+ ['mouseenter', 'mousemove'].includes(data.dataRef.current.openEvent?.type || '') &&
+ data.pointerType !== 'touch';
+
+ let width = domRect.width;
+ let height = domRect.height;
+ let x = domRect.x;
+ let y = domRect.y;
+
+ if (offsetX == null && data.x && isXAxis) {
+ offsetX = domRect.x - data.x;
+ }
+
+ if (offsetY == null && data.y && isYAxis) {
+ offsetY = domRect.y - data.y;
+ }
+
+ x -= offsetX || 0;
+ y -= offsetY || 0;
+ width = 0;
+ height = 0;
+
+ if (!isAutoUpdateEvent || canTrackCursorOnAutoUpdate) {
+ width = data.axis === 'y' ? domRect.width : 0;
+ height = data.axis === 'x' ? domRect.height : 0;
+ x = isXAxis && data.x != null ? data.x : x;
+ y = isYAxis && data.y != null ? data.y : y;
+ } else if (isAutoUpdateEvent && !canTrackCursorOnAutoUpdate) {
+ height = data.axis === 'x' ? domRect.height : height;
+ width = data.axis === 'y' ? domRect.width : width;
+ }
+
+ isAutoUpdateEvent = true;
+
+ return {
+ width,
+ height,
+ x,
+ y,
+ top: y,
+ right: x + width,
+ bottom: y + height,
+ left: x,
+ };
+ },
+ };
+}
+
+function isMouseBasedEvent(event: Event | undefined): event is MouseEvent {
+ return event != null && (event as MouseEvent).clientX != null;
+}
+
+export interface UseClientPointProps {
+ /**
+ * Whether the Hook is enabled, including all internal Effects and event
+ * handlers.
+ * @default true
+ */
+ enabled?: boolean;
+ /**
+ * Whether to restrict the client point to an axis and use the reference
+ * element (if it exists) as the other axis. This can be useful if the
+ * floating element is also interactive.
+ * @default 'both'
+ */
+ axis?: 'x' | 'y' | 'both';
+ /**
+ * An explicitly defined `x` client coordinate.
+ * @default null
+ */
+ x?: number | null;
+ /**
+ * An explicitly defined `y` client coordinate.
+ * @default null
+ */
+ y?: number | null;
+}
+
+/**
+ * Positions the floating element relative to a client point (in the viewport),
+ * such as the mouse position. By default, it follows the mouse cursor.
+ * @see https://floating-ui.com/docs/useClientPoint
+ */
+export function useClientPoint(
+ context: FloatingRootContext,
+ props: UseClientPointProps = {},
+): ElementProps {
+ const {
+ open,
+ dataRef,
+ elements: { floating, domReference },
+ refs,
+ } = context;
+ const { enabled = true, axis = 'both', x = null, y = null } = props;
+
+ const initialRef = React.useRef(false);
+ const cleanupListenerRef = React.useRef void)>(null);
+
+ const [pointerType, setPointerType] = React.useState();
+ const [reactive, setReactive] = React.useState([]);
+
+ const setReference = useEventCallback((newX: number | null, newY: number | null) => {
+ if (initialRef.current) {
+ return;
+ }
+
+ // Prevent setting if the open event was not a mouse-like one
+ // (e.g. focus to open, then hover over the reference element).
+ // Only apply if the event exists.
+ if (dataRef.current.openEvent && !isMouseBasedEvent(dataRef.current.openEvent)) {
+ return;
+ }
+
+ refs.setPositionReference(
+ createVirtualElement(domReference, {
+ x: newX,
+ y: newY,
+ axis,
+ dataRef,
+ pointerType,
+ }),
+ );
+ });
+
+ const handleReferenceEnterOrMove = useEventCallback((event: React.MouseEvent) => {
+ if (x != null || y != null) {
+ return;
+ }
+
+ if (!open) {
+ setReference(event.clientX, event.clientY);
+ } else if (!cleanupListenerRef.current) {
+ // If there's no cleanup, there's no listener, but we want to ensure
+ // we add the listener if the cursor landed on the floating element and
+ // then back on the reference (i.e. it's interactive).
+ setReactive([]);
+ }
+ });
+
+ // If the pointer is a mouse-like pointer, we want to continue following the
+ // mouse even if the floating element is transitioning out. On touch
+ // devices, this is undesirable because the floating element will move to
+ // the dismissal touch point.
+ const openCheck = isMouseLikePointerType(pointerType) ? floating : open;
+
+ const addListener = React.useCallback(() => {
+ // Explicitly specified `x`/`y` coordinates shouldn't add a listener.
+ if (!openCheck || !enabled || x != null || y != null) {
+ return undefined;
+ }
+
+ const win = getWindow(floating);
+
+ function handleMouseMove(event: MouseEvent) {
+ const target = getTarget(event) as Element | null;
+
+ if (!contains(floating, target)) {
+ setReference(event.clientX, event.clientY);
+ } else {
+ win.removeEventListener('mousemove', handleMouseMove);
+ cleanupListenerRef.current = null;
+ }
+ }
+
+ if (!dataRef.current.openEvent || isMouseBasedEvent(dataRef.current.openEvent)) {
+ win.addEventListener('mousemove', handleMouseMove);
+ const cleanup = () => {
+ win.removeEventListener('mousemove', handleMouseMove);
+ cleanupListenerRef.current = null;
+ };
+ cleanupListenerRef.current = cleanup;
+ return cleanup;
+ }
+
+ refs.setPositionReference(domReference);
+ return undefined;
+ }, [openCheck, enabled, x, y, floating, dataRef, refs, domReference, setReference]);
+
+ React.useEffect(() => {
+ return addListener();
+ }, [addListener, reactive]);
+
+ React.useEffect(() => {
+ if (enabled && !floating) {
+ initialRef.current = false;
+ }
+ }, [enabled, floating]);
+
+ React.useEffect(() => {
+ if (!enabled && open) {
+ initialRef.current = true;
+ }
+ }, [enabled, open]);
+
+ useModernLayoutEffect(() => {
+ if (enabled && (x != null || y != null)) {
+ initialRef.current = false;
+ setReference(x, y);
+ }
+ }, [enabled, x, y, setReference]);
+
+ const reference: ElementProps['reference'] = React.useMemo(() => {
+ function setPointerTypeRef(event: React.PointerEvent) {
+ setPointerType(event.pointerType);
+ }
+
+ return {
+ onPointerDown: setPointerTypeRef,
+ onPointerEnter: setPointerTypeRef,
+ onMouseMove: handleReferenceEnterOrMove,
+ onMouseEnter: handleReferenceEnterOrMove,
+ };
+ }, [handleReferenceEnterOrMove]);
+
+ return React.useMemo(() => (enabled ? { reference } : {}), [enabled, reference]);
+}
diff --git a/packages/react/src/floating-ui-react/hooks/useDismiss.ts b/packages/react/src/floating-ui-react/hooks/useDismiss.ts
new file mode 100644
index 0000000000..b884ba7301
--- /dev/null
+++ b/packages/react/src/floating-ui-react/hooks/useDismiss.ts
@@ -0,0 +1,503 @@
+import * as React from 'react';
+import { getOverflowAncestors } from '@floating-ui/react-dom';
+import {
+ getComputedStyle,
+ getParentNode,
+ isElement,
+ isHTMLElement,
+ isLastTraversableNode,
+ isWebKit,
+} from '@floating-ui/utils/dom';
+import { useTimeout, Timeout } from '../../utils/useTimeout';
+import { useEventCallback } from '../../utils/useEventCallback';
+import {
+ contains,
+ getDocument,
+ getTarget,
+ isEventTargetWithin,
+ isReactEvent,
+ isRootElement,
+ getNodeChildren,
+} from '../utils';
+
+/* eslint-disable no-underscore-dangle */
+
+import { useFloatingTree } from '../components/FloatingTree';
+import type { ElementProps, FloatingRootContext } from '../types';
+import { createAttribute } from '../utils/createAttribute';
+
+const bubbleHandlerKeys = {
+ pointerdown: 'onPointerDown',
+ mousedown: 'onMouseDown',
+ click: 'onClick',
+};
+
+const captureHandlerKeys = {
+ pointerdown: 'onPointerDownCapture',
+ mousedown: 'onMouseDownCapture',
+ click: 'onClickCapture',
+};
+
+export const normalizeProp = (
+ normalizable?: boolean | { escapeKey?: boolean; outsidePress?: boolean },
+) => {
+ return {
+ escapeKey:
+ typeof normalizable === 'boolean' ? normalizable : (normalizable?.escapeKey ?? false),
+ outsidePress:
+ typeof normalizable === 'boolean' ? normalizable : (normalizable?.outsidePress ?? true),
+ };
+};
+
+export interface UseDismissProps {
+ /**
+ * Whether the Hook is enabled, including all internal Effects and event
+ * handlers.
+ * @default true
+ */
+ enabled?: boolean;
+ /**
+ * Whether to dismiss the floating element upon pressing the `esc` key.
+ * @default true
+ */
+ escapeKey?: boolean;
+ /**
+ * Whether to dismiss the floating element upon pressing the reference
+ * element. You likely want to ensure the `move` option in the `useHover()`
+ * Hook has been disabled when this is in use.
+ * @default false
+ */
+ referencePress?: boolean;
+ /**
+ * The type of event to use to determine a “press”.
+ * - `pointerdown` is eager on both mouse + touch input.
+ * - `mousedown` is eager on mouse input, but lazy on touch input.
+ * - `click` is lazy on both mouse + touch input.
+ * @default 'pointerdown'
+ */
+ referencePressEvent?: 'pointerdown' | 'mousedown' | 'click';
+ /**
+ * Whether to dismiss the floating element upon pressing outside of the
+ * floating element.
+ * If you have another element, like a toast, that is rendered outside the
+ * floating element’s React tree and don’t want the floating element to close
+ * when pressing it, you can guard the check like so:
+ * ```jsx
+ * useDismiss(context, {
+ * outsidePress: (event) => !event.target.closest('.toast'),
+ * });
+ * ```
+ * @default true
+ */
+ outsidePress?: boolean | ((event: MouseEvent) => boolean);
+ /**
+ * The type of event to use to determine an outside “press”.
+ * - `pointerdown` is eager on both mouse + touch input.
+ * - `mousedown` is eager on mouse input, but lazy on touch input.
+ * - `click` is lazy on both mouse + touch input.
+ * @default 'pointerdown'
+ */
+ outsidePressEvent?: 'pointerdown' | 'mousedown' | 'click';
+ /**
+ * Whether to dismiss the floating element upon scrolling an overflow
+ * ancestor.
+ * @default false
+ */
+ ancestorScroll?: boolean;
+ /**
+ * Determines whether event listeners bubble upwards through a tree of
+ * floating elements.
+ */
+ bubbles?: boolean | { escapeKey?: boolean; outsidePress?: boolean };
+ /**
+ * Determines whether to use capture phase event listeners.
+ */
+ capture?: boolean | { escapeKey?: boolean; outsidePress?: boolean };
+}
+
+/**
+ * Closes the floating element when a dismissal is requested — by default, when
+ * the user presses the `escape` key or outside of the floating element.
+ * @see https://floating-ui.com/docs/useDismiss
+ */
+export function useDismiss(
+ context: FloatingRootContext,
+ props: UseDismissProps = {},
+): ElementProps {
+ const { open, onOpenChange, elements, dataRef } = context;
+ const {
+ enabled = true,
+ escapeKey = true,
+ outsidePress: outsidePressProp = true,
+ outsidePressEvent = 'pointerdown',
+ referencePress = false,
+ referencePressEvent = 'pointerdown',
+ ancestorScroll = false,
+ bubbles,
+ capture,
+ } = props;
+
+ const tree = useFloatingTree();
+ const outsidePressFn = useEventCallback(
+ typeof outsidePressProp === 'function' ? outsidePressProp : () => false,
+ );
+ const outsidePress = typeof outsidePressProp === 'function' ? outsidePressFn : outsidePressProp;
+
+ const endedOrStartedInsideRef = React.useRef(false);
+ const { escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles } = normalizeProp(bubbles);
+ const { escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture } = normalizeProp(capture);
+
+ const isComposingRef = React.useRef(false);
+ const blurTimeout = useTimeout();
+
+ const closeOnEscapeKeyDown = useEventCallback(
+ (event: React.KeyboardEvent | KeyboardEvent) => {
+ if (!open || !enabled || !escapeKey || event.key !== 'Escape') {
+ return;
+ }
+
+ // Wait until IME is settled. Pressing `Escape` while composing should
+ // close the compose menu, but not the floating element.
+ if (isComposingRef.current) {
+ return;
+ }
+
+ const nodeId = dataRef.current.floatingContext?.nodeId;
+
+ const children = tree ? getNodeChildren(tree.nodesRef.current, nodeId) : [];
+
+ if (!escapeKeyBubbles) {
+ event.stopPropagation();
+
+ if (children.length > 0) {
+ let shouldDismiss = true;
+
+ children.forEach((child) => {
+ if (child.context?.open && !child.context.dataRef.current.__escapeKeyBubbles) {
+ shouldDismiss = false;
+ }
+ });
+
+ if (!shouldDismiss) {
+ return;
+ }
+ }
+ }
+
+ onOpenChange(false, isReactEvent(event) ? event.nativeEvent : event, 'escape-key');
+ },
+ );
+
+ const closeOnEscapeKeyDownCapture = useEventCallback((event: KeyboardEvent) => {
+ const callback = () => {
+ closeOnEscapeKeyDown(event);
+ getTarget(event)?.removeEventListener('keydown', callback);
+ };
+ getTarget(event)?.addEventListener('keydown', callback);
+ });
+
+ const closeOnPressOutside = useEventCallback((event: MouseEvent) => {
+ // Given developers can stop the propagation of the synthetic event,
+ // we can only be confident with a positive value.
+ const insideReactTree = dataRef.current.insideReactTree;
+ dataRef.current.insideReactTree = false;
+
+ // When click outside is lazy (`click` event), handle dragging.
+ // Don't close if:
+ // - The click started inside the floating element.
+ // - The click ended inside the floating element.
+ const endedOrStartedInside = endedOrStartedInsideRef.current;
+ endedOrStartedInsideRef.current = false;
+
+ if (outsidePressEvent === 'click' && endedOrStartedInside) {
+ return;
+ }
+
+ if (insideReactTree) {
+ return;
+ }
+
+ if (typeof outsidePress === 'function' && !outsidePress(event)) {
+ return;
+ }
+
+ const target = getTarget(event);
+ const inertSelector = `[${createAttribute('inert')}]`;
+ const markers = getDocument(elements.floating).querySelectorAll(inertSelector);
+
+ let targetRootAncestor = isElement(target) ? target : null;
+ while (targetRootAncestor && !isLastTraversableNode(targetRootAncestor)) {
+ const nextParent = getParentNode(targetRootAncestor);
+ if (isLastTraversableNode(nextParent) || !isElement(nextParent)) {
+ break;
+ }
+
+ targetRootAncestor = nextParent;
+ }
+
+ // Check if the click occurred on a third-party element injected after the
+ // floating element rendered.
+ if (
+ markers.length &&
+ isElement(target) &&
+ !isRootElement(target) &&
+ // Clicked on a direct ancestor (e.g. FloatingOverlay).
+ !contains(target, elements.floating) &&
+ // If the target root element contains none of the markers, then the
+ // element was injected after the floating element rendered.
+ Array.from(markers).every((marker) => !contains(targetRootAncestor, marker))
+ ) {
+ return;
+ }
+
+ // Check if the click occurred on the scrollbar
+ if (isHTMLElement(target)) {
+ const lastTraversableNode = isLastTraversableNode(target);
+ const style = getComputedStyle(target);
+ const scrollRe = /auto|scroll/;
+ const isScrollableX = lastTraversableNode || scrollRe.test(style.overflowX);
+ const isScrollableY = lastTraversableNode || scrollRe.test(style.overflowY);
+
+ const canScrollX =
+ isScrollableX && target.clientWidth > 0 && target.scrollWidth > target.clientWidth;
+ const canScrollY =
+ isScrollableY && target.clientHeight > 0 && target.scrollHeight > target.clientHeight;
+
+ const isRTL = style.direction === 'rtl';
+
+ // Check click position relative to scrollbar.
+ // In some browsers it is possible to change the (or window)
+ // scrollbar to the left side, but is very rare and is difficult to
+ // check for. Plus, for modal dialogs with backdrops, it is more
+ // important that the backdrop is checked but not so much the window.
+ const pressedVerticalScrollbar =
+ canScrollY &&
+ (isRTL
+ ? event.offsetX <= target.offsetWidth - target.clientWidth
+ : event.offsetX > target.clientWidth);
+
+ const pressedHorizontalScrollbar = canScrollX && event.offsetY > target.clientHeight;
+
+ if (pressedVerticalScrollbar || pressedHorizontalScrollbar) {
+ return;
+ }
+ }
+
+ const nodeId = dataRef.current.floatingContext?.nodeId;
+
+ const targetIsInsideChildren =
+ tree &&
+ getNodeChildren(tree.nodesRef.current, nodeId).some((node) =>
+ isEventTargetWithin(event, node.context?.elements.floating),
+ );
+
+ if (
+ isEventTargetWithin(event, elements.floating) ||
+ isEventTargetWithin(event, elements.domReference) ||
+ targetIsInsideChildren
+ ) {
+ return;
+ }
+
+ const children = tree ? getNodeChildren(tree.nodesRef.current, nodeId) : [];
+ if (children.length > 0) {
+ let shouldDismiss = true;
+
+ children.forEach((child) => {
+ if (child.context?.open && !child.context.dataRef.current.__outsidePressBubbles) {
+ shouldDismiss = false;
+ }
+ });
+
+ if (!shouldDismiss) {
+ return;
+ }
+ }
+
+ onOpenChange(false, event, 'outside-press');
+ });
+
+ const closeOnPressOutsideCapture = useEventCallback((event: MouseEvent) => {
+ const callback = () => {
+ closeOnPressOutside(event);
+ getTarget(event)?.removeEventListener(outsidePressEvent, callback);
+ };
+ getTarget(event)?.addEventListener(outsidePressEvent, callback);
+ });
+
+ React.useEffect(() => {
+ if (!open || !enabled) {
+ return undefined;
+ }
+
+ dataRef.current.__escapeKeyBubbles = escapeKeyBubbles;
+ dataRef.current.__outsidePressBubbles = outsidePressBubbles;
+
+ const compositionTimeout = new Timeout();
+
+ function onScroll(event: Event) {
+ onOpenChange(false, event, 'ancestor-scroll');
+ }
+
+ function handleCompositionStart() {
+ compositionTimeout.clear();
+ isComposingRef.current = true;
+ }
+
+ function handleCompositionEnd() {
+ // Safari fires `compositionend` before `keydown`, so we need to wait
+ // until the next tick to set `isComposing` to `false`.
+ // https://bugs.webkit.org/show_bug.cgi?id=165004
+ compositionTimeout.start(
+ // 0ms or 1ms don't work in Safari. 5ms appears to consistently work.
+ // Only apply to WebKit for the test to remain 0ms.
+ isWebKit() ? 5 : 0,
+ () => {
+ isComposingRef.current = false;
+ },
+ );
+ }
+
+ const doc = getDocument(elements.floating);
+
+ if (escapeKey) {
+ doc.addEventListener(
+ 'keydown',
+ escapeKeyCapture ? closeOnEscapeKeyDownCapture : closeOnEscapeKeyDown,
+ escapeKeyCapture,
+ );
+ doc.addEventListener('compositionstart', handleCompositionStart);
+ doc.addEventListener('compositionend', handleCompositionEnd);
+ }
+
+ if (outsidePress) {
+ doc.addEventListener(
+ outsidePressEvent,
+ outsidePressCapture ? closeOnPressOutsideCapture : closeOnPressOutside,
+ outsidePressCapture,
+ );
+ }
+
+ let ancestors: (Element | Window | VisualViewport)[] = [];
+
+ if (ancestorScroll) {
+ if (isElement(elements.domReference)) {
+ ancestors = getOverflowAncestors(elements.domReference);
+ }
+
+ if (isElement(elements.floating)) {
+ ancestors = ancestors.concat(getOverflowAncestors(elements.floating));
+ }
+
+ if (
+ !isElement(elements.reference) &&
+ elements.reference &&
+ elements.reference.contextElement
+ ) {
+ ancestors = ancestors.concat(getOverflowAncestors(elements.reference.contextElement));
+ }
+ }
+
+ // Ignore the visual viewport for scrolling dismissal (allow pinch-zoom)
+ ancestors = ancestors.filter((ancestor) => ancestor !== doc.defaultView?.visualViewport);
+
+ ancestors.forEach((ancestor) => {
+ ancestor.addEventListener('scroll', onScroll, { passive: true });
+ });
+
+ return () => {
+ if (escapeKey) {
+ doc.removeEventListener(
+ 'keydown',
+ escapeKeyCapture ? closeOnEscapeKeyDownCapture : closeOnEscapeKeyDown,
+ escapeKeyCapture,
+ );
+ doc.removeEventListener('compositionstart', handleCompositionStart);
+ doc.removeEventListener('compositionend', handleCompositionEnd);
+ }
+
+ if (outsidePress) {
+ doc.removeEventListener(
+ outsidePressEvent,
+ outsidePressCapture ? closeOnPressOutsideCapture : closeOnPressOutside,
+ outsidePressCapture,
+ );
+ }
+
+ ancestors.forEach((ancestor) => {
+ ancestor.removeEventListener('scroll', onScroll);
+ });
+
+ compositionTimeout.clear();
+ };
+ }, [
+ dataRef,
+ elements,
+ escapeKey,
+ outsidePress,
+ outsidePressEvent,
+ open,
+ onOpenChange,
+ ancestorScroll,
+ enabled,
+ escapeKeyBubbles,
+ outsidePressBubbles,
+ closeOnEscapeKeyDown,
+ escapeKeyCapture,
+ closeOnEscapeKeyDownCapture,
+ closeOnPressOutside,
+ outsidePressCapture,
+ closeOnPressOutsideCapture,
+ ]);
+
+ React.useEffect(() => {
+ dataRef.current.insideReactTree = false;
+ }, [dataRef, outsidePress, outsidePressEvent]);
+
+ const reference: ElementProps['reference'] = React.useMemo(
+ () => ({
+ onKeyDown: closeOnEscapeKeyDown,
+ ...(referencePress && {
+ [bubbleHandlerKeys[referencePressEvent]]: (event: React.SyntheticEvent) => {
+ onOpenChange(false, event.nativeEvent, 'reference-press');
+ },
+ ...(referencePressEvent !== 'click' && {
+ onClick(event) {
+ onOpenChange(false, event.nativeEvent, 'reference-press');
+ },
+ }),
+ }),
+ }),
+ [closeOnEscapeKeyDown, onOpenChange, referencePress, referencePressEvent],
+ );
+
+ const floating: ElementProps['floating'] = React.useMemo(
+ () => ({
+ onKeyDown: closeOnEscapeKeyDown,
+ onMouseDown() {
+ endedOrStartedInsideRef.current = true;
+ },
+ onMouseUp() {
+ endedOrStartedInsideRef.current = true;
+ },
+ [captureHandlerKeys[outsidePressEvent]]: () => {
+ dataRef.current.insideReactTree = true;
+ },
+ onBlurCapture() {
+ if (tree) {
+ return;
+ }
+ dataRef.current.insideReactTree = true;
+ blurTimeout.start(0, () => {
+ dataRef.current.insideReactTree = false;
+ });
+ },
+ }),
+ [closeOnEscapeKeyDown, outsidePressEvent, dataRef, tree, blurTimeout],
+ );
+
+ return React.useMemo(
+ () => (enabled ? { reference, floating } : {}),
+ [enabled, reference, floating],
+ );
+}
diff --git a/packages/react/src/floating-ui-react/hooks/useFloating.ts b/packages/react/src/floating-ui-react/hooks/useFloating.ts
new file mode 100644
index 0000000000..e1eb2e9706
--- /dev/null
+++ b/packages/react/src/floating-ui-react/hooks/useFloating.ts
@@ -0,0 +1,147 @@
+import * as React from 'react';
+import { useFloating as usePosition, type VirtualElement } from '@floating-ui/react-dom';
+import { isElement } from '@floating-ui/utils/dom';
+import { useModernLayoutEffect } from '../../utils/useModernLayoutEffect';
+
+import { useFloatingTree } from '../components/FloatingTree';
+import type {
+ FloatingContext,
+ NarrowedElement,
+ ReferenceType,
+ UseFloatingOptions,
+ UseFloatingReturn,
+} from '../types';
+import { useFloatingRootContext } from './useFloatingRootContext';
+
+/**
+ * Provides data to position a floating element and context to add interactions.
+ * @see https://floating-ui.com/docs/useFloating
+ */
+export function useFloating(
+ options: UseFloatingOptions = {},
+): UseFloatingReturn {
+ const { nodeId } = options;
+
+ const internalRootContext = useFloatingRootContext({
+ ...options,
+ elements: {
+ reference: null,
+ floating: null,
+ ...options.elements,
+ },
+ });
+
+ const rootContext = options.rootContext || internalRootContext;
+ const computedElements = rootContext.elements;
+
+ const [domReferenceState, setDomReference] = React.useState | null>(null);
+ const [positionReference, setPositionReferenceRaw] = React.useState(null);
+
+ const optionDomReference = computedElements?.domReference;
+ const domReference = (optionDomReference || domReferenceState) as NarrowedElement;
+ const domReferenceRef = React.useRef | null>(null);
+
+ const tree = useFloatingTree();
+
+ useModernLayoutEffect(() => {
+ if (domReference) {
+ domReferenceRef.current = domReference;
+ }
+ }, [domReference]);
+
+ const position = usePosition({
+ ...options,
+ elements: {
+ ...computedElements,
+ ...(positionReference && { reference: positionReference }),
+ },
+ });
+
+ const setPositionReference = React.useCallback(
+ (node: ReferenceType | null) => {
+ const computedPositionReference = isElement(node)
+ ? ({
+ getBoundingClientRect: () => node.getBoundingClientRect(),
+ getClientRects: () => node.getClientRects(),
+ contextElement: node,
+ } satisfies VirtualElement)
+ : node;
+ // Store the positionReference in state if the DOM reference is specified externally via the
+ // `elements.reference` option. This ensures that it won't be overridden on future renders.
+ setPositionReferenceRaw(computedPositionReference);
+ position.refs.setReference(computedPositionReference);
+ },
+ [position.refs],
+ );
+
+ const setReference = React.useCallback(
+ (node: RT | null) => {
+ if (isElement(node) || node === null) {
+ (domReferenceRef as React.MutableRefObject).current = node;
+ setDomReference(node as NarrowedElement | null);
+ }
+
+ // Backwards-compatibility for passing a virtual element to `reference`
+ // after it has set the DOM reference.
+ if (
+ isElement(position.refs.reference.current) ||
+ position.refs.reference.current === null ||
+ // Don't allow setting virtual elements using the old technique back to
+ // `null` to support `positionReference` + an unstable `reference`
+ // callback ref.
+ (node !== null && !isElement(node))
+ ) {
+ position.refs.setReference(node);
+ }
+ },
+ [position.refs],
+ );
+
+ const refs = React.useMemo(
+ () => ({
+ ...position.refs,
+ setReference,
+ setPositionReference,
+ domReference: domReferenceRef,
+ }),
+ [position.refs, setReference, setPositionReference],
+ );
+
+ const elements = React.useMemo(
+ () => ({
+ ...position.elements,
+ domReference,
+ }),
+ [position.elements, domReference],
+ );
+
+ const context = React.useMemo>(
+ () => ({
+ ...position,
+ ...rootContext,
+ refs,
+ elements,
+ nodeId,
+ }),
+ [position, refs, elements, nodeId, rootContext],
+ );
+
+ useModernLayoutEffect(() => {
+ rootContext.dataRef.current.floatingContext = context;
+
+ const node = tree?.nodesRef.current.find((n) => n.id === nodeId);
+ if (node) {
+ node.context = context;
+ }
+ });
+
+ return React.useMemo(
+ () => ({
+ ...position,
+ context,
+ refs,
+ elements,
+ }),
+ [position, refs, elements, context],
+ ) as UseFloatingReturn;
+}
diff --git a/packages/react/src/floating-ui-react/hooks/useFloatingRootContext.ts b/packages/react/src/floating-ui-react/hooks/useFloatingRootContext.ts
new file mode 100644
index 0000000000..255dbe3a46
--- /dev/null
+++ b/packages/react/src/floating-ui-react/hooks/useFloatingRootContext.ts
@@ -0,0 +1,85 @@
+import * as React from 'react';
+import { isElement } from '@floating-ui/utils/dom';
+import { useEventCallback } from '../../utils/useEventCallback';
+
+import type {
+ FloatingRootContext,
+ ReferenceElement,
+ ContextData,
+ OpenChangeReason,
+} from '../types';
+import { createEventEmitter } from '../utils/createEventEmitter';
+import { useId } from '../../utils/useId';
+import { useFloatingParentNodeId } from '../components/FloatingTree';
+
+export interface UseFloatingRootContextOptions {
+ open?: boolean;
+ onOpenChange?: (open: boolean, event?: Event, reason?: OpenChangeReason) => void;
+ elements: {
+ reference: Element | null;
+ floating: HTMLElement | null;
+ };
+}
+
+export function useFloatingRootContext(
+ options: UseFloatingRootContextOptions,
+): FloatingRootContext {
+ const { open = false, onOpenChange: onOpenChangeProp, elements: elementsProp } = options;
+
+ const floatingId = useId();
+ const dataRef = React.useRef({});
+ const [events] = React.useState(() => createEventEmitter());
+ const nested = useFloatingParentNodeId() != null;
+
+ if (process.env.NODE_ENV !== 'production') {
+ const optionDomReference = elementsProp.reference;
+ if (optionDomReference && !isElement(optionDomReference)) {
+ console.error(
+ 'Cannot pass a virtual element to the `elements.reference` option,',
+ 'as it must be a real DOM element. Use `refs.setPositionReference()`',
+ 'instead.',
+ );
+ }
+ }
+
+ const [positionReference, setPositionReference] = React.useState(
+ elementsProp.reference,
+ );
+
+ const onOpenChange = useEventCallback(
+ (newOpen: boolean, event?: Event, reason?: OpenChangeReason) => {
+ dataRef.current.openEvent = newOpen ? event : undefined;
+ events.emit('openchange', { open: newOpen, event, reason, nested });
+ onOpenChangeProp?.(newOpen, event, reason);
+ },
+ );
+
+ const refs = React.useMemo(
+ () => ({
+ setPositionReference,
+ }),
+ [],
+ );
+
+ const elements = React.useMemo(
+ () => ({
+ reference: positionReference || elementsProp.reference || null,
+ floating: elementsProp.floating || null,
+ domReference: elementsProp.reference as Element | null,
+ }),
+ [positionReference, elementsProp.reference, elementsProp.floating],
+ );
+
+ return React.useMemo(
+ () => ({
+ dataRef,
+ open,
+ onOpenChange,
+ elements,
+ events,
+ floatingId,
+ refs,
+ }),
+ [open, onOpenChange, elements, events, floatingId, refs],
+ );
+}
diff --git a/packages/react/src/floating-ui-react/hooks/useFocus.ts b/packages/react/src/floating-ui-react/hooks/useFocus.ts
new file mode 100644
index 0000000000..2a0551fcd9
--- /dev/null
+++ b/packages/react/src/floating-ui-react/hooks/useFocus.ts
@@ -0,0 +1,181 @@
+import * as React from 'react';
+import { getWindow, isElement, isHTMLElement } from '@floating-ui/utils/dom';
+import { isMac, isSafari } from '../../utils/detectBrowser';
+import { useTimeout } from '../../utils/useTimeout';
+import {
+ activeElement,
+ contains,
+ getDocument,
+ getTarget,
+ isTypeableElement,
+ matchesFocusVisible,
+} from '../utils';
+
+import type { ElementProps, FloatingRootContext, OpenChangeReason } from '../types';
+import { createAttribute } from '../utils/createAttribute';
+
+const isMacSafari = isMac && isSafari;
+
+export interface UseFocusProps {
+ /**
+ * Whether the Hook is enabled, including all internal Effects and event
+ * handlers.
+ * @default true
+ */
+ enabled?: boolean;
+ /**
+ * Whether the open state only changes if the focus event is considered
+ * visible (`:focus-visible` CSS selector).
+ * @default true
+ */
+ visibleOnly?: boolean;
+}
+
+/**
+ * Opens the floating element while the reference element has focus, like CSS
+ * `:focus`.
+ * @see https://floating-ui.com/docs/useFocus
+ */
+export function useFocus(context: FloatingRootContext, props: UseFocusProps = {}): ElementProps {
+ const { open, onOpenChange, events, dataRef, elements } = context;
+ const { enabled = true, visibleOnly = true } = props;
+
+ const blockFocusRef = React.useRef(false);
+ const timeout = useTimeout();
+ const keyboardModalityRef = React.useRef(true);
+
+ React.useEffect(() => {
+ if (!enabled) {
+ return undefined;
+ }
+
+ const win = getWindow(elements.domReference);
+
+ // If the reference was focused and the user left the tab/window, and the
+ // floating element was not open, the focus should be blocked when they
+ // return to the tab/window.
+ function onBlur() {
+ if (
+ !open &&
+ isHTMLElement(elements.domReference) &&
+ elements.domReference === activeElement(getDocument(elements.domReference))
+ ) {
+ blockFocusRef.current = true;
+ }
+ }
+
+ function onKeyDown() {
+ keyboardModalityRef.current = true;
+ }
+
+ function onPointerDown() {
+ keyboardModalityRef.current = false;
+ }
+
+ win.addEventListener('blur', onBlur);
+
+ if (isMacSafari) {
+ win.addEventListener('keydown', onKeyDown, true);
+ win.addEventListener('pointerdown', onPointerDown, true);
+ }
+
+ return () => {
+ win.removeEventListener('blur', onBlur);
+
+ if (isMacSafari) {
+ win.removeEventListener('keydown', onKeyDown, true);
+ win.removeEventListener('pointerdown', onPointerDown, true);
+ }
+ };
+ }, [elements.domReference, open, enabled]);
+
+ React.useEffect(() => {
+ if (!enabled) {
+ return undefined;
+ }
+
+ function onOpenChangeLocal({ reason }: { reason: OpenChangeReason }) {
+ if (reason === 'reference-press' || reason === 'escape-key') {
+ blockFocusRef.current = true;
+ }
+ }
+
+ events.on('openchange', onOpenChangeLocal);
+ return () => {
+ events.off('openchange', onOpenChangeLocal);
+ };
+ }, [events, enabled]);
+
+ const reference: ElementProps['reference'] = React.useMemo(
+ () => ({
+ onMouseLeave() {
+ blockFocusRef.current = false;
+ },
+ onFocus(event) {
+ if (blockFocusRef.current) {
+ return;
+ }
+
+ const target = getTarget(event.nativeEvent);
+
+ if (visibleOnly && isElement(target)) {
+ // Safari fails to match `:focus-visible` if focus was initially
+ // outside the document.
+ if (isMacSafari && !event.relatedTarget) {
+ if (!keyboardModalityRef.current && !isTypeableElement(target)) {
+ return;
+ }
+ } else if (!matchesFocusVisible(target)) {
+ return;
+ }
+ }
+
+ onOpenChange(true, event.nativeEvent, 'focus');
+ },
+ onBlur(event) {
+ blockFocusRef.current = false;
+ const relatedTarget = event.relatedTarget;
+ const nativeEvent = event.nativeEvent;
+
+ // Hit the non-modal focus management portal guard. Focus will be
+ // moved into the floating element immediately after.
+ const movedToFocusGuard =
+ isElement(relatedTarget) &&
+ relatedTarget.hasAttribute(createAttribute('focus-guard')) &&
+ relatedTarget.getAttribute('data-type') === 'outside';
+
+ // Wait for the window blur listener to fire.
+ timeout.start(0, () => {
+ const activeEl = activeElement(
+ elements.domReference ? elements.domReference.ownerDocument : document,
+ );
+
+ // Focus left the page, keep it open.
+ if (!relatedTarget && activeEl === elements.domReference) {
+ return;
+ }
+
+ // When focusing the reference element (e.g. regular click), then
+ // clicking into the floating element, prevent it from hiding.
+ // Note: it must be focusable, e.g. `tabindex="-1"`.
+ // We can not rely on relatedTarget to point to the correct element
+ // as it will only point to the shadow host of the newly focused element
+ // and not the element that actually has received focus if it is located
+ // inside a shadow root.
+ if (
+ contains(dataRef.current.floatingContext?.refs.floating.current, activeEl) ||
+ contains(elements.domReference, activeEl) ||
+ movedToFocusGuard
+ ) {
+ return;
+ }
+
+ onOpenChange(false, nativeEvent, 'focus');
+ });
+ },
+ }),
+ [dataRef, elements.domReference, onOpenChange, visibleOnly, timeout],
+ );
+
+ return React.useMemo(() => (enabled ? { reference } : {}), [enabled, reference]);
+}
diff --git a/packages/react/src/floating-ui-react/hooks/useHover.ts b/packages/react/src/floating-ui-react/hooks/useHover.ts
new file mode 100644
index 0000000000..90f023eb53
--- /dev/null
+++ b/packages/react/src/floating-ui-react/hooks/useHover.ts
@@ -0,0 +1,518 @@
+import * as React from 'react';
+import { isElement } from '@floating-ui/utils/dom';
+import { useTimeout } from '../../utils/useTimeout';
+import { useLatestRef } from '../../utils/useLatestRef';
+import { useEventCallback } from '../../utils/useEventCallback';
+import { useModernLayoutEffect } from '../../utils/useModernLayoutEffect';
+import { contains, getDocument, isMouseLikePointerType } from '../utils';
+
+import { useFloatingParentNodeId, useFloatingTree } from '../components/FloatingTree';
+import type {
+ Delay,
+ ElementProps,
+ FloatingContext,
+ FloatingRootContext,
+ FloatingTreeType,
+ OpenChangeReason,
+ SafePolygonOptions,
+} from '../types';
+import { createAttribute } from '../utils/createAttribute';
+
+const safePolygonIdentifier = createAttribute('safe-polygon');
+
+export interface HandleCloseContext extends FloatingContext {
+ onClose: () => void;
+ tree?: FloatingTreeType | null;
+ leave?: boolean;
+}
+
+export interface HandleClose {
+ (context: HandleCloseContext): (event: MouseEvent) => void;
+ __options?: SafePolygonOptions;
+}
+
+export function getDelay(
+ value: UseHoverProps['delay'],
+ prop: 'open' | 'close',
+ pointerType?: PointerEvent['pointerType'],
+) {
+ if (pointerType && !isMouseLikePointerType(pointerType)) {
+ return 0;
+ }
+
+ if (typeof value === 'number') {
+ return value;
+ }
+
+ if (typeof value === 'function') {
+ const result = value();
+ if (typeof result === 'number') {
+ return result;
+ }
+ return result?.[prop];
+ }
+
+ return value?.[prop];
+}
+
+function getRestMs(value: number | (() => number)) {
+ if (typeof value === 'function') {
+ return value();
+ }
+ return value;
+}
+
+export interface UseHoverProps {
+ /**
+ * Whether the Hook is enabled, including all internal Effects and event
+ * handlers.
+ * @default true
+ */
+ enabled?: boolean;
+ /**
+ * Accepts an event handler that runs on `mousemove` to control when the
+ * floating element closes once the cursor leaves the reference element.
+ * @default null
+ */
+ handleClose?: HandleClose | null;
+ /**
+ * Waits until the user’s cursor is at “rest” over the reference element
+ * before changing the `open` state.
+ * @default 0
+ */
+ restMs?: number | (() => number);
+ /**
+ * Waits for the specified time when the event listener runs before changing
+ * the `open` state.
+ * @default 0
+ */
+ delay?: Delay | (() => Delay);
+ /**
+ * Whether the logic only runs for mouse input, ignoring touch input.
+ * Note: due to a bug with Linux Chrome, "pen" inputs are considered "mouse".
+ * @default false
+ */
+ mouseOnly?: boolean;
+ /**
+ * Whether moving the cursor over the floating element will open it, without a
+ * regular hover event required.
+ * @default true
+ */
+ move?: boolean;
+}
+
+/**
+ * Opens the floating element while hovering over the reference element, like
+ * CSS `:hover`.
+ * @see https://floating-ui.com/docs/useHover
+ */
+export function useHover(context: FloatingRootContext, props: UseHoverProps = {}): ElementProps {
+ const { open, onOpenChange, dataRef, events, elements } = context;
+ const {
+ enabled = true,
+ delay = 0,
+ handleClose = null,
+ mouseOnly = false,
+ restMs = 0,
+ move = true,
+ } = props;
+
+ const tree = useFloatingTree();
+ const parentId = useFloatingParentNodeId();
+ const handleCloseRef = useLatestRef(handleClose);
+ const delayRef = useLatestRef(delay);
+ const openRef = useLatestRef(open);
+ const restMsRef = useLatestRef(restMs);
+
+ const pointerTypeRef = React.useRef(undefined);
+ const timeout = useTimeout();
+ const handlerRef = React.useRef<(event: MouseEvent) => void>(undefined);
+ const restTimeout = useTimeout();
+ const blockMouseMoveRef = React.useRef(true);
+ const performedPointerEventsMutationRef = React.useRef(false);
+ const unbindMouseMoveRef = React.useRef(() => {});
+ const restTimeoutPendingRef = React.useRef(false);
+
+ const isHoverOpen = useEventCallback(() => {
+ const type = dataRef.current.openEvent?.type;
+ return type?.includes('mouse') && type !== 'mousedown';
+ });
+
+ // When closing before opening, clear the delay timeouts to cancel it
+ // from showing.
+ React.useEffect(() => {
+ if (!enabled) {
+ return undefined;
+ }
+
+ function onOpenChangeLocal({ open: newOpen }: { open: boolean }) {
+ if (!newOpen) {
+ timeout.clear();
+ restTimeout.clear();
+ blockMouseMoveRef.current = true;
+ restTimeoutPendingRef.current = false;
+ }
+ }
+
+ events.on('openchange', onOpenChangeLocal);
+ return () => {
+ events.off('openchange', onOpenChangeLocal);
+ };
+ }, [enabled, events, timeout, restTimeout]);
+
+ React.useEffect(() => {
+ if (!enabled) {
+ return undefined;
+ }
+ if (!handleCloseRef.current) {
+ return undefined;
+ }
+ if (!open) {
+ return undefined;
+ }
+
+ function onLeave(event: MouseEvent) {
+ if (isHoverOpen()) {
+ onOpenChange(false, event, 'hover');
+ }
+ }
+
+ const html = getDocument(elements.floating).documentElement;
+ html.addEventListener('mouseleave', onLeave);
+ return () => {
+ html.removeEventListener('mouseleave', onLeave);
+ };
+ }, [elements.floating, open, onOpenChange, enabled, handleCloseRef, isHoverOpen]);
+
+ const closeWithDelay = React.useCallback(
+ (event: Event, runElseBranch = true, reason: OpenChangeReason = 'hover') => {
+ const closeDelay = getDelay(delayRef.current, 'close', pointerTypeRef.current);
+ if (closeDelay && !handlerRef.current) {
+ timeout.start(closeDelay, () => onOpenChange(false, event, reason));
+ } else if (runElseBranch) {
+ timeout.clear();
+ onOpenChange(false, event, reason);
+ }
+ },
+ [delayRef, onOpenChange, timeout],
+ );
+
+ const cleanupMouseMoveHandler = useEventCallback(() => {
+ unbindMouseMoveRef.current();
+ handlerRef.current = undefined;
+ });
+
+ const clearPointerEvents = useEventCallback(() => {
+ if (performedPointerEventsMutationRef.current) {
+ const body = getDocument(elements.floating).body;
+ body.style.pointerEvents = '';
+ body.removeAttribute(safePolygonIdentifier);
+ performedPointerEventsMutationRef.current = false;
+ }
+ });
+
+ const isClickLikeOpenEvent = useEventCallback(() => {
+ return dataRef.current.openEvent
+ ? ['click', 'mousedown'].includes(dataRef.current.openEvent.type)
+ : false;
+ });
+
+ // Registering the mouse events on the reference directly to bypass React's
+ // delegation system. If the cursor was on a disabled element and then entered
+ // the reference (no gap), `mouseenter` doesn't fire in the delegation system.
+ React.useEffect(() => {
+ if (!enabled) {
+ return undefined;
+ }
+
+ function onReferenceMouseEnter(event: MouseEvent) {
+ timeout.clear();
+ blockMouseMoveRef.current = false;
+
+ if (
+ (mouseOnly && !isMouseLikePointerType(pointerTypeRef.current)) ||
+ (getRestMs(restMsRef.current) > 0 && !getDelay(delayRef.current, 'open'))
+ ) {
+ return;
+ }
+
+ const openDelay = getDelay(delayRef.current, 'open', pointerTypeRef.current);
+
+ if (openDelay) {
+ timeout.start(openDelay, () => {
+ if (!openRef.current) {
+ onOpenChange(true, event, 'hover');
+ }
+ });
+ } else if (!open) {
+ onOpenChange(true, event, 'hover');
+ }
+ }
+
+ function onReferenceMouseLeave(event: MouseEvent) {
+ if (isClickLikeOpenEvent()) {
+ clearPointerEvents();
+ return;
+ }
+
+ unbindMouseMoveRef.current();
+
+ const doc = getDocument(elements.floating);
+ restTimeout.clear();
+ restTimeoutPendingRef.current = false;
+
+ if (handleCloseRef.current && dataRef.current.floatingContext) {
+ // Prevent clearing `onScrollMouseLeave` timeout.
+ if (!open) {
+ timeout.clear();
+ }
+
+ handlerRef.current = handleCloseRef.current({
+ ...dataRef.current.floatingContext,
+ tree,
+ x: event.clientX,
+ y: event.clientY,
+ onClose() {
+ clearPointerEvents();
+ cleanupMouseMoveHandler();
+ if (!isClickLikeOpenEvent()) {
+ closeWithDelay(event, true, 'safe-polygon');
+ }
+ },
+ });
+
+ const handler = handlerRef.current;
+
+ doc.addEventListener('mousemove', handler);
+ unbindMouseMoveRef.current = () => {
+ doc.removeEventListener('mousemove', handler);
+ };
+
+ return;
+ }
+
+ // Allow interactivity without `safePolygon` on touch devices. With a
+ // pointer, a short close delay is an alternative, so it should work
+ // consistently.
+ const shouldClose =
+ pointerTypeRef.current === 'touch'
+ ? !contains(elements.floating, event.relatedTarget as Element | null)
+ : true;
+ if (shouldClose) {
+ closeWithDelay(event);
+ }
+ }
+
+ // Ensure the floating element closes after scrolling even if the pointer
+ // did not move.
+ // https://github.com/floating-ui/floating-ui/discussions/1692
+ function onScrollMouseLeave(event: MouseEvent) {
+ if (isClickLikeOpenEvent()) {
+ return;
+ }
+ if (!dataRef.current.floatingContext) {
+ return;
+ }
+
+ handleCloseRef.current?.({
+ ...dataRef.current.floatingContext,
+ tree,
+ x: event.clientX,
+ y: event.clientY,
+ onClose() {
+ clearPointerEvents();
+ cleanupMouseMoveHandler();
+ if (!isClickLikeOpenEvent()) {
+ closeWithDelay(event);
+ }
+ },
+ })(event);
+ }
+
+ function onFloatingMouseEnter() {
+ timeout.clear();
+ }
+
+ function onFloatingMouseLeave(event: MouseEvent) {
+ if (!isClickLikeOpenEvent()) {
+ closeWithDelay(event, false);
+ }
+ }
+
+ if (isElement(elements.domReference)) {
+ const reference = elements.domReference as unknown as HTMLElement;
+ const floating = elements.floating;
+
+ if (open) {
+ reference.addEventListener('mouseleave', onScrollMouseLeave);
+ }
+
+ if (move) {
+ reference.addEventListener('mousemove', onReferenceMouseEnter, {
+ once: true,
+ });
+ }
+
+ reference.addEventListener('mouseenter', onReferenceMouseEnter);
+ reference.addEventListener('mouseleave', onReferenceMouseLeave);
+
+ if (floating) {
+ floating.addEventListener('mouseleave', onScrollMouseLeave);
+ floating.addEventListener('mouseenter', onFloatingMouseEnter);
+ floating.addEventListener('mouseleave', onFloatingMouseLeave);
+ }
+
+ return () => {
+ if (open) {
+ reference.removeEventListener('mouseleave', onScrollMouseLeave);
+ }
+
+ if (move) {
+ reference.removeEventListener('mousemove', onReferenceMouseEnter);
+ }
+
+ reference.removeEventListener('mouseenter', onReferenceMouseEnter);
+ reference.removeEventListener('mouseleave', onReferenceMouseLeave);
+
+ if (floating) {
+ floating.removeEventListener('mouseleave', onScrollMouseLeave);
+ floating.removeEventListener('mouseenter', onFloatingMouseEnter);
+ floating.removeEventListener('mouseleave', onFloatingMouseLeave);
+ }
+ };
+ }
+
+ return undefined;
+ }, [
+ elements,
+ enabled,
+ context,
+ mouseOnly,
+ move,
+ closeWithDelay,
+ cleanupMouseMoveHandler,
+ clearPointerEvents,
+ onOpenChange,
+ open,
+ openRef,
+ tree,
+ delayRef,
+ handleCloseRef,
+ dataRef,
+ isClickLikeOpenEvent,
+ restMsRef,
+ timeout,
+ restTimeout,
+ ]);
+
+ // Block pointer-events of every element other than the reference and floating
+ // while the floating element is open and has a `handleClose` handler. Also
+ // handles nested floating elements.
+ // https://github.com/floating-ui/floating-ui/issues/1722
+ useModernLayoutEffect(() => {
+ if (!enabled) {
+ return undefined;
+ }
+
+ // eslint-disable-next-line no-underscore-dangle
+ if (open && handleCloseRef.current?.__options?.blockPointerEvents && isHoverOpen()) {
+ performedPointerEventsMutationRef.current = true;
+ const floatingEl = elements.floating;
+
+ if (isElement(elements.domReference) && floatingEl) {
+ const body = getDocument(elements.floating).body;
+ body.setAttribute(safePolygonIdentifier, '');
+
+ const ref = elements.domReference as unknown as HTMLElement | SVGSVGElement;
+
+ const parentFloating = tree?.nodesRef.current.find((node) => node.id === parentId)?.context
+ ?.elements.floating;
+
+ if (parentFloating) {
+ parentFloating.style.pointerEvents = '';
+ }
+
+ body.style.pointerEvents = 'none';
+ ref.style.pointerEvents = 'auto';
+ floatingEl.style.pointerEvents = 'auto';
+
+ return () => {
+ body.style.pointerEvents = '';
+ ref.style.pointerEvents = '';
+ floatingEl.style.pointerEvents = '';
+ };
+ }
+ }
+
+ return undefined;
+ }, [enabled, open, parentId, elements, tree, handleCloseRef, isHoverOpen]);
+
+ useModernLayoutEffect(() => {
+ if (!open) {
+ pointerTypeRef.current = undefined;
+ restTimeoutPendingRef.current = false;
+ cleanupMouseMoveHandler();
+ clearPointerEvents();
+ }
+ }, [open, cleanupMouseMoveHandler, clearPointerEvents]);
+
+ React.useEffect(() => {
+ return () => {
+ cleanupMouseMoveHandler();
+ timeout.clear();
+ restTimeout.clear();
+ clearPointerEvents();
+ };
+ }, [
+ enabled,
+ elements.domReference,
+ cleanupMouseMoveHandler,
+ clearPointerEvents,
+ timeout,
+ restTimeout,
+ ]);
+
+ const reference: ElementProps['reference'] = React.useMemo(() => {
+ function setPointerRef(event: React.PointerEvent) {
+ pointerTypeRef.current = event.pointerType;
+ }
+
+ return {
+ onPointerDown: setPointerRef,
+ onPointerEnter: setPointerRef,
+ onMouseMove(event) {
+ const { nativeEvent } = event;
+
+ function handleMouseMove() {
+ if (!blockMouseMoveRef.current && !openRef.current) {
+ onOpenChange(true, nativeEvent, 'hover');
+ }
+ }
+
+ if (mouseOnly && !isMouseLikePointerType(pointerTypeRef.current)) {
+ return;
+ }
+
+ if (open || getRestMs(restMsRef.current) === 0) {
+ return;
+ }
+
+ // Ignore insignificant movements to account for tremors.
+ if (restTimeoutPendingRef.current && event.movementX ** 2 + event.movementY ** 2 < 2) {
+ return;
+ }
+
+ restTimeout.clear();
+
+ if (pointerTypeRef.current === 'touch') {
+ handleMouseMove();
+ } else {
+ restTimeoutPendingRef.current = true;
+ restTimeout.start(getRestMs(restMsRef.current), handleMouseMove);
+ }
+ },
+ };
+ }, [mouseOnly, onOpenChange, open, openRef, restMsRef, restTimeout]);
+
+ return React.useMemo(() => (enabled ? { reference } : {}), [enabled, reference]);
+}
diff --git a/packages/react/src/floating-ui-react/hooks/useInteractions.test.tsx b/packages/react/src/floating-ui-react/hooks/useInteractions.test.tsx
new file mode 100644
index 0000000000..3b1a79746f
--- /dev/null
+++ b/packages/react/src/floating-ui-react/hooks/useInteractions.test.tsx
@@ -0,0 +1,149 @@
+import * as React from 'react';
+import { render } from '@testing-library/react';
+import { vi } from 'vitest';
+
+import {
+ useClick,
+ useDismiss,
+ useFloating,
+ useFocus,
+ useHover,
+ useInteractions,
+ useListNavigation,
+ useRole,
+ useTypeahead,
+} from '../index';
+
+describe('useInteractions', () => {
+ it('correctly merges functions', () => {
+ const firstInteractionOnClick = vi.fn();
+ const secondInteractionOnClick = vi.fn();
+ const secondInteractionOnKeyDown = vi.fn();
+ const userOnClick = vi.fn();
+
+ function App() {
+ const { getReferenceProps } = useInteractions([
+ { reference: { onClick: firstInteractionOnClick } },
+ {
+ reference: {
+ onClick: secondInteractionOnClick,
+ onKeyDown: secondInteractionOnKeyDown,
+ },
+ },
+ ]);
+
+ const { onClick, onKeyDown } = getReferenceProps({ onClick: userOnClick });
+
+ // @ts-expect-error
+ onClick();
+ // @ts-expect-error
+ onKeyDown();
+
+ return null;
+ }
+
+ render( );
+
+ expect(firstInteractionOnClick).toHaveBeenCalledTimes(1);
+ expect(secondInteractionOnClick).toHaveBeenCalledTimes(1);
+ expect(userOnClick).toHaveBeenCalledTimes(1);
+ expect(secondInteractionOnKeyDown).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not error with undefined user supplied functions', () => {
+ function App() {
+ const { getReferenceProps } = useInteractions([{ reference: { onClick() {} } }]);
+ expect(() =>
+ // @ts-expect-error
+ getReferenceProps({ onClick: undefined }).onClick(),
+ ).not.toThrowError();
+ return null;
+ }
+
+ render( );
+ });
+
+ it('does not break props that start with `on`', () => {
+ function App() {
+ const { getReferenceProps } = useInteractions([]);
+
+ const props = getReferenceProps({
+ // @ts-expect-error
+ onlyShowVotes: true,
+ onyx: () => {},
+ });
+
+ expect(props.onlyShowVotes).toBe(true);
+ expect(typeof props.onyx).toBe('function');
+
+ return null;
+ }
+
+ render( );
+ });
+
+ it('does not break props that return values', () => {
+ function App() {
+ const { getReferenceProps } = useInteractions([]);
+
+ const props = getReferenceProps({
+ // @ts-expect-error
+ onyx: () => 'returned value',
+ });
+
+ // @ts-expect-error
+ expect(props.onyx()).toBe('returned value');
+
+ return null;
+ }
+
+ render( );
+ });
+
+ it('prop getters are memoized', () => {
+ function App() {
+ const [open, setOpen] = React.useState(false);
+ const [, setCount] = React.useState(0);
+
+ const handleClose = () => () => {};
+ // eslint-disable-next-line
+ handleClose.__options = { blockPointerEvents: true };
+
+ const listRef = React.useRef([]);
+ const { context } = useFloating({ open, onOpenChange: setOpen });
+
+ // NOTE: if `ref`-related props are not memoized, this will cause
+ // an infinite loop as they must be memoized externally (as done by React).
+ // Other non-primitives like functions and arrays get memoized by the hooks.
+ const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
+ useHover(context, { handleClose }),
+ useFocus(context),
+ useClick(context),
+ useRole(context),
+ useDismiss(context),
+ useListNavigation(context, {
+ listRef,
+ activeIndex: 0,
+ onNavigate: () => {},
+ disabledIndices: [],
+ }),
+ useTypeahead(context, {
+ listRef,
+ activeIndex: 0,
+ ignoreKeys: [],
+ onMatch: () => {},
+ findMatch: () => '',
+ }),
+ ]);
+
+ React.useEffect(() => {
+ // Should NOT cause an infinite loop as the prop getters are memoized.
+ setCount((c) => c + 1);
+ }, [getReferenceProps, getFloatingProps, getItemProps]);
+
+ return null;
+ }
+
+ render( );
+ });
+});
diff --git a/packages/react/src/floating-ui-react/hooks/useInteractions.ts b/packages/react/src/floating-ui-react/hooks/useInteractions.ts
new file mode 100644
index 0000000000..0e06803aac
--- /dev/null
+++ b/packages/react/src/floating-ui-react/hooks/useInteractions.ts
@@ -0,0 +1,134 @@
+import * as React from 'react';
+
+import type { ElementProps } from '../types';
+import { ACTIVE_KEY, FOCUSABLE_ATTRIBUTE, SELECTED_KEY } from '../utils/constants';
+
+export type ExtendedUserProps = {
+ [ACTIVE_KEY]?: boolean;
+ [SELECTED_KEY]?: boolean;
+};
+
+export interface UseInteractionsReturn {
+ getReferenceProps: (userProps?: React.HTMLProps) => Record;
+ getFloatingProps: (userProps?: React.HTMLProps) => Record;
+ getItemProps: (
+ userProps?: Omit, 'selected' | 'active'> & ExtendedUserProps,
+ ) => Record;
+}
+
+/**
+ * Merges an array of interaction hooks' props into prop getters, allowing
+ * event handler functions to be composed together without overwriting one
+ * another.
+ * @see https://floating-ui.com/docs/useInteractions
+ */
+export function useInteractions(propsList: Array = []): UseInteractionsReturn {
+ const referenceDeps = propsList.map((key) => key?.reference);
+ const floatingDeps = propsList.map((key) => key?.floating);
+ const itemDeps = propsList.map((key) => key?.item);
+
+ const getReferenceProps = React.useCallback(
+ (userProps?: React.HTMLProps) => mergeProps(userProps, propsList, 'reference'),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ referenceDeps,
+ );
+
+ const getFloatingProps = React.useCallback(
+ (userProps?: React.HTMLProps) => mergeProps(userProps, propsList, 'floating'),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ floatingDeps,
+ );
+
+ const getItemProps = React.useCallback(
+ (userProps?: Omit, 'selected' | 'active'> & ExtendedUserProps) =>
+ mergeProps(userProps, propsList, 'item'),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ itemDeps,
+ );
+
+ return React.useMemo(
+ () => ({ getReferenceProps, getFloatingProps, getItemProps }),
+ [getReferenceProps, getFloatingProps, getItemProps],
+ );
+}
+
+/* eslint-disable guard-for-in */
+
+function mergeProps(
+ userProps: (React.HTMLProps & ExtendedUserProps) | undefined,
+ propsList: Array,
+ elementKey: Key,
+): Record {
+ const eventHandlers = new Map void>>();
+ const isItem = elementKey === 'item';
+
+ const outputProps = {} as Record;
+
+ if (elementKey === 'floating') {
+ outputProps.tabIndex = -1;
+ outputProps[FOCUSABLE_ATTRIBUTE] = '';
+ }
+
+ for (const key in userProps) {
+ if (isItem && userProps) {
+ if (key === ACTIVE_KEY || key === SELECTED_KEY) {
+ continue;
+ }
+ }
+ outputProps[key] = (userProps as any)[key];
+ }
+
+ for (let i = 0; i < propsList.length; i += 1) {
+ let props;
+
+ const propsOrGetProps = propsList[i]?.[elementKey];
+ if (typeof propsOrGetProps === 'function') {
+ props = userProps ? propsOrGetProps(userProps) : null;
+ } else {
+ props = propsOrGetProps;
+ }
+ if (!props) {
+ continue;
+ }
+
+ mutablyMergeProps(outputProps, props, isItem, eventHandlers);
+ }
+
+ mutablyMergeProps(outputProps, userProps, isItem, eventHandlers);
+
+ return outputProps;
+}
+
+function mutablyMergeProps(
+ outputProps: Record,
+ props: any,
+ isItem: boolean,
+ eventHandlers: Map void>>,
+) {
+ for (const key in props) {
+ const value = (props as any)[key];
+
+ if (isItem && (key === ACTIVE_KEY || key === SELECTED_KEY)) {
+ continue;
+ }
+
+ if (!key.startsWith('on')) {
+ outputProps[key] = value;
+ } else {
+ if (!eventHandlers.has(key)) {
+ eventHandlers.set(key, []);
+ }
+
+ if (typeof value === 'function') {
+ eventHandlers.get(key)?.push(value);
+
+ outputProps[key] = (...args: unknown[]) => {
+ return eventHandlers
+ .get(key)
+ ?.map((fn) => fn(...args))
+ .find((val) => val !== undefined);
+ };
+ }
+ }
+ }
+}
diff --git a/packages/react/src/floating-ui-react/hooks/useListNavigation.test.tsx b/packages/react/src/floating-ui-react/hooks/useListNavigation.test.tsx
new file mode 100644
index 0000000000..6bcfc5b56f
--- /dev/null
+++ b/packages/react/src/floating-ui-react/hooks/useListNavigation.test.tsx
@@ -0,0 +1,1239 @@
+import * as React from 'react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { vi, it, describe } from 'vitest';
+
+import { useClick, useDismiss, useFloating, useInteractions, useListNavigation } from '../index';
+import type { UseListNavigationProps } from '../types';
+import { Main as ComplexGrid } from '../test-components/ComplexGrid';
+import { Main as Grid } from '../test-components/Grid';
+import { Main as EmojiPicker } from '../test-components/EmojiPicker';
+import { Main as ListboxFocus } from '../test-components/ListboxFocus';
+import { Main as NestedMenu } from '../test-components/Menu';
+import { HorizontalMenu } from '../test-components/MenuOrientation';
+import { Menu, MenuItem } from '../test-components/MenuVirtual';
+import { isJSDOM } from '../../utils/detectBrowser';
+
+/* eslint-disable testing-library/no-unnecessary-act */
+
+function App(props: Omit, 'listRef'>) {
+ const [open, setOpen] = React.useState(false);
+ const listRef = React.useRef>([]);
+ const [activeIndex, setActiveIndex] = React.useState(null);
+ const { refs, context } = useFloating({
+ open,
+ onOpenChange: setOpen,
+ });
+ const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
+ useClick(context),
+ useListNavigation(context, {
+ ...props,
+ listRef,
+ activeIndex,
+ onNavigate(index) {
+ setActiveIndex(index);
+ props.onNavigate?.(index);
+ },
+ }),
+ ]);
+
+ return (
+
+
+ {open && (
+
+
+ {['one', 'two', 'three'].map((string, index) => (
+ // eslint-disable-next-line
+
+ {string}
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+describe('useListNavigation', () => {
+ it('opens on ArrowDown and focuses first item', async () => {
+ render( );
+
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowDown' });
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByTestId('item-0')).toHaveFocus();
+ });
+ });
+
+ it('opens on ArrowUp and focuses last item', async () => {
+ render( );
+
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowUp' });
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByTestId('item-2')).toHaveFocus();
+ });
+ });
+
+ it('navigates down on ArrowDown', async () => {
+ render( );
+
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowDown' });
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByTestId('item-0')).toHaveFocus();
+ });
+
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowDown' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-1')).toHaveFocus();
+ });
+
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowDown' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-2')).toHaveFocus();
+ });
+
+ // Reached the end of the list.
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowDown' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-2')).toHaveFocus();
+ });
+ });
+
+ it('navigates up on ArrowUp', async () => {
+ render( );
+
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowUp' });
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByTestId('item-2')).toHaveFocus();
+ });
+
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowUp' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-1')).toHaveFocus();
+ });
+
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowUp' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-0')).toHaveFocus();
+ });
+
+ // Reached the end of the list.
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowUp' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-0')).toHaveFocus();
+ });
+ });
+
+ it('resets indexRef to -1 upon close', async () => {
+ const data = ['a', 'ab', 'abc', 'abcd'];
+
+ function Autocomplete() {
+ const [open, setOpen] = React.useState(false);
+ const [inputValue, setInputValue] = React.useState('');
+ const [activeIndex, setActiveIndex] = React.useState(null);
+
+ const listRef = React.useRef>([]);
+
+ const { x, y, strategy, context, refs } = useFloating({
+ open,
+ onOpenChange: setOpen,
+ });
+
+ const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
+ useDismiss(context),
+ useListNavigation(context, {
+ listRef,
+ activeIndex,
+ onNavigate: setActiveIndex,
+ virtual: true,
+ loop: true,
+ }),
+ ]);
+
+ function onChange(event: React.ChangeEvent) {
+ const value = event.target.value;
+ setInputValue(value);
+
+ if (value) {
+ setActiveIndex(null);
+ setOpen(true);
+ } else {
+ setOpen(false);
+ }
+ }
+
+ const items = data.filter((item) => item.toLowerCase().startsWith(inputValue.toLowerCase()));
+
+ return (
+
+
+ {open && (
+
+
+ {items.map((item, index) => (
+
+ {item}
+
+ ))}
+
+
+ )}
+ {activeIndex}
+
+ );
+ }
+
+ render( );
+
+ act(() => screen.getByTestId('reference').focus());
+ await userEvent.keyboard('a');
+ await act(async () => {});
+
+ expect(screen.getByTestId('floating')).toBeInTheDocument();
+ expect(screen.getByTestId('active-index').textContent).toBe('');
+
+ await userEvent.keyboard('{ArrowDown}');
+ await userEvent.keyboard('{ArrowDown}');
+ await userEvent.keyboard('{ArrowDown}');
+
+ expect(screen.getByTestId('active-index').textContent).toBe('2');
+
+ await userEvent.keyboard('{Escape}');
+
+ expect(screen.getByTestId('active-index').textContent).toBe('');
+
+ await userEvent.keyboard('{Backspace}');
+ await userEvent.keyboard('a');
+
+ expect(screen.getByTestId('floating')).toBeInTheDocument();
+ expect(screen.getByTestId('active-index').textContent).toBe('');
+
+ await userEvent.keyboard('{ArrowDown}');
+
+ expect(screen.getByTestId('active-index').textContent).toBe('0');
+ });
+
+ describe('loop', () => {
+ it('ArrowDown looping', async () => {
+ render( );
+
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowDown' });
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByTestId('item-0')).toHaveFocus();
+ });
+
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowDown' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-1')).toHaveFocus();
+ });
+
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowDown' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-2')).toHaveFocus();
+ });
+
+ // Reached the end of the list and loops.
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowDown' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-0')).toHaveFocus();
+ });
+ });
+
+ it('ArrowUp looping', async () => {
+ render( );
+
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowUp' });
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByTestId('item-2')).toHaveFocus();
+ });
+
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowUp' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-1')).toHaveFocus();
+ });
+
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowUp' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-0')).toHaveFocus();
+ });
+
+ // Reached the end of the list and loops.
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowUp' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-2')).toHaveFocus();
+ });
+ });
+ });
+
+ describe('orientation', () => {
+ it('navigates down on ArrowRight', async () => {
+ render( );
+
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowRight' });
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByTestId('item-0')).toHaveFocus();
+ });
+
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowRight' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-1')).toHaveFocus();
+ });
+
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowRight' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-2')).toHaveFocus();
+ });
+
+ // Reached the end of the list.
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowRight' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-2')).toHaveFocus();
+ });
+ });
+
+ it('navigates up on ArrowLeft', async () => {
+ render( );
+
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowLeft' });
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByTestId('item-2')).toHaveFocus();
+ });
+
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowLeft' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-1')).toHaveFocus();
+ });
+
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowLeft' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-0')).toHaveFocus();
+ });
+
+ // Reached the end of the list.
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowLeft' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-0')).toHaveFocus();
+ });
+ });
+ });
+
+ describe('rtl', () => {
+ it('navigates down on ArrowLeft', async () => {
+ render( );
+
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowLeft' });
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByTestId('item-0')).toHaveFocus();
+ });
+
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowLeft' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-1')).toHaveFocus();
+ });
+
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowLeft' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-2')).toHaveFocus();
+ });
+
+ // Reached the end of the list.
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowLeft' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-2')).toHaveFocus();
+ });
+ });
+
+ it('navigates up on ArrowRight', async () => {
+ render( );
+
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowRight' });
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByTestId('item-2')).toHaveFocus();
+ });
+
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowRight' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-1')).toHaveFocus();
+ });
+
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowRight' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-0')).toHaveFocus();
+ });
+
+ // Reached the end of the list.
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowRight' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-0')).toHaveFocus();
+ });
+ });
+ });
+
+ describe('focusItemOnOpen', () => {
+ it('true click', async () => {
+ render( );
+ fireEvent.click(screen.getByRole('button'));
+ await waitFor(() => {
+ expect(screen.getByTestId('item-0')).toHaveFocus();
+ });
+ });
+
+ it('false click', async () => {
+ render( );
+ fireEvent.click(screen.getByRole('button'));
+ await waitFor(() => {
+ expect(screen.getByTestId('item-0')).not.toHaveFocus();
+ });
+ });
+ });
+
+ describe('selectedIndex', () => {
+ it('scrollIntoView on open', ({ onTestFinished }) => {
+ const requestAnimationFrame = vi
+ .spyOn(window, 'requestAnimationFrame')
+ .mockImplementation(() => 0);
+ const scrollIntoView = vi.fn();
+ const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
+ HTMLElement.prototype.scrollIntoView = scrollIntoView;
+
+ onTestFinished(() => {
+ requestAnimationFrame.mockRestore();
+ HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
+ });
+
+ render( );
+ fireEvent.click(screen.getByRole('button'));
+ expect(requestAnimationFrame).toHaveBeenCalled();
+ // Run the timer
+ requestAnimationFrame.mock.calls.forEach((call) => call[0](0));
+ expect(scrollIntoView).toHaveBeenCalled();
+ });
+ });
+
+ describe('allowEscape + virtual', () => {
+ it('true', () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowDown' });
+ expect(screen.getByTestId('item-0').getAttribute('aria-selected')).toBe('true');
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowUp' });
+ expect(screen.getByTestId('item-0').getAttribute('aria-selected')).toBe('false');
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowDown' });
+ expect(screen.getByTestId('item-0').getAttribute('aria-selected')).toBe('true');
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowDown' });
+ expect(screen.getByTestId('item-1').getAttribute('aria-selected')).toBe('true');
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowDown' });
+ expect(screen.getByTestId('item-2').getAttribute('aria-selected')).toBe('true');
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowDown' });
+ expect(screen.getByTestId('item-2').getAttribute('aria-selected')).toBe('false');
+ });
+
+ it('false', () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowDown' });
+ expect(screen.getByTestId('item-0').getAttribute('aria-selected')).toBe('true');
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowDown' });
+ expect(screen.getByTestId('item-1').getAttribute('aria-selected')).toBe('true');
+ });
+
+ it('true - onNavigate is called with `null` when escaped', () => {
+ const spy = vi.fn();
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowDown' });
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowUp' });
+ expect(spy).toHaveBeenCalledTimes(2);
+ expect(spy).toHaveBeenCalledWith(null);
+ });
+ });
+
+ describe('openOnArrowKeyDown', () => {
+ it('true ArrowDown', () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowDown' });
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ });
+
+ it('true ArrowUp', () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowUp' });
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ });
+
+ it('false ArrowDown', () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowDown' });
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+ });
+
+ it('false ArrowUp', () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowUp' });
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('disabledIndices', () => {
+ it('indices are skipped in focus order', async () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'ArrowDown' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-1')).toHaveFocus();
+ });
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'ArrowUp' });
+ await waitFor(() => {
+ expect(screen.getByTestId('item-1')).toHaveFocus();
+ });
+ });
+ });
+
+ describe('focusOnHover', () => {
+ it('true - focuses item on hover and syncs the active index', () => {
+ const spy = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole('button'));
+ fireEvent.mouseMove(screen.getByTestId('item-1'));
+ expect(screen.getByTestId('item-1')).toHaveFocus();
+ fireEvent.pointerLeave(screen.getByTestId('item-1'));
+ expect(screen.getByRole('menu')).toHaveFocus();
+ expect(spy).toHaveBeenCalledWith(1);
+ });
+
+ it('false - does not focus item on hover and does not sync the active index', async () => {
+ const spy = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole('button'));
+ fireEvent.mouseMove(screen.getByTestId('item-1'));
+ expect(screen.getByTestId('item-1')).not.toHaveFocus();
+ expect(spy).toHaveBeenCalledTimes(0);
+ });
+ });
+
+ describe('grid navigation', () => {
+ it('ArrowDown focuses first item', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button'));
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ fireEvent.keyDown(document, { key: 'ArrowDown' });
+ await waitFor(() => {
+ expect(screen.getAllByRole('option')[8]).toHaveFocus();
+ });
+ });
+
+ it('focuses first non-disabled item in grid', async () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ fireEvent.click(screen.getByRole('button'));
+ await waitFor(() => {
+ expect(screen.getAllByRole('option')[8]).toHaveFocus();
+ });
+ });
+
+ it('focuses next item using ArrowRight key, skipping disabled items', () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ fireEvent.click(screen.getByRole('button'));
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowRight' });
+ expect(screen.getAllByRole('option')[9]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowRight' });
+ expect(screen.getAllByRole('option')[11]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowRight' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowRight' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowRight' });
+ expect(screen.getAllByRole('option')[14]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowRight' });
+ expect(screen.getAllByRole('option')[16]).toHaveFocus();
+ });
+
+ it('focuses previous item using ArrowLeft key, skipping disabled items', () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ fireEvent.click(screen.getByRole('button'));
+
+ act(() => screen.getAllByRole('option')[47].focus());
+
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowLeft' });
+ expect(screen.getAllByRole('option')[46]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowLeft' });
+ expect(screen.getAllByRole('option')[44]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowLeft' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowLeft' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowLeft' });
+ expect(screen.getAllByRole('option')[41]).toHaveFocus();
+ });
+
+ it('skips row and remains on same column when pressing ArrowDown', () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ fireEvent.click(screen.getByRole('button'));
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ expect(screen.getAllByRole('option')[13]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ expect(screen.getAllByRole('option')[18]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ expect(screen.getAllByRole('option')[23]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ expect(screen.getAllByRole('option')[28]).toHaveFocus();
+ });
+
+ it('skips row and remains on same column when pressing ArrowUp', () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ fireEvent.click(screen.getByRole('button'));
+
+ act(() => screen.getAllByRole('option')[47].focus());
+
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ expect(screen.getAllByRole('option')[42]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ expect(screen.getAllByRole('option')[37]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ expect(screen.getAllByRole('option')[32]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ expect(screen.getAllByRole('option')[27]).toHaveFocus();
+ });
+
+ it('loops on the same column with ArrowDown', () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ fireEvent.click(screen.getByRole('button'));
+
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+
+ expect(screen.getAllByRole('option')[8]).toHaveFocus();
+ });
+
+ it('loops on the same column with ArrowUp', () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ fireEvent.click(screen.getByRole('button'));
+
+ act(() => screen.getAllByRole('option')[43].focus());
+
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+
+ expect(screen.getAllByRole('option')[43]).toHaveFocus();
+ });
+
+ it('does not leave row with "both" orientation while looping', () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ fireEvent.click(screen.getByRole('button'));
+
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowRight' });
+ expect(screen.getAllByRole('option')[9]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowRight' });
+ expect(screen.getAllByRole('option')[8]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowLeft' });
+ expect(screen.getAllByRole('option')[9]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowLeft' });
+ expect(screen.getAllByRole('option')[8]).toHaveFocus();
+
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ expect(screen.getAllByRole('option')[13]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowRight' });
+ expect(screen.getAllByRole('option')[14]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowRight' });
+ expect(screen.getAllByRole('option')[11]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowLeft' });
+ expect(screen.getAllByRole('option')[14]).toHaveFocus();
+ });
+
+ it('looping works on last row', () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ fireEvent.click(screen.getByRole('button'));
+
+ act(() => screen.getAllByRole('option')[46].focus());
+
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowRight' });
+ expect(screen.getAllByRole('option')[47]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowRight' });
+ expect(screen.getAllByRole('option')[46]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowLeft' });
+ expect(screen.getAllByRole('option')[47]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowLeft' });
+ expect(screen.getAllByRole('option')[46]).toHaveFocus();
+ });
+ });
+
+ describe('grid navigation when items have different sizes', () => {
+ it('focuses first non-disabled item in grid', async () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ fireEvent.click(screen.getByRole('button'));
+ await waitFor(() => {
+ expect(screen.getAllByRole('option')[7]).toHaveFocus();
+ });
+ });
+
+ describe.each([
+ { rtl: false, arrowToStart: 'ArrowLeft', arrowToEnd: 'ArrowRight' },
+ { rtl: true, arrowToStart: 'ArrowRight', arrowToEnd: 'ArrowLeft' },
+ ])('with rtl $rtl', ({ rtl, arrowToStart, arrowToEnd }) => {
+ it(`focuses next item using ${arrowToEnd} key, skipping disabled items`, () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ fireEvent.click(screen.getByRole('button'));
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ expect(screen.getAllByRole('option')[8]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ expect(screen.getAllByRole('option')[10]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ expect(screen.getAllByRole('option')[13]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ expect(screen.getAllByRole('option')[15]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ expect(screen.getAllByRole('option')[20]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ expect(screen.getAllByRole('option')[24]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ expect(screen.getAllByRole('option')[34]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ expect(screen.getAllByRole('option')[36]).toHaveFocus();
+ });
+
+ it(`focuses previous item using ${arrowToStart} key, skipping disabled items`, async () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ fireEvent.click(screen.getByRole('button'));
+
+ act(() => screen.getAllByRole('option')[36].focus());
+
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ await waitFor(() => {
+ expect(screen.getAllByRole('option')[34]).toHaveFocus();
+ });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ await waitFor(() => {
+ expect(screen.getAllByRole('option')[28]).toHaveFocus();
+ });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ await waitFor(() => {
+ expect(screen.getAllByRole('option')[20]).toHaveFocus();
+ });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ await waitFor(() => {
+ expect(screen.getAllByRole('option')[7]).toHaveFocus();
+ });
+ });
+
+ it(`moves through rows when pressing ArrowDown, prefers ${
+ rtl ? 'right' : 'left'
+ } side of wide items`, () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ fireEvent.click(screen.getByRole('button'));
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ expect(screen.getAllByRole('option')[20]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ expect(screen.getAllByRole('option')[25]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ expect(screen.getAllByRole('option')[31]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ expect(screen.getAllByRole('option')[36]).toHaveFocus();
+ });
+
+ it(`moves through rows when pressing ArrowUp, prefers ${
+ rtl ? 'right' : 'left'
+ } side of wide items`, () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ fireEvent.click(screen.getByRole('button'));
+
+ act(() => screen.getAllByRole('option')[29].focus());
+
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ expect(screen.getAllByRole('option')[21]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ expect(screen.getAllByRole('option')[15]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ expect(screen.getAllByRole('option')[8]).toHaveFocus();
+ });
+
+ it(`loops over column with ArrowDown, prefers ${
+ rtl ? 'right' : 'left'
+ } side of wide items`, () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ fireEvent.click(screen.getByRole('button'));
+
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+
+ expect(screen.getAllByRole('option')[13]).toHaveFocus();
+ });
+
+ it(`loops over column with ArrowUp, prefers ${
+ rtl ? 'right' : 'left'
+ } side of wide items`, () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ fireEvent.click(screen.getByRole('button'));
+
+ act(() => screen.getAllByRole('option')[30].focus());
+
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowUp' });
+
+ expect(screen.getAllByRole('option')[8]).toHaveFocus();
+ });
+
+ it('loops over row with "both" orientation, prefers top side of tall items', () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ fireEvent.click(screen.getByRole('button'));
+
+ act(() => screen.getAllByRole('option')[20].focus());
+
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ expect(screen.getAllByRole('option')[21]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ expect(screen.getAllByRole('option')[20]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ expect(screen.getAllByRole('option')[21]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToStart });
+ expect(screen.getAllByRole('option')[21]).toHaveFocus();
+
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: 'ArrowDown' });
+ expect(screen.getAllByRole('option')[22]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ expect(screen.getAllByRole('option')[24]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ expect(screen.getAllByRole('option')[20]).toHaveFocus();
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ expect(screen.getAllByRole('option')[21]).toHaveFocus();
+ });
+
+ it('looping works on last row', () => {
+ render( );
+ fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+ fireEvent.click(screen.getByRole('button'));
+
+ act(() => screen.getAllByRole('option')[36].focus());
+
+ fireEvent.keyDown(screen.getByTestId('floating'), { key: arrowToEnd });
+ expect(screen.getAllByRole('option')[36]).toHaveFocus();
+ });
+ });
+ });
+
+ it('grid navigation with changing list items', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button'));
+
+ await act(async () => {});
+
+ expect(screen.getByRole('textbox')).toHaveFocus();
+
+ await userEvent.keyboard('appl');
+ await userEvent.keyboard('{ArrowDown}');
+
+ expect(screen.getByLabelText('apple')).toHaveAttribute('data-active');
+
+ await userEvent.keyboard('{ArrowDown}');
+
+ expect(screen.getByLabelText('apple')).toHaveAttribute('data-active');
+ });
+
+ it('grid navigation with disabled list items', async () => {
+ const { unmount } = render( );
+
+ fireEvent.click(screen.getByRole('button'));
+
+ await act(async () => {});
+
+ await waitFor(() => {
+ expect(screen.getByRole('textbox')).toHaveFocus();
+ });
+
+ await userEvent.keyboard('o');
+ await userEvent.keyboard('{ArrowDown}');
+
+ expect(screen.getByLabelText('orange')).not.toHaveAttribute('data-active');
+ expect(screen.getByLabelText('watermelon')).toHaveAttribute('data-active');
+
+ await userEvent.keyboard('{ArrowDown}');
+
+ expect(screen.getByLabelText('watermelon')).toHaveAttribute('data-active');
+
+ unmount();
+
+ render( );
+
+ fireEvent.click(screen.getByRole('button'));
+
+ await act(async () => {});
+
+ await waitFor(() => {
+ expect(screen.getByRole('textbox')).toHaveFocus();
+ });
+
+ await userEvent.keyboard('{ArrowDown}');
+ await userEvent.keyboard('{ArrowDown}');
+ await userEvent.keyboard('{ArrowRight}');
+ await userEvent.keyboard('{ArrowUp}');
+
+ expect(screen.getByLabelText('cherry')).toHaveAttribute('data-active');
+ });
+
+ it('selectedIndex changing does not steal focus', async () => {
+ render( );
+
+ await userEvent.click(screen.getByTestId('reference'));
+ await act(async () => {});
+
+ expect(screen.getByTestId('reference')).toHaveFocus();
+ });
+
+ // In JSDOM it will not focus the first item, but will in the browser
+ it.skipIf(!isJSDOM)('focus management in nested lists', async () => {
+ render( );
+ await userEvent.click(screen.getByRole('button', { name: 'Edit' }));
+ await userEvent.keyboard('{ArrowDown}');
+ await userEvent.keyboard('{ArrowDown}');
+ await userEvent.keyboard('{ArrowDown}');
+ await userEvent.keyboard('{ArrowRight}');
+
+ expect(screen.getByText('Text')).toHaveFocus();
+ });
+
+ // In JSDOM it will not focus the first item, but will in the browser
+ it.skipIf(!isJSDOM)('keyboard navigation in nested menus lists', async () => {
+ render( );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Edit' }));
+ await act(async () => {});
+ await userEvent.keyboard('{ArrowDown}');
+ await userEvent.keyboard('{ArrowDown}');
+ await userEvent.keyboard('{ArrowDown}');
+ await userEvent.keyboard('{ArrowRight}'); // opens first submenu
+ await act(async () => {});
+
+ await userEvent.keyboard('{ArrowDown}');
+ await userEvent.keyboard('{ArrowDown}');
+ await userEvent.keyboard('{ArrowRight}'); // opens second submenu
+ await act(async () => {});
+
+ expect(screen.getByText('.png')).toHaveFocus();
+
+ // it navigation with orientation = 'both'
+ await userEvent.keyboard('{ArrowRight}');
+ expect(screen.getByText('.jpg')).toHaveFocus();
+
+ await userEvent.keyboard('{ArrowDown}');
+ expect(screen.getByText('.gif')).toHaveFocus();
+
+ await userEvent.keyboard('{ArrowLeft}');
+ expect(screen.getByText('.svg')).toHaveFocus();
+
+ await userEvent.keyboard('{ArrowUp}');
+ expect(screen.getByText('.png')).toHaveFocus();
+
+ // escape closes the submenu
+ await userEvent.keyboard('{Escape}');
+ expect(screen.getByText('Image')).toHaveFocus();
+ });
+
+ // In JSDOM it will not focus the first item, but will in the browser
+ it.skipIf(!isJSDOM)(
+ 'keyboard navigation in nested menus with different orientation',
+ async () => {
+ render( );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Edit' }));
+ await act(async () => {});
+ await userEvent.keyboard('{ArrowRight}');
+ await userEvent.keyboard('{ArrowRight}');
+ await userEvent.keyboard('{ArrowRight}');
+ await userEvent.keyboard('{ArrowDown}'); // opens the Copy as submenu
+ await act(async () => {});
+
+ await userEvent.keyboard('{ArrowRight}');
+ await userEvent.keyboard('{ArrowDown}'); // opens the Share submenu
+ await act(async () => {});
+
+ expect(screen.getByText('Mail')).toHaveFocus();
+
+ await userEvent.keyboard('{ArrowLeft}');
+ expect(screen.getByText('Copy as')).toHaveFocus();
+ },
+ );
+
+ it('virtual nested Home or End key press', async () => {
+ const ref = { current: null } as any;
+ render(
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ act(() => {
+ screen.getByRole('combobox').focus();
+ });
+
+ await userEvent.keyboard('{ArrowDown}'); // open menu
+ await userEvent.keyboard('{ArrowDown}');
+ await userEvent.keyboard('{ArrowDown}'); // focus Copy as menu
+ await userEvent.keyboard('{ArrowRight}'); // open Copy as submenu
+ await act(async () => {});
+ await userEvent.keyboard('{End}');
+
+ expect(screen.getByText('Audio')).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByText('Share')).not.toHaveAttribute('aria-selected', 'true');
+ });
+
+ it('domReference trigger in nested virtual menu is set as virtual item', async () => {
+ const ref = { current: null } as any;
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ render( );
+
+ act(() => {
+ screen.getByRole('combobox').focus();
+ });
+
+ await userEvent.keyboard('{ArrowDown}'); // open menu
+ await userEvent.keyboard('{ArrowDown}');
+ await userEvent.keyboard('{ArrowDown}'); // focus Copy as menu
+ await userEvent.keyboard('{ArrowRight}'); // open Copy as submenu
+ await act(async () => {});
+
+ expect(screen.getByText('Text')).toHaveAttribute('aria-selected', 'true');
+
+ await userEvent.keyboard('{ArrowLeft}'); // close Copy as submenu
+
+ expect(ref.current).toBe(screen.getByTestId('copy'));
+ });
+
+ it('Home or End key press is ignored for typeable combobox reference', async () => {
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ function App() {
+ const [open, setOpen] = React.useState(false);
+ const listRef = React.useRef>([]);
+ const [activeIndex, setActiveIndex] = React.useState(null);
+ const { refs, context } = useFloating({
+ open,
+ onOpenChange: setOpen,
+ });
+ const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
+ useClick(context),
+ useListNavigation(context, {
+ listRef,
+ activeIndex,
+ onNavigate: setActiveIndex,
+ }),
+ ]);
+
+ return (
+ /* eslint-disable jsx-a11y/role-has-required-aria-props */
+
+
+ {open && (
+
+
+ {['one', 'two', 'three'].map((string, index) => (
+ // eslint-disable-next-line jsx-a11y/role-supports-aria-props
+
+ {string}
+
+ ))}
+
+
+ )}
+
+ );
+ }
+
+ render( );
+
+ act(() => {
+ screen.getByRole('combobox').focus();
+ });
+
+ await userEvent.keyboard('{ArrowDown}');
+
+ await waitFor(() => {
+ expect(screen.getByTestId('item-0')).toHaveFocus();
+ });
+
+ await userEvent.keyboard('{End}');
+
+ expect(screen.getByTestId('item-0')).toHaveFocus();
+
+ await userEvent.keyboard('{ArrowDown}');
+ await userEvent.keyboard('{Home}');
+
+ await waitFor(() => {
+ expect(screen.getByTestId('item-1')).toHaveFocus();
+ });
+ });
+});
diff --git a/packages/react/src/floating-ui-react/hooks/useListNavigation.ts b/packages/react/src/floating-ui-react/hooks/useListNavigation.ts
new file mode 100644
index 0000000000..200f0af84b
--- /dev/null
+++ b/packages/react/src/floating-ui-react/hooks/useListNavigation.ts
@@ -0,0 +1,983 @@
+import * as React from 'react';
+import { isHTMLElement } from '@floating-ui/utils/dom';
+import { useLatestRef } from '../../utils/useLatestRef';
+import { useEventCallback } from '../../utils/useEventCallback';
+import { useModernLayoutEffect } from '../../utils/useModernLayoutEffect';
+import {
+ activeElement,
+ contains,
+ getDocument,
+ isTypeableCombobox,
+ isVirtualClick,
+ isVirtualPointerEvent,
+ stopEvent,
+ getDeepestNode,
+ getFloatingFocusElement,
+ isIndexOutOfListBounds,
+ getMinListIndex,
+ getMaxListIndex,
+ getGridNavigatedIndex,
+ isListIndexDisabled,
+ createGridCellMap,
+ getGridCellIndices,
+ getGridCellIndexOfCorner,
+ findNonDisabledListIndex,
+} from '../utils';
+
+import { useFloatingParentNodeId, useFloatingTree } from '../components/FloatingTree';
+import type { Dimensions, ElementProps, FloatingRootContext } from '../types';
+import { enqueueFocus } from '../utils/enqueueFocus';
+import { ARROW_UP, ARROW_DOWN, ARROW_RIGHT, ARROW_LEFT } from '../utils/constants';
+
+export const ESCAPE = 'Escape';
+
+function doSwitch(
+ orientation: UseListNavigationProps['orientation'],
+ vertical: boolean,
+ horizontal: boolean,
+) {
+ switch (orientation) {
+ case 'vertical':
+ return vertical;
+ case 'horizontal':
+ return horizontal;
+ default:
+ return vertical || horizontal;
+ }
+}
+
+function isMainOrientationKey(key: string, orientation: UseListNavigationProps['orientation']) {
+ const vertical = key === ARROW_UP || key === ARROW_DOWN;
+ const horizontal = key === ARROW_LEFT || key === ARROW_RIGHT;
+ return doSwitch(orientation, vertical, horizontal);
+}
+
+function isMainOrientationToEndKey(
+ key: string,
+ orientation: UseListNavigationProps['orientation'],
+ rtl: boolean,
+) {
+ const vertical = key === ARROW_DOWN;
+ const horizontal = rtl ? key === ARROW_LEFT : key === ARROW_RIGHT;
+ return (
+ doSwitch(orientation, vertical, horizontal) || key === 'Enter' || key === ' ' || key === ''
+ );
+}
+
+function isCrossOrientationOpenKey(
+ key: string,
+ orientation: UseListNavigationProps['orientation'],
+ rtl: boolean,
+) {
+ const vertical = rtl ? key === ARROW_LEFT : key === ARROW_RIGHT;
+ const horizontal = key === ARROW_DOWN;
+ return doSwitch(orientation, vertical, horizontal);
+}
+
+function isCrossOrientationCloseKey(
+ key: string,
+ orientation: UseListNavigationProps['orientation'],
+ rtl: boolean,
+ cols?: number,
+) {
+ const vertical = rtl ? key === ARROW_RIGHT : key === ARROW_LEFT;
+ const horizontal = key === ARROW_UP;
+ if (orientation === 'both' || (orientation === 'horizontal' && cols && cols > 1)) {
+ return key === ESCAPE;
+ }
+ return doSwitch(orientation, vertical, horizontal);
+}
+
+export interface UseListNavigationProps {
+ /**
+ * A ref that holds an array of list items.
+ * @default empty list
+ */
+ listRef: React.MutableRefObject>;
+ /**
+ * The index of the currently active (focused or highlighted) item, which may
+ * or may not be selected.
+ * @default null
+ */
+ activeIndex: number | null;
+ /**
+ * A callback that is called when the user navigates to a new active item,
+ * passed in a new `activeIndex`.
+ */
+ onNavigate?: (activeIndex: number | null) => void;
+ /**
+ * Whether the Hook is enabled, including all internal Effects and event
+ * handlers.
+ * @default true
+ */
+ enabled?: boolean;
+ /**
+ * The currently selected item index, which may or may not be active.
+ * @default null
+ */
+ selectedIndex?: number | null;
+ /**
+ * Whether to focus the item upon opening the floating element. 'auto' infers
+ * what to do based on the input type (keyboard vs. pointer), while a boolean
+ * value will force the value.
+ * @default 'auto'
+ */
+ focusItemOnOpen?: boolean | 'auto';
+ /**
+ * Whether hovering an item synchronizes the focus.
+ * @default true
+ */
+ focusItemOnHover?: boolean;
+ /**
+ * Whether pressing an arrow key on the navigation’s main axis opens the
+ * floating element.
+ * @default true
+ */
+ openOnArrowKeyDown?: boolean;
+ /**
+ * By default elements with either a `disabled` or `aria-disabled` attribute
+ * are skipped in the list navigation — however, this requires the items to
+ * be rendered.
+ * This prop allows you to manually specify indices which should be disabled,
+ * overriding the default logic.
+ * For Windows-style select menus, where the menu does not open when
+ * navigating via arrow keys, specify an empty array.
+ * @default undefined
+ */
+ disabledIndices?: Array | ((index: number) => boolean);
+ /**
+ * Determines whether focus can escape the list, such that nothing is selected
+ * after navigating beyond the boundary of the list. In some
+ * autocomplete/combobox components, this may be desired, as screen
+ * readers will return to the input.
+ * `loop` must be `true`.
+ * @default false
+ */
+ allowEscape?: boolean;
+ /**
+ * Determines whether focus should loop around when navigating past the first
+ * or last item.
+ * @default false
+ */
+ loop?: boolean;
+ /**
+ * If the list is nested within another one (e.g. a nested submenu), the
+ * navigation semantics change.
+ * @default false
+ */
+ nested?: boolean;
+ /**
+ * Allows to specify the orientation of the parent list, which is used to
+ * determine the direction of the navigation.
+ * This is useful when list navigation is used within a Composite,
+ * as the hook can't determine the orientation of the parent list automatically.
+ */
+ parentOrientation?: UseListNavigationProps['orientation'];
+ /**
+ * Whether the direction of the floating element’s navigation is in RTL
+ * layout.
+ * @default false
+ */
+ rtl?: boolean;
+ /**
+ * Whether the focus is virtual (using `aria-activedescendant`).
+ * Use this if you need focus to remain on the reference element
+ * (such as an input), but allow arrow keys to navigate list items.
+ * This is common in autocomplete listbox components.
+ * Your virtually-focused list items must have a unique `id` set on them.
+ * If you’re using a component role with the `useRole()` Hook, then an `id` is
+ * generated automatically.
+ * @default false
+ */
+ virtual?: boolean;
+ /**
+ * The orientation in which navigation occurs.
+ * @default 'vertical'
+ */
+ orientation?: 'vertical' | 'horizontal' | 'both';
+ /**
+ * Specifies how many columns the list has (i.e., it’s a grid). Use an
+ * orientation of 'horizontal' (e.g. for an emoji picker/date picker, where
+ * pressing ArrowRight or ArrowLeft can change rows), or 'both' (where the
+ * current row cannot be escaped with ArrowRight or ArrowLeft, only ArrowUp
+ * and ArrowDown).
+ * @default 1
+ */
+ cols?: number;
+ /**
+ * Whether to scroll the active item into view when navigating. The default
+ * value uses nearest options.
+ */
+ scrollItemIntoView?: boolean | ScrollIntoViewOptions;
+ /**
+ * When using virtual focus management, this holds a ref to the
+ * virtually-focused item. This allows nested virtual navigation to be
+ * enabled, and lets you know when a nested element is virtually focused from
+ * the root reference handling the events. Requires `FloatingTree` to be
+ * setup.
+ */
+ virtualItemRef?: React.MutableRefObject;
+ /**
+ * Only for `cols > 1`, specify sizes for grid items.
+ * `{ width: 2, height: 2 }` means an item is 2 columns wide and 2 rows tall.
+ */
+ itemSizes?: Dimensions[];
+ /**
+ * Only relevant for `cols > 1` and items with different sizes, specify if
+ * the grid is dense (as defined in the CSS spec for `grid-auto-flow`).
+ * @default false
+ */
+ dense?: boolean;
+}
+
+/**
+ * Adds arrow key-based navigation of a list of items, either using real DOM
+ * focus or virtual focus.
+ * @see https://floating-ui.com/docs/useListNavigation
+ */
+export function useListNavigation(
+ context: FloatingRootContext,
+ props: UseListNavigationProps,
+): ElementProps {
+ const { open, onOpenChange, elements, floatingId } = context;
+ const {
+ listRef,
+ activeIndex,
+ onNavigate: onNavigateProp = () => {},
+ enabled = true,
+ selectedIndex = null,
+ allowEscape = false,
+ loop = false,
+ nested = false,
+ rtl = false,
+ virtual = false,
+ focusItemOnOpen = 'auto',
+ focusItemOnHover = true,
+ openOnArrowKeyDown = true,
+ disabledIndices = undefined,
+ orientation = 'vertical',
+ parentOrientation,
+ cols = 1,
+ scrollItemIntoView = true,
+ virtualItemRef,
+ itemSizes,
+ dense = false,
+ } = props;
+
+ if (process.env.NODE_ENV !== 'production') {
+ if (allowEscape) {
+ if (!loop) {
+ console.warn('`useListNavigation` looping must be enabled to allow escaping.');
+ }
+
+ if (!virtual) {
+ console.warn('`useListNavigation` must be virtual to allow escaping.');
+ }
+ }
+
+ if (orientation === 'vertical' && cols > 1) {
+ console.warn(
+ 'In grid list navigation mode (`cols` > 1), the `orientation` should',
+ 'be either "horizontal" or "both".',
+ );
+ }
+ }
+
+ const floatingFocusElement = getFloatingFocusElement(elements.floating);
+ const floatingFocusElementRef = useLatestRef(floatingFocusElement);
+
+ const parentId = useFloatingParentNodeId();
+ const tree = useFloatingTree();
+
+ useModernLayoutEffect(() => {
+ context.dataRef.current.orientation = orientation;
+ }, [context, orientation]);
+
+ const typeableComboboxReference = isTypeableCombobox(elements.domReference);
+
+ const focusItemOnOpenRef = React.useRef(focusItemOnOpen);
+ const indexRef = React.useRef(selectedIndex ?? -1);
+ const keyRef = React.useRef(null);
+ const isPointerModalityRef = React.useRef(true);
+
+ const onNavigate = useEventCallback(() => {
+ onNavigateProp(indexRef.current === -1 ? null : indexRef.current);
+ });
+
+ const previousOnNavigateRef = React.useRef(onNavigate);
+ const previousMountedRef = React.useRef(!!elements.floating);
+ const previousOpenRef = React.useRef(open);
+ const forceSyncFocusRef = React.useRef(false);
+ const forceScrollIntoViewRef = React.useRef(false);
+
+ const disabledIndicesRef = useLatestRef(disabledIndices);
+ const latestOpenRef = useLatestRef(open);
+ const scrollItemIntoViewRef = useLatestRef(scrollItemIntoView);
+ const selectedIndexRef = useLatestRef(selectedIndex);
+
+ const [activeId, setActiveId] = React.useState();
+ const [virtualId, setVirtualId] = React.useState();
+
+ const focusItem = useEventCallback(() => {
+ function runFocus(item: HTMLElement) {
+ if (virtual) {
+ if (item.id?.endsWith('-fui-option')) {
+ item.id = `${floatingId}-${Math.random().toString(16).slice(2, 10)}`;
+ }
+ setActiveId(item.id);
+ tree?.events.emit('virtualfocus', item);
+ if (virtualItemRef) {
+ virtualItemRef.current = item;
+ }
+ } else {
+ enqueueFocus(item, {
+ sync: forceSyncFocusRef.current,
+ preventScroll: true,
+ });
+ }
+ }
+
+ const initialItem = listRef.current[indexRef.current];
+ const forceScrollIntoView = forceScrollIntoViewRef.current;
+
+ if (initialItem) {
+ runFocus(initialItem);
+ }
+
+ const scheduler = forceSyncFocusRef.current ? (v: () => void) => v() : requestAnimationFrame;
+
+ scheduler(() => {
+ const waitedItem = listRef.current[indexRef.current] || initialItem;
+
+ if (!waitedItem) {
+ return;
+ }
+
+ if (!initialItem) {
+ runFocus(waitedItem);
+ }
+
+ const scrollIntoViewOptions = scrollItemIntoViewRef.current;
+ const shouldScrollIntoView =
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ scrollIntoViewOptions && item && (forceScrollIntoView || !isPointerModalityRef.current);
+
+ if (shouldScrollIntoView) {
+ // JSDOM doesn't support `.scrollIntoView()` but it's widely supported
+ // by all browsers.
+ waitedItem.scrollIntoView?.(
+ typeof scrollIntoViewOptions === 'boolean'
+ ? { block: 'nearest', inline: 'nearest' }
+ : scrollIntoViewOptions,
+ );
+ }
+ });
+ });
+
+ // Sync `selectedIndex` to be the `activeIndex` upon opening the floating
+ // element. Also, reset `activeIndex` upon closing the floating element.
+ useModernLayoutEffect(() => {
+ if (!enabled) {
+ return;
+ }
+
+ if (open && elements.floating) {
+ if (focusItemOnOpenRef.current && selectedIndex != null) {
+ // Regardless of the pointer modality, we want to ensure the selected
+ // item comes into view when the floating element is opened.
+ forceScrollIntoViewRef.current = true;
+ indexRef.current = selectedIndex;
+ onNavigate();
+ }
+ } else if (previousMountedRef.current) {
+ // Since the user can specify `onNavigate` conditionally
+ // (onNavigate: open ? setActiveIndex : setSelectedIndex),
+ // we store and call the previous function.
+ indexRef.current = -1;
+ previousOnNavigateRef.current();
+ }
+ }, [enabled, open, elements.floating, selectedIndex, onNavigate]);
+
+ // Sync `activeIndex` to be the focused item while the floating element is
+ // open.
+ useModernLayoutEffect(() => {
+ if (!enabled) {
+ return;
+ }
+ if (!open) {
+ return;
+ }
+ if (!elements.floating) {
+ return;
+ }
+
+ if (activeIndex == null) {
+ forceSyncFocusRef.current = false;
+
+ if (selectedIndexRef.current != null) {
+ return;
+ }
+
+ // Reset while the floating element was open (e.g. the list changed).
+ if (previousMountedRef.current) {
+ indexRef.current = -1;
+ focusItem();
+ }
+
+ // Initial sync.
+ if (
+ (!previousOpenRef.current || !previousMountedRef.current) &&
+ focusItemOnOpenRef.current &&
+ (keyRef.current != null || (focusItemOnOpenRef.current === true && keyRef.current == null))
+ ) {
+ let runs = 0;
+ const waitForListPopulated = () => {
+ if (listRef.current[0] == null) {
+ // Avoid letting the browser paint if possible on the first try,
+ // otherwise use rAF. Don't try more than twice, since something
+ // is wrong otherwise.
+ if (runs < 2) {
+ const scheduler = runs ? requestAnimationFrame : queueMicrotask;
+ scheduler(waitForListPopulated);
+ }
+ runs += 1;
+ } else {
+ indexRef.current =
+ keyRef.current == null ||
+ isMainOrientationToEndKey(keyRef.current, orientation, rtl) ||
+ nested
+ ? getMinListIndex(listRef, disabledIndicesRef.current)
+ : getMaxListIndex(listRef, disabledIndicesRef.current);
+ keyRef.current = null;
+ onNavigate();
+ }
+ };
+
+ waitForListPopulated();
+ }
+ } else if (!isIndexOutOfListBounds(listRef, activeIndex)) {
+ indexRef.current = activeIndex;
+ focusItem();
+ forceScrollIntoViewRef.current = false;
+ }
+ }, [
+ enabled,
+ open,
+ elements.floating,
+ activeIndex,
+ selectedIndexRef,
+ nested,
+ listRef,
+ orientation,
+ rtl,
+ onNavigate,
+ focusItem,
+ disabledIndicesRef,
+ ]);
+
+ // Ensure the parent floating element has focus when a nested child closes
+ // to allow arrow key navigation to work after the pointer leaves the child.
+ useModernLayoutEffect(() => {
+ if (!enabled || elements.floating || !tree || virtual || !previousMountedRef.current) {
+ return;
+ }
+
+ const nodes = tree.nodesRef.current;
+ const parent = nodes.find((node) => node.id === parentId)?.context?.elements.floating;
+ const activeEl = activeElement(getDocument(elements.floating));
+ const treeContainsActiveEl = nodes.some(
+ (node) => node.context && contains(node.context.elements.floating, activeEl),
+ );
+
+ if (parent && !treeContainsActiveEl && isPointerModalityRef.current) {
+ parent.focus({ preventScroll: true });
+ }
+ }, [enabled, elements.floating, tree, parentId, virtual]);
+
+ useModernLayoutEffect(() => {
+ if (!enabled) {
+ return undefined;
+ }
+ if (!tree) {
+ return undefined;
+ }
+ if (!virtual) {
+ return undefined;
+ }
+ if (parentId) {
+ return undefined;
+ }
+
+ function handleVirtualFocus(item: HTMLElement) {
+ setVirtualId(item.id);
+
+ if (virtualItemRef) {
+ virtualItemRef.current = item;
+ }
+ }
+
+ tree.events.on('virtualfocus', handleVirtualFocus);
+ return () => {
+ tree.events.off('virtualfocus', handleVirtualFocus);
+ };
+ }, [enabled, tree, virtual, parentId, virtualItemRef]);
+
+ useModernLayoutEffect(() => {
+ previousOnNavigateRef.current = onNavigate;
+ previousOpenRef.current = open;
+ previousMountedRef.current = !!elements.floating;
+ });
+
+ useModernLayoutEffect(() => {
+ if (!open) {
+ keyRef.current = null;
+ focusItemOnOpenRef.current = focusItemOnOpen;
+ }
+ }, [open, focusItemOnOpen]);
+
+ const hasActiveIndex = activeIndex != null;
+
+ const item = React.useMemo(() => {
+ function syncCurrentTarget(currentTarget: HTMLElement | null) {
+ if (!latestOpenRef.current) {
+ return;
+ }
+ const index = listRef.current.indexOf(currentTarget);
+ if (index !== -1 && indexRef.current !== index) {
+ indexRef.current = index;
+ onNavigate();
+ }
+ }
+
+ const itemProps: ElementProps['item'] = {
+ onFocus({ currentTarget }) {
+ forceSyncFocusRef.current = true;
+ syncCurrentTarget(currentTarget);
+ },
+ onClick: ({ currentTarget }) => currentTarget.focus({ preventScroll: true }), // Safari
+ ...(focusItemOnHover && {
+ onMouseMove({ currentTarget }) {
+ forceSyncFocusRef.current = true;
+ forceScrollIntoViewRef.current = false;
+ syncCurrentTarget(currentTarget);
+ },
+ onPointerLeave({ pointerType }) {
+ if (!isPointerModalityRef.current || pointerType === 'touch') {
+ return;
+ }
+
+ forceSyncFocusRef.current = true;
+ indexRef.current = -1;
+ onNavigate();
+
+ if (!virtual) {
+ floatingFocusElementRef.current?.focus({ preventScroll: true });
+ }
+ },
+ }),
+ };
+
+ return itemProps;
+ }, [latestOpenRef, floatingFocusElementRef, focusItemOnHover, listRef, onNavigate, virtual]);
+
+ const getParentOrientation = React.useCallback(() => {
+ return (
+ parentOrientation ??
+ (tree?.nodesRef.current.find((node) => node.id === parentId)?.context?.dataRef?.current
+ .orientation as UseListNavigationProps['orientation'])
+ );
+ }, [parentId, tree, parentOrientation]);
+
+ const commonOnKeyDown = useEventCallback((event: React.KeyboardEvent) => {
+ isPointerModalityRef.current = false;
+ forceSyncFocusRef.current = true;
+
+ // When composing a character, Chrome fires ArrowDown twice. Firefox/Safari
+ // don't appear to suffer from this. `event.isComposing` is avoided due to
+ // Safari not supporting it properly (although it's not needed in the first
+ // place for Safari, just avoiding any possible issues).
+ if (event.which === 229) {
+ return;
+ }
+
+ // If the floating element is animating out, ignore navigation. Otherwise,
+ // the `activeIndex` gets set to 0 despite not being open so the next time
+ // the user ArrowDowns, the first item won't be focused.
+ if (!latestOpenRef.current && event.currentTarget === floatingFocusElementRef.current) {
+ return;
+ }
+
+ if (nested && isCrossOrientationCloseKey(event.key, orientation, rtl, cols)) {
+ // If the nested list's close key is also the parent navigation key,
+ // let the parent navigate. Otherwise, stop propagating the event.
+ if (!isMainOrientationKey(event.key, getParentOrientation())) {
+ stopEvent(event);
+ }
+
+ onOpenChange(false, event.nativeEvent, 'list-navigation');
+
+ if (isHTMLElement(elements.domReference)) {
+ if (virtual) {
+ tree?.events.emit('virtualfocus', elements.domReference);
+ } else {
+ elements.domReference.focus();
+ }
+ }
+
+ return;
+ }
+
+ const currentIndex = indexRef.current;
+ const minIndex = getMinListIndex(listRef, disabledIndices);
+ const maxIndex = getMaxListIndex(listRef, disabledIndices);
+
+ if (!typeableComboboxReference) {
+ if (event.key === 'Home') {
+ stopEvent(event);
+ indexRef.current = minIndex;
+ onNavigate();
+ }
+
+ if (event.key === 'End') {
+ stopEvent(event);
+ indexRef.current = maxIndex;
+ onNavigate();
+ }
+ }
+
+ // Grid navigation.
+ if (cols > 1) {
+ const sizes =
+ itemSizes ||
+ Array.from({ length: listRef.current.length }, () => ({
+ width: 1,
+ height: 1,
+ }));
+ // To calculate movements on the grid, we use hypothetical cell indices
+ // as if every item was 1x1, then convert back to real indices.
+ const cellMap = createGridCellMap(sizes, cols, dense);
+ const minGridIndex = cellMap.findIndex(
+ (index) => index != null && !isListIndexDisabled(listRef, index, disabledIndices),
+ );
+ // last enabled index
+ const maxGridIndex = cellMap.reduce(
+ (foundIndex: number, index, cellIndex) =>
+ index != null && !isListIndexDisabled(listRef, index, disabledIndices)
+ ? cellIndex
+ : foundIndex,
+ -1,
+ );
+
+ const index =
+ cellMap[
+ getGridNavigatedIndex(
+ {
+ current: cellMap.map((itemIndex) =>
+ itemIndex != null ? listRef.current[itemIndex] : null,
+ ),
+ },
+ {
+ event,
+ orientation,
+ loop,
+ rtl,
+ cols,
+ // treat undefined (empty grid spaces) as disabled indices so we
+ // don't end up in them
+ disabledIndices: getGridCellIndices(
+ [
+ ...((typeof disabledIndices !== 'function' ? disabledIndices : null) ||
+ listRef.current.map((_, listIndex) =>
+ isListIndexDisabled(listRef, listIndex, disabledIndices)
+ ? listIndex
+ : undefined,
+ )),
+ undefined,
+ ],
+ cellMap,
+ ),
+ minIndex: minGridIndex,
+ maxIndex: maxGridIndex,
+ prevIndex: getGridCellIndexOfCorner(
+ indexRef.current > maxIndex ? minIndex : indexRef.current,
+ sizes,
+ cellMap,
+ cols,
+ // use a corner matching the edge closest to the direction
+ // we're moving in so we don't end up in the same item. Prefer
+ // top/left over bottom/right.
+ // eslint-disable-next-line no-nested-ternary
+ event.key === ARROW_DOWN
+ ? 'bl'
+ : event.key === (rtl ? ARROW_LEFT : ARROW_RIGHT)
+ ? 'tr'
+ : 'tl',
+ ),
+ stopEvent: true,
+ },
+ )
+ ];
+
+ if (index != null) {
+ indexRef.current = index;
+ onNavigate();
+ }
+
+ if (orientation === 'both') {
+ return;
+ }
+ }
+
+ if (isMainOrientationKey(event.key, orientation)) {
+ stopEvent(event);
+
+ // Reset the index if no item is focused.
+ if (
+ open &&
+ !virtual &&
+ activeElement(event.currentTarget.ownerDocument) === event.currentTarget
+ ) {
+ indexRef.current = isMainOrientationToEndKey(event.key, orientation, rtl)
+ ? minIndex
+ : maxIndex;
+ onNavigate();
+ return;
+ }
+
+ if (isMainOrientationToEndKey(event.key, orientation, rtl)) {
+ if (loop) {
+ indexRef.current =
+ // eslint-disable-next-line no-nested-ternary
+ currentIndex >= maxIndex
+ ? allowEscape && currentIndex !== listRef.current.length
+ ? -1
+ : minIndex
+ : findNonDisabledListIndex(listRef, {
+ startingIndex: currentIndex,
+ disabledIndices,
+ });
+ } else {
+ indexRef.current = Math.min(
+ maxIndex,
+ findNonDisabledListIndex(listRef, {
+ startingIndex: currentIndex,
+ disabledIndices,
+ }),
+ );
+ }
+ } else if (loop) {
+ indexRef.current =
+ // eslint-disable-next-line no-nested-ternary
+ currentIndex <= minIndex
+ ? allowEscape && currentIndex !== -1
+ ? listRef.current.length
+ : maxIndex
+ : findNonDisabledListIndex(listRef, {
+ startingIndex: currentIndex,
+ decrement: true,
+ disabledIndices,
+ });
+ } else {
+ indexRef.current = Math.max(
+ minIndex,
+ findNonDisabledListIndex(listRef, {
+ startingIndex: currentIndex,
+ decrement: true,
+ disabledIndices,
+ }),
+ );
+ }
+
+ if (isIndexOutOfListBounds(listRef, indexRef.current)) {
+ indexRef.current = -1;
+ }
+
+ onNavigate();
+ }
+ });
+
+ const ariaActiveDescendantProp = React.useMemo(() => {
+ return (
+ virtual &&
+ open &&
+ hasActiveIndex && {
+ 'aria-activedescendant': virtualId || activeId,
+ }
+ );
+ }, [virtual, open, hasActiveIndex, virtualId, activeId]);
+
+ const floating: ElementProps['floating'] = React.useMemo(() => {
+ return {
+ 'aria-orientation': orientation === 'both' ? undefined : orientation,
+ ...(!typeableComboboxReference ? ariaActiveDescendantProp : {}),
+ onKeyDown: commonOnKeyDown,
+ onPointerMove() {
+ isPointerModalityRef.current = true;
+ },
+ };
+ }, [ariaActiveDescendantProp, commonOnKeyDown, orientation, typeableComboboxReference]);
+
+ const reference: ElementProps['reference'] = React.useMemo(() => {
+ function checkVirtualMouse(event: React.PointerEvent) {
+ if (focusItemOnOpen === 'auto' && isVirtualClick(event.nativeEvent)) {
+ focusItemOnOpenRef.current = true;
+ }
+ }
+
+ function checkVirtualPointer(event: React.PointerEvent) {
+ // `pointerdown` fires first, reset the state then perform the checks.
+ focusItemOnOpenRef.current = focusItemOnOpen;
+ if (focusItemOnOpen === 'auto' && isVirtualPointerEvent(event.nativeEvent)) {
+ focusItemOnOpenRef.current = true;
+ }
+ }
+
+ return {
+ ...ariaActiveDescendantProp,
+ onKeyDown(event) {
+ isPointerModalityRef.current = false;
+
+ const isArrowKey = event.key.startsWith('Arrow');
+ const isHomeOrEndKey = ['Home', 'End'].includes(event.key);
+ const isMoveKey = isArrowKey || isHomeOrEndKey;
+ const isCrossOpenKey = isCrossOrientationOpenKey(event.key, orientation, rtl);
+ const isCrossCloseKey = isCrossOrientationCloseKey(event.key, orientation, rtl, cols);
+ const isParentCrossOpenKey = isCrossOrientationOpenKey(
+ event.key,
+ getParentOrientation(),
+ rtl,
+ );
+ const isMainKey = isMainOrientationKey(event.key, orientation);
+ const isNavigationKey =
+ (nested ? isParentCrossOpenKey : isMainKey) ||
+ event.key === 'Enter' ||
+ event.key.trim() === '';
+
+ if (virtual && open) {
+ const rootNode = tree?.nodesRef.current.find((node) => node.parentId == null);
+ const deepestNode =
+ tree && rootNode ? getDeepestNode(tree.nodesRef.current, rootNode.id) : null;
+
+ if (isMoveKey && deepestNode && virtualItemRef) {
+ const eventObject = new KeyboardEvent('keydown', {
+ key: event.key,
+ bubbles: true,
+ });
+
+ if (isCrossOpenKey || isCrossCloseKey) {
+ const isCurrentTarget =
+ deepestNode.context?.elements.domReference === event.currentTarget;
+ const dispatchItem =
+ // eslint-disable-next-line no-nested-ternary
+ isCrossCloseKey && !isCurrentTarget
+ ? deepestNode.context?.elements.domReference
+ : isCrossOpenKey
+ ? listRef.current.find((currentItem) => currentItem?.id === activeId)
+ : null;
+
+ if (dispatchItem) {
+ stopEvent(event);
+ dispatchItem.dispatchEvent(eventObject);
+ setVirtualId(undefined);
+ }
+ }
+
+ if ((isMainKey || isHomeOrEndKey) && deepestNode.context) {
+ if (
+ deepestNode.context.open &&
+ deepestNode.parentId &&
+ event.currentTarget !== deepestNode.context.elements.domReference
+ ) {
+ stopEvent(event);
+ deepestNode.context.elements.domReference?.dispatchEvent(eventObject);
+ return undefined;
+ }
+ }
+ }
+
+ return commonOnKeyDown(event);
+ }
+ // If a floating element should not open on arrow key down, avoid
+ // setting `activeIndex` while it's closed.
+ if (!open && !openOnArrowKeyDown && isArrowKey) {
+ return undefined;
+ }
+
+ if (isNavigationKey) {
+ const isParentMainKey = isMainOrientationKey(event.key, getParentOrientation());
+ keyRef.current = nested && isParentMainKey ? null : event.key;
+ }
+
+ if (nested) {
+ if (isParentCrossOpenKey) {
+ stopEvent(event);
+
+ if (open) {
+ indexRef.current = getMinListIndex(listRef, disabledIndicesRef.current);
+ onNavigate();
+ } else {
+ onOpenChange(true, event.nativeEvent, 'list-navigation');
+ }
+ }
+
+ return undefined;
+ }
+
+ if (isMainKey) {
+ if (selectedIndex != null) {
+ indexRef.current = selectedIndex;
+ }
+
+ stopEvent(event);
+
+ if (!open && openOnArrowKeyDown) {
+ onOpenChange(true, event.nativeEvent, 'list-navigation');
+ } else {
+ commonOnKeyDown(event);
+ }
+
+ if (open) {
+ onNavigate();
+ }
+ }
+
+ return undefined;
+ },
+ onFocus() {
+ if (open && !virtual) {
+ indexRef.current = -1;
+ onNavigate();
+ }
+ },
+ onPointerDown: checkVirtualPointer,
+ onPointerEnter: checkVirtualPointer,
+ onMouseDown: checkVirtualMouse,
+ onClick: checkVirtualMouse,
+ };
+ }, [
+ activeId,
+ ariaActiveDescendantProp,
+ cols,
+ commonOnKeyDown,
+ disabledIndicesRef,
+ focusItemOnOpen,
+ listRef,
+ nested,
+ onNavigate,
+ onOpenChange,
+ open,
+ openOnArrowKeyDown,
+ orientation,
+ getParentOrientation,
+ rtl,
+ selectedIndex,
+ tree,
+ virtual,
+ virtualItemRef,
+ ]);
+
+ return React.useMemo(
+ () => (enabled ? { reference, floating, item } : {}),
+ [enabled, reference, floating, item],
+ );
+}
diff --git a/packages/react/src/floating-ui-react/hooks/useRole.ts b/packages/react/src/floating-ui-react/hooks/useRole.ts
new file mode 100644
index 0000000000..18b43871a3
--- /dev/null
+++ b/packages/react/src/floating-ui-react/hooks/useRole.ts
@@ -0,0 +1,122 @@
+import * as React from 'react';
+import { getFloatingFocusElement } from '../utils';
+
+import { useFloatingParentNodeId } from '../components/FloatingTree';
+import type { ElementProps, FloatingRootContext } from '../types';
+import { useId } from '../../utils/useId';
+import type { ExtendedUserProps } from './useInteractions';
+
+type AriaRole = 'tooltip' | 'dialog' | 'alertdialog' | 'menu' | 'listbox' | 'grid' | 'tree';
+type ComponentRole = 'select' | 'label' | 'combobox';
+
+export interface UseRoleProps {
+ /**
+ * Whether the Hook is enabled, including all internal Effects and event
+ * handlers.
+ * @default true
+ */
+ enabled?: boolean;
+ /**
+ * The role of the floating element.
+ * @default 'dialog'
+ */
+ role?: AriaRole | ComponentRole;
+}
+
+const componentRoleToAriaRoleMap = new Map([
+ ['select', 'listbox'],
+ ['combobox', 'listbox'],
+ ['label', false],
+]);
+
+/**
+ * Adds base screen reader props to the reference and floating elements for a
+ * given floating element `role`.
+ * @see https://floating-ui.com/docs/useRole
+ */
+export function useRole(context: FloatingRootContext, props: UseRoleProps = {}): ElementProps {
+ const { open, elements, floatingId: defaultFloatingId } = context;
+ const { enabled = true, role = 'dialog' } = props;
+
+ const defaultReferenceId = useId();
+ const referenceId = elements.domReference?.id || defaultReferenceId;
+ const floatingId = React.useMemo(
+ () => getFloatingFocusElement(elements.floating)?.id || defaultFloatingId,
+ [elements.floating, defaultFloatingId],
+ );
+
+ const ariaRole = (componentRoleToAriaRoleMap.get(role) ?? role) as AriaRole | false | undefined;
+
+ const parentId = useFloatingParentNodeId();
+ const isNested = parentId != null;
+
+ const reference: ElementProps['reference'] = React.useMemo(() => {
+ if (ariaRole === 'tooltip' || role === 'label') {
+ return {
+ [`aria-${role === 'label' ? 'labelledby' : 'describedby'}`]: open ? floatingId : undefined,
+ };
+ }
+
+ return {
+ 'aria-expanded': open ? 'true' : 'false',
+ 'aria-haspopup': ariaRole === 'alertdialog' ? 'dialog' : ariaRole,
+ 'aria-controls': open ? floatingId : undefined,
+ ...(ariaRole === 'listbox' && { role: 'combobox' }),
+ ...(ariaRole === 'menu' && { id: referenceId }),
+ ...(ariaRole === 'menu' && isNested && { role: 'menuitem' }),
+ ...(role === 'select' && { 'aria-autocomplete': 'none' }),
+ ...(role === 'combobox' && { 'aria-autocomplete': 'list' }),
+ };
+ }, [ariaRole, floatingId, isNested, open, referenceId, role]);
+
+ const floating: ElementProps['floating'] = React.useMemo(() => {
+ const floatingProps = {
+ id: floatingId,
+ ...(ariaRole && { role: ariaRole }),
+ };
+
+ if (ariaRole === 'tooltip' || role === 'label') {
+ return floatingProps;
+ }
+
+ return {
+ ...floatingProps,
+ ...(ariaRole === 'menu' && { 'aria-labelledby': referenceId }),
+ };
+ }, [ariaRole, floatingId, referenceId, role]);
+
+ const item: ElementProps['item'] = React.useCallback(
+ ({ active, selected }: ExtendedUserProps) => {
+ const commonProps = {
+ role: 'option',
+ ...(active && { id: `${floatingId}-fui-option` }),
+ };
+
+ // For `menu`, we are unable to tell if the item is a `menuitemradio`
+ // or `menuitemcheckbox`. For backwards-compatibility reasons, also
+ // avoid defaulting to `menuitem` as it may overwrite custom role props.
+ switch (role) {
+ case 'select':
+ return {
+ ...commonProps,
+ 'aria-selected': active && selected,
+ };
+ case 'combobox': {
+ return {
+ ...commonProps,
+ 'aria-selected': selected,
+ };
+ }
+ default:
+ }
+
+ return {};
+ },
+ [floatingId, role],
+ );
+
+ return React.useMemo(
+ () => (enabled ? { reference, floating, item } : {}),
+ [enabled, reference, floating, item],
+ );
+}
diff --git a/packages/react/src/floating-ui-react/hooks/useTypeahead.test.tsx b/packages/react/src/floating-ui-react/hooks/useTypeahead.test.tsx
new file mode 100644
index 0000000000..f63289a25d
--- /dev/null
+++ b/packages/react/src/floating-ui-react/hooks/useTypeahead.test.tsx
@@ -0,0 +1,250 @@
+import * as React from 'react';
+import { act, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { vi } from 'vitest';
+
+import { useClick, useFloating, useInteractions, useTypeahead } from '../index';
+import type { UseTypeaheadProps } from './useTypeahead';
+import { Main } from '../test-components/Menu';
+
+/* eslint-disable testing-library/no-unnecessary-act */
+
+vi.useFakeTimers({ shouldAdvanceTime: true });
+
+const useImpl = ({
+ addUseClick = false,
+ ...props
+}: Pick & {
+ list?: Array;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ addUseClick?: boolean;
+}) => {
+ const [open, setOpen] = React.useState(true);
+ const [activeIndex, setActiveIndex] = React.useState(null);
+ const { refs, context } = useFloating({
+ open: props.open ?? open,
+ onOpenChange: props.onOpenChange ?? setOpen,
+ });
+ const listRef = React.useRef(props.list ?? ['one', 'two', 'three']);
+ const typeahead = useTypeahead(context, {
+ listRef,
+ activeIndex,
+ onMatch(index) {
+ setActiveIndex(index);
+ props.onMatch?.(index);
+ },
+ onTypingChange: props.onTypingChange,
+ });
+ const click = useClick(context, {
+ enabled: addUseClick,
+ });
+
+ const { getReferenceProps, getFloatingProps } = useInteractions([typeahead, click]);
+
+ return {
+ activeIndex,
+ open,
+ getReferenceProps: (userProps?: React.HTMLProps) =>
+ getReferenceProps({
+ role: 'combobox',
+ ...userProps,
+ ref: refs.setReference,
+ }),
+ getFloatingProps: () =>
+ getFloatingProps({
+ role: 'listbox',
+ ref: refs.setFloating,
+ }),
+ };
+};
+
+function Combobox(
+ props: Pick & {
+ list?: Array;
+ },
+) {
+ const { getReferenceProps, getFloatingProps } = useImpl(props);
+ return (
+
+
+
+
+ );
+}
+
+describe('useTypeahead', () => {
+ it('rapidly focuses list items when they start with the same letter', async () => {
+ const spy = vi.fn();
+ render( );
+
+ await userEvent.click(screen.getByRole('combobox'));
+
+ await userEvent.keyboard('t');
+ expect(spy).toHaveBeenCalledWith(1);
+
+ await userEvent.keyboard('t');
+ expect(spy).toHaveBeenCalledWith(2);
+
+ await userEvent.keyboard('t');
+ expect(spy).toHaveBeenCalledWith(1);
+ });
+
+ it('bails out of rapid focus of first letter if the list contains a string that starts with two of the same letter', async () => {
+ const spy = vi.fn();
+ render( );
+
+ await userEvent.click(screen.getByRole('combobox'));
+
+ await userEvent.keyboard('a');
+ expect(spy).toHaveBeenCalledWith(0);
+
+ await userEvent.keyboard('a');
+ expect(spy).toHaveBeenCalledWith(0);
+ });
+
+ it('starts from the current activeIndex and correctly loops', async () => {
+ const spy = vi.fn();
+ render( );
+
+ await userEvent.click(screen.getByRole('combobox'));
+
+ await userEvent.keyboard('t');
+ await userEvent.keyboard('o');
+ await userEvent.keyboard('y');
+ expect(spy).toHaveBeenCalledWith(0);
+
+ spy.mockReset();
+
+ await userEvent.keyboard('t');
+ await userEvent.keyboard('o');
+ await userEvent.keyboard('y');
+ expect(spy).not.toHaveBeenCalled();
+
+ vi.advanceTimersByTime(750);
+
+ await userEvent.keyboard('t');
+ await userEvent.keyboard('o');
+ await userEvent.keyboard('y');
+ expect(spy).toHaveBeenCalledWith(1);
+
+ vi.advanceTimersByTime(750);
+
+ await userEvent.keyboard('t');
+ await userEvent.keyboard('o');
+ await userEvent.keyboard('y');
+ expect(spy).toHaveBeenCalledWith(2);
+
+ vi.advanceTimersByTime(750);
+
+ await userEvent.keyboard('t');
+ await userEvent.keyboard('o');
+ await userEvent.keyboard('y');
+ expect(spy).toHaveBeenCalledWith(0);
+ });
+
+ it('capslock characters continue to match', async () => {
+ const spy = vi.fn();
+ render( );
+
+ await userEvent.click(screen.getByRole('combobox'));
+
+ await userEvent.keyboard('{CapsLock}t');
+ expect(spy).toHaveBeenCalledWith(1);
+ });
+
+ function App1(props: Pick & { list: Array }) {
+ const { getReferenceProps, getFloatingProps, activeIndex, open } = useImpl(props);
+ const inputRef = React.useRef(null);
+
+ return (
+
+ inputRef.current?.focus(),
+ })}
+ >
+
+
+ {open && (
+
+ {props.list.map((value, i) => (
+
+ {value}
+
+ ))}
+
+ )}
+
+ );
+ }
+
+ it('matches when focus is within reference', async () => {
+ const spy = vi.fn();
+ render( );
+
+ await userEvent.click(screen.getByRole('combobox'));
+
+ await userEvent.keyboard('t');
+ expect(spy).toHaveBeenCalledWith(1);
+ });
+
+ it('matches when focus is within floating', async () => {
+ const spy = vi.fn();
+ render( );
+
+ await userEvent.click(screen.getByRole('combobox'));
+
+ await userEvent.keyboard('t');
+ const option = await screen.findByRole('option', { selected: true });
+ expect(option.textContent).toBe('two');
+ option.focus();
+ expect(option).toHaveFocus();
+
+ await userEvent.keyboard('h');
+ expect((await screen.findByRole('option', { selected: true })).textContent).toBe('three');
+ });
+
+ it('onTypingChange is called when typing starts or stops', async () => {
+ const spy = vi.fn();
+ render( );
+
+ act(() => screen.getByRole('combobox').focus());
+
+ await userEvent.keyboard('t');
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(spy).toHaveBeenCalledWith(true);
+
+ vi.advanceTimersByTime(750);
+ expect(spy).toHaveBeenCalledTimes(2);
+ expect(spy).toHaveBeenCalledWith(false);
+ });
+
+ it('Menu - skips disabled items and opens submenu on space if no match', async () => {
+ vi.useRealTimers();
+
+ render( );
+
+ await userEvent.click(screen.getByText('Edit'));
+ await act(async () => {});
+
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+
+ await userEvent.keyboard('c');
+
+ expect(screen.getByText('Copy as')).toHaveFocus();
+
+ await userEvent.keyboard('opy as ');
+
+ expect(screen.getByText('Copy as').getAttribute('aria-expanded')).toBe('false');
+
+ await userEvent.keyboard(' ');
+
+ expect(screen.getByText('Copy as').getAttribute('aria-expanded')).toBe('true');
+ });
+});
diff --git a/packages/react/src/floating-ui-react/hooks/useTypeahead.ts b/packages/react/src/floating-ui-react/hooks/useTypeahead.ts
new file mode 100644
index 0000000000..e6529bab01
--- /dev/null
+++ b/packages/react/src/floating-ui-react/hooks/useTypeahead.ts
@@ -0,0 +1,214 @@
+import * as React from 'react';
+import { useLatestRef } from '../../utils/useLatestRef';
+import { useEventCallback } from '../../utils/useEventCallback';
+import { useModernLayoutEffect } from '../../utils/useModernLayoutEffect';
+import { useTimeout } from '../../utils/useTimeout';
+import { stopEvent } from '../utils';
+
+import type { ElementProps, FloatingRootContext } from '../types';
+
+export interface UseTypeaheadProps {
+ /**
+ * A ref which contains an array of strings whose indices match the HTML
+ * elements of the list.
+ * @default empty list
+ */
+ listRef: React.MutableRefObject>;
+ /**
+ * The index of the active (focused or highlighted) item in the list.
+ * @default null
+ */
+ activeIndex: number | null;
+ /**
+ * Callback invoked with the matching index if found as the user types.
+ */
+ onMatch?: (index: number) => void;
+ /**
+ * Callback invoked with the typing state as the user types.
+ */
+ onTypingChange?: (isTyping: boolean) => void;
+ /**
+ * Whether the Hook is enabled, including all internal Effects and event
+ * handlers.
+ * @default true
+ */
+ enabled?: boolean;
+ /**
+ * A function that returns the matching string from the list.
+ * @default lowercase-finder
+ */
+ findMatch?:
+ | null
+ | ((list: Array, typedString: string) => string | null | undefined);
+ /**
+ * The number of milliseconds to wait before resetting the typed string.
+ * @default 750
+ */
+ resetMs?: number;
+ /**
+ * An array of keys to ignore when typing.
+ * @default []
+ */
+ ignoreKeys?: Array;
+ /**
+ * The index of the selected item in the list, if available.
+ * @default null
+ */
+ selectedIndex?: number | null;
+}
+
+/**
+ * Provides a matching callback that can be used to focus an item as the user
+ * types, often used in tandem with `useListNavigation()`.
+ * @see https://floating-ui.com/docs/useTypeahead
+ */
+export function useTypeahead(context: FloatingRootContext, props: UseTypeaheadProps): ElementProps {
+ const { open, dataRef } = context;
+ const {
+ listRef,
+ activeIndex,
+ onMatch: onMatchProp,
+ onTypingChange: onTypingChangeProp,
+ enabled = true,
+ findMatch = null,
+ resetMs = 750,
+ ignoreKeys = [],
+ selectedIndex = null,
+ } = props;
+
+ const timeout = useTimeout();
+ const stringRef = React.useRef('');
+ const prevIndexRef = React.useRef(selectedIndex ?? activeIndex ?? -1);
+ const matchIndexRef = React.useRef(null);
+
+ const onMatch = useEventCallback(onMatchProp);
+ const onTypingChange = useEventCallback(onTypingChangeProp);
+
+ const findMatchRef = useLatestRef(findMatch);
+ const ignoreKeysRef = useLatestRef(ignoreKeys);
+
+ useModernLayoutEffect(() => {
+ if (open) {
+ timeout.clear();
+ matchIndexRef.current = null;
+ stringRef.current = '';
+ }
+ }, [open, timeout]);
+
+ useModernLayoutEffect(() => {
+ // Sync arrow key navigation but not typeahead navigation.
+ if (open && stringRef.current === '') {
+ prevIndexRef.current = selectedIndex ?? activeIndex ?? -1;
+ }
+ }, [open, selectedIndex, activeIndex]);
+
+ const setTypingChange = useEventCallback((value: boolean) => {
+ if (value) {
+ if (!dataRef.current.typing) {
+ dataRef.current.typing = value;
+ onTypingChange(value);
+ }
+ } else if (dataRef.current.typing) {
+ dataRef.current.typing = value;
+ onTypingChange(value);
+ }
+ });
+
+ const onKeyDown = useEventCallback((event: React.KeyboardEvent) => {
+ function getMatchingIndex(
+ list: Array,
+ orderedList: Array,
+ string: string,
+ ) {
+ const str = findMatchRef.current
+ ? findMatchRef.current(orderedList, string)
+ : orderedList.find(
+ (text) => text?.toLocaleLowerCase().indexOf(string.toLocaleLowerCase()) === 0,
+ );
+
+ return str ? list.indexOf(str) : -1;
+ }
+
+ const listContent = listRef.current;
+
+ if (stringRef.current.length > 0 && stringRef.current[0] !== ' ') {
+ if (getMatchingIndex(listContent, listContent, stringRef.current) === -1) {
+ setTypingChange(false);
+ } else if (event.key === ' ') {
+ stopEvent(event);
+ }
+ }
+
+ if (
+ listContent == null ||
+ ignoreKeysRef.current.includes(event.key) ||
+ // Character key.
+ event.key.length !== 1 ||
+ // Modifier key.
+ event.ctrlKey ||
+ event.metaKey ||
+ event.altKey
+ ) {
+ return;
+ }
+
+ if (open && event.key !== ' ') {
+ stopEvent(event);
+ setTypingChange(true);
+ }
+
+ // Bail out if the list contains a word like "llama" or "aaron". TODO:
+ // allow it in this case, too.
+ const allowRapidSuccessionOfFirstLetter = listContent.every((text) =>
+ text ? text[0]?.toLocaleLowerCase() !== text[1]?.toLocaleLowerCase() : true,
+ );
+
+ // Allows the user to cycle through items that start with the same letter
+ // in rapid succession.
+ if (allowRapidSuccessionOfFirstLetter && stringRef.current === event.key) {
+ stringRef.current = '';
+ prevIndexRef.current = matchIndexRef.current;
+ }
+
+ stringRef.current += event.key;
+ timeout.start(resetMs, () => {
+ stringRef.current = '';
+ prevIndexRef.current = matchIndexRef.current;
+ setTypingChange(false);
+ });
+
+ const prevIndex = prevIndexRef.current;
+
+ const index = getMatchingIndex(
+ listContent,
+ [...listContent.slice((prevIndex || 0) + 1), ...listContent.slice(0, (prevIndex || 0) + 1)],
+ stringRef.current,
+ );
+
+ if (index !== -1) {
+ onMatch(index);
+ matchIndexRef.current = index;
+ } else if (event.key !== ' ') {
+ stringRef.current = '';
+ setTypingChange(false);
+ }
+ });
+
+ const reference: ElementProps['reference'] = React.useMemo(() => ({ onKeyDown }), [onKeyDown]);
+
+ const floating: ElementProps['floating'] = React.useMemo(() => {
+ return {
+ onKeyDown,
+ onKeyUp(event) {
+ if (event.key === ' ') {
+ setTypingChange(false);
+ }
+ },
+ };
+ }, [onKeyDown, setTypingChange]);
+
+ return React.useMemo(
+ () => (enabled ? { reference, floating } : {}),
+ [enabled, reference, floating],
+ );
+}
diff --git a/packages/react/src/floating-ui-react/index.ts b/packages/react/src/floating-ui-react/index.ts
new file mode 100644
index 0000000000..67c9f70feb
--- /dev/null
+++ b/packages/react/src/floating-ui-react/index.ts
@@ -0,0 +1,39 @@
+export { FloatingDelayGroup, useDelayGroup } from './components/FloatingDelayGroup';
+export { FloatingFocusManager } from './components/FloatingFocusManager';
+export { FloatingPortal, useFloatingPortalNode } from './components/FloatingPortal';
+export {
+ FloatingNode,
+ FloatingTree,
+ useFloatingNodeId,
+ useFloatingParentNodeId,
+ useFloatingTree,
+} from './components/FloatingTree';
+export { useClick } from './hooks/useClick';
+export { useClientPoint } from './hooks/useClientPoint';
+export { useDismiss } from './hooks/useDismiss';
+export { useFloating } from './hooks/useFloating';
+export { useFloatingRootContext } from './hooks/useFloatingRootContext';
+export { useFocus } from './hooks/useFocus';
+export { useHover } from './hooks/useHover';
+export { useInteractions } from './hooks/useInteractions';
+export { useListNavigation } from './hooks/useListNavigation';
+export { useRole } from './hooks/useRole';
+export { useTypeahead } from './hooks/useTypeahead';
+export { safePolygon } from './safePolygon';
+export type * from './types';
+export {
+ arrow,
+ autoPlacement,
+ autoUpdate,
+ computePosition,
+ detectOverflow,
+ flip,
+ getOverflowAncestors,
+ hide,
+ inline,
+ limitShift,
+ offset,
+ platform,
+ shift,
+ size,
+} from '@floating-ui/react-dom';
diff --git a/packages/react/src/floating-ui-react/safePolygon.ts b/packages/react/src/floating-ui-react/safePolygon.ts
new file mode 100644
index 0000000000..815cb8dcc9
--- /dev/null
+++ b/packages/react/src/floating-ui-react/safePolygon.ts
@@ -0,0 +1,402 @@
+import { isElement } from '@floating-ui/utils/dom';
+import type { Rect, Side } from './types';
+import type { HandleClose } from './hooks/useHover';
+import { Timeout } from '../utils/useTimeout';
+import { contains, getTarget } from './utils/element';
+import { getNodeChildren } from './utils/nodes';
+
+/* eslint-disable no-nested-ternary */
+
+type Point = [number, number];
+type Polygon = Point[];
+
+function isPointInPolygon(point: Point, polygon: Polygon) {
+ const [x, y] = point;
+ let isInsideValue = false;
+ const length = polygon.length;
+ // eslint-disable-next-line no-plusplus
+ for (let i = 0, j = length - 1; i < length; j = i++) {
+ const [xi, yi] = polygon[i] || [0, 0];
+ const [xj, yj] = polygon[j] || [0, 0];
+ const intersect = yi >= y !== yj >= y && x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi;
+ if (intersect) {
+ isInsideValue = !isInsideValue;
+ }
+ }
+ return isInsideValue;
+}
+
+function isInside(point: Point, rect: Rect) {
+ return (
+ point[0] >= rect.x &&
+ point[0] <= rect.x + rect.width &&
+ point[1] >= rect.y &&
+ point[1] <= rect.y + rect.height
+ );
+}
+
+export interface SafePolygonOptions {
+ buffer?: number;
+ blockPointerEvents?: boolean;
+ requireIntent?: boolean;
+}
+
+/**
+ * Generates a safe polygon area that the user can traverse without closing the
+ * floating element once leaving the reference element.
+ * @see https://floating-ui.com/docs/useHover#safepolygon
+ */
+export function safePolygon(options: SafePolygonOptions = {}) {
+ const { buffer = 0.5, blockPointerEvents = false, requireIntent = true } = options;
+
+ const timeout = new Timeout();
+
+ let hasLanded = false;
+ let lastX: number | null = null;
+ let lastY: number | null = null;
+ let lastCursorTime = performance.now();
+
+ function getCursorSpeed(x: number, y: number): number | null {
+ const currentTime = performance.now();
+ const elapsedTime = currentTime - lastCursorTime;
+
+ if (lastX === null || lastY === null || elapsedTime === 0) {
+ lastX = x;
+ lastY = y;
+ lastCursorTime = currentTime;
+ return null;
+ }
+
+ const deltaX = x - lastX;
+ const deltaY = y - lastY;
+ const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
+ const speed = distance / elapsedTime; // px / ms
+
+ lastX = x;
+ lastY = y;
+ lastCursorTime = currentTime;
+
+ return speed;
+ }
+
+ const fn: HandleClose = ({ x, y, placement, elements, onClose, nodeId, tree }) => {
+ return function onMouseMove(event: MouseEvent) {
+ function close() {
+ timeout.clear();
+ onClose();
+ }
+
+ timeout.clear();
+
+ if (
+ !elements.domReference ||
+ !elements.floating ||
+ placement == null ||
+ x == null ||
+ y == null
+ ) {
+ return undefined;
+ }
+
+ const { clientX, clientY } = event;
+ const clientPoint: Point = [clientX, clientY];
+ const target = getTarget(event) as Element | null;
+ const isLeave = event.type === 'mouseleave';
+ const isOverFloatingEl = contains(elements.floating, target);
+ const isOverReferenceEl = contains(elements.domReference, target);
+ const refRect = elements.domReference.getBoundingClientRect();
+ const rect = elements.floating.getBoundingClientRect();
+ const side = placement.split('-')[0] as Side;
+ const cursorLeaveFromRight = x > rect.right - rect.width / 2;
+ const cursorLeaveFromBottom = y > rect.bottom - rect.height / 2;
+ const isOverReferenceRect = isInside(clientPoint, refRect);
+ const isFloatingWider = rect.width > refRect.width;
+ const isFloatingTaller = rect.height > refRect.height;
+ const left = (isFloatingWider ? refRect : rect).left;
+ const right = (isFloatingWider ? refRect : rect).right;
+ const top = (isFloatingTaller ? refRect : rect).top;
+ const bottom = (isFloatingTaller ? refRect : rect).bottom;
+
+ if (isOverFloatingEl) {
+ hasLanded = true;
+
+ if (!isLeave) {
+ return undefined;
+ }
+ }
+
+ if (isOverReferenceEl) {
+ hasLanded = false;
+ }
+
+ if (isOverReferenceEl && !isLeave) {
+ hasLanded = true;
+ return undefined;
+ }
+
+ // Prevent overlapping floating element from being stuck in an open-close
+ // loop: https://github.com/floating-ui/floating-ui/issues/1910
+ if (
+ isLeave &&
+ isElement(event.relatedTarget) &&
+ contains(elements.floating, event.relatedTarget)
+ ) {
+ return undefined;
+ }
+
+ // If any nested child is open, abort.
+ if (
+ tree &&
+ getNodeChildren(tree.nodesRef.current, nodeId).some(({ context }) => context?.open)
+ ) {
+ return undefined;
+ }
+
+ // If the pointer is leaving from the opposite side, the "buffer" logic
+ // creates a point where the floating element remains open, but should be
+ // ignored.
+ // A constant of 1 handles floating point rounding errors.
+ if (
+ (side === 'top' && y >= refRect.bottom - 1) ||
+ (side === 'bottom' && y <= refRect.top + 1) ||
+ (side === 'left' && x >= refRect.right - 1) ||
+ (side === 'right' && x <= refRect.left + 1)
+ ) {
+ return close();
+ }
+
+ // Ignore when the cursor is within the rectangular trough between the
+ // two elements. Since the triangle is created from the cursor point,
+ // which can start beyond the ref element's edge, traversing back and
+ // forth from the ref to the floating element can cause it to close. This
+ // ensures it always remains open in that case.
+ let rectPoly: Point[] = [];
+
+ switch (side) {
+ case 'top':
+ rectPoly = [
+ [left, refRect.top + 1],
+ [left, rect.bottom - 1],
+ [right, rect.bottom - 1],
+ [right, refRect.top + 1],
+ ];
+ break;
+ case 'bottom':
+ rectPoly = [
+ [left, rect.top + 1],
+ [left, refRect.bottom - 1],
+ [right, refRect.bottom - 1],
+ [right, rect.top + 1],
+ ];
+ break;
+ case 'left':
+ rectPoly = [
+ [rect.right - 1, bottom],
+ [rect.right - 1, top],
+ [refRect.left + 1, top],
+ [refRect.left + 1, bottom],
+ ];
+ break;
+ case 'right':
+ rectPoly = [
+ [refRect.right - 1, bottom],
+ [refRect.right - 1, top],
+ [rect.left + 1, top],
+ [rect.left + 1, bottom],
+ ];
+ break;
+ default:
+ }
+
+ function getPolygon([px, py]: Point): Array {
+ switch (side) {
+ case 'top': {
+ const cursorPointOne: Point = [
+ isFloatingWider
+ ? px + buffer / 2
+ : cursorLeaveFromRight
+ ? px + buffer * 4
+ : px - buffer * 4,
+ py + buffer + 1,
+ ];
+ const cursorPointTwo: Point = [
+ isFloatingWider
+ ? px - buffer / 2
+ : cursorLeaveFromRight
+ ? px + buffer * 4
+ : px - buffer * 4,
+ py + buffer + 1,
+ ];
+ const commonPoints: [Point, Point] = [
+ [
+ rect.left,
+ cursorLeaveFromRight
+ ? rect.bottom - buffer
+ : isFloatingWider
+ ? rect.bottom - buffer
+ : rect.top,
+ ],
+ [
+ rect.right,
+ cursorLeaveFromRight
+ ? isFloatingWider
+ ? rect.bottom - buffer
+ : rect.top
+ : rect.bottom - buffer,
+ ],
+ ];
+
+ return [cursorPointOne, cursorPointTwo, ...commonPoints];
+ }
+ case 'bottom': {
+ const cursorPointOne: Point = [
+ isFloatingWider
+ ? px + buffer / 2
+ : cursorLeaveFromRight
+ ? px + buffer * 4
+ : px - buffer * 4,
+ py - buffer,
+ ];
+ const cursorPointTwo: Point = [
+ isFloatingWider
+ ? px - buffer / 2
+ : cursorLeaveFromRight
+ ? px + buffer * 4
+ : px - buffer * 4,
+ py - buffer,
+ ];
+ const commonPoints: [Point, Point] = [
+ [
+ rect.left,
+ cursorLeaveFromRight
+ ? rect.top + buffer
+ : isFloatingWider
+ ? rect.top + buffer
+ : rect.bottom,
+ ],
+ [
+ rect.right,
+ cursorLeaveFromRight
+ ? isFloatingWider
+ ? rect.top + buffer
+ : rect.bottom
+ : rect.top + buffer,
+ ],
+ ];
+
+ return [cursorPointOne, cursorPointTwo, ...commonPoints];
+ }
+ case 'left': {
+ const cursorPointOne: Point = [
+ px + buffer + 1,
+ isFloatingTaller
+ ? py + buffer / 2
+ : cursorLeaveFromBottom
+ ? py + buffer * 4
+ : py - buffer * 4,
+ ];
+ const cursorPointTwo: Point = [
+ px + buffer + 1,
+ isFloatingTaller
+ ? py - buffer / 2
+ : cursorLeaveFromBottom
+ ? py + buffer * 4
+ : py - buffer * 4,
+ ];
+ const commonPoints: [Point, Point] = [
+ [
+ cursorLeaveFromBottom
+ ? rect.right - buffer
+ : isFloatingTaller
+ ? rect.right - buffer
+ : rect.left,
+ rect.top,
+ ],
+ [
+ cursorLeaveFromBottom
+ ? isFloatingTaller
+ ? rect.right - buffer
+ : rect.left
+ : rect.right - buffer,
+ rect.bottom,
+ ],
+ ];
+
+ return [...commonPoints, cursorPointOne, cursorPointTwo];
+ }
+ case 'right': {
+ const cursorPointOne: Point = [
+ px - buffer,
+ isFloatingTaller
+ ? py + buffer / 2
+ : cursorLeaveFromBottom
+ ? py + buffer * 4
+ : py - buffer * 4,
+ ];
+ const cursorPointTwo: Point = [
+ px - buffer,
+ isFloatingTaller
+ ? py - buffer / 2
+ : cursorLeaveFromBottom
+ ? py + buffer * 4
+ : py - buffer * 4,
+ ];
+ const commonPoints: [Point, Point] = [
+ [
+ cursorLeaveFromBottom
+ ? rect.left + buffer
+ : isFloatingTaller
+ ? rect.left + buffer
+ : rect.right,
+ rect.top,
+ ],
+ [
+ cursorLeaveFromBottom
+ ? isFloatingTaller
+ ? rect.left + buffer
+ : rect.right
+ : rect.left + buffer,
+ rect.bottom,
+ ],
+ ];
+
+ return [cursorPointOne, cursorPointTwo, ...commonPoints];
+ }
+ default:
+ return [];
+ }
+ }
+
+ if (isPointInPolygon([clientX, clientY], rectPoly)) {
+ return undefined;
+ }
+
+ if (hasLanded && !isOverReferenceRect) {
+ return close();
+ }
+
+ if (!isLeave && requireIntent) {
+ const cursorSpeed = getCursorSpeed(event.clientX, event.clientY);
+ const cursorSpeedThreshold = 0.1;
+ if (cursorSpeed !== null && cursorSpeed < cursorSpeedThreshold) {
+ return close();
+ }
+ }
+
+ if (!isPointInPolygon([clientX, clientY], getPolygon([x, y]))) {
+ close();
+ } else if (!hasLanded && requireIntent) {
+ timeout.start(40, close);
+ }
+
+ return undefined;
+ };
+ };
+
+ // eslint-disable-next-line no-underscore-dangle
+ fn.__options = {
+ blockPointerEvents,
+ };
+
+ return fn;
+}
diff --git a/packages/react/src/floating-ui-react/test-components/Button.tsx b/packages/react/src/floating-ui-react/test-components/Button.tsx
new file mode 100644
index 0000000000..9e0b24658c
--- /dev/null
+++ b/packages/react/src/floating-ui-react/test-components/Button.tsx
@@ -0,0 +1,20 @@
+import c from 'clsx';
+import * as React from 'react';
+
+/** @internal */
+export const Button = React.forwardRef<
+ HTMLButtonElement,
+ React.ButtonHTMLAttributes
+>(function Button(props, ref) {
+ return (
+ // eslint-disable-next-line react/button-has-type
+
+ );
+});
diff --git a/packages/react/src/floating-ui-react/test-components/ComplexGrid.tsx b/packages/react/src/floating-ui-react/test-components/ComplexGrid.tsx
new file mode 100644
index 0000000000..f8f0494ac7
--- /dev/null
+++ b/packages/react/src/floating-ui-react/test-components/ComplexGrid.tsx
@@ -0,0 +1,121 @@
+import * as React from 'react';
+import {
+ FloatingFocusManager,
+ useClick,
+ useDismiss,
+ useFloating,
+ useInteractions,
+ useListNavigation,
+} from '../index';
+
+interface Props {
+ orientation?: 'horizontal' | 'both';
+ loop?: boolean;
+ rtl?: boolean;
+}
+
+/*
+ * Grid diagram for reference:
+ * Disabled indices marked with ()
+ *
+ * (0) (1) (1) (2) (3) (4) (5)
+ * (6) 7 8 (9) 10 11 12
+ * 13 (14) 15 16 17 18 19
+ * 20 20 21 21 21 21 21
+ * 20 20 22 (23) (23) (23) 24
+ * 25 26 27 28 29 29 30
+ * 31 32 33 34 29 29 (35)
+ * 36 36
+ */
+
+/** @internal */
+export function Main({ orientation = 'horizontal', loop = false, rtl = false }: Props) {
+ const [open, setOpen] = React.useState(false);
+ const [activeIndex, setActiveIndex] = React.useState(null);
+
+ const listRef = React.useRef>([]);
+
+ const { floatingStyles, refs, context } = useFloating({
+ open,
+ onOpenChange: setOpen,
+ placement: 'bottom-start',
+ });
+
+ const disabledIndices = [0, 1, 2, 3, 4, 5, 6, 9, 14, 23, 35];
+
+ const itemSizes = Array.from(Array(37), () => ({ width: 1, height: 1 }));
+ itemSizes[1].width = 2;
+ itemSizes[20].width = 2;
+ itemSizes[20].height = 2;
+ itemSizes[21].width = 5;
+ itemSizes[23].width = 3;
+ itemSizes[29].width = 2;
+ itemSizes[29].height = 2;
+ itemSizes[36].width = 2;
+
+ const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
+ useClick(context),
+ useListNavigation(context, {
+ listRef,
+ activeIndex,
+ onNavigate: setActiveIndex,
+ cols: 7,
+ orientation,
+ loop,
+ rtl,
+ openOnArrowKeyDown: false,
+ disabledIndices,
+ itemSizes,
+ }),
+ useDismiss(context),
+ ]);
+
+ return (
+
+ Complex Grid
+
+
+ Reference
+
+ {open && (
+
+
+ {[...Array(37)].map((_, index) => (
+ {
+ listRef.current[index] = node;
+ }}
+ className="border border-black disabled:opacity-20"
+ style={{
+ gridRow: `span ${itemSizes[index].height}`,
+ gridColumn: `span ${itemSizes[index].width}`,
+ }}
+ {...getItemProps()}
+ >
+ Item {index}
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/react/src/floating-ui-react/test-components/EmojiPicker.tsx b/packages/react/src/floating-ui-react/test-components/EmojiPicker.tsx
new file mode 100644
index 0000000000..200d418141
--- /dev/null
+++ b/packages/react/src/floating-ui-react/test-components/EmojiPicker.tsx
@@ -0,0 +1,278 @@
+import * as React from 'react';
+import c from 'clsx';
+import { useId } from '../../utils/useId';
+import type { Placement } from '../types';
+import {
+ arrow,
+ autoUpdate,
+ flip,
+ FloatingFocusManager,
+ FloatingPortal,
+ offset,
+ useClick,
+ useDismiss,
+ useFloating,
+ useInteractions,
+ useListNavigation,
+ useRole,
+} from '../index';
+import { Button } from './Button';
+
+const emojis = [
+ {
+ name: 'apple',
+ emoji: '🍎',
+ },
+ {
+ name: 'orange',
+ emoji: '🍊',
+ },
+ {
+ name: 'watermelon',
+ emoji: '🍉',
+ },
+ {
+ name: 'strawberry',
+ emoji: '🍓',
+ },
+ {
+ name: 'pear',
+ emoji: '🍐',
+ },
+ {
+ name: 'banana',
+ emoji: '🍌',
+ },
+ {
+ name: 'pineapple',
+ emoji: '🍍',
+ },
+ {
+ name: 'cherry',
+ emoji: '🍒',
+ },
+ {
+ name: 'peach',
+ emoji: '🍑',
+ },
+];
+
+type OptionProps = React.HTMLAttributes & {
+ name: string;
+ active: boolean;
+ selected: boolean;
+ children: React.ReactNode;
+};
+
+/** @internal */
+const Option = React.forwardRef(function Option(
+ { name, active, selected, children, ...props },
+ ref,
+) {
+ const id = useId();
+ return (
+
+ {children}
+
+ );
+});
+
+/** @internal */
+export function Main() {
+ const [open, setOpen] = React.useState(false);
+ const [search, setSearch] = React.useState('');
+ const [selectedEmoji, setSelectedEmoji] = React.useState(null);
+ const [activeIndex, setActiveIndex] = React.useState(null);
+ const [placement, setPlacement] = React.useState(null);
+
+ const arrowRef = React.useRef(null);
+
+ const listRef = React.useRef>([]);
+
+ const noResultsId = useId();
+
+ const {
+ floatingStyles,
+ refs,
+ context,
+ placement: resultantPlacement,
+ } = useFloating({
+ placement: placement ?? 'bottom-start',
+ open,
+ onOpenChange: setOpen,
+ // We don't want flipping to occur while searching, as the floating element
+ // will resize and cause disorientation.
+ middleware: [
+ offset(8),
+ ...(placement ? [] : [flip()]),
+ arrow({
+ element: arrowRef,
+ padding: 20,
+ }),
+ ],
+ whileElementsMounted: autoUpdate,
+ });
+
+ // Handles opening the floating element via the Choose Emoji button.
+ const { getReferenceProps, getFloatingProps } = useInteractions([
+ useClick(context),
+ useDismiss(context),
+ useRole(context, { role: 'menu' }),
+ ]);
+
+ // Handles the list navigation where the reference is the inner input, not
+ // the button that opens the floating element.
+ const {
+ getReferenceProps: getInputProps,
+ getFloatingProps: getListFloatingProps,
+ getItemProps,
+ } = useInteractions([
+ useListNavigation(context, {
+ listRef,
+ onNavigate: open ? setActiveIndex : undefined,
+ activeIndex,
+ cols: 3,
+ orientation: 'horizontal',
+ loop: true,
+ focusItemOnOpen: false,
+ virtual: true,
+ allowEscape: true,
+ }),
+ ]);
+
+ React.useEffect(() => {
+ if (open) {
+ setPlacement(resultantPlacement);
+ } else {
+ setSearch('');
+ setActiveIndex(null);
+ setPlacement(null);
+ }
+ }, [open, resultantPlacement]);
+
+ const handleEmojiClick = () => {
+ if (activeIndex !== null) {
+ // eslint-disable-next-line
+ setSelectedEmoji(filteredEmojis[activeIndex].emoji);
+ setOpen(false);
+ }
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter') {
+ handleEmojiClick();
+ }
+ };
+
+ const handleInputChange = (event: React.ChangeEvent) => {
+ setActiveIndex(null);
+ setSearch(event.target.value);
+ };
+
+ const filteredEmojis = emojis.filter(({ name }) =>
+ name.toLocaleLowerCase().includes(search.toLocaleLowerCase()),
+ );
+
+ return (
+
+ Emoji Picker
+
+
+
+ ☻
+
+
+ {selectedEmoji && (
+
+ emoji === selectedEmoji)?.name}
+ >
+ {selectedEmoji}
+ {' '}
+ selected
+
+ )}
+
+ {open && (
+
+
+
Emoji Picker
+
+ {filteredEmojis.length === 0 && (
+
+ No results.
+
+ )}
+ {filteredEmojis.length > 0 && (
+
+ {filteredEmojis.map(({ name, emoji }, index) => (
+ {
+ listRef.current[index] = node;
+ }}
+ selected={selectedEmoji === emoji}
+ active={activeIndex === index}
+ {...getItemProps({
+ onClick: handleEmojiClick,
+ })}
+ >
+ {emoji}
+
+ ))}
+
+ )}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/react/src/floating-ui-react/test-components/Grid.tsx b/packages/react/src/floating-ui-react/test-components/Grid.tsx
new file mode 100644
index 0000000000..e54066ff7e
--- /dev/null
+++ b/packages/react/src/floating-ui-react/test-components/Grid.tsx
@@ -0,0 +1,90 @@
+import * as React from 'react';
+import {
+ FloatingFocusManager,
+ useClick,
+ useDismiss,
+ useFloating,
+ useInteractions,
+ useListNavigation,
+} from '../index';
+
+interface Props {
+ orientation?: 'horizontal' | 'both';
+ loop?: boolean;
+}
+
+/** @internal */
+export function Main({ orientation = 'horizontal', loop = false }: Props) {
+ const [open, setOpen] = React.useState(false);
+ const [activeIndex, setActiveIndex] = React.useState(null);
+
+ const listRef = React.useRef>([]);
+
+ const { floatingStyles, refs, context } = useFloating({
+ open,
+ onOpenChange: setOpen,
+ placement: 'bottom-start',
+ });
+
+ const disabledIndices = [0, 1, 2, 3, 4, 5, 6, 7, 10, 15, 45, 48];
+
+ const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
+ useClick(context),
+ useListNavigation(context, {
+ listRef,
+ activeIndex,
+ onNavigate: setActiveIndex,
+ cols: 5,
+ orientation,
+ loop,
+ openOnArrowKeyDown: false,
+ disabledIndices,
+ }),
+ useDismiss(context),
+ ]);
+
+ return (
+
+ Grid
+
+
+ Reference
+
+ {open && (
+
+
+ {[...Array(49)].map((_, index) => (
+ {
+ listRef.current[index] = node;
+ }}
+ className="border border-black disabled:opacity-20"
+ {...getItemProps()}
+ >
+ Item {index}
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/react/src/floating-ui-react/test-components/ListboxFocus.tsx b/packages/react/src/floating-ui-react/test-components/ListboxFocus.tsx
new file mode 100644
index 0000000000..07952c56bb
--- /dev/null
+++ b/packages/react/src/floating-ui-react/test-components/ListboxFocus.tsx
@@ -0,0 +1,120 @@
+import * as React from 'react';
+import { CompositeList } from '../../composite/list/CompositeList';
+import { useCompositeListItem } from '../../composite/list/useCompositeListItem';
+import { useFloating, useInteractions, useListNavigation, useTypeahead, useRole } from '../index';
+
+interface SelectContextValue {
+ activeIndex: number | null;
+ selectedIndex: number | null;
+ getItemProps: ReturnType['getItemProps'];
+ handleSelect: (index: number | null) => void;
+}
+
+const SelectContext = React.createContext({} as SelectContextValue);
+
+/** @internal */
+function Listbox({ children }: { children: React.ReactNode }) {
+ const [activeIndex, setActiveIndex] = React.useState(1);
+ const [selectedIndex, setSelectedIndex] = React.useState(null);
+
+ const { refs, context } = useFloating({
+ open: true,
+ });
+
+ const elementsRef = React.useRef>([]);
+ const labelsRef = React.useRef>([]);
+
+ const handleSelect = React.useCallback((index: number | null) => {
+ setSelectedIndex(index);
+ }, []);
+
+ function handleTypeaheadMatch(index: number | null) {
+ setActiveIndex(index);
+ }
+
+ const listNav = useListNavigation(context, {
+ listRef: elementsRef,
+ activeIndex,
+ selectedIndex,
+ onNavigate: setActiveIndex,
+ focusItemOnHover: false,
+ });
+ const typeahead = useTypeahead(context, {
+ listRef: labelsRef,
+ activeIndex,
+ selectedIndex,
+ onMatch: handleTypeaheadMatch,
+ });
+ const role = useRole(context, { role: 'listbox' });
+
+ const { getFloatingProps, getItemProps } = useInteractions([listNav, typeahead, role]);
+
+ const selectContext = React.useMemo(
+ () => ({
+ activeIndex,
+ selectedIndex,
+ getItemProps,
+ handleSelect,
+ }),
+ [activeIndex, selectedIndex, getItemProps, handleSelect],
+ );
+
+ return (
+
+ setSelectedIndex(1)} data-testid="reference" type="button">
+ Select
+
+
+
+ {children}
+
+
+
+ );
+}
+
+/** @internal */
+function Option({ label }: { label: string }) {
+ const { activeIndex, selectedIndex, getItemProps, handleSelect } =
+ React.useContext(SelectContext);
+
+ const { ref, index } = useCompositeListItem({ label });
+
+ const isActive = activeIndex === index;
+ const isSelected = selectedIndex === index;
+
+ const isFocusable =
+ // eslint-disable-next-line no-nested-ternary
+ activeIndex !== null ? isActive : selectedIndex !== null ? isSelected : index === 0;
+
+ return (
+ handleSelect(index),
+ })}
+ >
+ {label}
+
+ );
+}
+
+/** @internal */
+export function Main() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/packages/react/src/floating-ui-react/test-components/Menu.tsx b/packages/react/src/floating-ui-react/test-components/Menu.tsx
new file mode 100644
index 0000000000..326e56039e
--- /dev/null
+++ b/packages/react/src/floating-ui-react/test-components/Menu.tsx
@@ -0,0 +1,414 @@
+import c from 'clsx';
+import * as React from 'react';
+import { useForkRefN } from '../../utils/useForkRef';
+import { CompositeList } from '../../composite/list/CompositeList';
+import { useCompositeListItem } from '../../composite/list/useCompositeListItem';
+import {
+ autoUpdate,
+ flip,
+ FloatingFocusManager,
+ FloatingNode,
+ FloatingPortal,
+ FloatingTree,
+ offset,
+ safePolygon,
+ shift,
+ useClick,
+ useDismiss,
+ useFloating,
+ useFloatingNodeId,
+ useFloatingParentNodeId,
+ useFloatingTree,
+ useHover,
+ useInteractions,
+ useListNavigation,
+ useRole,
+ useTypeahead,
+ useFocus,
+} from '../index';
+
+type MenuContextType = {
+ getItemProps: ReturnType['getItemProps'];
+ activeIndex: number | null;
+ setActiveIndex: React.Dispatch>;
+ setHasFocusInside: React.Dispatch>;
+ allowHover: boolean;
+ isOpen: boolean;
+ setIsOpen: React.Dispatch>;
+ parent: MenuContextType | null;
+};
+
+const MenuContext = React.createContext({
+ getItemProps: () => ({}),
+ activeIndex: null,
+ setActiveIndex: () => {},
+ setHasFocusInside: () => {},
+ allowHover: true,
+ isOpen: false,
+ setIsOpen: () => {},
+ parent: null,
+});
+
+interface MenuProps {
+ label: string;
+ nested?: boolean;
+ children?: React.ReactNode;
+ keepMounted?: boolean;
+ orientation?: 'vertical' | 'horizontal' | 'both';
+ cols?: number;
+ openOnFocus?: boolean;
+}
+
+/** @internal */
+export const MenuComponent = React.forwardRef<
+ HTMLButtonElement,
+ MenuProps & React.HTMLProps
+>(function Menu(
+ {
+ children,
+ label,
+ keepMounted = false,
+ cols,
+ orientation: orientationOption,
+ openOnFocus = false,
+ ...props
+ },
+ forwardedRef,
+) {
+ const [isOpen, setIsOpen] = React.useState(false);
+ const [activeIndex, setActiveIndex] = React.useState(null);
+ const [allowHover, setAllowHover] = React.useState(false);
+ const [hasFocusInside, setHasFocusInside] = React.useState(false);
+
+ const elementsRef = React.useRef>([]);
+ const labelsRef = React.useRef>([]);
+
+ const tree = useFloatingTree();
+ const nodeId = useFloatingNodeId();
+ const parentId = useFloatingParentNodeId();
+ const isNested = parentId != null;
+ const orientation = orientationOption ?? (cols ? 'both' : 'vertical');
+
+ const parent = React.useContext(MenuContext);
+ const item = useCompositeListItem();
+
+ const { floatingStyles, refs, context } = useFloating({
+ nodeId,
+ open: isOpen,
+ onOpenChange: setIsOpen,
+ placement: isNested ? 'right-start' : 'bottom-start',
+ middleware: [
+ offset({ mainAxis: isNested ? 0 : 4, alignmentAxis: isNested ? -4 : 0 }),
+ flip(),
+ shift(),
+ ],
+ whileElementsMounted: autoUpdate,
+ });
+
+ const hover = useHover(context, {
+ enabled: isNested && allowHover,
+ delay: { open: 75 },
+ handleClose: safePolygon({ blockPointerEvents: true }),
+ });
+ const click = useClick(context, {
+ event: 'mousedown',
+ toggle: !isNested || !allowHover,
+ ignoreMouse: isNested,
+ });
+ const focus = useFocus(context, { enabled: openOnFocus });
+ const role = useRole(context, { role: 'menu' });
+ const dismiss = useDismiss(context, { bubbles: true });
+ const listNavigation = useListNavigation(context, {
+ listRef: elementsRef,
+ activeIndex,
+ nested: isNested,
+ onNavigate: setActiveIndex,
+ orientation,
+ cols,
+ });
+ const typeahead = useTypeahead(context, {
+ listRef: labelsRef,
+ onMatch: isOpen ? setActiveIndex : undefined,
+ activeIndex,
+ });
+
+ const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
+ hover,
+ click,
+ role,
+ dismiss,
+ focus,
+ listNavigation,
+ typeahead,
+ ]);
+
+ // Event emitter allows you to communicate across tree components.
+ // This effect closes all menus when an item gets clicked anywhere
+ // in the tree.
+ React.useEffect(() => {
+ if (!tree) {
+ return undefined;
+ }
+
+ function handleTreeClick() {
+ setIsOpen(false);
+ }
+
+ function onSubMenuOpen(event: { nodeId: string; parentId: string }) {
+ if (event.nodeId !== nodeId && event.parentId === parentId) {
+ setIsOpen(false);
+ }
+ }
+
+ tree.events.on('click', handleTreeClick);
+ tree.events.on('menuopen', onSubMenuOpen);
+
+ return () => {
+ tree.events.off('click', handleTreeClick);
+ tree.events.off('menuopen', onSubMenuOpen);
+ };
+ }, [tree, nodeId, parentId]);
+
+ React.useEffect(() => {
+ if (isOpen && tree) {
+ tree.events.emit('menuopen', { parentId, nodeId });
+ }
+ }, [tree, isOpen, nodeId, parentId]);
+
+ // Determine if "hover" logic can run based on the modality of input. This
+ // prevents unwanted focus synchronization as menus open and close with
+ // keyboard navigation and the cursor is resting on the menu.
+ React.useEffect(() => {
+ function onPointerMove({ pointerType }: PointerEvent) {
+ if (pointerType !== 'touch') {
+ setAllowHover(true);
+ }
+ }
+
+ function onKeyDown() {
+ setAllowHover(false);
+ }
+
+ window.addEventListener('pointermove', onPointerMove, {
+ once: true,
+ capture: true,
+ });
+ window.addEventListener('keydown', onKeyDown, true);
+ return () => {
+ window.removeEventListener('pointermove', onPointerMove, {
+ capture: true,
+ });
+ window.removeEventListener('keydown', onKeyDown, true);
+ };
+ }, [allowHover]);
+
+ return (
+
+ ) {
+ props.onFocus?.(event);
+ setHasFocusInside(false);
+ parent.setHasFocusInside(true);
+ },
+ onMouseEnter(event: React.MouseEvent) {
+ props.onMouseEnter?.(event);
+ if (parent.allowHover && parent.isOpen) {
+ parent.setActiveIndex(item.index);
+ }
+ },
+ }),
+ )}
+ >
+ {label}
+ {isNested && (
+
+ Icon
+
+ )}
+
+
+
+ {(keepMounted || isOpen) && (
+
+
+
+ {children}
+
+
+
+ )}
+
+
+
+ );
+});
+
+interface MenuItemProps {
+ label: string;
+ disabled?: boolean;
+}
+
+/** @internal */
+export const MenuItem = React.forwardRef<
+ HTMLButtonElement,
+ MenuItemProps & React.ButtonHTMLAttributes
+>(function MenuItem({ label, disabled, ...props }, forwardedRef) {
+ const menu = React.useContext(MenuContext);
+ const item = useCompositeListItem({ label: disabled ? null : label });
+ const tree = useFloatingTree();
+ const isActive = item.index === menu.activeIndex;
+
+ return (
+ ) {
+ props.onClick?.(event);
+ tree?.events.emit('click');
+ },
+ onFocus(event: React.FocusEvent) {
+ props.onFocus?.(event);
+ menu.setHasFocusInside(true);
+ },
+ onMouseEnter(event: React.MouseEvent) {
+ props.onMouseEnter?.(event);
+ if (menu.allowHover && menu.isOpen) {
+ menu.setActiveIndex(item.index);
+ }
+ },
+ onKeyDown(event) {
+ function closeParents(parent: MenuContextType | null) {
+ parent?.setIsOpen(false);
+ if (parent?.parent) {
+ closeParents(parent.parent);
+ }
+ }
+
+ if (
+ event.key === 'ArrowRight' &&
+ // If the root reference is in a menubar, close parents
+ tree?.nodesRef.current[0].context?.elements.domReference?.closest('[role="menubar"]')
+ ) {
+ closeParents(menu.parent);
+ }
+ },
+ })}
+ >
+ {label}
+
+ );
+});
+
+/** @internal */
+export const Menu = React.forwardRef<
+ HTMLButtonElement,
+ MenuProps & React.HTMLProps
+>(function MenuWrapper(props, ref) {
+ const parentId = useFloatingParentNodeId();
+
+ if (parentId === null) {
+ return (
+
+
+
+ );
+ }
+
+ return ;
+});
+
+/** @internal */
+export function Main() {
+ /* eslint-disable no-console */
+ return (
+
+ Menu
+
+
+ console.log('Undo')} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/react/src/floating-ui-react/test-components/MenuOrientation.tsx b/packages/react/src/floating-ui-react/test-components/MenuOrientation.tsx
new file mode 100644
index 0000000000..92efcd9a85
--- /dev/null
+++ b/packages/react/src/floating-ui-react/test-components/MenuOrientation.tsx
@@ -0,0 +1,499 @@
+import * as React from 'react';
+import c from 'clsx';
+import { useForkRefN } from '../../utils/useForkRef';
+import { CompositeList } from '../../composite/list/CompositeList';
+import { useCompositeListItem } from '../../composite/list/useCompositeListItem';
+import {
+ autoUpdate,
+ flip,
+ FloatingFocusManager,
+ FloatingNode,
+ FloatingPortal,
+ FloatingTree,
+ offset,
+ safePolygon,
+ shift,
+ useClick,
+ useDismiss,
+ useFloating,
+ useFloatingNodeId,
+ useFloatingParentNodeId,
+ useFloatingTree,
+ useHover,
+ useInteractions,
+ useListNavigation,
+ useRole,
+ useTypeahead,
+} from '../index';
+
+type MenuContextType = {
+ getItemProps: ReturnType['getItemProps'];
+ activeIndex: number | null;
+ setActiveIndex: React.Dispatch>;
+ setHasFocusInside: React.Dispatch>;
+ allowHover: boolean;
+ isOpen: boolean;
+ setIsOpen: React.Dispatch>;
+ parent: MenuContextType | null;
+ orientation: 'vertical' | 'horizontal' | 'both';
+};
+
+const MenuContext = React.createContext({
+ getItemProps: () => ({}),
+ activeIndex: null,
+ setActiveIndex: () => {},
+ setHasFocusInside: () => {},
+ allowHover: true,
+ isOpen: false,
+ setIsOpen: () => {},
+ parent: null,
+ orientation: 'vertical',
+});
+
+interface MenuProps {
+ label: string;
+ nested?: boolean;
+ children?: React.ReactNode;
+ keepMounted?: boolean;
+ orientation?: 'vertical' | 'horizontal' | 'both';
+ cols?: number;
+}
+
+/** @internal */
+export const MenuComponent = React.forwardRef<
+ HTMLButtonElement,
+ MenuProps & React.HTMLProps
+>(function Menu(
+ { children, label, keepMounted = false, cols, orientation: orientationOption, ...props },
+ forwardedRef,
+) {
+ const [isOpen, setIsOpen] = React.useState(false);
+ const [activeIndex, setActiveIndex] = React.useState(null);
+ const [allowHover, setAllowHover] = React.useState(false);
+ const [hasFocusInside, setHasFocusInside] = React.useState(false);
+
+ const elementsRef = React.useRef>([]);
+ const labelsRef = React.useRef>([]);
+
+ const tree = useFloatingTree();
+ const nodeId = useFloatingNodeId();
+ const parentId = useFloatingParentNodeId();
+ const isNested = parentId != null;
+ const orientation = orientationOption ?? (cols ? 'both' : 'vertical');
+
+ const parent = React.useContext(MenuContext);
+ const item = useCompositeListItem();
+
+ const { floatingStyles, refs, context } = useFloating({
+ nodeId,
+ open: isOpen,
+ onOpenChange: setIsOpen,
+ placement: isNested ? 'right-start' : 'bottom-start',
+ middleware: [
+ offset({ mainAxis: isNested ? 0 : 4, alignmentAxis: isNested ? -4 : 0 }),
+ flip(),
+ shift(),
+ ],
+ whileElementsMounted: autoUpdate,
+ });
+
+ const hover = useHover(context, {
+ enabled: isNested && allowHover,
+ delay: { open: 75 },
+ handleClose: safePolygon({ blockPointerEvents: true }),
+ });
+ const click = useClick(context, {
+ event: 'mousedown',
+ toggle: !isNested || !allowHover,
+ ignoreMouse: isNested,
+ });
+ const role = useRole(context, { role: 'menu' });
+ const dismiss = useDismiss(context, { bubbles: true });
+ const listNavigation = useListNavigation(context, {
+ listRef: elementsRef,
+ activeIndex,
+ nested: isNested,
+ onNavigate: setActiveIndex,
+ orientation,
+ cols,
+ });
+ const typeahead = useTypeahead(context, {
+ listRef: labelsRef,
+ onMatch: isOpen ? setActiveIndex : undefined,
+ activeIndex,
+ });
+
+ const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
+ hover,
+ click,
+ role,
+ dismiss,
+ listNavigation,
+ typeahead,
+ ]);
+
+ // Event emitter allows you to communicate across tree components.
+ // This effect closes all menus when an item gets clicked anywhere
+ // in the tree.
+ React.useEffect(() => {
+ if (!tree) {
+ return;
+ }
+
+ function handleTreeClick() {
+ setIsOpen(false);
+ }
+
+ function onSubMenuOpen(event: { nodeId: string; parentId: string }) {
+ if (event.nodeId !== nodeId && event.parentId === parentId) {
+ setIsOpen(false);
+ }
+ }
+
+ tree.events.on('click', handleTreeClick);
+ tree.events.on('menuopen', onSubMenuOpen);
+
+ // eslint-disable-next-line consistent-return
+ return () => {
+ tree.events.off('click', handleTreeClick);
+ tree.events.off('menuopen', onSubMenuOpen);
+ };
+ }, [tree, nodeId, parentId]);
+
+ React.useEffect(() => {
+ if (isOpen && tree) {
+ tree.events.emit('menuopen', { parentId, nodeId });
+ }
+ }, [tree, isOpen, nodeId, parentId]);
+
+ // Determine if "hover" logic can run based on the modality of input. This
+ // prevents unwanted focus synchronization as menus open and close with
+ // keyboard navigation and the cursor is resting on the menu.
+ React.useEffect(() => {
+ function onPointerMove({ pointerType }: PointerEvent) {
+ if (pointerType !== 'touch') {
+ setAllowHover(true);
+ }
+ }
+
+ function onKeyDown() {
+ setAllowHover(false);
+ }
+
+ window.addEventListener('pointermove', onPointerMove, {
+ once: true,
+ capture: true,
+ });
+ window.addEventListener('keydown', onKeyDown, true);
+ return () => {
+ window.removeEventListener('pointermove', onPointerMove, {
+ capture: true,
+ });
+ window.removeEventListener('keydown', onKeyDown, true);
+ };
+ }, [allowHover]);
+
+ return (
+
+ ) {
+ props.onFocus?.(event);
+ setHasFocusInside(false);
+ parent.setHasFocusInside(true);
+ },
+ onMouseEnter(event: React.MouseEvent) {
+ props.onMouseEnter?.(event);
+ if (parent.allowHover && parent.isOpen) {
+ parent.setActiveIndex(item.index);
+ }
+ },
+ }),
+ )}
+ >
+ {label}
+ {isNested && (
+
+ Icon
+
+ )}
+
+
+
+ {(keepMounted || isOpen) && (
+
+
+
+ {children}
+
+
+
+ )}
+
+
+
+ );
+});
+
+interface MenuItemProps {
+ label: string;
+ disabled?: boolean;
+}
+
+/** @internal */
+export const MenuItem = React.forwardRef<
+ HTMLButtonElement,
+ MenuItemProps & React.ButtonHTMLAttributes
+>(function MenuItem({ label, disabled, ...props }, forwardedRef) {
+ const menu = React.useContext(MenuContext);
+ const item = useCompositeListItem({ label: disabled ? null : label });
+ const tree = useFloatingTree();
+ const isActive = item.index === menu.activeIndex;
+
+ return (
+ ) {
+ props.onClick?.(event);
+ tree?.events.emit('click');
+ },
+ onFocus(event: React.FocusEvent) {
+ props.onFocus?.(event);
+ menu.setHasFocusInside(true);
+ },
+ onMouseEnter(event: React.MouseEvent) {
+ props.onMouseEnter?.(event);
+ if (menu.allowHover && menu.isOpen) {
+ menu.setActiveIndex(item.index);
+ }
+ },
+ onKeyDown(event) {
+ function closeParents(parent: MenuContextType | null) {
+ parent?.setIsOpen(false);
+ if (parent?.parent) {
+ closeParents(parent.parent);
+ }
+ }
+
+ if (
+ event.key === 'ArrowRight' &&
+ // If the root reference is in a menubar, close parents
+ tree?.nodesRef.current[0].context?.elements.domReference?.closest('[role="menubar"]')
+ ) {
+ closeParents(menu.parent);
+ }
+ },
+ })}
+ >
+ {label}
+
+ );
+});
+
+/** @internal */
+export const Menu = React.forwardRef<
+ HTMLButtonElement,
+ MenuProps & React.HTMLProps
+>(function MenuWrapper(props, ref) {
+ const parentId = useFloatingParentNodeId();
+
+ if (parentId === null) {
+ return (
+
+
+
+ );
+ }
+
+ return ;
+});
+
+/** @internal */
+export function HorizontalMenu() {
+ return (
+
+ Horizontal menu
+
+
+ {
+ // eslint-disable-next-line no-console
+ return console.log('Undo');
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** @internal */
+export function VerticalMenu() {
+ return (
+
+ Vertical menu
+
+
+ {
+ // eslint-disable-next-line no-console
+ return console.log('Undo');
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** @internal */
+export function HorizontalMenuWithHorizontalSubmenus() {
+ return (
+
+ Horizontal menu with horizontal submenus
+
+
+ {
+ // eslint-disable-next-line no-console
+ return console.log('Undo');
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** @internal */
+export function Main() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/react/src/floating-ui-react/test-components/MenuVirtual.tsx b/packages/react/src/floating-ui-react/test-components/MenuVirtual.tsx
new file mode 100644
index 0000000000..e67f963108
--- /dev/null
+++ b/packages/react/src/floating-ui-react/test-components/MenuVirtual.tsx
@@ -0,0 +1,398 @@
+import * as React from 'react';
+import c from 'clsx';
+import { useForkRefN } from '../../utils/useForkRef';
+import { CompositeList } from '../../composite/list/CompositeList';
+import { useCompositeListItem } from '../../composite/list/useCompositeListItem';
+import {
+ autoUpdate,
+ flip,
+ FloatingFocusManager,
+ FloatingNode,
+ FloatingPortal,
+ FloatingTree,
+ offset,
+ safePolygon,
+ shift,
+ useDismiss,
+ useFloating,
+ useFloatingNodeId,
+ useFloatingParentNodeId,
+ useFloatingTree,
+ useHover,
+ useInteractions,
+ useListNavigation,
+ useRole,
+} from '../index';
+
+type MenuContextType = {
+ getItemProps: (userProps?: React.HTMLProps) => Record;
+ activeIndex: number | null;
+ setActiveIndex: React.Dispatch>;
+ setHasFocusInside: React.Dispatch>;
+ allowHover: boolean;
+ isOpen: boolean;
+ setIsOpen: React.Dispatch>;
+ parent: MenuContextType | null;
+};
+
+const MenuContext = React.createContext({
+ getItemProps: () => ({}),
+ activeIndex: null,
+ setActiveIndex: () => {},
+ setHasFocusInside: () => {},
+ allowHover: true,
+ isOpen: false,
+ setIsOpen: () => {},
+ parent: null,
+});
+
+interface MenuProps {
+ label: string;
+ nested?: boolean;
+ children?: React.ReactNode;
+ virtualItemRef: React.RefObject;
+}
+
+/** @internal */
+export const MenuComponent = React.forwardRef<
+ HTMLElement,
+ MenuProps & React.HTMLAttributes
+>(function Menu({ children, label, virtualItemRef, ...props }, forwardedRef) {
+ const [isOpen, setIsOpen] = React.useState(false);
+ const [activeIndex, setActiveIndex] = React.useState(null);
+ const [allowHover, setAllowHover] = React.useState(false);
+ const [hasFocusInside, setHasFocusInside] = React.useState(false);
+
+ const elementsRef = React.useRef>([]);
+ const labelsRef = React.useRef>([]);
+
+ const tree = useFloatingTree();
+ const nodeId = useFloatingNodeId();
+ const parentId = useFloatingParentNodeId();
+ const isNested = parentId != null;
+
+ const parent = React.useContext(MenuContext);
+ const item = useCompositeListItem();
+
+ const { floatingStyles, refs, context } = useFloating({
+ nodeId,
+ open: isOpen,
+ onOpenChange: setIsOpen,
+ placement: isNested ? 'right-start' : 'bottom-start',
+ middleware: [
+ offset({ mainAxis: isNested ? 0 : 4, alignmentAxis: isNested ? -4 : 0 }),
+ flip(),
+ shift(),
+ ],
+ whileElementsMounted: autoUpdate,
+ });
+
+ const hover = useHover(context, {
+ enabled: isNested && allowHover,
+ delay: { open: 75 },
+ handleClose: safePolygon({ blockPointerEvents: true }),
+ });
+ const role = useRole(context, { role: 'menu' });
+ const dismiss = useDismiss(context, { bubbles: true });
+ const listNavigation = useListNavigation(context, {
+ listRef: elementsRef,
+ activeIndex,
+ nested: isNested,
+ onNavigate: setActiveIndex,
+ virtual: true,
+ virtualItemRef,
+ });
+
+ const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
+ hover,
+ role,
+ dismiss,
+ listNavigation,
+ ]);
+
+ // Event emitter allows you to communicate across tree components.
+ // This effect closes all menus when an item gets clicked anywhere
+ // in the tree.
+ React.useEffect(() => {
+ if (!tree) {
+ return;
+ }
+
+ function handleTreeClick() {
+ setIsOpen(false);
+ }
+
+ function onSubMenuOpen(event: { nodeId: string; parentId: string }) {
+ if (event.nodeId !== nodeId && event.parentId === parentId) {
+ setIsOpen(false);
+ }
+ }
+
+ tree.events.on('click', handleTreeClick);
+ tree.events.on('menuopen', onSubMenuOpen);
+
+ // eslint-disable-next-line consistent-return
+ return () => {
+ tree.events.off('click', handleTreeClick);
+ tree.events.off('menuopen', onSubMenuOpen);
+ };
+ }, [tree, nodeId, parentId]);
+
+ React.useEffect(() => {
+ if (isOpen && tree) {
+ tree.events.emit('menuopen', { parentId, nodeId });
+ }
+ }, [tree, isOpen, nodeId, parentId]);
+
+ // Determine if "hover" logic can run based on the modality of input. This
+ // prevents unwanted focus synchronization as menus open and close with
+ // keyboard navigation and the cursor is resting on the menu.
+ React.useEffect(() => {
+ function onPointerMove({ pointerType }: PointerEvent) {
+ if (pointerType !== 'touch') {
+ setAllowHover(true);
+ }
+ }
+
+ function onKeyDown() {
+ setAllowHover(false);
+ }
+
+ window.addEventListener('pointermove', onPointerMove, {
+ once: true,
+ capture: true,
+ });
+ window.addEventListener('keydown', onKeyDown, true);
+ return () => {
+ window.removeEventListener('pointermove', onPointerMove, {
+ capture: true,
+ });
+ window.removeEventListener('keydown', onKeyDown, true);
+ };
+ }, [allowHover]);
+
+ const id = React.useId();
+ const mergedRef = useForkRefN([refs.setReference, item.ref, forwardedRef]);
+
+ return (
+
+ {isNested ? (
+ // eslint-disable-next-line jsx-a11y/role-supports-aria-props
+
+ {label}
+ {isNested && (
+
+ Icon
+
+ )}
+
+ ) : (
+
+ )}
+
+
+ {isOpen && (
+
+
+
+ {children}
+
+
+
+ )}
+
+
+
+ );
+});
+
+interface MenuItemProps {
+ label: string;
+ disabled?: boolean;
+}
+
+/** @internal */
+export const MenuItem = React.forwardRef<
+ HTMLElement,
+ MenuItemProps & React.HTMLAttributes
+>(function MenuItem({ label, disabled, ...props }, forwardedRef) {
+ const menu = React.useContext(MenuContext);
+ const item = useCompositeListItem({ label: disabled ? null : label });
+ const tree = useFloatingTree();
+ const isActive = item.index === menu.activeIndex;
+ const id = React.useId();
+
+ return (
+ ) {
+ props.onClick?.(event);
+ tree?.events.emit('click');
+ },
+ onFocus(event: React.FocusEvent) {
+ props.onFocus?.(event);
+ menu.setHasFocusInside(true);
+ },
+ onMouseEnter(event: React.MouseEvent) {
+ props.onMouseEnter?.(event);
+ if (menu.allowHover && menu.isOpen) {
+ menu.setActiveIndex(item.index);
+ }
+ },
+ onKeyDown(event) {
+ function closeParents(parent: MenuContextType | null) {
+ parent?.setIsOpen(false);
+ if (parent?.parent) {
+ closeParents(parent.parent);
+ }
+ }
+
+ if (
+ event.key === 'ArrowRight' &&
+ // If the root reference is in a menubar, close parents
+ tree?.nodesRef.current[0].context?.elements.domReference?.closest('[role="menubar"]')
+ ) {
+ closeParents(menu.parent);
+ }
+ },
+ })}
+ >
+ {label}
+
+ );
+});
+
+/** @internal */
+export const Menu = React.forwardRef<
+ HTMLButtonElement,
+ MenuProps & React.HTMLProps
+>(function MenuWrapper(props, ref) {
+ const parentId = useFloatingParentNodeId();
+
+ if (parentId === null) {
+ return (
+
+
+
+ );
+ }
+
+ return ;
+});
+
+/** @internal */
+export function Main() {
+ const virtualItemRef = React.useRef(null) as any;
+
+ return (
+
+ Menu Virtual
+
+
+ {
+ // eslint-disable-next-line no-console
+ return console.log('Undo');
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/react/src/floating-ui-react/types.ts b/packages/react/src/floating-ui-react/types.ts
new file mode 100644
index 0000000000..63d7607aa8
--- /dev/null
+++ b/packages/react/src/floating-ui-react/types.ts
@@ -0,0 +1,227 @@
+import type {
+ UseFloatingOptions as UsePositionOptions,
+ UseFloatingReturn as UsePositionFloatingReturn,
+ VirtualElement,
+} from '@floating-ui/react-dom';
+import type * as React from 'react';
+
+import type { ExtendedUserProps } from './hooks/useInteractions';
+
+export * from '.';
+export type { FloatingDelayGroupProps } from './components/FloatingDelayGroup';
+export type { FloatingFocusManagerProps } from './components/FloatingFocusManager';
+export type { FloatingPortalProps, UseFloatingPortalNodeProps } from './components/FloatingPortal';
+export type { UseClientPointProps } from './hooks/useClientPoint';
+export type { UseDismissProps } from './hooks/useDismiss';
+export type { UseFocusProps } from './hooks/useFocus';
+export type { UseHoverProps, HandleCloseContext, HandleClose } from './hooks/useHover';
+export type { UseListNavigationProps } from './hooks/useListNavigation';
+export type { UseRoleProps } from './hooks/useRole';
+export type { UseTypeaheadProps } from './hooks/useTypeahead';
+export type { UseFloatingRootContextOptions } from './hooks/useFloatingRootContext';
+export type { UseInteractionsReturn } from './hooks/useInteractions';
+export type { SafePolygonOptions } from './safePolygon';
+export type { FloatingTreeProps, FloatingNodeProps } from './components/FloatingTree';
+export type {
+ AlignedPlacement,
+ Alignment,
+ ArrowOptions,
+ AutoPlacementOptions,
+ AutoUpdateOptions,
+ Axis,
+ Boundary,
+ ClientRectObject,
+ ComputePositionConfig,
+ ComputePositionReturn,
+ Coords,
+ DetectOverflowOptions,
+ Dimensions,
+ ElementContext,
+ ElementRects,
+ Elements,
+ FlipOptions,
+ FloatingElement,
+ HideOptions,
+ InlineOptions,
+ Length,
+ Middleware,
+ MiddlewareArguments,
+ MiddlewareData,
+ MiddlewareReturn,
+ MiddlewareState,
+ NodeScroll,
+ OffsetOptions,
+ Padding,
+ Placement,
+ Platform,
+ Rect,
+ ReferenceElement,
+ RootBoundary,
+ ShiftOptions,
+ Side,
+ SideObject,
+ SizeOptions,
+ Strategy,
+ VirtualElement,
+} from '@floating-ui/react-dom';
+export {
+ arrow,
+ autoPlacement,
+ autoUpdate,
+ computePosition,
+ detectOverflow,
+ flip,
+ getOverflowAncestors,
+ hide,
+ inline,
+ limitShift,
+ offset,
+ platform,
+ shift,
+ size,
+} from '@floating-ui/react-dom';
+
+type Prettify = {
+ [K in keyof T]: T[K];
+} & {};
+
+export type OpenChangeReason =
+ | 'outside-press'
+ | 'escape-key'
+ | 'ancestor-scroll'
+ | 'reference-press'
+ | 'click'
+ | 'hover'
+ | 'focus'
+ | 'focus-out'
+ | 'list-navigation'
+ | 'safe-polygon';
+
+export type Delay = number | Partial<{ open: number; close: number }>;
+
+export type NarrowedElement = T extends Element ? T : Element;
+
+export interface ExtendedRefs {
+ reference: React.MutableRefObject;
+ floating: React.MutableRefObject;
+ domReference: React.MutableRefObject | null>;
+ setReference(node: RT | null): void;
+ setFloating(node: HTMLElement | null): void;
+ setPositionReference(node: ReferenceType | null): void;
+}
+
+export interface ExtendedElements {
+ reference: ReferenceType | null;
+ floating: HTMLElement | null;
+ domReference: NarrowedElement | null;
+}
+
+export interface FloatingEvents {
+ emit(event: T, data?: any): void;
+ on(event: string, handler: (data: any) => void): void;
+ off(event: string, handler: (data: any) => void): void;
+}
+
+export interface ContextData {
+ openEvent?: Event;
+ floatingContext?: FloatingContext;
+ /** @deprecated use `onTypingChange` prop in `useTypeahead` */
+ typing?: boolean;
+ [key: string]: any;
+}
+
+export interface FloatingRootContext {
+ dataRef: React.MutableRefObject;
+ open: boolean;
+ onOpenChange: (open: boolean, event?: Event, reason?: OpenChangeReason) => void;
+ elements: {
+ domReference: Element | null;
+ reference: RT | null;
+ floating: HTMLElement | null;
+ };
+ events: FloatingEvents;
+ floatingId: string | undefined;
+ refs: {
+ setPositionReference(node: ReferenceType | null): void;
+ };
+}
+
+export type FloatingContext = Omit<
+ UsePositionFloatingReturn,
+ 'refs' | 'elements'
+> & {
+ open: boolean;
+ onOpenChange(open: boolean, event?: Event, reason?: OpenChangeReason): void;
+ events: FloatingEvents;
+ dataRef: React.MutableRefObject;
+ nodeId: string | undefined;
+ floatingId: string | undefined;
+ refs: ExtendedRefs;
+ elements: ExtendedElements;
+};
+
+export interface FloatingNodeType {
+ id: string | undefined;
+ parentId: string | null;
+ context?: FloatingContext;
+}
+
+export interface FloatingTreeType {
+ nodesRef: React.MutableRefObject>>;
+ events: FloatingEvents;
+ addNode(node: FloatingNodeType): void;
+ removeNode(node: FloatingNodeType): void;
+}
+
+export interface ElementProps {
+ reference?: React.HTMLProps;
+ floating?: React.HTMLProps;
+ item?:
+ | React.HTMLProps
+ | ((props: ExtendedUserProps) => React.HTMLProps);
+}
+
+export type ReferenceType = Element | VirtualElement;
+
+export type UseFloatingData = Prettify;
+
+export type UseFloatingReturn = Prettify<
+ UsePositionFloatingReturn & {
+ /**
+ * `FloatingContext`
+ */
+ context: Prettify>;
+ /**
+ * Object containing the reference and floating refs and reactive setters.
+ */
+ refs: ExtendedRefs;
+ elements: ExtendedElements;
+ }
+>;
+
+export interface UseFloatingOptions
+ extends Omit, 'elements'> {
+ rootContext?: FloatingRootContext;
+ /**
+ * Object of external elements as an alternative to the `refs` object setters.
+ */
+ elements?: {
+ /**
+ * Externally passed reference element. Store in state.
+ */
+ reference?: Element | null;
+ /**
+ * Externally passed floating element. Store in state.
+ */
+ floating?: HTMLElement | null;
+ };
+ /**
+ * An event callback that is invoked when the floating element is opened or
+ * closed.
+ */
+ onOpenChange?(open: boolean, event?: Event, reason?: OpenChangeReason): void;
+ /**
+ * Unique node id when using `FloatingTree`.
+ */
+ nodeId?: string;
+}
diff --git a/packages/react/src/floating-ui-react/utils.ts b/packages/react/src/floating-ui-react/utils.ts
new file mode 100644
index 0000000000..c185bdd5f0
--- /dev/null
+++ b/packages/react/src/floating-ui-react/utils.ts
@@ -0,0 +1,5 @@
+export * from './utils/element';
+export * from './utils/nodes';
+export * from './utils/event';
+export * from './utils/composite';
+export * from './utils/tabbable';
diff --git a/packages/react/src/floating-ui-react/utils/composite.ts b/packages/react/src/floating-ui-react/utils/composite.ts
new file mode 100644
index 0000000000..89aa1b062e
--- /dev/null
+++ b/packages/react/src/floating-ui-react/utils/composite.ts
@@ -0,0 +1,340 @@
+import { floor } from '@floating-ui/utils';
+
+import type { Dimensions } from '../types';
+import { stopEvent } from './event';
+import { ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, ARROW_UP } from './constants';
+
+type DisabledIndices = Array | ((index: number) => boolean);
+
+export function isDifferentGridRow(index: number, cols: number, prevRow: number) {
+ return Math.floor(index / cols) !== prevRow;
+}
+
+export function isIndexOutOfListBounds(
+ listRef: React.MutableRefObject>,
+ index: number,
+) {
+ return index < 0 || index >= listRef.current.length;
+}
+
+export function getMinListIndex(
+ listRef: React.MutableRefObject>,
+ disabledIndices: DisabledIndices | undefined,
+) {
+ return findNonDisabledListIndex(listRef, { disabledIndices });
+}
+
+export function getMaxListIndex(
+ listRef: React.MutableRefObject>,
+ disabledIndices: DisabledIndices | undefined,
+) {
+ return findNonDisabledListIndex(listRef, {
+ decrement: true,
+ startingIndex: listRef.current.length,
+ disabledIndices,
+ });
+}
+
+export function findNonDisabledListIndex(
+ listRef: React.MutableRefObject>,
+ {
+ startingIndex = -1,
+ decrement = false,
+ disabledIndices,
+ amount = 1,
+ }: {
+ startingIndex?: number;
+ decrement?: boolean;
+ disabledIndices?: DisabledIndices;
+ amount?: number;
+ } = {},
+): number {
+ let index = startingIndex;
+ do {
+ index += decrement ? -amount : amount;
+ } while (
+ index >= 0 &&
+ index <= listRef.current.length - 1 &&
+ isListIndexDisabled(listRef, index, disabledIndices)
+ );
+
+ return index;
+}
+
+export function getGridNavigatedIndex(
+ listRef: React.MutableRefObject>,
+ {
+ event,
+ orientation,
+ loop,
+ rtl,
+ cols,
+ disabledIndices,
+ minIndex,
+ maxIndex,
+ prevIndex,
+ stopEvent: stop = false,
+ }: {
+ event: React.KeyboardEvent;
+ orientation: 'horizontal' | 'vertical' | 'both';
+ loop: boolean;
+ rtl: boolean;
+ cols: number;
+ disabledIndices: DisabledIndices | undefined;
+ minIndex: number;
+ maxIndex: number;
+ prevIndex: number;
+ stopEvent?: boolean;
+ },
+) {
+ let nextIndex = prevIndex;
+
+ if (event.key === ARROW_UP) {
+ if (stop) {
+ stopEvent(event);
+ }
+
+ if (prevIndex === -1) {
+ nextIndex = maxIndex;
+ } else {
+ nextIndex = findNonDisabledListIndex(listRef, {
+ startingIndex: nextIndex,
+ amount: cols,
+ decrement: true,
+ disabledIndices,
+ });
+
+ if (loop && (prevIndex - cols < minIndex || nextIndex < 0)) {
+ const col = prevIndex % cols;
+ const maxCol = maxIndex % cols;
+ const offset = maxIndex - (maxCol - col);
+
+ if (maxCol === col) {
+ nextIndex = maxIndex;
+ } else {
+ nextIndex = maxCol > col ? offset : offset - cols;
+ }
+ }
+ }
+
+ if (isIndexOutOfListBounds(listRef, nextIndex)) {
+ nextIndex = prevIndex;
+ }
+ }
+
+ if (event.key === ARROW_DOWN) {
+ if (stop) {
+ stopEvent(event);
+ }
+
+ if (prevIndex === -1) {
+ nextIndex = minIndex;
+ } else {
+ nextIndex = findNonDisabledListIndex(listRef, {
+ startingIndex: prevIndex,
+ amount: cols,
+ disabledIndices,
+ });
+
+ if (loop && prevIndex + cols > maxIndex) {
+ nextIndex = findNonDisabledListIndex(listRef, {
+ startingIndex: (prevIndex % cols) - cols,
+ amount: cols,
+ disabledIndices,
+ });
+ }
+ }
+
+ if (isIndexOutOfListBounds(listRef, nextIndex)) {
+ nextIndex = prevIndex;
+ }
+ }
+
+ // Remains on the same row/column.
+ if (orientation === 'both') {
+ const prevRow = floor(prevIndex / cols);
+
+ if (event.key === (rtl ? ARROW_LEFT : ARROW_RIGHT)) {
+ if (stop) {
+ stopEvent(event);
+ }
+
+ if (prevIndex % cols !== cols - 1) {
+ nextIndex = findNonDisabledListIndex(listRef, {
+ startingIndex: prevIndex,
+ disabledIndices,
+ });
+
+ if (loop && isDifferentGridRow(nextIndex, cols, prevRow)) {
+ nextIndex = findNonDisabledListIndex(listRef, {
+ startingIndex: prevIndex - (prevIndex % cols) - 1,
+ disabledIndices,
+ });
+ }
+ } else if (loop) {
+ nextIndex = findNonDisabledListIndex(listRef, {
+ startingIndex: prevIndex - (prevIndex % cols) - 1,
+ disabledIndices,
+ });
+ }
+
+ if (isDifferentGridRow(nextIndex, cols, prevRow)) {
+ nextIndex = prevIndex;
+ }
+ }
+
+ if (event.key === (rtl ? ARROW_RIGHT : ARROW_LEFT)) {
+ if (stop) {
+ stopEvent(event);
+ }
+
+ if (prevIndex % cols !== 0) {
+ nextIndex = findNonDisabledListIndex(listRef, {
+ startingIndex: prevIndex,
+ decrement: true,
+ disabledIndices,
+ });
+
+ if (loop && isDifferentGridRow(nextIndex, cols, prevRow)) {
+ nextIndex = findNonDisabledListIndex(listRef, {
+ startingIndex: prevIndex + (cols - (prevIndex % cols)),
+ decrement: true,
+ disabledIndices,
+ });
+ }
+ } else if (loop) {
+ nextIndex = findNonDisabledListIndex(listRef, {
+ startingIndex: prevIndex + (cols - (prevIndex % cols)),
+ decrement: true,
+ disabledIndices,
+ });
+ }
+
+ if (isDifferentGridRow(nextIndex, cols, prevRow)) {
+ nextIndex = prevIndex;
+ }
+ }
+
+ const lastRow = floor(maxIndex / cols) === prevRow;
+
+ if (isIndexOutOfListBounds(listRef, nextIndex)) {
+ if (loop && lastRow) {
+ nextIndex =
+ event.key === (rtl ? ARROW_RIGHT : ARROW_LEFT)
+ ? maxIndex
+ : findNonDisabledListIndex(listRef, {
+ startingIndex: prevIndex - (prevIndex % cols) - 1,
+ disabledIndices,
+ });
+ } else {
+ nextIndex = prevIndex;
+ }
+ }
+ }
+
+ return nextIndex;
+}
+
+/** For each cell index, gets the item index that occupies that cell */
+export function createGridCellMap(sizes: Dimensions[], cols: number, dense: boolean) {
+ const cellMap: (number | undefined)[] = [];
+ let startIndex = 0;
+ sizes.forEach(({ width, height }, index) => {
+ if (width > cols) {
+ if (process.env.NODE_ENV !== 'production') {
+ throw new Error(
+ `[Floating UI]: Invalid grid - item width at index ${index} is greater than grid columns`,
+ );
+ }
+ }
+ let itemPlaced = false;
+ if (dense) {
+ startIndex = 0;
+ }
+ while (!itemPlaced) {
+ const targetCells: number[] = [];
+ for (let i = 0; i < width; i += 1) {
+ for (let j = 0; j < height; j += 1) {
+ targetCells.push(startIndex + i + j * cols);
+ }
+ }
+ if (
+ (startIndex % cols) + width <= cols &&
+ targetCells.every((cell) => cellMap[cell] == null)
+ ) {
+ targetCells.forEach((cell) => {
+ cellMap[cell] = index;
+ });
+ itemPlaced = true;
+ } else {
+ startIndex += 1;
+ }
+ }
+ });
+
+ // convert into a non-sparse array
+ return [...cellMap];
+}
+
+/** Gets cell index of an item's corner or -1 when index is -1. */
+export function getGridCellIndexOfCorner(
+ index: number,
+ sizes: Dimensions[],
+ cellMap: (number | undefined)[],
+ cols: number,
+ corner: 'tl' | 'tr' | 'bl' | 'br',
+) {
+ if (index === -1) {
+ return -1;
+ }
+
+ const firstCellIndex = cellMap.indexOf(index);
+ const sizeItem = sizes[index];
+
+ switch (corner) {
+ case 'tl':
+ return firstCellIndex;
+ case 'tr':
+ if (!sizeItem) {
+ return firstCellIndex;
+ }
+ return firstCellIndex + sizeItem.width - 1;
+ case 'bl':
+ if (!sizeItem) {
+ return firstCellIndex;
+ }
+ return firstCellIndex + (sizeItem.height - 1) * cols;
+ case 'br':
+ return cellMap.lastIndexOf(index);
+ default:
+ return -1;
+ }
+}
+
+/** Gets all cell indices that correspond to the specified indices */
+export function getGridCellIndices(
+ indices: (number | undefined)[],
+ cellMap: (number | undefined)[],
+) {
+ return cellMap.flatMap((index, cellIndex) => (indices.includes(index) ? [cellIndex] : []));
+}
+
+export function isListIndexDisabled(
+ listRef: React.MutableRefObject>,
+ index: number,
+ disabledIndices?: DisabledIndices,
+) {
+ if (typeof disabledIndices === 'function') {
+ return disabledIndices(index);
+ }
+ if (disabledIndices) {
+ return disabledIndices.includes(index);
+ }
+
+ const element = listRef.current[index];
+ return (
+ element == null ||
+ element.hasAttribute('disabled') ||
+ element.getAttribute('aria-disabled') === 'true'
+ );
+}
diff --git a/packages/react/src/floating-ui-react/utils/constants.ts b/packages/react/src/floating-ui-react/utils/constants.ts
new file mode 100644
index 0000000000..6d9faee063
--- /dev/null
+++ b/packages/react/src/floating-ui-react/utils/constants.ts
@@ -0,0 +1,10 @@
+export const FOCUSABLE_ATTRIBUTE = 'data-floating-ui-focusable';
+export const ACTIVE_KEY = 'active';
+export const SELECTED_KEY = 'selected';
+export const TYPEABLE_SELECTOR =
+ "input:not([type='hidden']):not([disabled])," +
+ "[contenteditable]:not([contenteditable='false']),textarea:not([disabled])";
+export const ARROW_LEFT = 'ArrowLeft';
+export const ARROW_RIGHT = 'ArrowRight';
+export const ARROW_UP = 'ArrowUp';
+export const ARROW_DOWN = 'ArrowDown';
diff --git a/packages/react/src/floating-ui-react/utils/createAttribute.ts b/packages/react/src/floating-ui-react/utils/createAttribute.ts
new file mode 100644
index 0000000000..8b661e3079
--- /dev/null
+++ b/packages/react/src/floating-ui-react/utils/createAttribute.ts
@@ -0,0 +1,3 @@
+export function createAttribute(name: string) {
+ return `data-floating-ui-${name}`;
+}
diff --git a/packages/react/src/floating-ui-react/utils/createEventEmitter.ts b/packages/react/src/floating-ui-react/utils/createEventEmitter.ts
new file mode 100644
index 0000000000..5f7c4ad94c
--- /dev/null
+++ b/packages/react/src/floating-ui-react/utils/createEventEmitter.ts
@@ -0,0 +1,17 @@
+export function createEventEmitter() {
+ const map = new Map void>>();
+ return {
+ emit(event: string, data: any) {
+ map.get(event)?.forEach((listener) => listener(data));
+ },
+ on(event: string, listener: (data: any) => void) {
+ if (!map.has(event)) {
+ map.set(event, new Set());
+ }
+ map.get(event)!.add(listener);
+ },
+ off(event: string, listener: (data: any) => void) {
+ map.get(event)?.delete(listener);
+ },
+ };
+}
diff --git a/packages/react/src/floating-ui-react/utils/element.ts b/packages/react/src/floating-ui-react/utils/element.ts
new file mode 100644
index 0000000000..1c78064151
--- /dev/null
+++ b/packages/react/src/floating-ui-react/utils/element.ts
@@ -0,0 +1,112 @@
+import { isHTMLElement, isShadowRoot } from '@floating-ui/utils/dom';
+import { isJSDOM } from '../../utils/detectBrowser';
+import { FOCUSABLE_ATTRIBUTE, TYPEABLE_SELECTOR } from './constants';
+
+export function activeElement(doc: Document) {
+ let element = doc.activeElement;
+
+ while (element?.shadowRoot?.activeElement != null) {
+ element = element.shadowRoot.activeElement;
+ }
+
+ return element;
+}
+
+export function contains(parent?: Element | null, child?: Element | null) {
+ if (!parent || !child) {
+ return false;
+ }
+
+ const rootNode = child.getRootNode?.();
+
+ // First, attempt with faster native method
+ if (parent.contains(child)) {
+ return true;
+ }
+
+ // then fallback to custom implementation with Shadow DOM support
+ if (rootNode && isShadowRoot(rootNode)) {
+ let next = child;
+ while (next) {
+ if (parent === next) {
+ return true;
+ }
+ // @ts-ignore
+ next = next.parentNode || next.host;
+ }
+ }
+
+ // Give up, the result is false
+ return false;
+}
+
+export function getTarget(event: Event) {
+ if ('composedPath' in event) {
+ return event.composedPath()[0];
+ }
+
+ // TS thinks `event` is of type never as it assumes all browsers support
+ // `composedPath()`, but browsers without shadow DOM don't.
+ return (event as Event).target;
+}
+
+export function isEventTargetWithin(event: Event, node: Node | null | undefined) {
+ if (node == null) {
+ return false;
+ }
+
+ if ('composedPath' in event) {
+ return event.composedPath().includes(node);
+ }
+
+ // TS thinks `event` is of type never as it assumes all browsers support composedPath, but browsers without shadow dom don't
+ const eventAgain = event as Event;
+ return eventAgain.target != null && node.contains(eventAgain.target as Node);
+}
+
+export function isRootElement(element: Element): boolean {
+ return element.matches('html,body');
+}
+
+export function getDocument(node: Element | null) {
+ return node?.ownerDocument || document;
+}
+
+export function isTypeableElement(element: unknown): boolean {
+ return isHTMLElement(element) && element.matches(TYPEABLE_SELECTOR);
+}
+
+export function isTypeableCombobox(element: Element | null) {
+ if (!element) {
+ return false;
+ }
+ return element.getAttribute('role') === 'combobox' && isTypeableElement(element);
+}
+
+export function matchesFocusVisible(element: Element | null) {
+ // We don't want to block focus from working with `visibleOnly`
+ // (JSDOM doesn't match `:focus-visible` when the element has `:focus`)
+ if (!element || isJSDOM) {
+ return true;
+ }
+ try {
+ return element.matches(':focus-visible');
+ } catch (_e) {
+ return true;
+ }
+}
+
+export function getFloatingFocusElement(
+ floatingElement: HTMLElement | null | undefined,
+): HTMLElement | null {
+ if (!floatingElement) {
+ return null;
+ }
+ // Try to find the element that has `{...getFloatingProps()}` spread on it.
+ // This indicates the floating element is acting as a positioning wrapper, and
+ // so focus should be managed on the child element with the event handlers and
+ // aria props.
+ return floatingElement.hasAttribute(FOCUSABLE_ATTRIBUTE)
+ ? floatingElement
+ : floatingElement.querySelector(`[${FOCUSABLE_ATTRIBUTE}]`) || floatingElement;
+}
diff --git a/packages/react/src/floating-ui-react/utils/enqueueFocus.ts b/packages/react/src/floating-ui-react/utils/enqueueFocus.ts
new file mode 100644
index 0000000000..27ba5f63ba
--- /dev/null
+++ b/packages/react/src/floating-ui-react/utils/enqueueFocus.ts
@@ -0,0 +1,21 @@
+import type { FocusableElement } from 'tabbable';
+
+interface Options {
+ preventScroll?: boolean;
+ cancelPrevious?: boolean;
+ sync?: boolean;
+}
+
+let rafId = 0;
+export function enqueueFocus(el: FocusableElement | null, options: Options = {}) {
+ const { preventScroll = false, cancelPrevious = true, sync = false } = options;
+ if (cancelPrevious) {
+ cancelAnimationFrame(rafId);
+ }
+ const exec = () => el?.focus({ preventScroll });
+ if (sync) {
+ exec();
+ } else {
+ rafId = requestAnimationFrame(exec);
+ }
+}
diff --git a/packages/react/src/floating-ui-react/utils/event.ts b/packages/react/src/floating-ui-react/utils/event.ts
new file mode 100644
index 0000000000..e2aa0382fb
--- /dev/null
+++ b/packages/react/src/floating-ui-react/utils/event.ts
@@ -0,0 +1,56 @@
+import { isAndroid, isJSDOM } from '../../utils/detectBrowser';
+
+export function stopEvent(event: Event | React.SyntheticEvent) {
+ event.preventDefault();
+ event.stopPropagation();
+}
+
+export function isReactEvent(event: any): event is React.SyntheticEvent {
+ return 'nativeEvent' in event;
+}
+
+// License: https://github.com/adobe/react-spectrum/blob/b35d5c02fe900badccd0cf1a8f23bb593419f238/packages/@react-aria/utils/src/isVirtualEvent.ts
+export function isVirtualClick(event: MouseEvent | PointerEvent): boolean {
+ // FIXME: Firefox is now emitting a deprecation warning for `mozInputSource`.
+ // Try to find a workaround for this. `react-aria` source still has the check.
+ if ((event as any).mozInputSource === 0 && event.isTrusted) {
+ return true;
+ }
+
+ if (isAndroid && (event as PointerEvent).pointerType) {
+ return event.type === 'click' && event.buttons === 1;
+ }
+
+ return event.detail === 0 && !(event as PointerEvent).pointerType;
+}
+
+export function isVirtualPointerEvent(event: PointerEvent) {
+ if (isJSDOM) {
+ return false;
+ }
+ return (
+ (!isAndroid && event.width === 0 && event.height === 0) ||
+ (isAndroid &&
+ event.width === 1 &&
+ event.height === 1 &&
+ event.pressure === 0 &&
+ event.detail === 0 &&
+ event.pointerType === 'mouse') ||
+ // iOS VoiceOver returns 0.333• for width/height.
+ (event.width < 1 &&
+ event.height < 1 &&
+ event.pressure === 0 &&
+ event.detail === 0 &&
+ event.pointerType === 'touch')
+ );
+}
+
+export function isMouseLikePointerType(pointerType: string | undefined, strict?: boolean) {
+ // On some Linux machines with Chromium, mouse inputs return a `pointerType`
+ // of "pen": https://github.com/floating-ui/floating-ui/issues/2015
+ const values: Array = ['mouse', 'pen'];
+ if (!strict) {
+ values.push('', undefined);
+ }
+ return values.includes(pointerType);
+}
diff --git a/packages/react/src/floating-ui-react/utils/markOthers.ts b/packages/react/src/floating-ui-react/utils/markOthers.ts
new file mode 100644
index 0000000000..e4bb393611
--- /dev/null
+++ b/packages/react/src/floating-ui-react/utils/markOthers.ts
@@ -0,0 +1,169 @@
+// Modified to add conditional `aria-hidden` support:
+// https://github.com/theKashey/aria-hidden/blob/9220c8f4a4fd35f63bee5510a9f41a37264382d4/src/index.ts
+import { getNodeName } from '@floating-ui/utils/dom';
+import { getDocument } from './element';
+
+type Undo = () => void;
+
+const counters = {
+ inert: new WeakMap(),
+ 'aria-hidden': new WeakMap(),
+ none: new WeakMap(),
+};
+
+function getCounterMap(control: 'inert' | 'aria-hidden' | null) {
+ if (control === 'inert') {
+ return counters.inert;
+ }
+ if (control === 'aria-hidden') {
+ return counters['aria-hidden'];
+ }
+ return counters.none;
+}
+
+let uncontrolledElementsSet = new WeakSet();
+let markerMap: Record> = {};
+let lockCount = 0;
+
+export const supportsInert = (): boolean =>
+ typeof HTMLElement !== 'undefined' && 'inert' in HTMLElement.prototype;
+
+const unwrapHost = (node: Element | ShadowRoot): Element | null =>
+ node && ((node as ShadowRoot).host || unwrapHost(node.parentNode as Element));
+
+const correctElements = (parent: HTMLElement, targets: Element[]): Element[] =>
+ targets
+ .map((target) => {
+ if (parent.contains(target)) {
+ return target;
+ }
+
+ const correctedTarget = unwrapHost(target);
+
+ if (parent.contains(correctedTarget)) {
+ return correctedTarget;
+ }
+
+ return null;
+ })
+ .filter((x): x is Element => x != null);
+
+function applyAttributeToOthers(
+ uncorrectedAvoidElements: Element[],
+ body: HTMLElement,
+ ariaHidden: boolean,
+ inert: boolean,
+): Undo {
+ const markerName = 'data-floating-ui-inert';
+ // eslint-disable-next-line no-nested-ternary
+ const controlAttribute = inert ? 'inert' : ariaHidden ? 'aria-hidden' : null;
+ const avoidElements = correctElements(body, uncorrectedAvoidElements);
+ const elementsToKeep = new Set();
+ const elementsToStop = new Set(avoidElements);
+ const hiddenElements: Element[] = [];
+
+ if (!markerMap[markerName]) {
+ markerMap[markerName] = new WeakMap();
+ }
+
+ const markerCounter = markerMap[markerName];
+
+ avoidElements.forEach(keep);
+ deep(body);
+ elementsToKeep.clear();
+
+ function keep(el: Node | undefined) {
+ if (!el || elementsToKeep.has(el)) {
+ return;
+ }
+
+ elementsToKeep.add(el);
+ if (el.parentNode) {
+ keep(el.parentNode);
+ }
+ }
+
+ function deep(parent: Element | null) {
+ if (!parent || elementsToStop.has(parent)) {
+ return;
+ }
+
+ [].forEach.call(parent.children, (node: Element) => {
+ if (getNodeName(node) === 'script') {
+ return;
+ }
+
+ if (elementsToKeep.has(node)) {
+ deep(node);
+ } else {
+ const attr = controlAttribute ? node.getAttribute(controlAttribute) : null;
+ const alreadyHidden = attr !== null && attr !== 'false';
+ const counterMap = getCounterMap(controlAttribute);
+ const counterValue = (counterMap.get(node) || 0) + 1;
+ const markerValue = (markerCounter.get(node) || 0) + 1;
+
+ counterMap.set(node, counterValue);
+ markerCounter.set(node, markerValue);
+ hiddenElements.push(node);
+
+ if (counterValue === 1 && alreadyHidden) {
+ uncontrolledElementsSet.add(node);
+ }
+
+ if (markerValue === 1) {
+ node.setAttribute(markerName, '');
+ }
+
+ if (!alreadyHidden && controlAttribute) {
+ node.setAttribute(controlAttribute, controlAttribute === 'inert' ? '' : 'true');
+ }
+ }
+ });
+ }
+
+ lockCount += 1;
+
+ return () => {
+ hiddenElements.forEach((element) => {
+ const counterMap = getCounterMap(controlAttribute);
+ const currentCounterValue = counterMap.get(element) || 0;
+ const counterValue = currentCounterValue - 1;
+ const markerValue = (markerCounter.get(element) || 0) - 1;
+
+ counterMap.set(element, counterValue);
+ markerCounter.set(element, markerValue);
+
+ if (!counterValue) {
+ if (!uncontrolledElementsSet.has(element) && controlAttribute) {
+ element.removeAttribute(controlAttribute);
+ }
+
+ uncontrolledElementsSet.delete(element);
+ }
+
+ if (!markerValue) {
+ element.removeAttribute(markerName);
+ }
+ });
+
+ lockCount -= 1;
+
+ if (!lockCount) {
+ counters.inert = new WeakMap();
+ counters['aria-hidden'] = new WeakMap();
+ counters.none = new WeakMap();
+ uncontrolledElementsSet = new WeakSet();
+ markerMap = {};
+ }
+ };
+}
+
+export function markOthers(avoidElements: Element[], ariaHidden = false, inert = false): Undo {
+ const body = getDocument(avoidElements[0]).body;
+ return applyAttributeToOthers(
+ avoidElements.concat(Array.from(body.querySelectorAll('[aria-live]'))),
+ body,
+ ariaHidden,
+ inert,
+ );
+}
diff --git a/packages/react/src/floating-ui-react/utils/nodes.ts b/packages/react/src/floating-ui-react/utils/nodes.ts
new file mode 100644
index 0000000000..04ca9a84bb
--- /dev/null
+++ b/packages/react/src/floating-ui-react/utils/nodes.ts
@@ -0,0 +1,68 @@
+import type { ReferenceType, FloatingNodeType } from '../types';
+
+/* eslint-disable @typescript-eslint/no-loop-func */
+
+export function getNodeChildren(
+ nodes: Array>,
+ id: string | undefined,
+ onlyOpenChildren = true,
+) {
+ let allChildren = nodes.filter((node) => node.parentId === id && node.context?.open);
+ let currentChildren = allChildren;
+
+ while (currentChildren.length) {
+ currentChildren = onlyOpenChildren
+ ? nodes.filter((node) =>
+ currentChildren?.some((n) => node.parentId === n.id && node.context?.open),
+ )
+ : nodes;
+
+ allChildren = allChildren.concat(currentChildren);
+ }
+
+ return allChildren;
+}
+
+export function getDeepestNode(
+ nodes: Array>,
+ id: string | undefined,
+) {
+ let deepestNodeId: string | undefined;
+ let maxDepth = -1;
+
+ function findDeepest(nodeId: string | undefined, depth: number) {
+ if (depth > maxDepth) {
+ deepestNodeId = nodeId;
+ maxDepth = depth;
+ }
+
+ const children = getNodeChildren(nodes, nodeId);
+
+ children.forEach((child) => {
+ findDeepest(child.id, depth + 1);
+ });
+ }
+
+ findDeepest(id, 0);
+
+ return nodes.find((node) => node.id === deepestNodeId);
+}
+
+export function getNodeAncestors(
+ nodes: Array>,
+ id: string | undefined,
+) {
+ let allAncestors: Array> = [];
+ let currentParentId = nodes.find((node) => node.id === id)?.parentId;
+
+ while (currentParentId) {
+ const currentNode = nodes.find((node) => node.id === currentParentId);
+ currentParentId = currentNode?.parentId;
+
+ if (currentNode) {
+ allAncestors = allAncestors.concat(currentNode);
+ }
+ }
+
+ return allAncestors;
+}
diff --git a/packages/react/src/floating-ui-react/utils/tabbable.ts b/packages/react/src/floating-ui-react/utils/tabbable.ts
new file mode 100644
index 0000000000..2209dc3547
--- /dev/null
+++ b/packages/react/src/floating-ui-react/utils/tabbable.ts
@@ -0,0 +1,68 @@
+import { tabbable, type FocusableElement } from 'tabbable';
+import { activeElement, contains, getDocument } from './element';
+
+export const getTabbableOptions = () =>
+ ({
+ getShadowRoot: true,
+ displayCheck:
+ // JSDOM does not support the `tabbable` library. To solve this we can
+ // check if `ResizeObserver` is a real function (not polyfilled), which
+ // determines if the current environment is JSDOM-like.
+ typeof ResizeObserver === 'function' && ResizeObserver.toString().includes('[native code]')
+ ? 'full'
+ : 'none',
+ }) as const;
+
+function getTabbableIn(container: HTMLElement, dir: 1 | -1): FocusableElement | undefined {
+ const list = tabbable(container, getTabbableOptions());
+ const len = list.length;
+ if (len === 0) {
+ return undefined;
+ }
+
+ const active = activeElement(getDocument(container)) as FocusableElement;
+ const index = list.indexOf(active);
+ // eslint-disable-next-line no-nested-ternary
+ const nextIndex = index === -1 ? (dir === 1 ? 0 : len - 1) : index + dir;
+
+ return list[nextIndex];
+}
+
+export function getNextTabbable(referenceElement: Element | null): FocusableElement | null {
+ return (
+ getTabbableIn(getDocument(referenceElement).body, 1) || (referenceElement as FocusableElement)
+ );
+}
+
+export function getPreviousTabbable(referenceElement: Element | null): FocusableElement | null {
+ return (
+ getTabbableIn(getDocument(referenceElement).body, -1) || (referenceElement as FocusableElement)
+ );
+}
+
+export function isOutsideEvent(event: FocusEvent | React.FocusEvent, container?: Element) {
+ const containerElement = container || (event.currentTarget as Element);
+ const relatedTarget = event.relatedTarget as HTMLElement | null;
+ return !relatedTarget || !contains(containerElement, relatedTarget);
+}
+
+export function disableFocusInside(container: HTMLElement) {
+ const tabbableElements = tabbable(container, getTabbableOptions());
+ tabbableElements.forEach((element) => {
+ element.dataset.tabindex = element.getAttribute('tabindex') || '';
+ element.setAttribute('tabindex', '-1');
+ });
+}
+
+export function enableFocusInside(container: HTMLElement) {
+ const elements = container.querySelectorAll('[data-tabindex]');
+ elements.forEach((element) => {
+ const tabindex = element.dataset.tabindex;
+ delete element.dataset.tabindex;
+ if (tabindex) {
+ element.setAttribute('tabindex', tabindex);
+ } else {
+ element.removeAttribute('tabindex');
+ }
+ });
+}
diff --git a/packages/react/src/menu/checkbox-item/MenuCheckboxItem.tsx b/packages/react/src/menu/checkbox-item/MenuCheckboxItem.tsx
index 500136f92b..3e61573745 100644
--- a/packages/react/src/menu/checkbox-item/MenuCheckboxItem.tsx
+++ b/packages/react/src/menu/checkbox-item/MenuCheckboxItem.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { FloatingEvents, useFloatingTree } from '@floating-ui/react';
+import { FloatingEvents, useFloatingTree } from '../../floating-ui-react';
import { MenuCheckboxItemContext } from './MenuCheckboxItemContext';
import { useMenuItem } from '../item/useMenuItem';
import { useCompositeListItem } from '../../composite/list/useCompositeListItem';
diff --git a/packages/react/src/menu/item/MenuItem.tsx b/packages/react/src/menu/item/MenuItem.tsx
index f6f9509493..80e92cc076 100644
--- a/packages/react/src/menu/item/MenuItem.tsx
+++ b/packages/react/src/menu/item/MenuItem.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { FloatingEvents, useFloatingTree } from '@floating-ui/react';
+import { FloatingEvents, useFloatingTree } from '../../floating-ui-react';
import { useMenuItem } from './useMenuItem';
import { useMenuRootContext } from '../root/MenuRootContext';
import { useRenderElement } from '../../utils/useRenderElement';
diff --git a/packages/react/src/menu/item/useMenuItem.ts b/packages/react/src/menu/item/useMenuItem.ts
index 6a14ee908f..3fb514b9ee 100644
--- a/packages/react/src/menu/item/useMenuItem.ts
+++ b/packages/react/src/menu/item/useMenuItem.ts
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { FloatingEvents } from '@floating-ui/react';
+import { FloatingEvents } from '../../floating-ui-react';
import { useButton } from '../../use-button';
import { mergeProps } from '../../merge-props';
import { HTMLProps, BaseUIEvent } from '../../utils/types';
diff --git a/packages/react/src/menu/popup/MenuPopup.tsx b/packages/react/src/menu/popup/MenuPopup.tsx
index 97e13b5546..c9d873b217 100644
--- a/packages/react/src/menu/popup/MenuPopup.tsx
+++ b/packages/react/src/menu/popup/MenuPopup.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { FloatingFocusManager, useFloatingTree } from '@floating-ui/react';
+import { FloatingFocusManager, useFloatingTree } from '../../floating-ui-react';
import { useMenuRootContext } from '../root/MenuRootContext';
import type { MenuRoot } from '../root/MenuRoot';
import { useMenuPositionerContext } from '../positioner/MenuPositionerContext';
diff --git a/packages/react/src/menu/portal/MenuPortal.tsx b/packages/react/src/menu/portal/MenuPortal.tsx
index a415bfc486..4a98f043f8 100644
--- a/packages/react/src/menu/portal/MenuPortal.tsx
+++ b/packages/react/src/menu/portal/MenuPortal.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { FloatingPortal } from '@floating-ui/react';
+import { FloatingPortal } from '../../floating-ui-react';
import { useMenuRootContext } from '../root/MenuRootContext';
import { MenuPortalContext } from './MenuPortalContext';
diff --git a/packages/react/src/menu/positioner/MenuPositioner.tsx b/packages/react/src/menu/positioner/MenuPositioner.tsx
index 13cb476566..4a8d0d9311 100644
--- a/packages/react/src/menu/positioner/MenuPositioner.tsx
+++ b/packages/react/src/menu/positioner/MenuPositioner.tsx
@@ -5,7 +5,7 @@ import {
useFloatingNodeId,
useFloatingParentNodeId,
useFloatingTree,
-} from '@floating-ui/react';
+} from '../../floating-ui-react';
import { MenuPositionerContext } from './MenuPositionerContext';
import { useMenuRootContext } from '../root/MenuRootContext';
import { useAnchorPositioning, type Align, type Side } from '../../utils/useAnchorPositioning';
diff --git a/packages/react/src/menu/positioner/MenuPositionerContext.ts b/packages/react/src/menu/positioner/MenuPositionerContext.ts
index 52bd319565..bd37476924 100644
--- a/packages/react/src/menu/positioner/MenuPositionerContext.ts
+++ b/packages/react/src/menu/positioner/MenuPositionerContext.ts
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import type { FloatingContext } from '@floating-ui/react';
+import type { FloatingContext } from '../../floating-ui-react';
import type { Side } from '../../utils/useAnchorPositioning';
export interface MenuPositionerContext {
diff --git a/packages/react/src/menu/radio-item/MenuRadioItem.tsx b/packages/react/src/menu/radio-item/MenuRadioItem.tsx
index 89d4775819..78573e8570 100644
--- a/packages/react/src/menu/radio-item/MenuRadioItem.tsx
+++ b/packages/react/src/menu/radio-item/MenuRadioItem.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { FloatingEvents, useFloatingTree } from '@floating-ui/react';
+import { FloatingEvents, useFloatingTree } from '../../floating-ui-react';
import { useMenuRootContext } from '../root/MenuRootContext';
import { useRenderElement } from '../../utils/useRenderElement';
import { useBaseUiId } from '../../utils/useBaseUiId';
diff --git a/packages/react/src/menu/root/MenuRoot.tsx b/packages/react/src/menu/root/MenuRoot.tsx
index f1660d5310..42fc1ec406 100644
--- a/packages/react/src/menu/root/MenuRoot.tsx
+++ b/packages/react/src/menu/root/MenuRoot.tsx
@@ -3,6 +3,7 @@ import * as React from 'react';
import * as ReactDOM from 'react-dom';
import {
FloatingTree,
+ useClick,
useDismiss,
useFloatingRootContext,
useFocus,
@@ -12,8 +13,7 @@ import {
useRole,
useTypeahead,
safePolygon,
-} from '@floating-ui/react';
-import { useClick } from '../../utils/floating-ui/useClick';
+} from '../../floating-ui-react';
import { MenuRootContext, useMenuRootContext } from './MenuRootContext';
import { MenubarContext, useMenubarContext } from '../../menubar/MenubarContext';
import { useTimeout } from '../../utils/useTimeout';
diff --git a/packages/react/src/menu/root/MenuRootContext.ts b/packages/react/src/menu/root/MenuRootContext.ts
index c19e7eef0d..aab967bd38 100644
--- a/packages/react/src/menu/root/MenuRootContext.ts
+++ b/packages/react/src/menu/root/MenuRootContext.ts
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import type { FloatingRootContext } from '@floating-ui/react';
+import type { FloatingRootContext } from '../../floating-ui-react';
import type { MenuParent, MenuRoot } from './MenuRoot';
import { HTMLProps } from '../../utils/types';
import { TransitionStatus } from '../../utils';
diff --git a/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.tsx b/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.tsx
index e2778ee5b0..3a267182cc 100644
--- a/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.tsx
+++ b/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { useFloatingTree } from '@floating-ui/react';
+import { useFloatingTree } from '../../floating-ui-react';
import { BaseUIComponentProps } from '../../utils/types';
import { useMenuRootContext } from '../root/MenuRootContext';
import { useBaseUiId } from '../../utils/useBaseUiId';
diff --git a/packages/react/src/menu/trigger/MenuTrigger.tsx b/packages/react/src/menu/trigger/MenuTrigger.tsx
index 2aaf11552e..40293ca381 100644
--- a/packages/react/src/menu/trigger/MenuTrigger.tsx
+++ b/packages/react/src/menu/trigger/MenuTrigger.tsx
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
-import { contains } from '@floating-ui/react/utils';
-import { useFloatingTree } from '@floating-ui/react';
+import { contains } from '../../floating-ui-react/utils';
+import { useFloatingTree } from '../../floating-ui-react/index';
import { CompositeItem } from '../../composite/item/CompositeItem';
import { useMenuRootContext } from '../root/MenuRootContext';
import { pressableTriggerOpenStateMapping } from '../../utils/popupStateMapping';
diff --git a/packages/react/src/menubar/Menubar.tsx b/packages/react/src/menubar/Menubar.tsx
index 9508faabd8..a8ed06e8f4 100644
--- a/packages/react/src/menubar/Menubar.tsx
+++ b/packages/react/src/menubar/Menubar.tsx
@@ -1,7 +1,12 @@
'use client';
import * as React from 'react';
-import { FloatingNode, FloatingTree, useFloatingNodeId, useFloatingTree } from '@floating-ui/react';
+import {
+ FloatingNode,
+ FloatingTree,
+ useFloatingNodeId,
+ useFloatingTree,
+} from '../floating-ui-react';
import { type MenuRoot } from '../menu/root/MenuRoot';
import { BaseUIComponentProps } from '../utils/types';
import { MenubarContext, useMenubarContext } from './MenubarContext';
diff --git a/packages/react/src/navigation-menu/content/NavigationMenuContent.tsx b/packages/react/src/navigation-menu/content/NavigationMenuContent.tsx
index e6d442ef44..cb683e8cd6 100644
--- a/packages/react/src/navigation-menu/content/NavigationMenuContent.tsx
+++ b/packages/react/src/navigation-menu/content/NavigationMenuContent.tsx
@@ -1,8 +1,8 @@
'use client';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
-import { FloatingNode } from '@floating-ui/react';
-import { contains } from '@floating-ui/react/utils';
+import { FloatingNode } from '../../floating-ui-react';
+import { contains } from '../../floating-ui-react/utils';
import type { BaseUIComponentProps } from '../../utils/types';
import { useRenderElement } from '../../utils/useRenderElement';
import {
diff --git a/packages/react/src/navigation-menu/link/NavigationMenuLink.tsx b/packages/react/src/navigation-menu/link/NavigationMenuLink.tsx
index beac61bbd3..2e81c05f6a 100644
--- a/packages/react/src/navigation-menu/link/NavigationMenuLink.tsx
+++ b/packages/react/src/navigation-menu/link/NavigationMenuLink.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { useFloatingTree } from '@floating-ui/react';
+import { useFloatingTree } from '../../floating-ui-react';
import type { BaseUIComponentProps } from '../../utils/types';
import { useRenderElement } from '../../utils/useRenderElement';
import { CompositeItem } from '../../composite/item/CompositeItem';
diff --git a/packages/react/src/navigation-menu/popup/NavigationMenuPopup.tsx b/packages/react/src/navigation-menu/popup/NavigationMenuPopup.tsx
index c07d3e5295..a6fc12638a 100644
--- a/packages/react/src/navigation-menu/popup/NavigationMenuPopup.tsx
+++ b/packages/react/src/navigation-menu/popup/NavigationMenuPopup.tsx
@@ -1,6 +1,10 @@
'use client';
import * as React from 'react';
-import { getNextTabbable, getPreviousTabbable, isOutsideEvent } from '@floating-ui/react/utils';
+import {
+ getNextTabbable,
+ getPreviousTabbable,
+ isOutsideEvent,
+} from '../../floating-ui-react/utils';
import type { BaseUIComponentProps } from '../../utils/types';
import { useRenderElement } from '../../utils/useRenderElement';
import { useNavigationMenuRootContext } from '../root/NavigationMenuRootContext';
@@ -8,7 +12,7 @@ import { useModernLayoutEffect } from '../../utils/useModernLayoutEffect';
import type { TransitionStatus } from '../../utils/useTransitionStatus';
import { transitionStatusMapping } from '../../utils/styleHookMapping';
import { useBaseUiId } from '../../utils/useBaseUiId';
-import { FocusGuard } from '../../toast/viewport/FocusGuard';
+import { FocusGuard } from '../../utils/FocusGuard';
import { useNavigationMenuPositionerContext } from '../positioner/NavigationMenuPositionerContext';
import { useDirection } from '../../direction-provider/DirectionContext';
import { CustomStyleHookMapping } from '../../utils/getStyleHookProps';
diff --git a/packages/react/src/navigation-menu/portal/NavigationMenuPortal.tsx b/packages/react/src/navigation-menu/portal/NavigationMenuPortal.tsx
index 098a7583c5..1a4d46eed1 100644
--- a/packages/react/src/navigation-menu/portal/NavigationMenuPortal.tsx
+++ b/packages/react/src/navigation-menu/portal/NavigationMenuPortal.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { FloatingPortal } from '@floating-ui/react';
+import { FloatingPortal } from '../../floating-ui-react';
import { useNavigationMenuRootContext } from '../root/NavigationMenuRootContext';
import { NavigationMenuPortalContext } from './NavigationMenuPortalContext';
diff --git a/packages/react/src/navigation-menu/positioner/NavigationMenuPositioner.tsx b/packages/react/src/navigation-menu/positioner/NavigationMenuPositioner.tsx
index cd93c6183e..d0b534f266 100644
--- a/packages/react/src/navigation-menu/positioner/NavigationMenuPositioner.tsx
+++ b/packages/react/src/navigation-menu/positioner/NavigationMenuPositioner.tsx
@@ -1,8 +1,12 @@
'use client';
import * as React from 'react';
-import type { Middleware } from '@floating-ui/react';
import { getSide } from '@floating-ui/utils';
-import { disableFocusInside, enableFocusInside, isOutsideEvent } from '@floating-ui/react/utils';
+import type { Middleware } from '../../floating-ui-react';
+import {
+ disableFocusInside,
+ enableFocusInside,
+ isOutsideEvent,
+} from '../../floating-ui-react/utils';
import type { BaseUIComponentProps } from '../../utils/types';
import { useRenderElement } from '../../utils/useRenderElement';
import {
diff --git a/packages/react/src/navigation-menu/root/NavigationMenuRoot.tsx b/packages/react/src/navigation-menu/root/NavigationMenuRoot.tsx
index 64bdeb0aa9..3cb75abd66 100644
--- a/packages/react/src/navigation-menu/root/NavigationMenuRoot.tsx
+++ b/packages/react/src/navigation-menu/root/NavigationMenuRoot.tsx
@@ -1,13 +1,13 @@
'use client';
import * as React from 'react';
+import { isHTMLElement } from '@floating-ui/utils/dom';
import {
FloatingTree,
useFloatingNodeId,
useFloatingParentNodeId,
type FloatingRootContext,
-} from '@floating-ui/react';
-import { activeElement, contains } from '@floating-ui/react/utils';
-import { isHTMLElement } from '@floating-ui/utils/dom';
+} from '../../floating-ui-react';
+import { activeElement, contains } from '../../floating-ui-react/utils';
import type { BaseUIComponentProps } from '../../utils/types';
import { useRenderElement } from '../../utils/useRenderElement';
import {
diff --git a/packages/react/src/navigation-menu/root/NavigationMenuRootContext.ts b/packages/react/src/navigation-menu/root/NavigationMenuRootContext.ts
index f7f418e29b..1c1561db1b 100644
--- a/packages/react/src/navigation-menu/root/NavigationMenuRootContext.ts
+++ b/packages/react/src/navigation-menu/root/NavigationMenuRootContext.ts
@@ -1,5 +1,5 @@
import * as React from 'react';
-import type { FloatingRootContext } from '@floating-ui/react';
+import type { FloatingRootContext } from '../../floating-ui-react';
import type { BaseOpenChangeReason } from '../../utils/translateOpenChangeReason';
import type { TransitionStatus } from '../../utils';
diff --git a/packages/react/src/navigation-menu/trigger/NavigationMenuTrigger.tsx b/packages/react/src/navigation-menu/trigger/NavigationMenuTrigger.tsx
index 771ad0fd5e..cbfb810402 100644
--- a/packages/react/src/navigation-menu/trigger/NavigationMenuTrigger.tsx
+++ b/packages/react/src/navigation-menu/trigger/NavigationMenuTrigger.tsx
@@ -9,7 +9,7 @@ import {
useFloatingTree,
useHover,
useInteractions,
-} from '@floating-ui/react';
+} from '../../floating-ui-react';
import {
contains,
getNextTabbable,
@@ -17,7 +17,7 @@ import {
getTarget,
isOutsideEvent,
stopEvent,
-} from '@floating-ui/react/utils';
+} from '../../floating-ui-react/utils';
import type { BaseUIComponentProps } from '../../utils/types';
import { useRenderElement } from '../../utils/useRenderElement';
import { useNavigationMenuItemContext } from '../item/NavigationMenuItemContext';
@@ -31,7 +31,7 @@ import {
translateOpenChangeReason,
} from '../../utils/translateOpenChangeReason';
import { PATIENT_CLICK_THRESHOLD } from '../../utils/constants';
-import { FocusGuard } from '../../toast/viewport/FocusGuard';
+import { FocusGuard } from '../../utils/FocusGuard';
import { useModernLayoutEffect } from '../../utils/useModernLayoutEffect';
import { visuallyHidden } from '../../utils/visuallyHidden';
import { CompositeItem } from '../../composite/item/CompositeItem';
diff --git a/packages/react/src/navigation-menu/utils/isOutsideMenuEvent.ts b/packages/react/src/navigation-menu/utils/isOutsideMenuEvent.ts
index 153b0a6731..ae684ced86 100644
--- a/packages/react/src/navigation-menu/utils/isOutsideMenuEvent.ts
+++ b/packages/react/src/navigation-menu/utils/isOutsideMenuEvent.ts
@@ -1,5 +1,5 @@
-import { FloatingTreeType } from '@floating-ui/react';
-import { contains, getNodeChildren } from '@floating-ui/react/utils';
+import { FloatingTreeType } from '../../floating-ui-react';
+import { contains, getNodeChildren } from '../../floating-ui-react/utils';
interface Targets {
currentTarget: HTMLElement | null;
diff --git a/packages/react/src/number-field/input/NumberFieldInput.tsx b/packages/react/src/number-field/input/NumberFieldInput.tsx
index eae4c38703..4c6bb9b5b4 100644
--- a/packages/react/src/number-field/input/NumberFieldInput.tsx
+++ b/packages/react/src/number-field/input/NumberFieldInput.tsx
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
-import { stopEvent, useModernLayoutEffect } from '@floating-ui/react/utils';
+import { useModernLayoutEffect } from '../../utils/useModernLayoutEffect';
+import { stopEvent } from '../../floating-ui-react/utils';
import { useNumberFieldRootContext } from '../root/NumberFieldRootContext';
import type { BaseUIComponentProps } from '../../utils/types';
import { useFieldRootContext } from '../../field/root/FieldRootContext';
diff --git a/packages/react/src/popover/description/PopoverDescription.tsx b/packages/react/src/popover/description/PopoverDescription.tsx
index 449eb4cb1b..0c61102fea 100644
--- a/packages/react/src/popover/description/PopoverDescription.tsx
+++ b/packages/react/src/popover/description/PopoverDescription.tsx
@@ -2,8 +2,8 @@
import * as React from 'react';
import { usePopoverRootContext } from '../root/PopoverRootContext';
import type { BaseUIComponentProps } from '../../utils/types';
-import { useModernLayoutEffect } from '../../utils';
import { useBaseUiId } from '../../utils/useBaseUiId';
+import { useModernLayoutEffect } from '../../utils/useModernLayoutEffect';
import { useRenderElement } from '../../utils/useRenderElement';
/**
diff --git a/packages/react/src/popover/popup/PopoverPopup.tsx b/packages/react/src/popover/popup/PopoverPopup.tsx
index e4a663fb9c..6538d28275 100644
--- a/packages/react/src/popover/popup/PopoverPopup.tsx
+++ b/packages/react/src/popover/popup/PopoverPopup.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { FloatingFocusManager } from '@floating-ui/react';
+import { FloatingFocusManager } from '../../floating-ui-react';
import { usePopoverRootContext } from '../root/PopoverRootContext';
import { usePopoverPositionerContext } from '../positioner/PopoverPositionerContext';
import { usePopoverPopup } from './usePopoverPopup';
diff --git a/packages/react/src/popover/portal/PopoverPortal.tsx b/packages/react/src/popover/portal/PopoverPortal.tsx
index 1360da6dae..4e9b8dfad3 100644
--- a/packages/react/src/popover/portal/PopoverPortal.tsx
+++ b/packages/react/src/popover/portal/PopoverPortal.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { FloatingPortal } from '@floating-ui/react';
+import { FloatingPortal } from '../../floating-ui-react';
import { usePopoverRootContext } from '../root/PopoverRootContext';
import { PopoverPortalContext } from './PopoverPortalContext';
diff --git a/packages/react/src/popover/positioner/PopoverPositioner.tsx b/packages/react/src/popover/positioner/PopoverPositioner.tsx
index 6053de008a..cdf303244c 100644
--- a/packages/react/src/popover/positioner/PopoverPositioner.tsx
+++ b/packages/react/src/popover/positioner/PopoverPositioner.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { FloatingNode, useFloatingNodeId } from '@floating-ui/react';
+import { FloatingNode, useFloatingNodeId } from '../../floating-ui-react';
import { usePopoverRootContext } from '../root/PopoverRootContext';
import { usePopoverPositioner } from './usePopoverPositioner';
import { PopoverPositionerContext } from './PopoverPositionerContext';
diff --git a/packages/react/src/popover/root/PopoverRoot.tsx b/packages/react/src/popover/root/PopoverRoot.tsx
index 0e2d7e8da8..0bd0e5bbd6 100644
--- a/packages/react/src/popover/root/PopoverRoot.tsx
+++ b/packages/react/src/popover/root/PopoverRoot.tsx
@@ -2,6 +2,7 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import {
+ useClick,
useDismiss,
useFloatingRootContext,
useHover,
@@ -9,8 +10,7 @@ import {
useRole,
FloatingTree,
safePolygon,
-} from '@floating-ui/react';
-import { useClick } from '../../utils/floating-ui/useClick';
+} from '../../floating-ui-react';
import { useTimeout } from '../../utils/useTimeout';
import { useControlled } from '../../utils/useControlled';
import { useEventCallback } from '../../utils/useEventCallback';
diff --git a/packages/react/src/popover/root/PopoverRootContext.ts b/packages/react/src/popover/root/PopoverRootContext.ts
index dc8bca8c31..2f69183c16 100644
--- a/packages/react/src/popover/root/PopoverRootContext.ts
+++ b/packages/react/src/popover/root/PopoverRootContext.ts
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import type { FloatingRootContext } from '@floating-ui/react';
+import type { FloatingRootContext } from '../../floating-ui-react';
import type { TransitionStatus } from '../../utils/useTransitionStatus';
import type { HTMLProps } from '../../utils/types';
import type { InteractionType } from '../../utils/useEnhancedClickHandler';
diff --git a/packages/react/src/preview-card/root/PreviewCardContext.ts b/packages/react/src/preview-card/root/PreviewCardContext.ts
index 0fdd17ef75..0244835344 100644
--- a/packages/react/src/preview-card/root/PreviewCardContext.ts
+++ b/packages/react/src/preview-card/root/PreviewCardContext.ts
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import type { FloatingRootContext } from '@floating-ui/react';
+import type { FloatingRootContext } from '../../floating-ui-react';
import type { TransitionStatus } from '../../utils/useTransitionStatus';
import type { HTMLProps } from '../../utils/types';
import type { BaseOpenChangeReason as OpenChangeReason } from '../../utils/translateOpenChangeReason';
diff --git a/packages/react/src/preview-card/root/PreviewCardRoot.tsx b/packages/react/src/preview-card/root/PreviewCardRoot.tsx
index d5e9c74f3c..2b07f21e10 100644
--- a/packages/react/src/preview-card/root/PreviewCardRoot.tsx
+++ b/packages/react/src/preview-card/root/PreviewCardRoot.tsx
@@ -7,14 +7,14 @@ import {
useHover,
useInteractions,
useFloatingRootContext,
-} from '@floating-ui/react';
+} from '../../floating-ui-react';
import { PreviewCardRootContext } from './PreviewCardContext';
import { CLOSE_DELAY, OPEN_DELAY } from '../utils/constants';
import {
translateOpenChangeReason,
type BaseOpenChangeReason,
} from '../../utils/translateOpenChangeReason';
-import { useFocusWithDelay } from '../../utils/floating-ui/useFocusWithDelay';
+import { useFocusWithDelay } from '../../utils/interactions/useFocusWithDelay';
import { useControlled } from '../../utils/useControlled';
import { useOpenChangeComplete } from '../../utils/useOpenChangeComplete';
import { useEventCallback } from '../../utils/useEventCallback';
diff --git a/packages/react/src/radio-group/RadioGroup.tsx b/packages/react/src/radio-group/RadioGroup.tsx
index 72b76260a9..5c6a499e25 100644
--- a/packages/react/src/radio-group/RadioGroup.tsx
+++ b/packages/react/src/radio-group/RadioGroup.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { contains } from '@floating-ui/react/utils';
+import { contains } from '../floating-ui-react/utils';
import { useBaseUiId } from '../utils/useBaseUiId';
import { useControlled } from '../utils/useControlled';
import { useForkRef } from '../utils/useForkRef';
diff --git a/packages/react/src/select/item/useSelectItem.ts b/packages/react/src/select/item/useSelectItem.ts
index 7a06b1f949..ecf0a828d0 100644
--- a/packages/react/src/select/item/useSelectItem.ts
+++ b/packages/react/src/select/item/useSelectItem.ts
@@ -1,5 +1,5 @@
import * as React from 'react';
-import type { FloatingEvents } from '@floating-ui/react';
+import type { FloatingEvents } from '../../floating-ui-react';
import type { HTMLProps } from '../../utils/types';
import { useButton } from '../../use-button';
import { mergeProps } from '../../merge-props';
diff --git a/packages/react/src/select/popup/SelectPopup.tsx b/packages/react/src/select/popup/SelectPopup.tsx
index cca88b9a47..939bc4ba71 100644
--- a/packages/react/src/select/popup/SelectPopup.tsx
+++ b/packages/react/src/select/popup/SelectPopup.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { FloatingFocusManager } from '@floating-ui/react';
+import { FloatingFocusManager } from '../../floating-ui-react';
import type { BaseUIComponentProps } from '../../utils/types';
import { useSelectRootContext } from '../root/SelectRootContext';
import { popupStateMapping } from '../../utils/popupStateMapping';
diff --git a/packages/react/src/select/portal/SelectPortal.tsx b/packages/react/src/select/portal/SelectPortal.tsx
index 9c86a1517b..e697e02d63 100644
--- a/packages/react/src/select/portal/SelectPortal.tsx
+++ b/packages/react/src/select/portal/SelectPortal.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { FloatingPortal } from '@floating-ui/react';
+import { FloatingPortal } from '../../floating-ui-react';
import { SelectPortalContext } from './SelectPortalContext';
import { useSelectRootContext } from '../root/SelectRootContext';
import { useSelector } from '../../utils/store';
diff --git a/packages/react/src/select/root/SelectRootContext.ts b/packages/react/src/select/root/SelectRootContext.ts
index bdba8aaf82..f185a0990e 100644
--- a/packages/react/src/select/root/SelectRootContext.ts
+++ b/packages/react/src/select/root/SelectRootContext.ts
@@ -1,5 +1,5 @@
import * as React from 'react';
-import { useFloatingRootContext, FloatingRootContext } from '@floating-ui/react';
+import { useFloatingRootContext, FloatingRootContext } from '../../floating-ui-react';
import type { SelectStore } from '../store';
import type { useFieldControlValidation } from '../../field/control/useFieldControlValidation';
import type { HTMLProps } from '../../utils/types';
diff --git a/packages/react/src/select/root/useSelectRoot.ts b/packages/react/src/select/root/useSelectRoot.ts
index b5aeb2ab6f..e812825f88 100644
--- a/packages/react/src/select/root/useSelectRoot.ts
+++ b/packages/react/src/select/root/useSelectRoot.ts
@@ -1,5 +1,6 @@
import * as React from 'react';
import {
+ useClick,
useDismiss,
useFloatingRootContext,
useInteractions,
@@ -7,8 +8,7 @@ import {
useRole,
useTypeahead,
FloatingRootContext,
-} from '@floating-ui/react';
-import { useClick } from '../../utils/floating-ui/useClick';
+} from '../../floating-ui-react';
import { useFieldControlValidation } from '../../field/control/useFieldControlValidation';
import { useFieldRootContext } from '../../field/root/FieldRootContext';
import { useBaseUiId } from '../../utils/useBaseUiId';
diff --git a/packages/react/src/select/trigger/useSelectTrigger.ts b/packages/react/src/select/trigger/useSelectTrigger.ts
index 26ed1b0af6..95e60da157 100644
--- a/packages/react/src/select/trigger/useSelectTrigger.ts
+++ b/packages/react/src/select/trigger/useSelectTrigger.ts
@@ -1,5 +1,5 @@
import * as React from 'react';
-import { contains } from '@floating-ui/react/utils';
+import { contains } from '../../floating-ui-react/utils';
import { useButton } from '../../use-button/useButton';
import type { HTMLProps } from '../../utils/types';
import { mergeProps } from '../../merge-props';
diff --git a/packages/react/src/slider/control/SliderControl.tsx b/packages/react/src/slider/control/SliderControl.tsx
index 24ebb117bb..c7e0b8d499 100644
--- a/packages/react/src/slider/control/SliderControl.tsx
+++ b/packages/react/src/slider/control/SliderControl.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { activeElement } from '@floating-ui/react/utils';
+import { activeElement } from '../../floating-ui-react/utils';
import { clamp } from '../../utils/clamp';
import { ownerDocument } from '../../utils/owner';
import type { BaseUIComponentProps, Orientation } from '../../utils/types';
diff --git a/packages/react/src/slider/root/SliderRoot.tsx b/packages/react/src/slider/root/SliderRoot.tsx
index db968d8616..16d31fae04 100644
--- a/packages/react/src/slider/root/SliderRoot.tsx
+++ b/packages/react/src/slider/root/SliderRoot.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { activeElement } from '@floating-ui/react/utils';
+import { activeElement } from '../../floating-ui-react/utils';
import { areArraysEqual } from '../../utils/areArraysEqual';
import { clamp } from '../../utils/clamp';
import { ownerDocument } from '../../utils/owner';
diff --git a/packages/react/src/toast/provider/ToastProvider.tsx b/packages/react/src/toast/provider/ToastProvider.tsx
index 809a8962fa..89042e4834 100644
--- a/packages/react/src/toast/provider/ToastProvider.tsx
+++ b/packages/react/src/toast/provider/ToastProvider.tsx
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
-import { activeElement, contains, useLatestRef } from '@floating-ui/react/utils';
+import { useLatestRef } from '../../utils/useLatestRef';
+import { activeElement, contains } from '../../floating-ui-react/utils';
import { ToastContext } from './ToastProviderContext';
import { ToastObject, useToastManager } from '../useToastManager';
import { ownerDocument } from '../../utils/owner';
diff --git a/packages/react/src/toast/root/ToastRoot.tsx b/packages/react/src/toast/root/ToastRoot.tsx
index 5b8c9e4b3c..16ed2cf483 100644
--- a/packages/react/src/toast/root/ToastRoot.tsx
+++ b/packages/react/src/toast/root/ToastRoot.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { activeElement, contains, getTarget } from '@floating-ui/react/utils';
+import { activeElement, contains, getTarget } from '../../floating-ui-react/utils';
import type { BaseUIComponentProps } from '../../utils/types';
import type { ToastObject as ToastObjectType } from '../useToastManager';
import { ToastRootContext } from './ToastRootContext';
diff --git a/packages/react/src/toast/utils/focusVisible.ts b/packages/react/src/toast/utils/focusVisible.ts
index 166290d6b6..efb95ba2ab 100644
--- a/packages/react/src/toast/utils/focusVisible.ts
+++ b/packages/react/src/toast/utils/focusVisible.ts
@@ -1 +1 @@
-export { matchesFocusVisible as isFocusVisible } from '@floating-ui/react/utils';
+export { matchesFocusVisible as isFocusVisible } from '../../floating-ui-react/utils';
diff --git a/packages/react/src/toast/viewport/ToastViewport.tsx b/packages/react/src/toast/viewport/ToastViewport.tsx
index be113d4c52..566a91f851 100644
--- a/packages/react/src/toast/viewport/ToastViewport.tsx
+++ b/packages/react/src/toast/viewport/ToastViewport.tsx
@@ -1,9 +1,10 @@
'use client';
import * as React from 'react';
-import { activeElement, contains, getTarget, useLatestRef } from '@floating-ui/react/utils';
+import { activeElement, contains, getTarget } from '../../floating-ui-react/utils';
+import { useLatestRef } from '../../utils/useLatestRef';
+import { FocusGuard } from '../../utils/FocusGuard';
import type { BaseUIComponentProps } from '../../utils/types';
import { ToastViewportContext } from './ToastViewportContext';
-import { FocusGuard } from './FocusGuard';
import { useToastContext } from '../provider/ToastProviderContext';
import { useRenderElement } from '../../utils/useRenderElement';
import { isFocusVisible } from '../utils/focusVisible';
diff --git a/packages/react/src/tooltip/provider/TooltipProvider.tsx b/packages/react/src/tooltip/provider/TooltipProvider.tsx
index f373be6d40..049d97bf41 100644
--- a/packages/react/src/tooltip/provider/TooltipProvider.tsx
+++ b/packages/react/src/tooltip/provider/TooltipProvider.tsx
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import { NextFloatingDelayGroup } from '@floating-ui/react';
+import { FloatingDelayGroup } from '../../floating-ui-react';
import { TooltipProviderContext } from './TooltipProviderContext';
/**
@@ -22,9 +22,9 @@ export const TooltipProvider: React.FC = function Tooltip
return (
-
+
{props.children}
-
+
);
};
diff --git a/packages/react/src/tooltip/root/TooltipRootContext.ts b/packages/react/src/tooltip/root/TooltipRootContext.ts
index 6336d16f7b..5d45126ac7 100644
--- a/packages/react/src/tooltip/root/TooltipRootContext.ts
+++ b/packages/react/src/tooltip/root/TooltipRootContext.ts
@@ -1,6 +1,6 @@
'use client';
import * as React from 'react';
-import type { FloatingRootContext } from '@floating-ui/react';
+import type { FloatingRootContext } from '../../floating-ui-react';
import type { HTMLProps } from '../../utils/types';
import type { TransitionStatus } from '../../utils/useTransitionStatus';
import type { TooltipOpenChangeReason } from './useTooltipRoot';
diff --git a/packages/react/src/tooltip/root/useTooltipRoot.ts b/packages/react/src/tooltip/root/useTooltipRoot.ts
index 4e6d9a0b21..63f0fe2eff 100644
--- a/packages/react/src/tooltip/root/useTooltipRoot.ts
+++ b/packages/react/src/tooltip/root/useTooltipRoot.ts
@@ -2,7 +2,7 @@ import * as React from 'react';
import * as ReactDOM from 'react-dom';
import {
useClientPoint,
- useNextDelayGroup,
+ useDelayGroup,
useDismiss,
useFloatingRootContext,
useFocus,
@@ -10,7 +10,7 @@ import {
useInteractions,
safePolygon,
type FloatingRootContext,
-} from '@floating-ui/react';
+} from '../../floating-ui-react';
import { useControlled } from '../../utils/useControlled';
import { useTransitionStatus } from '../../utils/useTransitionStatus';
import { useEventCallback } from '../../utils/useEventCallback';
@@ -120,7 +120,7 @@ export function useTooltipRoot(params: useTooltipRoot.Parameters): useTooltipRoo
});
const providerContext = useTooltipProviderContext();
- const { delayRef, isInstantPhase, hasProvider } = useNextDelayGroup(context);
+ const { delayRef, isInstantPhase, hasProvider } = useDelayGroup(context);
const instantType = isInstantPhase ? ('delay' as const) : instantTypeState;
diff --git a/packages/react/src/utils/FloatingPortalLite.tsx b/packages/react/src/utils/FloatingPortalLite.tsx
index d603adf8fc..0362480b1b 100644
--- a/packages/react/src/utils/FloatingPortalLite.tsx
+++ b/packages/react/src/utils/FloatingPortalLite.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useFloatingPortalNode } from '@floating-ui/react';
import * as ReactDOM from 'react-dom';
+import { useFloatingPortalNode } from '../floating-ui-react';
/**
* `FloatingPortal` includes tabbable logic handling for focus management.
diff --git a/packages/react/src/toast/viewport/FocusGuard.tsx b/packages/react/src/utils/FocusGuard.tsx
similarity index 79%
rename from packages/react/src/toast/viewport/FocusGuard.tsx
rename to packages/react/src/utils/FocusGuard.tsx
index 1309b45f1e..b24713b9cf 100644
--- a/packages/react/src/toast/viewport/FocusGuard.tsx
+++ b/packages/react/src/utils/FocusGuard.tsx
@@ -1,7 +1,8 @@
+'use client';
import * as React from 'react';
-import { isSafari } from '@floating-ui/react/utils';
-import { useModernLayoutEffect } from '../../utils/useModernLayoutEffect';
-import { visuallyHidden } from '../../utils/visuallyHidden';
+import { isSafari } from './detectBrowser';
+import { useModernLayoutEffect } from './useModernLayoutEffect';
+import { visuallyHidden } from './visuallyHidden';
/**
* @internal
@@ -13,7 +14,7 @@ export const FocusGuard = React.forwardRef(function FocusGuard(
const [role, setRole] = React.useState<'button' | undefined>();
useModernLayoutEffect(() => {
- if (isSafari()) {
+ if (isSafari) {
// Unlike other screen readers such as NVDA and JAWS, the virtual cursor
// on VoiceOver does trigger the onFocus event, so we can use the focus
// trap element. On Safari, only buttons trigger the onFocus event.
diff --git a/packages/react/src/utils/detectBrowser.ts b/packages/react/src/utils/detectBrowser.ts
index 04648ba55b..0ce43580fd 100644
--- a/packages/react/src/utils/detectBrowser.ts
+++ b/packages/react/src/utils/detectBrowser.ts
@@ -1,12 +1,14 @@
-import { getUserAgent } from '@floating-ui/react/utils';
-
interface NavigatorUAData {
brands: Array<{ brand: string; version: string }>;
mobile: boolean;
platform: string;
}
+const hasNavigator = typeof navigator !== 'undefined';
+
const nav = getNavigatorData();
+const platform = getPlatform();
+const userAgent = getUserAgent();
export const isWebKit =
typeof CSS === 'undefined' || !CSS.supports
@@ -19,7 +21,12 @@ export const isIOS =
? true
: /iP(hone|ad|od)|iOS/.test(nav.platform);
-export const isFirefox = typeof navigator !== 'undefined' && /firefox/i.test(getUserAgent());
+export const isFirefox = hasNavigator && /firefox/i.test(userAgent);
+export const isSafari = hasNavigator && /apple/i.test(navigator.vendor);
+export const isAndroid = (hasNavigator && /android/i.test(platform)) || /android/i.test(userAgent);
+export const isMac =
+ hasNavigator && platform.toLowerCase().startsWith('mac') && !navigator.maxTouchPoints;
+export const isJSDOM = userAgent.includes('jsdom/');
// Avoid Chrome DevTools blue warning.
function getNavigatorData(): { platform: string; maxTouchPoints: number } {
@@ -41,3 +48,31 @@ function getNavigatorData(): { platform: string; maxTouchPoints: number } {
maxTouchPoints: navigator.maxTouchPoints,
};
}
+
+function getUserAgent(): string {
+ if (!hasNavigator) {
+ return '';
+ }
+
+ const uaData = (navigator as any).userAgentData as NavigatorUAData | undefined;
+
+ if (uaData && Array.isArray(uaData.brands)) {
+ return uaData.brands.map(({ brand, version }) => `${brand}/${version}`).join(' ');
+ }
+
+ return navigator.userAgent;
+}
+
+function getPlatform(): string {
+ if (!hasNavigator) {
+ return '';
+ }
+
+ const uaData = (navigator as any).userAgentData as NavigatorUAData | undefined;
+
+ if (uaData?.platform) {
+ return uaData.platform;
+ }
+
+ return navigator.platform;
+}
diff --git a/packages/react/src/utils/floating-ui/useFocusWithDelay.ts b/packages/react/src/utils/interactions/useFocusWithDelay.ts
similarity index 94%
rename from packages/react/src/utils/floating-ui/useFocusWithDelay.ts
rename to packages/react/src/utils/interactions/useFocusWithDelay.ts
index d391587efd..839c965fe4 100644
--- a/packages/react/src/utils/floating-ui/useFocusWithDelay.ts
+++ b/packages/react/src/utils/interactions/useFocusWithDelay.ts
@@ -1,8 +1,8 @@
'use client';
import * as React from 'react';
import { getWindow, isHTMLElement } from '@floating-ui/utils/dom';
-import type { FloatingRootContext, ElementProps } from '@floating-ui/react';
-import { activeElement, contains, getDocument } from '@floating-ui/react/utils';
+import type { FloatingRootContext, ElementProps } from '../../floating-ui-react';
+import { activeElement, contains, getDocument } from '../../floating-ui-react/utils';
import { useTimeout } from '../useTimeout';
interface UseFocusWithDelayProps {
diff --git a/packages/react/src/utils/owner.ts b/packages/react/src/utils/owner.ts
index 04dfb63f37..b263ec5f8e 100644
--- a/packages/react/src/utils/owner.ts
+++ b/packages/react/src/utils/owner.ts
@@ -1,2 +1,2 @@
export { getWindow as ownerWindow } from '@floating-ui/utils/dom';
-export { getDocument as ownerDocument } from '@floating-ui/react/utils';
+export { getDocument as ownerDocument } from '../floating-ui-react/utils';
diff --git a/packages/react/src/utils/safeReact.ts b/packages/react/src/utils/safeReact.ts
new file mode 100644
index 0000000000..7f1bc75bd4
--- /dev/null
+++ b/packages/react/src/utils/safeReact.ts
@@ -0,0 +1,4 @@
+import * as React from 'react';
+
+// https://github.com/mui/material-ui/issues/41190#issuecomment-2040873379
+export const SafeReact = { ...React } as typeof React;
diff --git a/packages/react/src/utils/translateOpenChangeReason.ts b/packages/react/src/utils/translateOpenChangeReason.ts
index dacd1681c9..0f050ce66c 100644
--- a/packages/react/src/utils/translateOpenChangeReason.ts
+++ b/packages/react/src/utils/translateOpenChangeReason.ts
@@ -1,4 +1,4 @@
-import type { OpenChangeReason as FloatingUIOpenChangeReason } from '@floating-ui/react';
+import type { OpenChangeReason as FloatingUIOpenChangeReason } from '../floating-ui-react';
export type BaseOpenChangeReason =
| 'trigger-press'
diff --git a/packages/react/src/utils/useAnchorPositioning.ts b/packages/react/src/utils/useAnchorPositioning.ts
index cffa03ea8c..6a3ca861f9 100644
--- a/packages/react/src/utils/useAnchorPositioning.ts
+++ b/packages/react/src/utils/useAnchorPositioning.ts
@@ -1,5 +1,6 @@
'use client';
import * as React from 'react';
+import { getSide, getAlignment, type Rect, getSideAxis } from '@floating-ui/utils';
import {
autoUpdate,
flip,
@@ -20,8 +21,7 @@ import {
type MiddlewareState,
type AutoUpdateOptions,
type Middleware,
-} from '@floating-ui/react';
-import { getSide, getAlignment, type Rect, getSideAxis } from '@floating-ui/utils';
+} from '../floating-ui-react/index';
import { useModernLayoutEffect } from './useModernLayoutEffect';
import { useDirection } from '../direction-provider/DirectionContext';
import { useLatestRef } from './useLatestRef';
diff --git a/packages/react/src/utils/useForkRef.ts b/packages/react/src/utils/useForkRef.ts
index 320bb85c75..f33d8f28c4 100644
--- a/packages/react/src/utils/useForkRef.ts
+++ b/packages/react/src/utils/useForkRef.ts
@@ -39,7 +39,7 @@ export function useForkRef(
/**
* Merges variadic amount of refs into a single memoized callback ref or `null`.
*/
-export function useForkRefN(...refs: InputRef[]): Result {
+export function useForkRefN(refs: InputRef[]): Result {
const forkRef = useLazyRef(createForkRef).current;
if (didChangeN(forkRef, refs)) {
update(forkRef, refs);
diff --git a/packages/react/src/utils/useId.ts b/packages/react/src/utils/useId.ts
index cae63d7321..2d2cf04dbe 100644
--- a/packages/react/src/utils/useId.ts
+++ b/packages/react/src/utils/useId.ts
@@ -1,5 +1,6 @@
'use client';
import * as React from 'react';
+import { SafeReact } from './safeReact';
let globalId = 0;
@@ -20,9 +21,7 @@ function useGlobalId(idOverride?: string, prefix: string = 'mui'): string | unde
return id;
}
-// See https://github.com/mui/material-ui/issues/41190#issuecomment-2040873379 for why
-const safeReact = { ...React };
-const maybeReactUseId: undefined | (() => string) = safeReact.useId;
+const maybeReactUseId: undefined | (() => string) = SafeReact.useId;
/**
*
diff --git a/packages/react/src/utils/useModernLayoutEffect.ts b/packages/react/src/utils/useModernLayoutEffect.ts
index 4b5dec0325..ca05069169 100644
--- a/packages/react/src/utils/useModernLayoutEffect.ts
+++ b/packages/react/src/utils/useModernLayoutEffect.ts
@@ -1,2 +1,6 @@
'use client';
-export { useModernLayoutEffect } from '@floating-ui/react/utils';
+import * as React from 'react';
+
+const noop = () => {};
+
+export const useModernLayoutEffect = typeof document !== 'undefined' ? React.useLayoutEffect : noop;
diff --git a/packages/react/src/utils/useRenderElement.tsx b/packages/react/src/utils/useRenderElement.tsx
index 08cd2b1c1a..a3a18aa742 100644
--- a/packages/react/src/utils/useRenderElement.tsx
+++ b/packages/react/src/utils/useRenderElement.tsx
@@ -86,7 +86,7 @@ function useRenderElementProps<
if (!enabled) {
useForkRef(null, null);
} else if (Array.isArray(ref)) {
- outProps.ref = useForkRefN(outProps.ref, getChildRef(renderProp), ...ref);
+ outProps.ref = useForkRefN([outProps.ref, getChildRef(renderProp), ...ref]);
} else {
outProps.ref = useForkRef(outProps.ref, getChildRef(renderProp), ref);
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index caa056632d..b00ba7240a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -554,15 +554,18 @@ importers:
'@babel/runtime':
specifier: ^7.27.0
version: 7.27.4
- '@floating-ui/react':
- specifier: ^0.27.10
- version: 0.27.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@floating-ui/react-dom':
+ specifier: ^2.1.2
+ version: 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@floating-ui/utils':
specifier: ^0.2.9
version: 0.2.9
reselect:
specifier: ^5.1.1
version: 5.1.1
+ tabbable:
+ specifier: ^6.2.0
+ version: 6.2.0
use-sync-external-store:
specifier: ^1.5.0
version: 1.5.0(react@19.1.0)
@@ -597,6 +600,9 @@ importers:
chai:
specifier: ^4.5.0
version: 4.5.0
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
fs-extra:
specifier: ^11.3.0
version: 11.3.0
@@ -664,6 +670,9 @@ importers:
'@testing-library/dom':
specifier: ^10.4.0
version: 10.4.0
+ '@testing-library/jest-dom':
+ specifier: ^6.6.3
+ version: 6.6.3
'@types/chai':
specifier: ^4.3.20
version: 4.3.20
@@ -736,6 +745,9 @@ importers:
packages:
+ '@adobe/css-tools@4.4.3':
+ resolution: {integrity: sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==}
+
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -1856,12 +1868,6 @@ packages:
react: '>=16.8.0'
react-dom: '>=16.8.0'
- '@floating-ui/react@0.27.10':
- resolution: {integrity: sha512-2tQScvQgzeYAdReavy/ql0JimAruQ2qtDHQFlcXSv8WP/1S1/YaIftyvhGfHknIB6rns0K3P6CV/lw/Tjm5Oaw==}
- peerDependencies:
- react: '>=17.0.0'
- react-dom: '>=17.0.0'
-
'@floating-ui/utils@0.2.9':
resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
@@ -3989,6 +3995,10 @@ packages:
resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==}
engines: {node: '>=18'}
+ '@testing-library/jest-dom@6.6.3':
+ resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==}
+ engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
+
'@testing-library/react@16.3.0':
resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==}
engines: {node: '>=18'}
@@ -5100,6 +5110,10 @@ packages:
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
engines: {node: '>=4'}
+ chalk@3.0.0:
+ resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==}
+ engines: {node: '>=8'}
+
chalk@4.1.0:
resolution: {integrity: sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==}
engines: {node: '>=10'}
@@ -5514,6 +5528,9 @@ packages:
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+ css.escape@1.5.1:
+ resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -5743,6 +5760,9 @@ packages:
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+ dom-accessibility-api@0.6.3:
+ resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+
dom-accessibility-api@0.7.0:
resolution: {integrity: sha512-LjjdFmd9AITAet3Hy6Y6rwB7Sq1+x5NiwbOpnkLHC1bCXJqJKiV9DyppSSWobuSKvjKXt9G2u3hW402MPt6m+g==}
@@ -10828,6 +10848,8 @@ packages:
snapshots:
+ '@adobe/css-tools@4.4.3': {}
+
'@alloc/quick-lru@5.2.0': {}
'@ampproject/remapping@2.3.0':
@@ -12477,14 +12499,6 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
- '@floating-ui/react@0.27.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
- dependencies:
- '@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
- '@floating-ui/utils': 0.2.9
- react: 19.1.0
- react-dom: 19.1.0(react@19.1.0)
- tabbable: 6.2.0
-
'@floating-ui/utils@0.2.9': {}
'@gitbeaker/core@38.12.1':
@@ -15063,6 +15077,16 @@ snapshots:
lz-string: 1.5.0
pretty-format: 27.5.1
+ '@testing-library/jest-dom@6.6.3':
+ dependencies:
+ '@adobe/css-tools': 4.4.3
+ aria-query: 5.3.2
+ chalk: 3.0.0
+ css.escape: 1.5.1
+ dom-accessibility-api: 0.6.3
+ lodash: 4.17.21
+ redent: 3.0.0
+
'@testing-library/react@16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@babel/runtime': 7.27.4
@@ -16373,6 +16397,11 @@ snapshots:
escape-string-regexp: 1.0.5
supports-color: 5.5.0
+ chalk@3.0.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ supports-color: 7.2.0
+
chalk@4.1.0:
dependencies:
ansi-styles: 4.3.0
@@ -16784,6 +16813,8 @@ snapshots:
mdn-data: 2.12.2
source-map-js: 1.2.1
+ css.escape@1.5.1: {}
+
cssesc@3.0.0: {}
cssstyle@4.3.1:
@@ -17026,6 +17057,8 @@ snapshots:
dom-accessibility-api@0.5.16: {}
+ dom-accessibility-api@0.6.3: {}
+
dom-accessibility-api@0.7.0: {}
dot-prop@5.3.0:
diff --git a/test/package.json b/test/package.json
index 2be71f0a19..62b0d447ce 100644
--- a/test/package.json
+++ b/test/package.json
@@ -9,6 +9,7 @@
"@mui/internal-test-utils": "^2.0.9",
"@playwright/test": "1.52.0",
"@testing-library/dom": "^10.4.0",
+ "@testing-library/jest-dom": "^6.6.3",
"@types/chai": "^4.3.20",
"@types/chai-dom": "^1.11.3",
"@types/react": "^19.1.6",
diff --git a/test/setupVitest.ts b/test/setupVitest.ts
index 09c9f30b63..ab8b0ae501 100644
--- a/test/setupVitest.ts
+++ b/test/setupVitest.ts
@@ -5,6 +5,8 @@ import chai from 'chai';
import chaiDom from 'chai-dom';
import chaiPlugin from '@mui/internal-test-utils/chaiPlugin';
+import '@testing-library/jest-dom/vitest';
+
declare global {
var before: typeof beforeAll;
var after: typeof afterAll;
diff --git a/test/tsconfig.json b/test/tsconfig.json
index dd8127255e..7fbdf4b4fe 100644
--- a/test/tsconfig.json
+++ b/test/tsconfig.json
@@ -3,7 +3,7 @@
"compilerOptions": {
"module": "es2022",
"moduleResolution": "bundler",
- "types": ["vite/client", "vitest/globals"]
+ "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
},
"include": ["e2e/**/*", "regressions/**/*", "./*.ts"],
"exclude": ["node_modules", "build"]