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 + {open && ( + +
+ {[...Array(37)].map((_, 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 ( + + ); +}); + +/** @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) => ( + + ))} +
+ )} +
+
+ )} +
+
+
+
+ ); +} 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

+
+ + {open && ( + +
+ {[...Array(49)].map((_, 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 ( + + +
+ + {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 ( + + ); +} + +/** @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 ( + + + + + {(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 ( + + ); +}); + +/** @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 ( + + + + + {(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 ( + + ); +}); + +/** @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 + + ) : ( + + )} + + + {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"]