diff --git a/packages/react/src/combobox/arrow/ComboboxArrow.tsx b/packages/react/src/combobox/arrow/ComboboxArrow.tsx index 6444a9e6b89..f9a5d92c80c 100644 --- a/packages/react/src/combobox/arrow/ComboboxArrow.tsx +++ b/packages/react/src/combobox/arrow/ComboboxArrow.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { useComboboxPositionerContext } from '../positioner/ComboboxPositionerContext'; import { useComboboxRootContext } from '../root/ComboboxRootContext'; import { selectors } from '../store'; diff --git a/packages/react/src/combobox/backdrop/ComboboxBackdrop.tsx b/packages/react/src/combobox/backdrop/ComboboxBackdrop.tsx index a6a70a09184..0cb9dc1af83 100644 --- a/packages/react/src/combobox/backdrop/ComboboxBackdrop.tsx +++ b/packages/react/src/combobox/backdrop/ComboboxBackdrop.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import type { BaseUIComponentProps } from '../../internals/types'; import { useComboboxRootContext } from '../root/ComboboxRootContext'; import { popupStateMapping } from '../../utils/popupStateMapping'; diff --git a/packages/react/src/combobox/chip-remove/ComboboxChipRemove.tsx b/packages/react/src/combobox/chip-remove/ComboboxChipRemove.tsx index d222f3c1542..6d5f8ce5d53 100644 --- a/packages/react/src/combobox/chip-remove/ComboboxChipRemove.tsx +++ b/packages/react/src/combobox/chip-remove/ComboboxChipRemove.tsx @@ -1,12 +1,12 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { useRenderElement } from '../../internals/useRenderElement'; import { BaseUIComponentProps, NativeButtonProps } from '../../internals/types'; import { useComboboxRootContext } from '../root/ComboboxRootContext'; import { useComboboxChipContext } from '../chip/ComboboxChipContext'; import { useButton } from '../../internals/use-button'; -import { stopEvent } from '../../floating-ui-react/utils'; +import { stopEvent } from '../../floating-ui-react/utils/event'; import { selectors } from '../store'; import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; import { REASONS } from '../../internals/reasons'; diff --git a/packages/react/src/combobox/chip/ComboboxChip.tsx b/packages/react/src/combobox/chip/ComboboxChip.tsx index 4937aff3bbb..a1fe01a1146 100644 --- a/packages/react/src/combobox/chip/ComboboxChip.tsx +++ b/packages/react/src/combobox/chip/ComboboxChip.tsx @@ -1,14 +1,14 @@ 'use client'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { useRenderElement } from '../../internals/useRenderElement'; import { BaseUIComponentProps } from '../../internals/types'; import { useComboboxChipsContext } from '../chips/ComboboxChipsContext'; import { useComboboxRootContext } from '../root/ComboboxRootContext'; import { useCompositeListItem } from '../../internals/composite/list/useCompositeListItem'; import { ComboboxChipContext } from './ComboboxChipContext'; -import { stopEvent } from '../../floating-ui-react/utils'; +import { stopEvent } from '../../floating-ui-react/utils/event'; import { selectors } from '../store'; import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; import { REASONS } from '../../internals/reasons'; diff --git a/packages/react/src/combobox/chips/ComboboxChips.tsx b/packages/react/src/combobox/chips/ComboboxChips.tsx index 15391d38f86..af3044d9cf3 100644 --- a/packages/react/src/combobox/chips/ComboboxChips.tsx +++ b/packages/react/src/combobox/chips/ComboboxChips.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { EMPTY_OBJECT } from '@base-ui/utils/empty'; import { useRenderElement } from '../../internals/useRenderElement'; import type { BaseUIComponentProps } from '../../internals/types'; diff --git a/packages/react/src/combobox/clear/ComboboxClear.tsx b/packages/react/src/combobox/clear/ComboboxClear.tsx index be56ea01a5e..823eb4f13ea 100644 --- a/packages/react/src/combobox/clear/ComboboxClear.tsx +++ b/packages/react/src/combobox/clear/ComboboxClear.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { useComboboxInputValueContext, useComboboxRootContext } from '../root/ComboboxRootContext'; import type { BaseUIComponentProps, NativeButtonProps } from '../../internals/types'; import { useRenderElement } from '../../internals/useRenderElement'; diff --git a/packages/react/src/combobox/input-group/ComboboxInputGroup.tsx b/packages/react/src/combobox/input-group/ComboboxInputGroup.tsx index 200a49b199e..097505a6a52 100644 --- a/packages/react/src/combobox/input-group/ComboboxInputGroup.tsx +++ b/packages/react/src/combobox/input-group/ComboboxInputGroup.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { useRenderElement } from '../../internals/useRenderElement'; import type { BaseUIComponentProps } from '../../internals/types'; import { useFieldRootContext } from '../../internals/field-root-context/FieldRootContext'; diff --git a/packages/react/src/combobox/input/ComboboxInput.tsx b/packages/react/src/combobox/input/ComboboxInput.tsx index 55fa6c1992a..b78cafa4271 100644 --- a/packages/react/src/combobox/input/ComboboxInput.tsx +++ b/packages/react/src/combobox/input/ComboboxInput.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { isAndroid, isFirefox } from '@base-ui/utils/detectBrowser'; import { BaseUIComponentProps } from '../../internals/types'; @@ -22,7 +22,7 @@ import { import { DEFAULT_FIELD_STATE_ATTRIBUTES } from '../../internals/field-constants/constants'; import { useLabelableContext } from '../../internals/labelable-provider/LabelableContext'; import { useComboboxChipsContext } from '../chips/ComboboxChipsContext'; -import { stopEvent } from '../../floating-ui-react/utils'; +import { stopEvent } from '../../floating-ui-react/utils/event'; import { useComboboxPositionerContext } from '../positioner/ComboboxPositionerContext'; import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; import { REASONS } from '../../internals/reasons'; diff --git a/packages/react/src/combobox/item/ComboboxItem.tsx b/packages/react/src/combobox/item/ComboboxItem.tsx index 9ad1e25022c..a13cc4a3432 100644 --- a/packages/react/src/combobox/item/ComboboxItem.tsx +++ b/packages/react/src/combobox/item/ComboboxItem.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { useComboboxRootContext, diff --git a/packages/react/src/combobox/label/ComboboxLabel.tsx b/packages/react/src/combobox/label/ComboboxLabel.tsx index 8f1064e9743..6886bc14a81 100644 --- a/packages/react/src/combobox/label/ComboboxLabel.tsx +++ b/packages/react/src/combobox/label/ComboboxLabel.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { error } from '@base-ui/utils/error'; import { SafeReact } from '@base-ui/utils/safeReact'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import type { BaseUIComponentProps } from '../../internals/types'; import { useRenderElement } from '../../internals/useRenderElement'; import type { FieldRoot } from '../../field/root/FieldRoot'; diff --git a/packages/react/src/combobox/list/ComboboxList.tsx b/packages/react/src/combobox/list/ComboboxList.tsx index f3c2edff2b2..bc952402bbf 100644 --- a/packages/react/src/combobox/list/ComboboxList.tsx +++ b/packages/react/src/combobox/list/ComboboxList.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import type { BaseUIComponentProps } from '../../internals/types'; import { useRenderElement } from '../../internals/useRenderElement'; @@ -13,7 +13,7 @@ import { useComboboxPositionerContext } from '../positioner/ComboboxPositionerCo import { selectors } from '../store'; import { ComboboxCollection } from '../collection/ComboboxCollection'; import { CompositeList } from '../../internals/composite/list/CompositeList'; -import { stopEvent } from '../../floating-ui-react/utils'; +import { stopEvent } from '../../floating-ui-react/utils/event'; /** * A list container for the items. diff --git a/packages/react/src/combobox/popup/ComboboxPopup.tsx b/packages/react/src/combobox/popup/ComboboxPopup.tsx index bccc663f13f..e6e07d69e04 100644 --- a/packages/react/src/combobox/popup/ComboboxPopup.tsx +++ b/packages/react/src/combobox/popup/ComboboxPopup.tsx @@ -1,8 +1,8 @@ 'use client'; import * as React from 'react'; import { InteractionType } from '@base-ui/utils/useEnhancedClickHandler'; -import { useStore } from '@base-ui/utils/store'; -import { FloatingFocusManager } from '../../floating-ui-react'; +import { useStore } from '@base-ui/utils/store/core'; +import { FloatingFocusManager } from '../../floating-ui-react/components/FloatingFocusManager'; import { BaseUIComponentProps } from '../../internals/types'; import { useRenderElement } from '../../internals/useRenderElement'; import { @@ -18,7 +18,7 @@ import { useOpenChangeComplete } from '../../internals/useOpenChangeComplete'; import type { TransitionStatus } from '../../internals/useTransitionStatus'; import { transitionStatusMapping } from '../../internals/stateAttributesMapping'; import { StateAttributesMapping } from '../../internals/getStateAttributesProps'; -import { contains, getTarget } from '../../floating-ui-react/utils'; +import { contains, getTarget } from '../../internals/shadowDom'; import { getDisabledMountTransitionStyles } from '../../utils/getDisabledMountTransitionStyles'; import { ComboboxInternalDismissButton } from '../utils/ComboboxInternalDismissButton'; diff --git a/packages/react/src/combobox/portal/ComboboxPortal.tsx b/packages/react/src/combobox/portal/ComboboxPortal.tsx index eaf263e69b1..819babb9d37 100644 --- a/packages/react/src/combobox/portal/ComboboxPortal.tsx +++ b/packages/react/src/combobox/portal/ComboboxPortal.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui/utils/store'; -import { FloatingPortal } from '../../floating-ui-react'; +import { useStore } from '@base-ui/utils/store/core'; +import { FloatingPortal } from '../../floating-ui-react/components/FloatingPortal'; import { useComboboxRootContext } from '../root/ComboboxRootContext'; import { ComboboxPortalContext } from './ComboboxPortalContext'; import { selectors } from '../store'; diff --git a/packages/react/src/combobox/positioner/ComboboxPositioner.tsx b/packages/react/src/combobox/positioner/ComboboxPositioner.tsx index 6ef5865c876..ac4e241d914 100644 --- a/packages/react/src/combobox/positioner/ComboboxPositioner.tsx +++ b/packages/react/src/combobox/positioner/ComboboxPositioner.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { inertValue } from '@base-ui/utils/inertValue'; diff --git a/packages/react/src/combobox/root/AriaCombobox.tsx b/packages/react/src/combobox/root/AriaCombobox.tsx index ccc46e19a30..513a18e8d3f 100644 --- a/packages/react/src/combobox/root/AriaCombobox.tsx +++ b/packages/react/src/combobox/root/AriaCombobox.tsx @@ -8,16 +8,13 @@ import { useMergedRefs } from '@base-ui/utils/useMergedRefs'; import { useValueAsRef } from '@base-ui/utils/useValueAsRef'; import { visuallyHidden, visuallyHiddenInput } from '@base-ui/utils/visuallyHidden'; import { useRefWithInit } from '@base-ui/utils/useRefWithInit'; -import { Store, useStore } from '@base-ui/utils/store'; +import { Store, useStore } from '@base-ui/utils/store/core'; import { EMPTY_ARRAY, EMPTY_OBJECT } from '@base-ui/utils/empty'; -import { - ElementProps, - useDismiss, - useFloatingRootContext, - useListNavigation, - useClick, -} from '../../floating-ui-react'; -import { contains, getTarget } from '../../floating-ui-react/utils'; +import type { ElementProps } from '../../floating-ui-react/types'; +import { useDismissCore as useDismiss } from '../../floating-ui-react/hooks/useDismissCore'; +import { useFloatingRootContext } from '../../floating-ui-react/hooks/useFloatingRootContext'; +import { useListNavigation } from '../../floating-ui-react/hooks/useListNavigation'; +import { contains, getTarget } from '../../internals/shadowDom'; import { createChangeEventDetails, createGenericEventDetails, @@ -41,10 +38,11 @@ import { createCollatorItemFilter, createSingleSelectionCollatorFilter } from '. import { useCoreFilter } from './utils/useFilter'; import { useTransitionStatus } from '../../internals/useTransitionStatus'; import { useOpenInteractionType } from '../../utils/useOpenInteractionType'; -import { HTMLProps } from '../../internals/types'; +import { useInputPress } from '../../utils/popups/useTriggerPress'; +import type { HTMLProps } from '../../internals/types'; import { useValueChanged } from '../../internals/useValueChanged'; import { NOOP } from '../../internals/noop'; -import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups'; +import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups/popupStoreUtils'; import { mergeProps } from '../../merge-props'; import { stringifyAsLabel, @@ -266,11 +264,10 @@ export function AriaCombobox( } if (isGrouped) { - const groupedItems = items; const resultingGroups: Group[] = []; let currentCount = 0; - for (const group of groupedItems) { + for (const group of items) { if (limit > -1 && currentCount >= limit) { break; } @@ -284,14 +281,10 @@ export function AriaCombobox( continue; } - const remainingLimit = limit > -1 ? limit - currentCount : Infinity; - const itemsToTake = candidateItems.slice(0, remainingLimit); - - if (itemsToTake.length > 0) { - const newGroup = { ...group, items: itemsToTake }; - resultingGroups.push(newGroup); - currentCount += itemsToTake.length; - } + const itemsToTake = + limit > -1 ? candidateItems.slice(0, limit - currentCount) : candidateItems; + resultingGroups.push({ ...group, items: itemsToTake }); + currentCount += itemsToTake.length; } return resultingGroups; @@ -385,9 +378,9 @@ export function AriaCombobox( inline: inlineProp, activeIndex: null, selectedIndex: null, - popupProps: {}, + popupProps: EMPTY_OBJECT, inputProps: {}, - triggerProps: {}, + triggerProps: EMPTY_OBJECT, itemProps: EMPTY_OBJECT, positionerElement: null, listElement: null, @@ -1021,14 +1014,11 @@ export function AriaCombobox( }; }, [inputElement, open, ariaExpanded, ariaHasPopup, listElement?.id, autoComplete]); - const click = useClick(floatingRootContext, { + const click = useInputPress(floatingRootContext, { enabled: !readOnly && !disabled && openOnInputClick, - event: 'mousedown-only', - toggle: false, // Apply a small delay for touch to let mobile viewport/keyboard positioning settle. // This avoids top-bottom flip flickers if the preferred position is "top" when first tapping. touchOpenDelay: inputInsidePopup ? 0 : 100, - reason: REASONS.inputPress, }); const dismiss = useDismiss(floatingRootContext, { diff --git a/packages/react/src/combobox/root/ComboboxRoot.test.tsx b/packages/react/src/combobox/root/ComboboxRoot.test.tsx index c75dee895f8..70d0650282f 100644 --- a/packages/react/src/combobox/root/ComboboxRoot.test.tsx +++ b/packages/react/src/combobox/root/ComboboxRoot.test.tsx @@ -15,7 +15,7 @@ import { Dialog } from '@base-ui/react/dialog'; import { Field } from '@base-ui/react/field'; import { Form } from '@base-ui/react/form'; import { Input } from '@base-ui/react/input'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { CompositeRoot } from '../../internals/composite/root/CompositeRoot'; import { CompositeItem } from '../../internals/composite/item/CompositeItem'; import { REASONS } from '../../internals/reasons'; diff --git a/packages/react/src/combobox/root/ComboboxRootContext.tsx b/packages/react/src/combobox/root/ComboboxRootContext.tsx index 43f9a13eabe..bfccf13eec0 100644 --- a/packages/react/src/combobox/root/ComboboxRootContext.tsx +++ b/packages/react/src/combobox/root/ComboboxRootContext.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; import { ComboboxStore } from '../store'; -import type { FloatingRootContext } from '../../floating-ui-react'; +import type { FloatingRootContext } from '../../floating-ui-react/types'; export interface ComboboxDerivedItemsContext { query: string; diff --git a/packages/react/src/combobox/store.ts b/packages/react/src/combobox/store.ts index bc3564f2947..ebba9b95949 100644 --- a/packages/react/src/combobox/store.ts +++ b/packages/react/src/combobox/store.ts @@ -1,4 +1,4 @@ -import { Store, createSelector } from '@base-ui/utils/store'; +import { Store, createSelector } from '@base-ui/utils/store/core'; import type { InteractionType } from '@base-ui/utils/useEnhancedClickHandler'; import type { TransitionStatus } from '../internals/useTransitionStatus'; import type { HTMLProps } from '../internals/types'; diff --git a/packages/react/src/combobox/trigger/ComboboxTrigger.tsx b/packages/react/src/combobox/trigger/ComboboxTrigger.tsx index a9c59161cba..f418e43156e 100644 --- a/packages/react/src/combobox/trigger/ComboboxTrigger.tsx +++ b/packages/react/src/combobox/trigger/ComboboxTrigger.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { useTimeout } from '@base-ui/utils/useTimeout'; import { ownerDocument } from '@base-ui/utils/owner'; @@ -17,15 +17,17 @@ import { triggerStateAttributesMapping } from '../utils/stateAttributesMapping'; import { selectors } from '../store'; import { useFieldRootContext } from '../../internals/field-root-context/FieldRootContext'; import { useLabelableContext } from '../../internals/labelable-provider/LabelableContext'; -import { stopEvent, contains, getTarget } from '../../floating-ui-react/utils'; +import { stopEvent } from '../../floating-ui-react/utils/event'; +import { contains, getTarget } from '../../internals/shadowDom'; import { getPseudoElementBounds } from '../../utils/getPseudoElementBounds'; import type { FieldRootState } from '../../field/root/FieldRoot'; import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; import { REASONS } from '../../internals/reasons'; -import { useClick, useTypeahead } from '../../floating-ui-react'; +import { useTypeahead } from '../../floating-ui-react/hooks/useTypeahead'; import type { Side } from '../../utils/useAnchorPositioning'; import { useLabelableId } from '../../internals/labelable-provider/useLabelableId'; import { resolveAriaLabelledBy } from '../../utils/resolveAriaLabelledBy'; +import { useTriggerPress } from '../../utils/popups/useTriggerPress'; const BOUNDARY_OFFSET = 2; @@ -125,7 +127,7 @@ export const ComboboxTrigger = React.forwardRef(function ComboboxTrigger( }, }); - const { reference: triggerClickProps } = useClick(floatingRootContext, { + const { reference: triggerClickProps } = useTriggerPress(floatingRootContext, { enabled: !readOnly && !comboboxDisabled, event: 'mousedown', }); diff --git a/packages/react/src/combobox/value/ComboboxValue.tsx b/packages/react/src/combobox/value/ComboboxValue.tsx index 0a257f335e0..6c1230f9911 100644 --- a/packages/react/src/combobox/value/ComboboxValue.tsx +++ b/packages/react/src/combobox/value/ComboboxValue.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { useComboboxRootContext } from '../root/ComboboxRootContext'; import { resolveMultipleLabels, resolveSelectedLabel } from '../../internals/resolveValueLabel'; import { selectors } from '../store'; diff --git a/packages/react/src/dialog/popup/DialogPopup.tsx b/packages/react/src/dialog/popup/DialogPopup.tsx index 098a9900464..5aa5b9be25b 100644 --- a/packages/react/src/dialog/popup/DialogPopup.tsx +++ b/packages/react/src/dialog/popup/DialogPopup.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; import { InteractionType } from '@base-ui/utils/useEnhancedClickHandler'; -import { FloatingFocusManager } from '../../floating-ui-react'; +import { FloatingFocusManager } from '../../floating-ui-react/components/FloatingFocusManager'; import { useDialogRootContext } from '../root/DialogRootContext'; import { useRenderElement } from '../../internals/useRenderElement'; import { type BaseUIComponentProps } from '../../internals/types'; @@ -14,7 +14,7 @@ import { DialogPopupDataAttributes } from './DialogPopupDataAttributes'; import { useDialogPortalContext } from '../portal/DialogPortalContext'; import { useOpenChangeComplete } from '../../internals/useOpenChangeComplete'; import { COMPOSITE_KEYS } from '../../internals/composite/composite'; -import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups'; +import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups/popupStoreUtils'; const stateAttributesMapping: StateAttributesMapping = { ...baseMapping, diff --git a/packages/react/src/dialog/portal/DialogPortal.tsx b/packages/react/src/dialog/portal/DialogPortal.tsx index 4a4a9c8e228..067e9029c62 100644 --- a/packages/react/src/dialog/portal/DialogPortal.tsx +++ b/packages/react/src/dialog/portal/DialogPortal.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; import { inertValue } from '@base-ui/utils/inertValue'; -import { FloatingPortal } from '../../floating-ui-react'; +import { FloatingPortal } from '../../floating-ui-react/components/FloatingPortal'; import { useDialogRootContext } from '../root/DialogRootContext'; import { DialogPortalContext } from './DialogPortalContext'; import { InternalBackdrop } from '../../utils/InternalBackdrop'; diff --git a/packages/react/src/dialog/root/DialogRoot.tsx b/packages/react/src/dialog/root/DialogRoot.tsx index 1c82aa11b14..84aa80da068 100644 --- a/packages/react/src/dialog/root/DialogRoot.tsx +++ b/packages/react/src/dialog/root/DialogRoot.tsx @@ -7,7 +7,7 @@ import type { BaseUIChangeEventDetails } from '../../internals/createBaseUIEvent import { REASONS } from '../../internals/reasons'; import { DialogStore } from '../store/DialogStore'; import { DialogHandle } from '../store/DialogHandle'; -import { type PayloadChildRenderFunction } from '../../utils/popups'; +import { type PayloadChildRenderFunction } from '../../utils/popups/popupStoreUtils'; export const IsDrawerContext = React.createContext(false); diff --git a/packages/react/src/dialog/root/useDialogRoot.ts b/packages/react/src/dialog/root/useDialogRoot.ts index 49386bf20c7..9cdadd838ac 100644 --- a/packages/react/src/dialog/root/useDialogRoot.ts +++ b/packages/react/src/dialog/root/useDialogRoot.ts @@ -3,8 +3,8 @@ import * as React from 'react'; import { useScrollLock } from '@base-ui/utils/useScrollLock'; import { EMPTY_OBJECT } from '@base-ui/utils/empty'; import { mergeProps } from '../../merge-props'; -import { useDismiss } from '../../floating-ui-react'; -import { contains, getTarget } from '../../floating-ui-react/utils'; +import { useDismissCore as useDismiss } from '../../floating-ui-react/hooks/useDismissCore'; +import { contains, getTarget } from '../../internals/shadowDom'; import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; import { REASONS } from '../../internals/reasons'; import { type DialogRoot } from './DialogRoot'; @@ -15,7 +15,7 @@ import { useOpenStateTransitions, usePopupInteractionProps, usePopupRootSync, -} from '../../utils/popups'; +} from '../../utils/popups/popupStoreUtils'; export function useDialogRoot(params: UseDialogRootParameters): UseDialogRootReturnValue { const { store, parentContext, actionsRef, isDrawer } = params; diff --git a/packages/react/src/dialog/store/DialogStore.ts b/packages/react/src/dialog/store/DialogStore.ts index b0f055646c7..e657e9819c5 100644 --- a/packages/react/src/dialog/store/DialogStore.ts +++ b/packages/react/src/dialog/store/DialogStore.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import { createSelector, ReactStore } from '@base-ui/utils/store'; +import { createSelector, ReactStore } from '@base-ui/utils/store/core'; import { type InteractionType } from '@base-ui/utils/useEnhancedClickHandler'; import { type DialogRoot } from '../root/DialogRoot'; import { @@ -8,10 +8,9 @@ import { PopupStoreContext, popupStoreSelectors, PopupStoreState, - PopupTriggerMap, - setOpenTriggerState, - usePopupStore, -} from '../../utils/popups'; +} from '../../utils/popups/store'; +import { PopupTriggerMap } from '../../utils/popups/popupTriggerMap'; +import { setOpenTriggerState, usePopupStore } from '../../utils/popups/popupStoreUtils'; export type State = PopupStoreState & { modal: boolean | 'trap-focus'; diff --git a/packages/react/src/dialog/trigger/DialogTrigger.tsx b/packages/react/src/dialog/trigger/DialogTrigger.tsx index e7630d35f39..e0a9002bd8c 100644 --- a/packages/react/src/dialog/trigger/DialogTrigger.tsx +++ b/packages/react/src/dialog/trigger/DialogTrigger.tsx @@ -7,9 +7,9 @@ import type { BaseUIComponentProps, NativeButtonProps } from '../../internals/ty import { triggerOpenStateMapping } from '../../utils/popupStateMapping'; import { CLICK_TRIGGER_IDENTIFIER } from '../../internals/constants'; import { DialogHandle } from '../store/DialogHandle'; -import { useTriggerDataForwarding } from '../../utils/popups'; +import { useTriggerDataForwarding } from '../../utils/popups/popupStoreUtils'; import { useBaseUiId } from '../../internals/useBaseUiId'; -import { useClick } from '../../floating-ui-react'; +import { useClickTriggerPress } from '../../utils/popups/useTriggerPress'; import { useOpenMethodTriggerProps } from '../../utils/useOpenInteractionType'; /** @@ -63,7 +63,7 @@ export const DialogTrigger = React.forwardRef(function DialogTrigger( native: nativeButton, }); - const click = useClick(floatingContext, { enabled: floatingContext != null }); + const click = useClickTriggerPress(floatingContext); const interactionTypeProps = useOpenMethodTriggerProps( () => store.select('open'), (interactionType) => { diff --git a/packages/react/src/drawer/popup/DrawerPopup.tsx b/packages/react/src/drawer/popup/DrawerPopup.tsx index dea621004a7..be9732d3018 100644 --- a/packages/react/src/drawer/popup/DrawerPopup.tsx +++ b/packages/react/src/drawer/popup/DrawerPopup.tsx @@ -6,7 +6,7 @@ import type { InteractionType } from '@base-ui/utils/useEnhancedClickHandler'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { EMPTY_OBJECT } from '@base-ui/utils/empty'; -import { FloatingFocusManager } from '../../floating-ui-react'; +import { FloatingFocusManager } from '../../floating-ui-react/components/FloatingFocusManager'; import { useDialogRootContext } from '../../dialog/root/DialogRootContext'; import { useRenderElement } from '../../internals/useRenderElement'; import type { BaseUIComponentProps } from '../../internals/types'; @@ -23,7 +23,7 @@ import { COMPOSITE_KEYS } from '../../internals/composite/composite'; import { useDrawerRootContext, type DrawerSwipeDirection } from '../root/DrawerRootContext'; import { useDrawerSnapPoints } from '../root/useDrawerSnapPoints'; import { useDrawerViewportContext } from '../viewport/DrawerViewportContext'; -import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups'; +import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups/popupStoreUtils'; // Module-level flag to ensure we only register the CSS properties once, // regardless of how many Drawer components are mounted. let drawerSwipeVarsRegistered = false; diff --git a/packages/react/src/drawer/portal/DrawerPortal.tsx b/packages/react/src/drawer/portal/DrawerPortal.tsx index 8dad6f8d06a..270f3e7bbd0 100644 --- a/packages/react/src/drawer/portal/DrawerPortal.tsx +++ b/packages/react/src/drawer/portal/DrawerPortal.tsx @@ -1,7 +1,7 @@ 'use client'; import type * as React from 'react'; import { DialogPortal } from '../../dialog/portal/DialogPortal'; -import type { FloatingPortal } from '../../floating-ui-react'; +import type { FloatingPortal } from '../../floating-ui-react/components/FloatingPortal'; /** * A portal element that moves the popup to a different part of the DOM. diff --git a/packages/react/src/drawer/root/DrawerRoot.tsx b/packages/react/src/drawer/root/DrawerRoot.tsx index a13b259ca61..4dcaa077ffa 100644 --- a/packages/react/src/drawer/root/DrawerRoot.tsx +++ b/packages/react/src/drawer/root/DrawerRoot.tsx @@ -23,7 +23,7 @@ import { REASONS } from '../../internals/reasons'; import { useDialogRootContext } from '../../dialog/root/DialogRootContext'; import { useDrawerProviderContext } from '../provider/DrawerProviderContext'; import type { DialogHandle } from '../../dialog/store/DialogHandle'; -import type { PayloadChildRenderFunction } from '../../utils/popups'; +import type { PayloadChildRenderFunction } from '../../utils/popups/popupStoreUtils'; /** * Groups all parts of the drawer. diff --git a/packages/react/src/drawer/swipe-area/DrawerSwipeArea.tsx b/packages/react/src/drawer/swipe-area/DrawerSwipeArea.tsx index b1d81e4daf1..f3b96714fec 100644 --- a/packages/react/src/drawer/swipe-area/DrawerSwipeArea.tsx +++ b/packages/react/src/drawer/swipe-area/DrawerSwipeArea.tsx @@ -19,7 +19,7 @@ import { DrawerPopupDataAttributes } from '../popup/DrawerPopupDataAttributes'; import { DrawerBackdropCssVars } from '../backdrop/DrawerBackdropCssVars'; import { useDrawerRootContext, type DrawerSwipeDirection } from '../root/DrawerRootContext'; import { useBaseUiId } from '../../internals/useBaseUiId'; -import { useTriggerRegistration } from '../../utils/popups'; +import { useTriggerRegistration } from '../../utils/popups/popupStoreUtils'; import { useDrawerProviderContext } from '../provider/DrawerProviderContext'; import { DrawerSwipeAreaDataAttributes } from './DrawerSwipeAreaDataAttributes'; diff --git a/packages/react/src/drawer/viewport/DrawerViewport.tsx b/packages/react/src/drawer/viewport/DrawerViewport.tsx index 1dadc5d38be..705c850f1a8 100644 --- a/packages/react/src/drawer/viewport/DrawerViewport.tsx +++ b/packages/react/src/drawer/viewport/DrawerViewport.tsx @@ -24,7 +24,7 @@ import { DrawerBackdropCssVars } from '../backdrop/DrawerBackdropCssVars'; import { DRAWER_CONTENT_ATTRIBUTE } from '../content/DrawerContentDataAttributes'; import { REASONS } from '../../internals/reasons'; import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; -import { activeElement, contains, getTarget } from '../../floating-ui-react/utils'; +import { activeElement, contains, getTarget } from '../../internals/shadowDom'; import { DrawerViewportContext } from './DrawerViewportContext'; import { TransitionStatusDataAttributes } from '../../internals/stateAttributesMapping'; import { findScrollableTouchTarget, type ScrollAxis } from '../../utils/scrollable'; diff --git a/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx b/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx index 82ea513270c..31c35899fab 100644 --- a/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx +++ b/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx @@ -11,7 +11,7 @@ import { useTimeout } from '@base-ui/utils/useTimeout'; import { isWebKit } from '@base-ui/utils/detectBrowser'; import type { InteractionType } from '@base-ui/utils/useEnhancedClickHandler'; import { useAnimationFrame } from '@base-ui/utils/useAnimationFrame'; -import { ownerDocument, ownerWindow } from '@base-ui/utils/owner'; +import { ownerDocument } from '@base-ui/utils/owner'; import { FocusGuard } from '../../utils/FocusGuard'; import { activeElement, @@ -32,14 +32,14 @@ import { type FocusableElement, } from '../utils/tabbable'; import { getNodeAncestors, getNodeChildren } from '../utils/nodes'; -import { isElementVisible } from '../utils/composite'; +import { isElementVisible } from '../utils/visibility'; import type { FloatingContext, FloatingRootContext } from '../types'; import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; import { REASONS } from '../../internals/reasons'; import { createAttribute } from '../utils/createAttribute'; import { enqueueFocus } from '../utils/enqueueFocus'; import { markOthers } from '../utils/markOthers'; -import { usePortalContext } from './FloatingPortal'; +import { usePortalContext } from './FloatingPortalContext'; import { useFloatingTree } from './FloatingTree'; import { FloatingTreeStore } from '../components/FloatingTreeStore'; import { CLICK_TRIGGER_IDENTIFIER } from '../../internals/constants'; @@ -47,11 +47,10 @@ import { FloatingUIOpenChangeDetails } from '../../internals/types'; import { resolveRef } from '../../utils/resolveRef'; function getEventType(event: Event, lastInteractionType?: InteractionType): InteractionType { - const win = ownerWindow(getTarget(event)); - if (event instanceof win.KeyboardEvent) { + if ('key' in event) { return 'keyboard'; } - if (event instanceof win.FocusEvent) { + if (event.type.startsWith('focus')) { // Focus events can be caused by a preceding pointer interaction (e.g., focusout on outside press). // Prefer the last known pointer type if provided, else treat as keyboard. return lastInteractionType || 'keyboard'; @@ -62,26 +61,25 @@ function getEventType(event: Event, lastInteractionType?: InteractionType): Inte if ('touches' in event) { return 'touch'; } - if (event instanceof win.MouseEvent) { + if (event.type === 'click' || event.type.startsWith('mouse')) { // onClick events may not contain pointer events, and will fall through to here - return lastInteractionType || (event.detail === 0 ? 'keyboard' : 'mouse'); + return lastInteractionType || ((event as MouseEvent).detail === 0 ? 'keyboard' : 'mouse'); } return ''; } const LIST_LIMIT = 20; -let previouslyFocusedElements: WeakRef[] = []; +const EMPTY_ELEMENTS: Element[] = []; +let previouslyFocusedElements: Element[] = []; function clearDisconnectedPreviouslyFocusedElements() { - previouslyFocusedElements = previouslyFocusedElements.filter((entry) => { - return entry.deref()?.isConnected; - }); + previouslyFocusedElements = previouslyFocusedElements.filter((element) => element.isConnected); } function addPreviouslyFocusedElement(element: Element | null | undefined) { clearDisconnectedPreviouslyFocusedElements(); if (element && getNodeName(element) !== 'body') { - previouslyFocusedElements.push(new WeakRef(element)); + previouslyFocusedElements.push(element); if (previouslyFocusedElements.length > LIST_LIMIT) { previouslyFocusedElements = previouslyFocusedElements.slice(-LIST_LIMIT); } @@ -90,7 +88,7 @@ function addPreviouslyFocusedElement(element: Element | null | undefined) { function getPreviouslyFocusedElement() { clearDisconnectedPreviouslyFocusedElements(); - return previouslyFocusedElements[previouslyFocusedElements.length - 1]?.deref(); + return previouslyFocusedElements[previouslyFocusedElements.length - 1]; } function getFirstTabbableElement(container: Element | null) { @@ -105,10 +103,7 @@ function getFirstTabbableElement(container: Element | null) { return tabbable(container)[0] || container; } -function handleTabIndex( - floatingFocusElement: HTMLElement, - orderRef: React.RefObject>, -) { +function handleTabIndex(floatingFocusElement: HTMLElement) { if ( floatingFocusElement.hasAttribute('tabindex') && !floatingFocusElement.hasAttribute('data-tabindex') @@ -116,15 +111,11 @@ function handleTabIndex( return; } - if ( - !orderRef.current.includes('floating') && - !floatingFocusElement.getAttribute('role')?.includes('dialog') - ) { + if (!floatingFocusElement.getAttribute('role')?.includes('dialog')) { return; } - const focusableElements = focusable(floatingFocusElement); - const tabbableContent = focusableElements.filter((element) => { + const hasTabbableContent = focusable(floatingFocusElement).some((element) => { const dataTabIndex = element.getAttribute('data-tabindex') || ''; return ( isTabbable(element) || @@ -133,7 +124,7 @@ function handleTabIndex( }); const tabIndex = floatingFocusElement.getAttribute('tabindex'); - if (orderRef.current.includes('floating') || tabbableContent.length === 0) { + if (!hasTabbableContent) { if (tabIndex !== '0') { floatingFocusElement.setAttribute('tabindex', '0'); } @@ -286,7 +277,6 @@ export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JS // start. const isUntrappedTypeableCombobox = isTypeableCombobox(domReference) && ignoreInitialFocus; - const orderRef = React.useRef>(['content']); const initialFocusRef = useValueAsRef(initialFocus); const returnFocusRef = useValueAsRef(returnFocus); const openInteractionTypeRef = useValueAsRef(openInteractionType); @@ -325,7 +315,9 @@ export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JS ); const getResolvedInsideElements = useStableCallback( - () => getInsideElements?.().filter((element): element is Element => element != null) ?? [], + () => + getInsideElements?.().filter((element): element is Element => element != null) ?? + EMPTY_ELEMENTS, ); // Prevent Tab from escaping the modal when there are no tabbable elements. @@ -457,12 +449,12 @@ export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JS triggers.hasMatchingElement((trigger) => contains(trigger, relatedTarget)) || isRelatedFocusGuard || (tree && - (getNodeChildren(tree.nodesRef.current, nodeId).find( + (getNodeChildren(tree.nodesRef.current, nodeId).some( (node) => contains(node.context?.elements.floating, relatedTarget) || contains(node.context?.elements.domReference, relatedTarget), ) || - getNodeAncestors(tree.nodesRef.current, nodeId).find( + getNodeAncestors(tree.nodesRef.current, nodeId).some( (node) => [ node.context?.elements.floating, @@ -473,7 +465,7 @@ export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JS ); if (currentTarget === domReference && floatingFocusElement) { - handleTabIndex(floatingFocusElement, orderRef); + handleTabIndex(floatingFocusElement); } // Restore focus to the previous tabbable element index to prevent @@ -581,7 +573,6 @@ export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JS getTabbableContent, isUntrappedTypeableCombobox, getNodeId, - orderRef, dataRef, blurTimeout, pointerDownTimeout, @@ -624,17 +615,20 @@ export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JS isUntrappedTypeableCombobox ? domReference : null, ].filter((x): x is Element => x != null); - const ariaHiddenCleanup = markOthers(insideElements, { - ariaHidden: modal || isUntrappedTypeableCombobox, - mark: false, - }); + const shouldAriaHide = modal || isUntrappedTypeableCombobox; + const ariaHiddenCleanup = shouldAriaHide + ? markOthers(insideElements, { + ariaHidden: true, + mark: false, + }) + : undefined; const markerInsideElements = [floating, ...portalNodes].filter((x): x is Element => x != null); const markerCleanup = markOthers(markerInsideElements); return () => { markerCleanup(); - ariaHiddenCleanup(); + ariaHiddenCleanup?.(); }; }, [ open, @@ -886,11 +880,11 @@ export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JS if (disabled || !floatingFocusElement) { return undefined; } - handleTabIndex(floatingFocusElement, orderRef); + handleTabIndex(floatingFocusElement); return () => { queueMicrotask(clearDisconnectedPreviouslyFocusedElements); }; - }, [disabled, floatingFocusElement, orderRef]); + }, [disabled, floatingFocusElement]); const shouldRenderGuards = !disabled && (modal ? !isUntrappedTypeableCombobox : true) && (isInsidePortal || modal); diff --git a/packages/react/src/floating-ui-react/components/FloatingPortal.tsx b/packages/react/src/floating-ui-react/components/FloatingPortal.tsx index 813082394f5..2ff6ce95b9a 100644 --- a/packages/react/src/floating-ui-react/components/FloatingPortal.tsx +++ b/packages/react/src/floating-ui-react/components/FloatingPortal.tsx @@ -1,13 +1,8 @@ 'use client'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import { isNode } from '@floating-ui/utils/dom'; import { addEventListener } from '@base-ui/utils/addEventListener'; import { mergeCleanups } from '@base-ui/utils/mergeCleanups'; -import { useId } from '@base-ui/utils/useId'; -import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; -import { useStableCallback } from '@base-ui/utils/useStableCallback'; -import { EMPTY_OBJECT } from '@base-ui/utils/empty'; import { FocusGuard } from '../../utils/FocusGuard'; import { enableFocusInside, @@ -18,139 +13,12 @@ import { } from '../utils/tabbable'; import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; import { REASONS } from '../../internals/reasons'; -import { createAttribute } from '../utils/createAttribute'; -import { - useRenderElement, - type UseRenderElementComponentProps, -} from '../../internals/useRenderElement'; import { ownerVisuallyHidden } from '../../internals/constants'; import type { BaseUIComponentProps } from '../../internals/types'; +import { PortalContext, type FocusManagerState } from './FloatingPortalContext'; +import { useFloatingPortalNode, type UseFloatingPortalNodeProps } from './useFloatingPortalNode'; -type FocusManagerState = null | { - modal: boolean; - open: boolean; - onOpenChange( - open: boolean, - data?: { reason?: string | undefined; event?: Event | undefined }, - ): void; - domReference: Element | null; - closeOnFocusOut: boolean; -}; - -const PortalContext = React.createContext>; - beforeInsideRef: React.RefObject; - afterInsideRef: React.RefObject; - beforeOutsideRef: React.RefObject; - afterOutsideRef: React.RefObject; -}>(null); - -export const usePortalContext = () => React.useContext(PortalContext); - -const attr = createAttribute('portal'); - -export interface UseFloatingPortalNodeProps { - ref?: React.Ref | undefined; - container?: - | HTMLElement - | ShadowRoot - | null - | React.RefObject - | undefined; - componentProps?: UseRenderElementComponentProps | undefined; - elementProps?: React.HTMLAttributes | undefined; -} - -export interface UseFloatingPortalNodeResult { - portalNode: HTMLElement | null; - portalSubtree: React.ReactPortal | null; -} - -export function useFloatingPortalNode( - props: UseFloatingPortalNodeProps = {}, -): UseFloatingPortalNodeResult { - const { ref, container: containerProp, componentProps = EMPTY_OBJECT, elementProps } = props; - - const uniqueId = useId(); - const portalContext = usePortalContext(); - const parentPortalNode = portalContext?.portalNode; - - const [containerElement, setContainerElement] = React.useState( - null, - ); - const [portalNode, setPortalNode] = React.useState(null); - const setPortalNodeRef = useStableCallback((node: HTMLElement | null) => { - if (node !== null) { - // the useIsoLayoutEffect below watching containerProp / parentPortalNode - // sets setPortalNode(null) when the container becomes null or changes. - // So even though the ref callback now ignores null, the portal node still gets cleared. - setPortalNode(node); - } - }); - - const containerRef = React.useRef(null); - - useIsoLayoutEffect(() => { - // Wait for the container to be resolved if explicitly `null`. - if (containerProp === null) { - if (containerRef.current) { - containerRef.current = null; - setPortalNode(null); - setContainerElement(null); - } - return; - } - - // React 17 does not use React.useId(). - if (uniqueId == null) { - return; - } - - const resolvedContainer = - (containerProp && (isNode(containerProp) ? containerProp : containerProp.current)) ?? - parentPortalNode ?? - document.body; - - if (resolvedContainer == null) { - if (containerRef.current) { - containerRef.current = null; - setPortalNode(null); - setContainerElement(null); - } - return; - } - - if (containerRef.current !== resolvedContainer) { - containerRef.current = resolvedContainer; - setPortalNode(null); - setContainerElement(resolvedContainer); - } - }, [containerProp, parentPortalNode, uniqueId]); - - const portalElement = useRenderElement('div', componentProps, { - ref: [ref, setPortalNodeRef], - props: [ - { - id: uniqueId, - [attr]: '', - }, - elementProps, - ], - }); - - // This `createPortal` call injects `portalElement` into the `container`. - // Another call inside `FloatingPortal`/`FloatingPortalLite` then injects the children into `portalElement`. - const portalSubtree = - containerElement && portalElement - ? ReactDOM.createPortal(portalElement, containerElement) - : null; - - return { - portalNode, - portalSubtree, - }; -} +export type { UseFloatingPortalNodeProps }; /** * Portals the floating element into a given container element — by default, diff --git a/packages/react/src/floating-ui-react/components/FloatingPortalContext.ts b/packages/react/src/floating-ui-react/components/FloatingPortalContext.ts new file mode 100644 index 00000000000..177d625b896 --- /dev/null +++ b/packages/react/src/floating-ui-react/components/FloatingPortalContext.ts @@ -0,0 +1,24 @@ +'use client'; +import * as React from 'react'; + +export type FocusManagerState = null | { + modal: boolean; + open: boolean; + onOpenChange( + open: boolean, + data?: { reason?: string | undefined; event?: Event | undefined }, + ): void; + domReference: Element | null; + closeOnFocusOut: boolean; +}; + +export const PortalContext = React.createContext>; + beforeInsideRef: React.RefObject; + afterInsideRef: React.RefObject; + beforeOutsideRef: React.RefObject; + afterOutsideRef: React.RefObject; +}>(null); + +export const usePortalContext = () => React.useContext(PortalContext); diff --git a/packages/react/src/floating-ui-react/components/FloatingRootStore.ts b/packages/react/src/floating-ui-react/components/FloatingRootStore.ts index 145f0eb84a9..a14982c56de 100644 --- a/packages/react/src/floating-ui-react/components/FloatingRootStore.ts +++ b/packages/react/src/floating-ui-react/components/FloatingRootStore.ts @@ -1,10 +1,10 @@ import * as React from 'react'; -import { createSelector, ReactStore } from '@base-ui/utils/store'; +import { createSelector, ReactStore } from '@base-ui/utils/store/core'; import type { FloatingEvents, ContextData, ReferenceType } from '../types'; import { type BaseUIChangeEventDetails } from '../../internals/createBaseUIEventDetails'; import { createEventEmitter } from '../utils/createEventEmitter'; import { type FloatingUIOpenChangeDetails } from '../../internals/types'; -import { type PopupTriggerMap } from '../../utils/popups'; +import { type PopupTriggerMap } from '../../utils/popups/popupTriggerMap'; import { isClickLikeEvent } from '../utils'; import type { TransitionStatus } from '../../internals/useTransitionStatus'; diff --git a/packages/react/src/floating-ui-react/components/useFloatingPortalNode.tsx b/packages/react/src/floating-ui-react/components/useFloatingPortalNode.tsx new file mode 100644 index 00000000000..ed23112bcdf --- /dev/null +++ b/packages/react/src/floating-ui-react/components/useFloatingPortalNode.tsx @@ -0,0 +1,120 @@ +'use client'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { isNode } from '@floating-ui/utils/dom'; +import { useId } from '@base-ui/utils/useId'; +import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; +import { useStableCallback } from '@base-ui/utils/useStableCallback'; +import { EMPTY_OBJECT } from '@base-ui/utils/empty'; +import { + useRenderElement, + type UseRenderElementComponentProps, +} from '../../internals/useRenderElement'; +import { createAttribute } from '../utils/createAttribute'; +import { usePortalContext } from './FloatingPortalContext'; + +const attr = createAttribute('portal'); + +export interface UseFloatingPortalNodeProps { + ref?: React.Ref | undefined; + container?: + | HTMLElement + | ShadowRoot + | null + | React.RefObject + | undefined; + componentProps?: UseRenderElementComponentProps | undefined; + elementProps?: React.HTMLAttributes | undefined; +} + +export interface UseFloatingPortalNodeResult { + portalNode: HTMLElement | null; + portalSubtree: React.ReactPortal | null; +} + +export function useFloatingPortalNode( + props: UseFloatingPortalNodeProps = {}, +): UseFloatingPortalNodeResult { + const { ref, container: containerProp, componentProps = EMPTY_OBJECT, elementProps } = props; + + const uniqueId = useId(); + const portalContext = usePortalContext(); + const parentPortalNode = portalContext?.portalNode; + + const [containerElement, setContainerElement] = React.useState( + null, + ); + const [portalNode, setPortalNode] = React.useState(null); + const setPortalNodeRef = useStableCallback((node: HTMLElement | null) => { + if (node !== null) { + // the useIsoLayoutEffect below watching containerProp / parentPortalNode + // sets setPortalNode(null) when the container becomes null or changes. + // So even though the ref callback now ignores null, the portal node still gets cleared. + setPortalNode(node); + } + }); + + const containerRef = React.useRef(null); + + useIsoLayoutEffect(() => { + function resetPortalNode() { + containerRef.current = null; + setPortalNode(null); + setContainerElement(null); + } + + // Wait for the container to be resolved if explicitly `null`. + if (containerProp === null) { + if (containerRef.current) { + resetPortalNode(); + } + return; + } + + // React 17 does not use React.useId(). + if (uniqueId == null) { + return; + } + + const resolvedContainer = + (containerProp && (isNode(containerProp) ? containerProp : containerProp.current)) ?? + parentPortalNode ?? + document.body; + + if (resolvedContainer == null) { + if (containerRef.current) { + resetPortalNode(); + } + return; + } + + if (containerRef.current !== resolvedContainer) { + containerRef.current = resolvedContainer; + setPortalNode(null); + setContainerElement(resolvedContainer); + } + }, [containerProp, parentPortalNode, uniqueId]); + + const portalElement = useRenderElement('div', componentProps, { + ref: [ref, setPortalNodeRef], + props: [ + { + id: uniqueId, + [attr]: '', + }, + elementProps, + ], + }); + + // This `createPortal` call injects `portalElement` into the `container`. + // Another call inside `FloatingPortal`/`FloatingPortalLite` then injects the children into `portalElement`. + const portalSubtree = + containerElement && portalElement + ? ReactDOM.createPortal(portalElement, containerElement) + : null; + + return { + portalNode, + portalSubtree, + }; +} diff --git a/packages/react/src/floating-ui-react/hooks/useDismiss.ts b/packages/react/src/floating-ui-react/hooks/useDismiss.ts index d247e1dc1ce..b4ab4681abf 100644 --- a/packages/react/src/floating-ui-react/hooks/useDismiss.ts +++ b/packages/react/src/floating-ui-react/hooks/useDismiss.ts @@ -1,64 +1,16 @@ 'use client'; -/* eslint-disable no-underscore-dangle */ import * as React from 'react'; -import { addEventListener } from '@base-ui/utils/addEventListener'; -import { mergeCleanups } from '@base-ui/utils/mergeCleanups'; -import { ownerDocument } from '@base-ui/utils/owner'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; -import { Timeout, useTimeout } from '@base-ui/utils/useTimeout'; -import { - getComputedStyle, - getParentNode, - isElement, - isHTMLElement, - isLastTraversableNode, - isShadowRoot, - isWebKit, -} from '@floating-ui/utils/dom'; -import { useFloatingTree } from '../components/FloatingTree'; -import { FloatingTreeStore } from '../components/FloatingTreeStore'; import type { ElementProps, FloatingContext, FloatingRootContext } from '../types'; import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; import { REASONS } from '../../internals/reasons'; -import { createAttribute } from '../utils/createAttribute'; -import { contains, getTarget, isEventTargetWithin, isRootElement } from '../utils/element'; -import { isReactEvent } from '../utils/event'; -import { getNodeChildren } from '../utils/nodes'; +import { normalizeProp, useDismissCore, type UseDismissCoreProps } from './useDismissCore'; type PressType = 'intentional' | 'sloppy'; -const bubbleHandlerKeys = { - intentional: 'onClick', - sloppy: 'onPointerDown', -} as const; +export { normalizeProp }; -function alwaysFalse() { - return false; -} - -export function normalizeProp( - normalizable?: boolean | { escapeKey?: boolean | undefined; outsidePress?: boolean | undefined }, -) { - return { - escapeKey: - typeof normalizable === 'boolean' ? normalizable : (normalizable?.escapeKey ?? false), - outsidePress: - typeof normalizable === 'boolean' ? normalizable : (normalizable?.outsidePress ?? true), - }; -} - -export interface UseDismissProps { - /** - * Whether the Hook is enabled, including all internal Effects and event - * handlers. - * @default true - */ - enabled?: boolean | undefined; - /** - * Whether to dismiss the floating element upon pressing the `esc` key. - * @default true - */ - escapeKey?: boolean | undefined; +export interface UseDismissProps extends UseDismissCoreProps { /** * Whether to dismiss the floating element upon pressing the reference * element. You likely want to ensure the `move` option in the `useHover()` @@ -75,50 +27,6 @@ export interface UseDismissProps { * @default 'down' */ referencePressEvent?: PressType | undefined; - /** - * Whether to dismiss the floating element upon pressing outside of the - * floating element. - * If you have another element, like a toast, that is rendered outside the - * floating element's React tree and don't want the floating element to close - * when pressing it, you can guard the check like so: - * ```jsx - * useDismiss(context, { - * outsidePress: (event) => !event.target.closest('.toast'), - * }); - * ``` - * @default true - */ - outsidePress?: boolean | ((event: MouseEvent | TouchEvent) => boolean) | undefined; - /** - * The type of event to use to determine an outside "press". - * - `intentional` requires the user to click outside intentionally, firing on `pointerup` for mouse, and requiring minimal `touchmove`s for touch. - * - `sloppy` fires on `pointerdown` for mouse, while for touch it fires on `touchend` (within 1 second) or while scrolling away after `touchstart`. - */ - outsidePressEvent?: - | PressType - | { - mouse: PressType; - touch: PressType; - } - | (() => - | PressType - | { - mouse: PressType; - touch: PressType; - }) - | undefined; - /** - * Determines whether event listeners bubble upwards through a tree of - * floating elements. - */ - bubbles?: - | boolean - | { escapeKey?: boolean | undefined; outsidePress?: boolean | undefined } - | undefined; - /** - * External FloatingTree to use when the one provided by context can't be used. - */ - externalTree?: FloatingTreeStore | undefined; } /** @@ -130,76 +38,12 @@ export function useDismiss( context: FloatingRootContext | FloatingContext, props: UseDismissProps = {}, ): ElementProps { - const { - enabled = true, - escapeKey = true, - outsidePress: outsidePressProp = true, - outsidePressEvent = 'sloppy', - referencePress = alwaysFalse, - referencePressEvent = 'sloppy', - bubbles, - externalTree, - } = props; - + const { enabled = true, referencePress, referencePressEvent = 'sloppy' } = props; const store = 'rootStore' in context ? context.rootStore : context; - - const open = store.useState('open'); - const floatingElement = store.useState('floatingElement'); - const { dataRef } = store.context; - - const tree = useFloatingTree(externalTree); - const outsidePressFn = useStableCallback( - typeof outsidePressProp === 'function' ? outsidePressProp : () => false, - ); - const outsidePress = typeof outsidePressProp === 'function' ? outsidePressFn : outsidePressProp; - const outsidePressEnabled = outsidePress !== false; - const getOutsidePressEventProp = useStableCallback(() => outsidePressEvent); - - const { escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles } = normalizeProp(bubbles); - - const pressStartedInsideRef = React.useRef(false); - const pressStartPreventedRef = React.useRef(false); - // Ignore only the very next outside click after dragging from inside to outside. - const suppressNextOutsideClickRef = React.useRef(false); - const isComposingRef = React.useRef(false); - const currentPointerTypeRef = React.useRef(''); - - const touchStateRef = React.useRef<{ - startTime: number; - startX: number; - startY: number; - dismissOnTouchEnd: boolean; - dismissOnMouseDown: boolean; - } | null>(null); - - const cancelDismissOnEndTimeout = useTimeout(); - const clearInsideReactTreeTimeout = useTimeout(); - - const clearInsideReactTree = useStableCallback(() => { - clearInsideReactTreeTimeout.clear(); - dataRef.current.insideReactTree = false; - }); - - const hasBlockingChild = useStableCallback( - (bubbleKey: '__escapeKeyBubbles' | '__outsidePressBubbles') => { - const nodeId = dataRef.current.floatingContext?.nodeId; - const children = tree ? getNodeChildren(tree.nodesRef.current, nodeId) : []; - - return children.some( - (child) => child.context?.open && !child.context.dataRef.current[bubbleKey], - ); - }, - ); - - const isEventWithinOwnElements = useStableCallback((event: Event) => { - return ( - isEventTargetWithin(event, store.select('floatingElement')) || - isEventTargetWithin(event, store.select('domReferenceElement')) - ); - }); + const dismiss = useDismissCore(context, props); const closeOnReferencePress = useStableCallback((event: React.SyntheticEvent) => { - if (!referencePress()) { + if (!referencePress?.()) { return; } @@ -212,552 +56,19 @@ export function useDismiss( ); }); - const closeOnEscapeKeyDown = useStableCallback( - (event: React.KeyboardEvent | KeyboardEvent) => { - if (!open || !enabled || !escapeKey || event.key !== 'Escape') { - return; - } - - // Wait until IME is settled. Pressing `Escape` while composing should - // close the compose menu, but not the floating element. - if (isComposingRef.current) { - return; - } - - if (!escapeKeyBubbles && hasBlockingChild('__escapeKeyBubbles')) { - return; - } - - const native = isReactEvent(event) ? event.nativeEvent : event; - const eventDetails = createChangeEventDetails(REASONS.escapeKey, native); - - store.setOpen(false, eventDetails); - - if (!eventDetails.isCanceled) { - event.preventDefault(); - } - - if (!escapeKeyBubbles && !eventDetails.isPropagationAllowed) { - event.stopPropagation(); - } - }, - ); - - const markInsideReactTree = useStableCallback(() => { - dataRef.current.insideReactTree = true; - clearInsideReactTreeTimeout.start(0, clearInsideReactTree); - }); - - const markPressStartedInsideReactTree = useStableCallback( - (event: React.PointerEvent | React.MouseEvent) => { - if (!open || !enabled || event.button !== 0) { - return; - } - - const target = getTarget(event.nativeEvent) as Element | null; - - // Only treat presses that start within the floating DOM subtree as inside. - // This avoids suppressing parent dismissal when interacting with nested portals. - if (!contains(store.select('floatingElement'), target)) { - return; - } - - if (!pressStartedInsideRef.current) { - pressStartedInsideRef.current = true; - pressStartPreventedRef.current = false; - } - }, - ); - - const markInsidePressStartPrevented = useStableCallback( - (event: React.PointerEvent | React.MouseEvent) => { - if (!open || !enabled) { - return; - } - - if (!(event.defaultPrevented || event.nativeEvent.defaultPrevented)) { - return; - } - - if (pressStartedInsideRef.current) { - pressStartPreventedRef.current = true; - } - }, - ); - - React.useEffect(() => { - if (!open || !enabled) { - return undefined; - } - - dataRef.current.__escapeKeyBubbles = escapeKeyBubbles; - dataRef.current.__outsidePressBubbles = outsidePressBubbles; - - const compositionTimeout = new Timeout(); - const preventedPressSuppressionTimeout = new Timeout(); - - function handleCompositionStart() { - compositionTimeout.clear(); - isComposingRef.current = true; - } - - function handleCompositionEnd() { - // Safari fires `compositionend` before `keydown`, so we need to wait - // until the next tick to set `isComposing` to `false`. - // https://bugs.webkit.org/show_bug.cgi?id=165004 - compositionTimeout.start( - // 0ms or 1ms don't work in Safari. 5ms appears to consistently work. - // Only apply to WebKit for the test to remain 0ms. - isWebKit() ? 5 : 0, - () => { - isComposingRef.current = false; - }, - ); - } - - function suppressImmediateOutsideClickAfterPreventedStart() { - suppressNextOutsideClickRef.current = true; - // Firefox can emit the synthetic outside click in a later task after - // pointer lock exit, so microtask clearing is too early here. - preventedPressSuppressionTimeout.start(0, () => { - suppressNextOutsideClickRef.current = false; - }); - } - - function resetPressStartState() { - pressStartedInsideRef.current = false; - pressStartPreventedRef.current = false; - } - - function getOutsidePressEvent(): PressType { - const type = currentPointerTypeRef.current as 'pen' | 'mouse' | 'touch' | ''; - const computedType = type === 'pen' || !type ? 'mouse' : type; - - const outsidePressEventValue = getOutsidePressEventProp(); - const resolved = - typeof outsidePressEventValue === 'function' - ? outsidePressEventValue() - : outsidePressEventValue; - - if (typeof resolved === 'string') { - return resolved; - } - - return resolved[computedType]; - } - - function shouldIgnoreEvent(event: Event) { - const computedOutsidePressEvent = getOutsidePressEvent(); - return ( - (computedOutsidePressEvent === 'intentional' && event.type !== 'click') || - (computedOutsidePressEvent === 'sloppy' && event.type === 'click') - ); - } - - function isEventWithinFloatingTree(event: Event) { - const nodeId = dataRef.current.floatingContext?.nodeId; - const targetIsInsideChildren = - tree && - getNodeChildren(tree.nodesRef.current, nodeId).some((node) => - isEventTargetWithin(event, node.context?.elements.floating), - ); - - return isEventWithinOwnElements(event) || targetIsInsideChildren; - } - - function closeOnPressOutside(event: MouseEvent | PointerEvent | TouchEvent) { - if (shouldIgnoreEvent(event)) { - clearInsideReactTree(); - return; - } - - if (dataRef.current.insideReactTree) { - clearInsideReactTree(); - return; - } - - const target = getTarget(event); - const inertSelector = `[${createAttribute('inert')}]`; - const targetRoot = isElement(target) ? target.getRootNode() : null; - const markers = Array.from( - (isShadowRoot(targetRoot) - ? targetRoot - : ownerDocument(store.select('floatingElement')) - ).querySelectorAll(inertSelector), - ); - - const triggers = store.context.triggerElements; - - // If another trigger is clicked, don't close the floating element. - if ( - target && - (triggers.hasElement(target as Element) || - triggers.hasMatchingElement((trigger) => contains(trigger, target as Element))) - ) { - return; - } - - let targetRootAncestor = isElement(target) ? target : null; - while (targetRootAncestor && !isLastTraversableNode(targetRootAncestor)) { - const nextParent = getParentNode(targetRootAncestor); - if (isLastTraversableNode(nextParent) || !isElement(nextParent)) { - break; - } - - targetRootAncestor = nextParent; - } - - // Check if the click occurred on a third-party element injected after the - // floating element rendered. - if ( - markers.length && - isElement(target) && - !isRootElement(target) && - // Clicked on a direct ancestor (e.g. FloatingOverlay). - !contains(target, store.select('floatingElement')) && - // If the target root element contains none of the markers, then the - // element was injected after the floating element rendered. - markers.every((marker) => !contains(targetRootAncestor, marker)) - ) { - return; - } - - // Check if the click occurred on the scrollbar - // Skip for touch events: scrollbars don't receive touch events on most platforms - if (isHTMLElement(target) && !('touches' in event)) { - const lastTraversableNode = isLastTraversableNode(target); - const style = getComputedStyle(target); - const scrollRe = /auto|scroll/; - const isScrollableX = lastTraversableNode || scrollRe.test(style.overflowX); - const isScrollableY = lastTraversableNode || scrollRe.test(style.overflowY); - - const canScrollX = - isScrollableX && target.clientWidth > 0 && target.scrollWidth > target.clientWidth; - const canScrollY = - isScrollableY && target.clientHeight > 0 && target.scrollHeight > target.clientHeight; - - const isRTL = style.direction === 'rtl'; - - // Check click position relative to scrollbar. - // In some browsers it is possible to change the (or window) - // scrollbar to the left side, but is very rare and is difficult to - // check for. Plus, for modal dialogs with backdrops, it is more - // important that the backdrop is checked but not so much the window. - const pressedVerticalScrollbar = - canScrollY && - (isRTL - ? event.offsetX <= target.offsetWidth - target.clientWidth - : event.offsetX > target.clientWidth); - - const pressedHorizontalScrollbar = canScrollX && event.offsetY > target.clientHeight; - - if (pressedVerticalScrollbar || pressedHorizontalScrollbar) { - return; - } - } - - if (isEventWithinFloatingTree(event)) { - return; - } - - // In intentional mode, a press that starts inside and ends outside gets - // one suppressed outside click. Run this after inside-target checks so - // inside clicks don't consume the one-shot suppression. - if (getOutsidePressEvent() === 'intentional' && suppressNextOutsideClickRef.current) { - preventedPressSuppressionTimeout.clear(); - suppressNextOutsideClickRef.current = false; - return; - } - - if (typeof outsidePress === 'function' && !outsidePress(event)) { - return; - } - - if (hasBlockingChild('__outsidePressBubbles')) { - return; - } - - store.setOpen(false, createChangeEventDetails(REASONS.outsidePress, event)); - clearInsideReactTree(); - } - - function handlePointerDown(event: PointerEvent) { - if ( - getOutsidePressEvent() !== 'sloppy' || - event.pointerType === 'touch' || - !store.select('open') || - !enabled || - isEventWithinOwnElements(event) - ) { - return; - } - - closeOnPressOutside(event); - } - - function handleTouchStart(event: TouchEvent) { - if ( - getOutsidePressEvent() !== 'sloppy' || - !store.select('open') || - !enabled || - isEventWithinOwnElements(event) - ) { - return; - } - - const touch = event.touches[0]; - if (touch) { - touchStateRef.current = { - startTime: Date.now(), - startX: touch.clientX, - startY: touch.clientY, - dismissOnTouchEnd: false, - dismissOnMouseDown: true, - }; - - cancelDismissOnEndTimeout.start(1000, () => { - if (touchStateRef.current) { - touchStateRef.current.dismissOnTouchEnd = false; - touchStateRef.current.dismissOnMouseDown = false; - } - }); - } - } - - function addTargetEventListenerOnce( - event: EventType, - listener: (event: EventType) => void, - ) { - const target = getTarget(event); - - if (!target) { - return; - } - - const unsubscribe = addEventListener(target, event.type, () => { - listener(event); - unsubscribe(); - }); - } - - function handleTouchStartCapture(event: TouchEvent) { - currentPointerTypeRef.current = 'touch'; - addTargetEventListenerOnce(event, handleTouchStart); - } - - function closeOnPressOutsideCapture(event: PointerEvent | MouseEvent) { - cancelDismissOnEndTimeout.clear(); - - if (event.type === 'pointerdown') { - currentPointerTypeRef.current = (event as PointerEvent).pointerType; - } - - if ( - event.type === 'mousedown' && - touchStateRef.current && - !touchStateRef.current.dismissOnMouseDown - ) { - return; - } - - addTargetEventListenerOnce(event, (targetEvent) => { - if (targetEvent.type === 'pointerdown') { - handlePointerDown(targetEvent as PointerEvent); - } else { - closeOnPressOutside(targetEvent as MouseEvent); - } - }); - } - - function handlePressEndCapture(event: PointerEvent | MouseEvent) { - if (!pressStartedInsideRef.current) { - return; - } - - const pressStartedInsideDefaultPrevented = pressStartPreventedRef.current; - resetPressStartState(); - - if (getOutsidePressEvent() !== 'intentional') { - return; - } - - if (event.type === 'pointercancel') { - if (pressStartedInsideDefaultPrevented) { - suppressImmediateOutsideClickAfterPreventedStart(); - } - return; - } - - if (isEventWithinFloatingTree(event)) { - return; - } - - // If pointerdown was prevented, no click may be generated for that - // interaction. However, Firefox may still emit an immediate click after - // pointerup (e.g. NumberField scrub with pointer lock), so suppress for - // one tick to absorb that synthetic click only. - if (pressStartedInsideDefaultPrevented) { - suppressImmediateOutsideClickAfterPreventedStart(); - return; - } - - // Avoid suppressing when outsidePress explicitly ignores this target. - if (typeof outsidePress === 'function' && !outsidePress(event as MouseEvent)) { - return; - } - - preventedPressSuppressionTimeout.clear(); - suppressNextOutsideClickRef.current = true; - clearInsideReactTree(); - } - - function handleTouchMove(event: TouchEvent) { - if ( - getOutsidePressEvent() !== 'sloppy' || - !touchStateRef.current || - isEventWithinOwnElements(event) - ) { - return; - } - - const touch = event.touches[0]; - if (!touch) { - return; - } - - const deltaX = Math.abs(touch.clientX - touchStateRef.current.startX); - const deltaY = Math.abs(touch.clientY - touchStateRef.current.startY); - const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); - - if (distance > 5) { - touchStateRef.current.dismissOnTouchEnd = true; - } - - if (distance > 10) { - closeOnPressOutside(event); - cancelDismissOnEndTimeout.clear(); - touchStateRef.current = null; - } - } - - function handleTouchMoveCapture(event: TouchEvent) { - addTargetEventListenerOnce(event, handleTouchMove); - } - - function handleTouchEnd(event: TouchEvent) { - if ( - getOutsidePressEvent() !== 'sloppy' || - !touchStateRef.current || - isEventWithinOwnElements(event) - ) { - return; - } - - if (touchStateRef.current.dismissOnTouchEnd) { - closeOnPressOutside(event); - } - - cancelDismissOnEndTimeout.clear(); - touchStateRef.current = null; - } - - function handleTouchEndCapture(event: TouchEvent) { - addTargetEventListenerOnce(event, handleTouchEnd); - } - - const doc = ownerDocument(floatingElement); - const unsubscribe = mergeCleanups( - escapeKey && - mergeCleanups( - addEventListener(doc, 'keydown', closeOnEscapeKeyDown), - addEventListener(doc, 'compositionstart', handleCompositionStart), - addEventListener(doc, 'compositionend', handleCompositionEnd), - ), - outsidePressEnabled && - mergeCleanups( - addEventListener(doc, 'click', closeOnPressOutsideCapture, true), - addEventListener(doc, 'pointerdown', closeOnPressOutsideCapture, true), - addEventListener(doc, 'pointerup', handlePressEndCapture, true), - addEventListener(doc, 'pointercancel', handlePressEndCapture, true), - addEventListener(doc, 'mousedown', closeOnPressOutsideCapture, true), - addEventListener(doc, 'mouseup', handlePressEndCapture, true), - addEventListener(doc, 'touchstart', handleTouchStartCapture, true), - addEventListener(doc, 'touchmove', handleTouchMoveCapture, true), - addEventListener(doc, 'touchend', handleTouchEndCapture, true), - ), - ); - - return () => { - unsubscribe(); - compositionTimeout.clear(); - preventedPressSuppressionTimeout.clear(); - resetPressStartState(); - suppressNextOutsideClickRef.current = false; - }; - }, [ - dataRef, - floatingElement, - escapeKey, - outsidePressEnabled, - outsidePress, - open, - enabled, - escapeKeyBubbles, - outsidePressBubbles, - closeOnEscapeKeyDown, - clearInsideReactTree, - getOutsidePressEventProp, - hasBlockingChild, - isEventWithinOwnElements, - tree, - store, - cancelDismissOnEndTimeout, - ]); - - React.useEffect(clearInsideReactTree, [outsidePress, clearInsideReactTree]); - const reference: ElementProps['reference'] = React.useMemo( () => ({ - onKeyDown: closeOnEscapeKeyDown, - [bubbleHandlerKeys[referencePressEvent]]: closeOnReferencePress, + ...dismiss.reference, + [referencePressEvent === 'intentional' ? 'onClick' : 'onPointerDown']: closeOnReferencePress, ...(referencePressEvent !== 'intentional' && { onClick: closeOnReferencePress, }), }), - [closeOnEscapeKeyDown, closeOnReferencePress, referencePressEvent], - ); - - const floating: ElementProps['floating'] = React.useMemo( - () => ({ - onKeyDown: closeOnEscapeKeyDown, - // `onMouseDown` may be blocked if `event.preventDefault()` is called in - // `onPointerDown`, such as with . - // See https://github.com/mui/base-ui/pull/3379 - onPointerDown: markInsidePressStartPrevented, - onMouseDown: markInsidePressStartPrevented, - onClickCapture: markInsideReactTree, - onMouseDownCapture(event) { - markInsideReactTree(); - markPressStartedInsideReactTree(event); - }, - onPointerDownCapture(event) { - markInsideReactTree(); - markPressStartedInsideReactTree(event); - }, - onMouseUpCapture: markInsideReactTree, - onTouchEndCapture: markInsideReactTree, - onTouchMoveCapture: markInsideReactTree, - }), - [ - closeOnEscapeKeyDown, - markInsideReactTree, - markPressStartedInsideReactTree, - markInsidePressStartPrevented, - ], + [dismiss.reference, closeOnReferencePress, referencePressEvent], ); return React.useMemo( - () => (enabled ? { reference, floating, trigger: reference } : {}), - [enabled, reference, floating], + () => (enabled ? { ...dismiss, reference, trigger: reference } : {}), + [dismiss, enabled, reference], ); } diff --git a/packages/react/src/floating-ui-react/hooks/useDismissCore.ts b/packages/react/src/floating-ui-react/hooks/useDismissCore.ts new file mode 100644 index 00000000000..409af2842c6 --- /dev/null +++ b/packages/react/src/floating-ui-react/hooks/useDismissCore.ts @@ -0,0 +1,712 @@ +'use client'; +/* eslint-disable no-underscore-dangle */ +import * as React from 'react'; +import { addEventListener } from '@base-ui/utils/addEventListener'; +import { mergeCleanups } from '@base-ui/utils/mergeCleanups'; +import { ownerDocument } from '@base-ui/utils/owner'; +import { useStableCallback } from '@base-ui/utils/useStableCallback'; +import { Timeout, useTimeout } from '@base-ui/utils/useTimeout'; +import { + getComputedStyle, + getParentNode, + isElement, + isHTMLElement, + isLastTraversableNode, + isShadowRoot, + isWebKit, +} from '@floating-ui/utils/dom'; +import { useFloatingTree } from '../components/FloatingTree'; +import { FloatingTreeStore } from '../components/FloatingTreeStore'; +import type { ElementProps, FloatingContext, FloatingRootContext } from '../types'; +import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; +import { REASONS } from '../../internals/reasons'; +import { contains, getTarget, isEventTargetWithin, isRootElement } from '../utils/element'; +import { isReactEvent } from '../utils/event'; +import { getNodeChildren } from '../utils/nodes'; + +type PressType = 'intentional' | 'sloppy'; + +const inertSelector = '[data-base-ui-inert]'; +const scrollRe = /auto|scroll/; + +export function normalizeProp( + normalizable?: boolean | { escapeKey?: boolean | undefined; outsidePress?: boolean | undefined }, +) { + return { + escapeKey: + typeof normalizable === 'boolean' ? normalizable : (normalizable?.escapeKey ?? false), + outsidePress: + typeof normalizable === 'boolean' ? normalizable : (normalizable?.outsidePress ?? true), + }; +} + +export interface UseDismissCoreProps { + /** + * Whether the Hook is enabled, including all internal Effects and event + * handlers. + * @default true + */ + enabled?: boolean | undefined; + /** + * Whether to dismiss the floating element upon pressing the `esc` key. + * @default true + */ + escapeKey?: boolean | undefined; + /** + * Whether to dismiss the floating element upon pressing outside of the + * floating element. + * If you have another element, like a toast, that is rendered outside the + * floating element's React tree and don't want the floating element to close + * when pressing it, you can guard the check like so: + * ```jsx + * useDismiss(context, { + * outsidePress: (event) => !event.target.closest('.toast'), + * }); + * ``` + * @default true + */ + outsidePress?: boolean | ((event: MouseEvent | TouchEvent) => boolean) | undefined; + /** + * The type of event to use to determine an outside "press". + * - `intentional` requires the user to click outside intentionally, firing on `pointerup` for mouse, and requiring minimal `touchmove`s for touch. + * - `sloppy` fires on `pointerdown` for mouse, while for touch it fires on `touchend` (within 1 second) or while scrolling away after `touchstart`. + */ + outsidePressEvent?: + | PressType + | { + mouse: PressType; + touch: PressType; + } + | (() => + | PressType + | { + mouse: PressType; + touch: PressType; + }) + | undefined; + /** + * Determines whether event listeners bubble upwards through a tree of + * floating elements. + */ + bubbles?: + | boolean + | { escapeKey?: boolean | undefined; outsidePress?: boolean | undefined } + | undefined; + /** + * External FloatingTree to use when the one provided by context can't be used. + */ + externalTree?: FloatingTreeStore | undefined; +} + +/** + * Closes the floating element when a dismissal is requested — by default, when + * the user presses the `escape` key or outside of the floating element. + * @see https://floating-ui.com/docs/useDismiss + */ +export function useDismissCore( + context: FloatingRootContext | FloatingContext, + props: UseDismissCoreProps = {}, +): ElementProps { + const { + enabled = true, + escapeKey = true, + outsidePress: outsidePressProp = true, + outsidePressEvent = 'sloppy', + bubbles, + externalTree, + } = props; + + const store = 'rootStore' in context ? context.rootStore : context; + + const open = store.useState('open'); + const floatingElement = store.useState('floatingElement'); + const { dataRef } = store.context; + + const tree = useFloatingTree(externalTree); + const outsidePressFn = useStableCallback( + typeof outsidePressProp === 'function' ? outsidePressProp : undefined, + ); + const outsidePress = typeof outsidePressProp === 'function' ? outsidePressFn : outsidePressProp; + const outsidePressEnabled = outsidePress !== false; + const getOutsidePressEventProp = useStableCallback(() => outsidePressEvent); + + const escapeKeyBubbles = typeof bubbles === 'boolean' ? bubbles : (bubbles?.escapeKey ?? false); + const outsidePressBubbles = + typeof bubbles === 'boolean' ? bubbles : (bubbles?.outsidePress ?? true); + + const pressStartedInsideRef = React.useRef(false); + const pressStartPreventedRef = React.useRef(false); + // Ignore only the very next outside click after dragging from inside to outside. + const suppressNextOutsideClickRef = React.useRef(false); + const isComposingRef = React.useRef(false); + const currentPointerTypeRef = React.useRef(''); + + const touchStateRef = React.useRef<{ + startTime: number; + startX: number; + startY: number; + dismissOnTouchEnd: boolean; + dismissOnMouseDown: boolean; + } | null>(null); + + const cancelDismissOnEndTimeout = useTimeout(); + const clearInsideReactTreeTimeout = useTimeout(); + + const clearInsideReactTree = useStableCallback(() => { + clearInsideReactTreeTimeout.clear(); + dataRef.current.insideReactTree = false; + }); + + const hasBlockingChild = useStableCallback( + (bubbleKey: '__escapeKeyBubbles' | '__outsidePressBubbles') => { + const nodeId = dataRef.current.floatingContext?.nodeId; + const children = tree ? getNodeChildren(tree.nodesRef.current, nodeId) : []; + + return children.some( + (child) => child.context?.open && !child.context.dataRef.current[bubbleKey], + ); + }, + ); + + const isEventWithinOwnElements = useStableCallback((event: Event) => { + return ( + isEventTargetWithin(event, store.select('floatingElement')) || + isEventTargetWithin(event, store.select('domReferenceElement')) + ); + }); + + const closeOnEscapeKeyDown = useStableCallback( + (event: React.KeyboardEvent | KeyboardEvent) => { + if (!open || !enabled || !escapeKey || event.key !== 'Escape') { + return; + } + + // Wait until IME is settled. Pressing `Escape` while composing should + // close the compose menu, but not the floating element. + if (isComposingRef.current) { + return; + } + + if (!escapeKeyBubbles && hasBlockingChild('__escapeKeyBubbles')) { + return; + } + + const native = isReactEvent(event) ? event.nativeEvent : event; + const eventDetails = createChangeEventDetails(REASONS.escapeKey, native); + + store.setOpen(false, eventDetails); + + if (!eventDetails.isCanceled) { + event.preventDefault(); + } + + if (!escapeKeyBubbles && !eventDetails.isPropagationAllowed) { + event.stopPropagation(); + } + }, + ); + + const markInsideReactTree = useStableCallback(() => { + dataRef.current.insideReactTree = true; + clearInsideReactTreeTimeout.start(0, clearInsideReactTree); + }); + + const markPressStartedInsideReactTree = useStableCallback( + (event: React.PointerEvent | React.MouseEvent) => { + if (!open || !enabled || event.button !== 0) { + return; + } + + const target = getTarget(event.nativeEvent) as Element | null; + + // Only treat presses that start within the floating DOM subtree as inside. + // This avoids suppressing parent dismissal when interacting with nested portals. + if (!contains(store.select('floatingElement'), target)) { + return; + } + + if (!pressStartedInsideRef.current) { + pressStartedInsideRef.current = true; + pressStartPreventedRef.current = false; + } + }, + ); + + const markInsidePressStartPrevented = useStableCallback( + (event: React.PointerEvent | React.MouseEvent) => { + if (!open || !enabled) { + return; + } + + if (!(event.defaultPrevented || event.nativeEvent.defaultPrevented)) { + return; + } + + if (pressStartedInsideRef.current) { + pressStartPreventedRef.current = true; + } + }, + ); + + React.useEffect(() => { + if (!open || !enabled) { + return undefined; + } + + dataRef.current.__escapeKeyBubbles = escapeKeyBubbles; + dataRef.current.__outsidePressBubbles = outsidePressBubbles; + + const compositionTimeout = new Timeout(); + const preventedPressSuppressionTimeout = new Timeout(); + + function handleCompositionStart() { + compositionTimeout.clear(); + isComposingRef.current = true; + } + + function handleCompositionEnd() { + // Safari fires `compositionend` before `keydown`, so we need to wait + // until the next tick to set `isComposing` to `false`. + // https://bugs.webkit.org/show_bug.cgi?id=165004 + compositionTimeout.start( + // 0ms or 1ms don't work in Safari. 5ms appears to consistently work. + // Only apply to WebKit for the test to remain 0ms. + isWebKit() ? 5 : 0, + () => { + isComposingRef.current = false; + }, + ); + } + + function suppressImmediateOutsideClickAfterPreventedStart() { + suppressNextOutsideClickRef.current = true; + // Firefox can emit the synthetic outside click in a later task after + // pointer lock exit, so microtask clearing is too early here. + preventedPressSuppressionTimeout.start(0, () => { + suppressNextOutsideClickRef.current = false; + }); + } + + function resetPressStartState() { + pressStartedInsideRef.current = false; + pressStartPreventedRef.current = false; + } + + function getOutsidePressEvent(): PressType { + const type = currentPointerTypeRef.current; + + const outsidePressEventValue = getOutsidePressEventProp(); + const resolved = + typeof outsidePressEventValue === 'function' + ? outsidePressEventValue() + : outsidePressEventValue; + + if (typeof resolved === 'string') { + return resolved; + } + + return resolved[type === 'touch' ? 'touch' : 'mouse']; + } + + function isEventWithinFloatingTree(event: Event) { + const nodeId = dataRef.current.floatingContext?.nodeId; + const targetIsInsideChildren = + tree && + getNodeChildren(tree.nodesRef.current, nodeId).some((node) => + isEventTargetWithin(event, node.context?.elements.floating), + ); + + return isEventWithinOwnElements(event) || targetIsInsideChildren; + } + + function closeOnPressOutside(event: MouseEvent | PointerEvent | TouchEvent) { + const computedOutsidePressEvent = getOutsidePressEvent(); + + if ( + (computedOutsidePressEvent === 'intentional' && event.type !== 'click') || + (computedOutsidePressEvent === 'sloppy' && event.type === 'click') + ) { + clearInsideReactTree(); + return; + } + + if (dataRef.current.insideReactTree) { + clearInsideReactTree(); + return; + } + + const target = getTarget(event); + const targetRoot = isElement(target) ? target.getRootNode() : null; + const markers = Array.from( + (isShadowRoot(targetRoot) + ? targetRoot + : ownerDocument(store.select('floatingElement')) + ).querySelectorAll(inertSelector), + ); + + const triggers = store.context.triggerElements; + + // If another trigger is clicked, don't close the floating element. + if ( + target && + (triggers.hasElement(target as Element) || + triggers.hasMatchingElement((trigger) => contains(trigger, target as Element))) + ) { + return; + } + + let targetRootAncestor = isElement(target) ? target : null; + while (targetRootAncestor && !isLastTraversableNode(targetRootAncestor)) { + const nextParent = getParentNode(targetRootAncestor); + if (isLastTraversableNode(nextParent) || !isElement(nextParent)) { + break; + } + + targetRootAncestor = nextParent; + } + + // Check if the click occurred on a third-party element injected after the + // floating element rendered. + if ( + markers.length && + isElement(target) && + !isRootElement(target) && + // Clicked on a direct ancestor (e.g. FloatingOverlay). + !contains(target, store.select('floatingElement')) && + // If the target root element contains none of the markers, then the + // element was injected after the floating element rendered. + markers.every((marker) => !contains(targetRootAncestor, marker)) + ) { + return; + } + + // Check if the click occurred on the scrollbar + // Skip for touch events: scrollbars don't receive touch events on most platforms + if (isHTMLElement(target) && !('touches' in event)) { + const lastTraversableNode = isLastTraversableNode(target); + const style = getComputedStyle(target); + const isScrollableX = lastTraversableNode || scrollRe.test(style.overflowX); + const isScrollableY = lastTraversableNode || scrollRe.test(style.overflowY); + + const canScrollX = + isScrollableX && target.clientWidth > 0 && target.scrollWidth > target.clientWidth; + const canScrollY = + isScrollableY && target.clientHeight > 0 && target.scrollHeight > target.clientHeight; + + const isRTL = style.direction === 'rtl'; + + // Check click position relative to scrollbar. + // In some browsers it is possible to change the (or window) + // scrollbar to the left side, but is very rare and is difficult to + // check for. Plus, for modal dialogs with backdrops, it is more + // important that the backdrop is checked but not so much the window. + const pressedVerticalScrollbar = + canScrollY && + (isRTL + ? event.offsetX <= target.offsetWidth - target.clientWidth + : event.offsetX > target.clientWidth); + + const pressedHorizontalScrollbar = canScrollX && event.offsetY > target.clientHeight; + + if (pressedVerticalScrollbar || pressedHorizontalScrollbar) { + return; + } + } + + if (isEventWithinFloatingTree(event)) { + return; + } + + // In intentional mode, a press that starts inside and ends outside gets + // one suppressed outside click. Run this after inside-target checks so + // inside clicks don't consume the one-shot suppression. + if (computedOutsidePressEvent === 'intentional' && suppressNextOutsideClickRef.current) { + preventedPressSuppressionTimeout.clear(); + suppressNextOutsideClickRef.current = false; + return; + } + + if (typeof outsidePress === 'function' && !outsidePress(event)) { + return; + } + + if (hasBlockingChild('__outsidePressBubbles')) { + return; + } + + store.setOpen(false, createChangeEventDetails(REASONS.outsidePress, event)); + clearInsideReactTree(); + } + + function handlePointerDown(event: PointerEvent) { + if ( + getOutsidePressEvent() !== 'sloppy' || + event.pointerType === 'touch' || + !store.select('open') || + !enabled || + isEventWithinOwnElements(event) + ) { + return; + } + + closeOnPressOutside(event); + } + + function handleTouchStart(event: TouchEvent) { + if ( + getOutsidePressEvent() !== 'sloppy' || + !store.select('open') || + !enabled || + isEventWithinOwnElements(event) + ) { + return; + } + + const touch = event.touches[0]; + if (touch) { + touchStateRef.current = { + startTime: Date.now(), + startX: touch.clientX, + startY: touch.clientY, + dismissOnTouchEnd: false, + dismissOnMouseDown: true, + }; + + cancelDismissOnEndTimeout.start(1000, () => { + if (touchStateRef.current) { + touchStateRef.current.dismissOnTouchEnd = false; + touchStateRef.current.dismissOnMouseDown = false; + } + }); + } + } + + function addTargetEventListenerOnce( + event: EventType, + listener: (event: EventType) => void, + ) { + const target = getTarget(event); + + if (!target) { + return; + } + + addEventListener(target, event.type, listener as EventListener, { once: true }); + } + + function handleTouchStartCapture(event: TouchEvent) { + currentPointerTypeRef.current = 'touch'; + addTargetEventListenerOnce(event, handleTouchStart); + } + + function closeOnPressOutsideCapture(event: PointerEvent | MouseEvent) { + cancelDismissOnEndTimeout.clear(); + + if (event.type === 'pointerdown') { + currentPointerTypeRef.current = (event as PointerEvent).pointerType; + } + + if ( + event.type === 'mousedown' && + touchStateRef.current && + !touchStateRef.current.dismissOnMouseDown + ) { + return; + } + + addTargetEventListenerOnce( + event, + (event.type === 'pointerdown' ? handlePointerDown : closeOnPressOutside) as ( + event: PointerEvent | MouseEvent, + ) => void, + ); + } + + function handlePressEndCapture(event: PointerEvent | MouseEvent) { + if (!pressStartedInsideRef.current) { + return; + } + + const pressStartedInsideDefaultPrevented = pressStartPreventedRef.current; + resetPressStartState(); + + if (getOutsidePressEvent() !== 'intentional') { + return; + } + + if (event.type === 'pointercancel') { + if (pressStartedInsideDefaultPrevented) { + suppressImmediateOutsideClickAfterPreventedStart(); + } + return; + } + + if (isEventWithinFloatingTree(event)) { + return; + } + + // If pointerdown was prevented, no click may be generated for that + // interaction. However, Firefox may still emit an immediate click after + // pointerup (e.g. NumberField scrub with pointer lock), so suppress for + // one tick to absorb that synthetic click only. + if (pressStartedInsideDefaultPrevented) { + suppressImmediateOutsideClickAfterPreventedStart(); + return; + } + + // Avoid suppressing when outsidePress explicitly ignores this target. + if (typeof outsidePress === 'function' && !outsidePress(event as MouseEvent)) { + return; + } + + preventedPressSuppressionTimeout.clear(); + suppressNextOutsideClickRef.current = true; + clearInsideReactTree(); + } + + function handleTouchMove(event: TouchEvent) { + if ( + getOutsidePressEvent() !== 'sloppy' || + !touchStateRef.current || + isEventWithinOwnElements(event) + ) { + return; + } + + const touch = event.touches[0]; + if (!touch) { + return; + } + + const deltaX = Math.abs(touch.clientX - touchStateRef.current.startX); + const deltaY = Math.abs(touch.clientY - touchStateRef.current.startY); + const distanceSquared = deltaX * deltaX + deltaY * deltaY; + + if (distanceSquared > 25) { + touchStateRef.current.dismissOnTouchEnd = true; + } + + if (distanceSquared > 100) { + closeOnPressOutside(event); + cancelDismissOnEndTimeout.clear(); + touchStateRef.current = null; + } + } + + function handleTouchMoveCapture(event: TouchEvent) { + addTargetEventListenerOnce(event, handleTouchMove); + } + + function handleTouchEnd(event: TouchEvent) { + if ( + getOutsidePressEvent() !== 'sloppy' || + !touchStateRef.current || + isEventWithinOwnElements(event) + ) { + return; + } + + if (touchStateRef.current.dismissOnTouchEnd) { + closeOnPressOutside(event); + } + + cancelDismissOnEndTimeout.clear(); + touchStateRef.current = null; + } + + function handleTouchEndCapture(event: TouchEvent) { + addTargetEventListenerOnce(event, handleTouchEnd); + } + + const doc = ownerDocument(floatingElement); + const unsubscribe = mergeCleanups( + escapeKey && + mergeCleanups( + addEventListener(doc, 'keydown', closeOnEscapeKeyDown), + addEventListener(doc, 'compositionstart', handleCompositionStart), + addEventListener(doc, 'compositionend', handleCompositionEnd), + ), + outsidePressEnabled && + mergeCleanups( + addEventListener(doc, 'click', closeOnPressOutsideCapture, true), + addEventListener(doc, 'pointerdown', closeOnPressOutsideCapture, true), + addEventListener(doc, 'pointerup', handlePressEndCapture, true), + addEventListener(doc, 'pointercancel', handlePressEndCapture, true), + addEventListener(doc, 'mousedown', closeOnPressOutsideCapture, true), + addEventListener(doc, 'mouseup', handlePressEndCapture, true), + addEventListener(doc, 'touchstart', handleTouchStartCapture, true), + addEventListener(doc, 'touchmove', handleTouchMoveCapture, true), + addEventListener(doc, 'touchend', handleTouchEndCapture, true), + ), + ); + + return () => { + unsubscribe(); + compositionTimeout.clear(); + preventedPressSuppressionTimeout.clear(); + resetPressStartState(); + suppressNextOutsideClickRef.current = false; + }; + }, [ + dataRef, + floatingElement, + escapeKey, + outsidePressEnabled, + outsidePress, + open, + enabled, + escapeKeyBubbles, + outsidePressBubbles, + closeOnEscapeKeyDown, + clearInsideReactTree, + getOutsidePressEventProp, + hasBlockingChild, + isEventWithinOwnElements, + tree, + store, + cancelDismissOnEndTimeout, + ]); + + React.useEffect(clearInsideReactTree, [outsidePress, clearInsideReactTree]); + + const reference: ElementProps['reference'] = React.useMemo( + () => ({ + onKeyDown: closeOnEscapeKeyDown, + }), + [closeOnEscapeKeyDown], + ); + + const floating: ElementProps['floating'] = React.useMemo( + () => ({ + onKeyDown: closeOnEscapeKeyDown, + // `onMouseDown` may be blocked if `event.preventDefault()` is called in + // `onPointerDown`, such as with . + // See https://github.com/mui/base-ui/pull/3379 + onPointerDown: markInsidePressStartPrevented, + onMouseDown: markInsidePressStartPrevented, + onClickCapture: markInsideReactTree, + onMouseDownCapture(event) { + markInsideReactTree(); + markPressStartedInsideReactTree(event); + }, + onPointerDownCapture(event) { + markInsideReactTree(); + markPressStartedInsideReactTree(event); + }, + onMouseUpCapture: markInsideReactTree, + onTouchEndCapture: markInsideReactTree, + onTouchMoveCapture: markInsideReactTree, + }), + [ + closeOnEscapeKeyDown, + markInsideReactTree, + markPressStartedInsideReactTree, + markInsidePressStartPrevented, + ], + ); + + return React.useMemo( + () => (enabled ? { reference, floating, trigger: reference } : {}), + [enabled, reference, floating], + ); +} diff --git a/packages/react/src/floating-ui-react/hooks/useFloatingRootContext.ts b/packages/react/src/floating-ui-react/hooks/useFloatingRootContext.ts index 2c62b8fbee3..19a94382e7a 100644 --- a/packages/react/src/floating-ui-react/hooks/useFloatingRootContext.ts +++ b/packages/react/src/floating-ui-react/hooks/useFloatingRootContext.ts @@ -3,7 +3,7 @@ import { isElement } from '@floating-ui/utils/dom'; import { useId } from '@base-ui/utils/useId'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { useRefWithInit } from '@base-ui/utils/useRefWithInit'; -import { PopupTriggerMap } from '../../utils/popups'; +import { PopupTriggerMap } from '../../utils/popups/popupTriggerMap'; import type { BaseUIChangeEventDetails } from '../../internals/createBaseUIEventDetails'; import { useFloatingParentNodeId } from '../components/FloatingTree'; import { FloatingRootStore, type FloatingRootState } from '../components/FloatingRootStore'; diff --git a/packages/react/src/floating-ui-react/hooks/useListNavigation.ts b/packages/react/src/floating-ui-react/hooks/useListNavigation.ts index f826878758e..c872a03e7e7 100644 --- a/packages/react/src/floating-ui-react/hooks/useListNavigation.ts +++ b/packages/react/src/floating-ui-react/hooks/useListNavigation.ts @@ -1,227 +1,9 @@ 'use client'; -import * as React from 'react'; -import { useAnimationFrame } from '@base-ui/utils/useAnimationFrame'; -import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; -import { ownerDocument } from '@base-ui/utils/owner'; -import { useStableCallback } from '@base-ui/utils/useStableCallback'; -import { useValueAsRef } from '@base-ui/utils/useValueAsRef'; -import { isHTMLElement } from '@floating-ui/utils/dom'; -import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; -import { REASONS } from '../../internals/reasons'; -import { useFloatingParentNodeId, useFloatingTree } from '../components/FloatingTree'; -import { FloatingTreeStore } from '../components/FloatingTreeStore'; +import { getGridNavigatedIndex } from '../utils/composite'; import type { ElementProps, FloatingContext, FloatingRootContext } from '../types'; -import { - createGridCellMap, - findNonDisabledListIndex, - getGridCellIndexOfCorner, - getGridCellIndices, - getGridNavigatedIndex, - getMaxListIndex, - getMinListIndex, - isIndexOutOfListBounds, - isListIndexDisabled, -} from '../utils/composite'; -import { ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, ARROW_UP } from '../utils/constants'; -import { - activeElement, - contains, - getFloatingFocusElement, - getTarget, - isTypeableCombobox, -} from '../utils/element'; -import { enqueueFocus } from '../utils/enqueueFocus'; -import { isVirtualClick, isVirtualPointerEvent, stopEvent } from '../utils/event'; +import { useListNavigationCore, type UseListNavigationProps } from './useListNavigationCore'; -export const ESCAPE = 'Escape'; - -function doSwitch( - orientation: UseListNavigationProps['orientation'], - vertical: boolean, - horizontal: boolean, -) { - switch (orientation) { - case 'vertical': - return vertical; - case 'horizontal': - return horizontal; - default: - return vertical || horizontal; - } -} - -function isMainOrientationKey(key: string, orientation: UseListNavigationProps['orientation']) { - const vertical = key === ARROW_UP || key === ARROW_DOWN; - const horizontal = key === ARROW_LEFT || key === ARROW_RIGHT; - return doSwitch(orientation, vertical, horizontal); -} - -function isMainOrientationToEndKey( - key: string, - orientation: UseListNavigationProps['orientation'], - rtl: boolean, -) { - const vertical = key === ARROW_DOWN; - const horizontal = rtl ? key === ARROW_LEFT : key === ARROW_RIGHT; - return ( - doSwitch(orientation, vertical, horizontal) || key === 'Enter' || key === ' ' || key === '' - ); -} - -function isCrossOrientationOpenKey( - key: string, - orientation: UseListNavigationProps['orientation'], - rtl: boolean, -) { - const vertical = rtl ? key === ARROW_LEFT : key === ARROW_RIGHT; - const horizontal = key === ARROW_DOWN; - return doSwitch(orientation, vertical, horizontal); -} - -function isCrossOrientationCloseKey( - key: string, - orientation: UseListNavigationProps['orientation'], - rtl: boolean, - cols?: number, -) { - const vertical = rtl ? key === ARROW_RIGHT : key === ARROW_LEFT; - const horizontal = key === ARROW_UP; - if (orientation === 'both' || (orientation === 'horizontal' && cols && cols > 1)) { - return key === ESCAPE; - } - return doSwitch(orientation, vertical, horizontal); -} - -export interface UseListNavigationProps { - /** - * A ref that holds an array of list items. - * @default empty list - */ - listRef: React.RefObject>; - /** - * The index of the currently active (focused or highlighted) item, which may - * or may not be selected. - * @default null - */ - activeIndex: number | null; - /** - * A callback that is called when the user navigates to a new active item, - * passed in a new `activeIndex`. - */ - onNavigate?: - | ((activeIndex: number | null, event: React.SyntheticEvent | undefined) => void) - | undefined; - /** - * Whether the Hook is enabled, including all internal Effects and event - * handlers. - * @default true - */ - enabled?: boolean | undefined; - /** - * The currently selected item index, which may or may not be active. - * @default null - */ - selectedIndex?: number | null | undefined; - /** - * Whether to focus the item upon opening the floating element. 'auto' infers - * what to do based on the input type (keyboard vs. pointer), while a boolean - * value will force the value. - * @default 'auto' - */ - focusItemOnOpen?: boolean | 'auto' | undefined; - /** - * Whether hovering an item synchronizes the focus. - * @default true - */ - focusItemOnHover?: boolean | undefined; - /** - * Whether pressing an arrow key on the navigation's main axis opens the - * floating element. - * @default true - */ - openOnArrowKeyDown?: boolean | undefined; - /** - * By default elements with either a `disabled` or `aria-disabled` attribute - * are skipped in the list navigation — however, this requires the items to - * be rendered. - * This prop allows you to manually specify indices which should be disabled, - * overriding the default logic. - * For Windows-style select popups, where the menu does not open when - * navigating via arrow keys, specify an empty array. - * @default undefined - */ - disabledIndices?: ReadonlyArray | ((index: number) => boolean) | undefined; - /** - * Determines whether focus can escape the list, such that nothing is selected - * after navigating beyond the boundary of the list. In some - * autocomplete/combobox components, this may be desired, as screen - * readers will return to the input. - * `loopFocus` must be `true`. - * @default false - */ - allowEscape?: boolean | undefined; - /** - * Determines whether focus should loop around when navigating past the first - * or last item. - * @default false - */ - loopFocus?: boolean | undefined; - /** - * If the list is nested within another one (e.g. a nested submenu), the - * navigation semantics change. - * @default false - */ - nested?: boolean | undefined; - /** - * Allows to specify the orientation of the parent list, which is used to - * determine the direction of the navigation. - * This is useful when list navigation is used within a Composite, - * as the hook can't determine the orientation of the parent list automatically. - */ - parentOrientation?: UseListNavigationProps['orientation'] | undefined; - /** - * Whether the direction of the floating element's navigation is in RTL - * layout. - * @default false - */ - rtl?: boolean | undefined; - /** - * Whether the focus is virtual (using `aria-activedescendant`). - * Use this if you need focus to remain on the reference element - * (such as an input), but allow arrow keys to navigate list items. - * This is common in autocomplete listbox components. - * Your virtually-focused list items must have a unique `id` set on them. - * @default false - */ - virtual?: boolean | undefined; - /** - * The orientation in which navigation occurs. - * @default 'vertical' - */ - orientation?: 'vertical' | 'horizontal' | 'both' | undefined; - /** - * Specifies how many columns the list has (i.e., it's a grid). Use an - * orientation of 'horizontal' (e.g. for an emoji picker/date picker, where - * pressing ArrowRight or ArrowLeft can change rows), or 'both' (where the - * current row cannot be escaped with ArrowRight or ArrowLeft, only ArrowUp - * and ArrowDown). - * @default 1 - */ - cols?: number | undefined; - /** - * The id of the root component. - */ - id?: string | undefined; - /** - * Whether to clear the active index when the pointer leaves an item. - * @default true - */ - resetOnPointerLeave?: boolean | undefined; - /** - * External FloatingTree to use when the one provided by context can't be used. - */ - externalTree?: FloatingTreeStore | undefined; -} +export type { UseListNavigationProps } from './useListNavigationCore'; /** * Adds arrow key-based navigation of a list of items, either using real DOM @@ -232,755 +14,12 @@ export function useListNavigation( context: FloatingRootContext | FloatingContext, props: UseListNavigationProps, ): ElementProps { - const { - listRef, - activeIndex, - onNavigate: onNavigateProp = () => {}, - enabled = true, - selectedIndex = null, - allowEscape = false, - loopFocus = false, - nested = false, - rtl = false, - virtual = false, - focusItemOnOpen = 'auto', - focusItemOnHover = true, - openOnArrowKeyDown = true, - disabledIndices = undefined, - orientation = 'vertical', - parentOrientation, - cols = 1, - id, - resetOnPointerLeave = true, - externalTree, - } = props; - - if (process.env.NODE_ENV !== 'production') { - if (allowEscape) { - if (!loopFocus) { - console.warn('`useListNavigation` looping must be enabled to allow escaping.'); - } - - if (!virtual) { - console.warn('`useListNavigation` must be virtual to allow escaping.'); - } - } - - if (orientation === 'vertical' && cols > 1) { - console.warn( - 'In grid list navigation mode (`cols` > 1), the `orientation` should', - 'be either "horizontal" or "both".', - ); - } - } - - const store = 'rootStore' in context ? context.rootStore : context; - - const open = store.useState('open'); - const floatingElement = store.useState('floatingElement'); - const domReferenceElement = store.useState('domReferenceElement'); - - const dataRef = store.context.dataRef; - - const floatingFocusElement = getFloatingFocusElement(floatingElement); - const typeableComboboxReference = isTypeableCombobox(domReferenceElement); - const floatingFocusElementRef = useValueAsRef(floatingFocusElement); - - const parentId = useFloatingParentNodeId(); - const tree = useFloatingTree(externalTree); - - const focusItemOnOpenRef = React.useRef(focusItemOnOpen); - const indexRef = React.useRef(selectedIndex ?? -1); - const keyRef = React.useRef(null); - const isPointerModalityRef = React.useRef(true); - - const onNavigate = useStableCallback((event?: React.SyntheticEvent) => { - onNavigateProp(indexRef.current === -1 ? null : indexRef.current, event); - }); - - const previousOnNavigateRef = React.useRef(onNavigate); - const previousMountedRef = React.useRef(!!floatingElement); - const previousOpenRef = React.useRef(open); - const forceSyncFocusRef = React.useRef(false); - const forceScrollIntoViewRef = React.useRef(false); - const cancelQueuedFocusRef = React.useRef<(() => void) | null>(null); - - const disabledIndicesRef = useValueAsRef(disabledIndices); - const latestOpenRef = useValueAsRef(open); - const selectedIndexRef = useValueAsRef(selectedIndex); - const resetOnPointerLeaveRef = useValueAsRef(resetOnPointerLeave); - - const focusFrame = useAnimationFrame(); - const waitForListPopulatedFrame = useAnimationFrame(); - - const focusItem = useStableCallback(() => { - function runFocus(item: HTMLElement) { - if (virtual) { - tree?.events.emit('virtualfocus', item); - } else { - cancelQueuedFocusRef.current = enqueueFocus(item, { - sync: forceSyncFocusRef.current, - preventScroll: true, - }); - } - } - - const initialItem = listRef.current[indexRef.current]; - const forceScrollIntoView = forceScrollIntoViewRef.current; - - if (initialItem) { - runFocus(initialItem); - } - - const scheduler = forceSyncFocusRef.current - ? (callback: () => void) => callback() - : (callback: () => void) => focusFrame.request(callback); - - scheduler(() => { - const waitedItem = listRef.current[indexRef.current] || initialItem; - - if (!waitedItem) { - return; - } - - if (!initialItem) { - runFocus(waitedItem); - } - - const shouldScrollIntoView = - // eslint-disable-next-line @typescript-eslint/no-use-before-define - item && (forceScrollIntoView || !isPointerModalityRef.current); - - if (shouldScrollIntoView) { - // JSDOM doesn't support `.scrollIntoView()` but it's widely supported - // by all browsers. - waitedItem.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); - } - }); - }); - - useIsoLayoutEffect(() => { - dataRef.current.orientation = orientation; - }, [dataRef, orientation]); - - // Sync `selectedIndex` to be the `activeIndex` upon opening the floating - // element. Also, reset `activeIndex` upon closing the floating element. - useIsoLayoutEffect(() => { - if (!enabled) { - return; - } - - if (open && floatingElement) { - indexRef.current = selectedIndex ?? -1; - if (focusItemOnOpenRef.current && selectedIndex != null) { - // Regardless of the pointer modality, we want to ensure the selected - // item comes into view when the floating element is opened. - forceScrollIntoViewRef.current = true; - onNavigate(); - } - } else if (previousMountedRef.current) { - // Since the user can specify `onNavigate` conditionally - // (onNavigate: open ? setActiveIndex : setSelectedIndex), - // we store and call the previous function. - indexRef.current = -1; - previousOnNavigateRef.current(); - } - }, [enabled, open, floatingElement, selectedIndex, onNavigate]); - - // Sync `activeIndex` to be the focused item while the floating element is - // open. - useIsoLayoutEffect(() => { - if (!enabled) { - return; - } - if (!open) { - forceSyncFocusRef.current = false; - return; - } - if (!floatingElement) { - return; - } - - if (activeIndex == null) { - forceSyncFocusRef.current = false; - - if (selectedIndexRef.current != null) { - return; - } - - // Reset while the floating element was open (e.g. the list changed). - if (previousMountedRef.current) { - indexRef.current = -1; - focusItem(); - } - - // Initial sync. - if ( - (!previousOpenRef.current || !previousMountedRef.current) && - focusItemOnOpenRef.current && - (keyRef.current != null || (focusItemOnOpenRef.current === true && keyRef.current == null)) - ) { - let runs = 0; - const waitForListPopulated = () => { - if (listRef.current[0] == null) { - // Avoid letting the browser paint if possible on the first try, - // otherwise use rAF. Don't try more than twice, since something - // is wrong otherwise. - if (runs < 2) { - const scheduler = runs - ? (callback: () => void) => waitForListPopulatedFrame.request(callback) - : queueMicrotask; - scheduler(waitForListPopulated); - } - runs += 1; - } else { - // initially focus the first non-disabled item - indexRef.current = - keyRef.current == null || - isMainOrientationToEndKey(keyRef.current, orientation, rtl) || - nested - ? getMinListIndex(listRef) - : getMaxListIndex(listRef); - keyRef.current = null; - onNavigate(); - } - }; - - waitForListPopulated(); - } - } else if (!isIndexOutOfListBounds(listRef.current, activeIndex)) { - indexRef.current = activeIndex; - focusItem(); - forceScrollIntoViewRef.current = false; - } - }, [ - enabled, - open, - floatingElement, - activeIndex, - selectedIndexRef, - nested, - listRef, - orientation, - rtl, - onNavigate, - focusItem, - waitForListPopulatedFrame, - ]); - - // Ensure the parent floating element has focus when a nested child closes - // to allow arrow key navigation to work after the pointer leaves the child. - useIsoLayoutEffect(() => { - if (!enabled || floatingElement || !tree || virtual || !previousMountedRef.current) { - return; - } - - const nodes = tree.nodesRef.current; - const parent = nodes.find((node) => node.id === parentId)?.context?.elements.floating; - const activeEl = activeElement(ownerDocument(floatingElement)); - const treeContainsActiveEl = nodes.some( - (node) => node.context && contains(node.context.elements.floating, activeEl), - ); - - if (parent && !treeContainsActiveEl && isPointerModalityRef.current) { - parent.focus({ preventScroll: true }); - } - }, [enabled, floatingElement, tree, parentId, virtual]); - - useIsoLayoutEffect(() => { - previousOnNavigateRef.current = onNavigate; - previousOpenRef.current = open; - previousMountedRef.current = !!floatingElement; - }); - - useIsoLayoutEffect(() => { - if (!open) { - keyRef.current = null; - focusItemOnOpenRef.current = focusItemOnOpen; - } - }, [open, focusItemOnOpen]); - - const hasActiveIndex = activeIndex != null; - - const syncCurrentTarget = useStableCallback((event: React.SyntheticEvent) => { - if (!latestOpenRef.current) { - return; - } - - const index = listRef.current.indexOf(event.currentTarget); - if (index !== -1 && (indexRef.current !== index || activeIndex !== index)) { - indexRef.current = index; - onNavigate(event); - } - }); - - const getParentOrientation = useStableCallback(() => { - return ( - parentOrientation ?? - (tree?.nodesRef.current.find((node) => node.id === parentId)?.context?.dataRef?.current - .orientation as UseListNavigationProps['orientation']) - ); - }); - - const getMinEnabledIndex = useStableCallback(() => { - return getMinListIndex(listRef, disabledIndicesRef.current); - }); - - const commonOnKeyDown = useStableCallback((event: React.KeyboardEvent) => { - isPointerModalityRef.current = false; - forceSyncFocusRef.current = true; - - // When composing a character, Chrome fires ArrowDown twice. Firefox/Safari - // don't appear to suffer from this. `event.isComposing` is avoided due to - // Safari not supporting it properly (although it's not needed in the first - // place for Safari, just avoiding any possible issues). - if (event.which === 229) { - return; - } - - // If the floating element is animating out, ignore navigation. Otherwise, - // the `activeIndex` gets set to 0 despite not being open so the next time - // the user ArrowDowns, the first item won't be focused. - if (!latestOpenRef.current && event.currentTarget === floatingFocusElementRef.current) { - return; - } - - if (nested && isCrossOrientationCloseKey(event.key, orientation, rtl, cols)) { - // If the nested list's close key is also the parent navigation key, - // let the parent navigate. Otherwise, stop propagating the event. - if (!isMainOrientationKey(event.key, getParentOrientation())) { - stopEvent(event); - } - - store.setOpen(false, createChangeEventDetails(REASONS.listNavigation, event.nativeEvent)); - - if (isHTMLElement(domReferenceElement)) { - if (virtual) { - tree?.events.emit('virtualfocus', domReferenceElement); - } else { - domReferenceElement.focus(); - } - } - - return; - } - - const currentIndex = indexRef.current; - const minIndex = getMinListIndex(listRef, disabledIndices); - const maxIndex = getMaxListIndex(listRef, disabledIndices); - - if (!typeableComboboxReference) { - if (event.key === 'Home') { - stopEvent(event); - indexRef.current = minIndex; - onNavigate(event); - } - - if (event.key === 'End') { - stopEvent(event); - indexRef.current = maxIndex; - onNavigate(event); - } - } - - // Grid navigation. - if (cols > 1) { - const sizes = Array.from({ length: listRef.current.length }, () => ({ - width: 1, - height: 1, - })); - // To calculate movements on the grid, we use hypothetical cell indices - // as if every item was 1x1, then convert back to real indices. - const cellMap = createGridCellMap(sizes, cols, false); - const minGridIndex = cellMap.findIndex( - (index) => index != null && !isListIndexDisabled(listRef.current, index, disabledIndices), - ); - // last enabled index - const maxGridIndex = cellMap.reduce( - (foundIndex: number, index, cellIndex) => - index != null && !isListIndexDisabled(listRef.current, index, disabledIndices) - ? cellIndex - : foundIndex, - -1, - ); - - const index = - cellMap[ - getGridNavigatedIndex( - cellMap.map((itemIndex) => (itemIndex != null ? listRef.current[itemIndex] : null)), - { - event, - orientation, - loopFocus, - rtl, - cols, - // treat undefined (empty grid spaces) as disabled indices so we - // don't end up in them - disabledIndices: getGridCellIndices( - [ - ...((typeof disabledIndices !== 'function' ? disabledIndices : null) || - listRef.current.map((_, listIndex) => - isListIndexDisabled(listRef.current, listIndex, disabledIndices) - ? listIndex - : undefined, - )), - undefined, - ], - cellMap, - ), - minIndex: minGridIndex, - maxIndex: maxGridIndex, - prevIndex: getGridCellIndexOfCorner( - indexRef.current > maxIndex ? minIndex : indexRef.current, - sizes, - cellMap, - cols, - // use a corner matching the edge closest to the direction - // we're moving in so we don't end up in the same item. Prefer - // top/left over bottom/right. - // eslint-disable-next-line no-nested-ternary - event.key === ARROW_DOWN - ? 'bl' - : event.key === (rtl ? ARROW_LEFT : ARROW_RIGHT) - ? 'tr' - : 'tl', - ), - stopEvent: true, - }, - ) - ]; - - if (index != null) { - indexRef.current = index; - onNavigate(event); - } - - if (orientation === 'both') { - return; - } - } - - if (isMainOrientationKey(event.key, orientation)) { - stopEvent(event); - - // Reset the index if no item is focused. - if ( - open && - !virtual && - activeElement(event.currentTarget.ownerDocument) === event.currentTarget - ) { - indexRef.current = isMainOrientationToEndKey(event.key, orientation, rtl) - ? minIndex - : maxIndex; - onNavigate(event); - return; - } - - if (isMainOrientationToEndKey(event.key, orientation, rtl)) { - if (loopFocus) { - if (currentIndex >= maxIndex) { - if (allowEscape && currentIndex !== listRef.current.length) { - indexRef.current = -1; - } else { - // Give time for virtualizers to update the listRef. - forceSyncFocusRef.current = false; - indexRef.current = minIndex; - } - } else { - indexRef.current = findNonDisabledListIndex(listRef.current, { - startingIndex: currentIndex, - disabledIndices, - }); - } - } else { - indexRef.current = Math.min( - maxIndex, - findNonDisabledListIndex(listRef.current, { - startingIndex: currentIndex, - disabledIndices, - }), - ); - } - } else if (loopFocus) { - if (currentIndex <= minIndex) { - if (allowEscape && currentIndex !== -1) { - indexRef.current = listRef.current.length; - } else { - // Give time for virtualizers to update the listRef. - forceSyncFocusRef.current = false; - indexRef.current = maxIndex; - } - } else { - indexRef.current = findNonDisabledListIndex(listRef.current, { - startingIndex: currentIndex, - decrement: true, - disabledIndices, - }); - } - } else { - indexRef.current = Math.max( - minIndex, - findNonDisabledListIndex(listRef.current, { - startingIndex: currentIndex, - decrement: true, - disabledIndices, - }), - ); - } - - if (isIndexOutOfListBounds(listRef.current, indexRef.current)) { - indexRef.current = -1; - } - - onNavigate(event); - } - }); - - const item = React.useMemo(() => { - const itemProps: ElementProps['item'] = { - onFocus(event) { - forceSyncFocusRef.current = true; - syncCurrentTarget(event); - }, - onClick: ({ currentTarget }) => currentTarget.focus({ preventScroll: true }), // Safari - onMouseMove(event) { - forceSyncFocusRef.current = true; - forceScrollIntoViewRef.current = false; - if (focusItemOnHover) { - syncCurrentTarget(event); - } - }, - onPointerLeave(event) { - if ( - !latestOpenRef.current || - !isPointerModalityRef.current || - event.pointerType === 'touch' - ) { - return; - } - - forceSyncFocusRef.current = true; - - const relatedTarget = event.relatedTarget as HTMLElement | null; - - if (!focusItemOnHover || listRef.current.includes(relatedTarget)) { - return; - } - - if (!resetOnPointerLeaveRef.current) { - return; - } - - cancelQueuedFocusRef.current?.(); - cancelQueuedFocusRef.current = null; - - indexRef.current = -1; - onNavigate(event); - - if (!virtual) { - const floatingFocusEl = floatingFocusElementRef.current; - const activeEl = activeElement(ownerDocument(floatingFocusEl)); - if (floatingFocusEl && contains(floatingFocusEl, activeEl)) { - floatingFocusEl.focus({ preventScroll: true }); - } - } - }, - }; - - return itemProps; - }, [ - syncCurrentTarget, - latestOpenRef, - floatingFocusElementRef, - focusItemOnHover, - listRef, - onNavigate, - resetOnPointerLeaveRef, - virtual, - ]); - - const ariaActiveDescendantProp = React.useMemo(() => { - return ( - virtual && - open && - hasActiveIndex && { - 'aria-activedescendant': `${id}-${activeIndex}`, - } - ); - }, [virtual, open, hasActiveIndex, id, activeIndex]); - - const floating: ElementProps['floating'] = React.useMemo(() => { - return { - 'aria-orientation': orientation === 'both' ? undefined : orientation, - ...(!typeableComboboxReference ? ariaActiveDescendantProp : {}), - onKeyDown(event: React.KeyboardEvent) { - // Close submenu on Shift+Tab - if (event.key === 'Tab' && event.shiftKey && open && !virtual) { - // If the event originated from within a nested element (e.g., a Dialog opened from - // within the menu), don't close the menu. The nested element has its own focus - // management and should handle the Tab key. - const target = getTarget(event.nativeEvent) as Element | null; - if (target && !contains(floatingFocusElementRef.current, target)) { - return; - } - - stopEvent(event); - store.setOpen(false, createChangeEventDetails(REASONS.focusOut, event.nativeEvent)); - - if (isHTMLElement(domReferenceElement)) { - domReferenceElement.focus(); - } - - return; - } - - commonOnKeyDown(event); - }, - onPointerMove() { - isPointerModalityRef.current = true; - }, - }; - }, [ - ariaActiveDescendantProp, - commonOnKeyDown, - floatingFocusElementRef, - orientation, - typeableComboboxReference, - store, - open, - virtual, - domReferenceElement, - ]); - - const trigger: ElementProps['trigger'] = React.useMemo(() => { - function openOnNavigationKeyDown(event: React.KeyboardEvent) { - store.setOpen( - true, - createChangeEventDetails( - REASONS.listNavigation, - event.nativeEvent, - event.currentTarget as HTMLElement, - ), - ); - } - - function checkVirtualMouse(event: React.PointerEvent) { - if (focusItemOnOpen === 'auto' && isVirtualClick(event.nativeEvent)) { - focusItemOnOpenRef.current = !virtual; - } - } - - function checkVirtualPointer(event: React.PointerEvent) { - // `pointerdown` fires first, reset the state then perform the checks. - focusItemOnOpenRef.current = focusItemOnOpen; - if (focusItemOnOpen === 'auto' && isVirtualPointerEvent(event.nativeEvent)) { - focusItemOnOpenRef.current = true; - } - } - - return { - onKeyDown(event) { - // non-reactive open state (to prevent re-creation of the handler) - const currentOpen = store.select('open'); - isPointerModalityRef.current = false; - - const isArrowKey = event.key.startsWith('Arrow'); - const isParentCrossOpenKey = isCrossOrientationOpenKey( - event.key, - getParentOrientation(), - rtl, - ); - const isMainKey = isMainOrientationKey(event.key, orientation); - const isNavigationKey = - (nested ? isParentCrossOpenKey : isMainKey) || - event.key === 'Enter' || - event.key.trim() === ''; - - if (virtual && currentOpen) { - return commonOnKeyDown(event); - } - - // If a floating element should not open on arrow key down, avoid - // setting `activeIndex` while it's closed. - if (!currentOpen && !openOnArrowKeyDown && isArrowKey) { - return undefined; - } - - if (isNavigationKey) { - const isParentMainKey = isMainOrientationKey(event.key, getParentOrientation()); - keyRef.current = nested && isParentMainKey ? null : event.key; - } - - if (nested) { - if (isParentCrossOpenKey) { - stopEvent(event); - - if (currentOpen) { - indexRef.current = getMinEnabledIndex(); - onNavigate(event); - } else { - openOnNavigationKeyDown(event); - } - } - - return undefined; - } - - if (isMainKey) { - if (selectedIndexRef.current != null) { - indexRef.current = selectedIndexRef.current; - } - - stopEvent(event); - - if (!currentOpen && openOnArrowKeyDown) { - openOnNavigationKeyDown(event); - } else { - commonOnKeyDown(event); - } - - if (currentOpen) { - onNavigate(event); - } - } - - return undefined; - }, - onFocus(event) { - if (store.select('open') && !virtual) { - indexRef.current = -1; - onNavigate(event); - } - }, - onPointerDown: checkVirtualPointer, - onPointerEnter: checkVirtualPointer, - onMouseDown: checkVirtualMouse, - onClick: checkVirtualMouse, - }; - }, [ - commonOnKeyDown, - focusItemOnOpen, - getMinEnabledIndex, - nested, - onNavigate, - store, - openOnArrowKeyDown, - orientation, - getParentOrientation, - rtl, - selectedIndexRef, - virtual, - ]); - - const reference: ElementProps['reference'] = React.useMemo(() => { - return { - ...ariaActiveDescendantProp, - ...trigger, - }; - }, [ariaActiveDescendantProp, trigger]); + return useListNavigationCore(context, props, getGridNavigatedIndex); +} - return React.useMemo( - () => (enabled ? { reference, floating, item, trigger } : {}), - [enabled, reference, floating, trigger, item], - ); +export function useListNavigationNoGrid( + context: FloatingRootContext | FloatingContext, + props: UseListNavigationProps, +): ElementProps { + return useListNavigationCore(context, props); } diff --git a/packages/react/src/floating-ui-react/hooks/useListNavigationCore.ts b/packages/react/src/floating-ui-react/hooks/useListNavigationCore.ts new file mode 100644 index 00000000000..b2a9df88cf0 --- /dev/null +++ b/packages/react/src/floating-ui-react/hooks/useListNavigationCore.ts @@ -0,0 +1,929 @@ +'use client'; +import * as React from 'react'; +import { useAnimationFrame } from '@base-ui/utils/useAnimationFrame'; +import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; +import { ownerDocument } from '@base-ui/utils/owner'; +import { useStableCallback } from '@base-ui/utils/useStableCallback'; +import { useValueAsRef } from '@base-ui/utils/useValueAsRef'; +import { isHTMLElement } from '@floating-ui/utils/dom'; +import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; +import { REASONS } from '../../internals/reasons'; +import { useFloatingParentNodeId, useFloatingTree } from '../components/FloatingTree'; +import { FloatingTreeStore } from '../components/FloatingTreeStore'; +import type { ElementProps, FloatingContext, FloatingRootContext } from '../types'; +import { + findNonDisabledListIndex, + getMaxListIndex, + getMinListIndex, + isIndexOutOfListBounds, +} from '../utils/composite'; +import type { getGridNavigatedIndex as getGridNavigatedIndexType } from '../utils/composite'; +import { ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, ARROW_UP } from '../utils/constants'; +import { + activeElement, + contains, + getFloatingFocusElement, + getTarget, + isTypeableCombobox, +} from '../utils/element'; +import { enqueueFocus } from '../utils/enqueueFocus'; +import { isVirtualClick, isVirtualPointerEvent, stopEvent } from '../utils/event'; + +export const ESCAPE = 'Escape'; + +function doSwitch( + orientation: UseListNavigationProps['orientation'], + vertical: boolean, + horizontal: boolean, +) { + // eslint-disable-next-line no-nested-ternary + return orientation === 'vertical' + ? vertical + : orientation === 'horizontal' + ? horizontal + : vertical || horizontal; +} + +function isMainOrientationKey(key: string, orientation: UseListNavigationProps['orientation']) { + const vertical = key === ARROW_UP || key === ARROW_DOWN; + const horizontal = key === ARROW_LEFT || key === ARROW_RIGHT; + return doSwitch(orientation, vertical, horizontal); +} + +function isMainOrientationToEndKey( + key: string, + orientation: UseListNavigationProps['orientation'], + rtl: boolean, +) { + const vertical = key === ARROW_DOWN; + const horizontal = rtl ? key === ARROW_LEFT : key === ARROW_RIGHT; + return ( + doSwitch(orientation, vertical, horizontal) || key === 'Enter' || key === ' ' || key === '' + ); +} + +function isCrossOrientationOpenKey( + key: string, + orientation: UseListNavigationProps['orientation'], + rtl: boolean, +) { + const vertical = rtl ? key === ARROW_LEFT : key === ARROW_RIGHT; + const horizontal = key === ARROW_DOWN; + return doSwitch(orientation, vertical, horizontal); +} + +function isCrossOrientationCloseKey( + key: string, + orientation: UseListNavigationProps['orientation'], + rtl: boolean, + cols?: number, +) { + const vertical = rtl ? key === ARROW_RIGHT : key === ARROW_LEFT; + const horizontal = key === ARROW_UP; + if (orientation === 'both' || (orientation === 'horizontal' && cols && cols > 1)) { + return key === ESCAPE; + } + return doSwitch(orientation, vertical, horizontal); +} + +export interface UseListNavigationProps { + /** + * A ref that holds an array of list items. + * @default empty list + */ + listRef: React.RefObject>; + /** + * The index of the currently active (focused or highlighted) item, which may + * or may not be selected. + * @default null + */ + activeIndex: number | null; + /** + * A callback that is called when the user navigates to a new active item, + * passed in a new `activeIndex`. + */ + onNavigate?: + | ((activeIndex: number | null, event: React.SyntheticEvent | undefined) => void) + | undefined; + /** + * Whether the Hook is enabled, including all internal Effects and event + * handlers. + * @default true + */ + enabled?: boolean | undefined; + /** + * The currently selected item index, which may or may not be active. + * @default null + */ + selectedIndex?: number | null | undefined; + /** + * Whether to focus the item upon opening the floating element. 'auto' infers + * what to do based on the input type (keyboard vs. pointer), while a boolean + * value will force the value. + * @default 'auto' + */ + focusItemOnOpen?: boolean | 'auto' | undefined; + /** + * Whether hovering an item synchronizes the focus. + * @default true + */ + focusItemOnHover?: boolean | undefined; + /** + * Whether pressing an arrow key on the navigation's main axis opens the + * floating element. + * @default true + */ + openOnArrowKeyDown?: boolean | undefined; + /** + * By default elements with either a `disabled` or `aria-disabled` attribute + * are skipped in the list navigation — however, this requires the items to + * be rendered. + * This prop allows you to manually specify indices which should be disabled, + * overriding the default logic. + * For Windows-style select popups, where the menu does not open when + * navigating via arrow keys, specify an empty array. + * @default undefined + */ + disabledIndices?: ReadonlyArray | ((index: number) => boolean) | undefined; + /** + * Determines whether focus can escape the list, such that nothing is selected + * after navigating beyond the boundary of the list. In some + * autocomplete/combobox components, this may be desired, as screen + * readers will return to the input. + * `loopFocus` must be `true`. + * @default false + */ + allowEscape?: boolean | undefined; + /** + * Determines whether focus should loop around when navigating past the first + * or last item. + * @default false + */ + loopFocus?: boolean | undefined; + /** + * If the list is nested within another one (e.g. a nested submenu), the + * navigation semantics change. + * @default false + */ + nested?: boolean | undefined; + /** + * Allows to specify the orientation of the parent list, which is used to + * determine the direction of the navigation. + * This is useful when list navigation is used within a Composite, + * as the hook can't determine the orientation of the parent list automatically. + */ + parentOrientation?: UseListNavigationProps['orientation'] | undefined; + /** + * Whether the direction of the floating element's navigation is in RTL + * layout. + * @default false + */ + rtl?: boolean | undefined; + /** + * Whether the focus is virtual (using `aria-activedescendant`). + * Use this if you need focus to remain on the reference element + * (such as an input), but allow arrow keys to navigate list items. + * This is common in autocomplete listbox components. + * Your virtually-focused list items must have a unique `id` set on them. + * @default false + */ + virtual?: boolean | undefined; + /** + * The orientation in which navigation occurs. + * @default 'vertical' + */ + orientation?: 'vertical' | 'horizontal' | 'both' | undefined; + /** + * Specifies how many columns the list has (i.e., it's a grid). Use an + * orientation of 'horizontal' (e.g. for an emoji picker/date picker, where + * pressing ArrowRight or ArrowLeft can change rows), or 'both' (where the + * current row cannot be escaped with ArrowRight or ArrowLeft, only ArrowUp + * and ArrowDown). + * @default 1 + */ + cols?: number | undefined; + /** + * The id of the root component. + */ + id?: string | undefined; + /** + * Whether to clear the active index when the pointer leaves an item. + * @default true + */ + resetOnPointerLeave?: boolean | undefined; + /** + * External FloatingTree to use when the one provided by context can't be used. + */ + externalTree?: FloatingTreeStore | undefined; +} + +export type GridNavigator = typeof getGridNavigatedIndexType; + +/** + * Adds arrow key-based navigation of a list of items, either using real DOM + * focus or virtual focus. + * @see https://floating-ui.com/docs/useListNavigation + */ +export function useListNavigationCore( + context: FloatingRootContext | FloatingContext, + props: UseListNavigationProps, + gridNavigator?: GridNavigator, +): ElementProps { + const { + listRef, + activeIndex, + onNavigate: onNavigateProp = () => {}, + enabled = true, + selectedIndex = null, + allowEscape = false, + loopFocus = false, + nested = false, + rtl = false, + virtual = false, + focusItemOnOpen = 'auto', + focusItemOnHover = true, + openOnArrowKeyDown = true, + disabledIndices = undefined, + orientation = 'vertical', + parentOrientation, + cols = 1, + id, + resetOnPointerLeave = true, + externalTree, + } = props; + + if (process.env.NODE_ENV !== 'production') { + if (allowEscape) { + if (!loopFocus) { + console.warn('`useListNavigation` looping must be enabled to allow escaping.'); + } + + if (!virtual) { + console.warn('`useListNavigation` must be virtual to allow escaping.'); + } + } + + if (orientation === 'vertical' && cols > 1) { + console.warn( + 'In grid list navigation mode (`cols` > 1), the `orientation` should', + 'be either "horizontal" or "both".', + ); + } + } + + const store = 'rootStore' in context ? context.rootStore : context; + + const open = store.useState('open'); + const floatingElement = store.useState('floatingElement'); + const domReferenceElement = store.useState('domReferenceElement'); + + const dataRef = store.context.dataRef; + + const floatingFocusElement = getFloatingFocusElement(floatingElement); + const typeableComboboxReference = isTypeableCombobox(domReferenceElement); + const floatingFocusElementRef = useValueAsRef(floatingFocusElement); + + const parentId = useFloatingParentNodeId(); + const tree = useFloatingTree(externalTree); + + const focusItemOnOpenRef = React.useRef(focusItemOnOpen); + const indexRef = React.useRef(selectedIndex ?? -1); + const keyRef = React.useRef(null); + const isPointerModalityRef = React.useRef(true); + + const onNavigate = useStableCallback((event?: React.SyntheticEvent) => { + onNavigateProp(indexRef.current === -1 ? null : indexRef.current, event); + }); + + const previousOnNavigateRef = React.useRef(onNavigate); + const previousMountedRef = React.useRef(!!floatingElement); + const previousOpenRef = React.useRef(open); + const forceSyncFocusRef = React.useRef(false); + const forceScrollIntoViewRef = React.useRef(false); + const cancelQueuedFocusRef = React.useRef<(() => void) | null>(null); + + const disabledIndicesRef = useValueAsRef(disabledIndices); + const latestOpenRef = useValueAsRef(open); + const selectedIndexRef = useValueAsRef(selectedIndex); + const resetOnPointerLeaveRef = useValueAsRef(resetOnPointerLeave); + + const focusFrame = useAnimationFrame(); + const waitForListPopulatedFrame = useAnimationFrame(); + + const focusItem = useStableCallback(() => { + function runFocus(item: HTMLElement) { + if (virtual) { + tree?.events.emit('virtualfocus', item); + } else { + cancelQueuedFocusRef.current = enqueueFocus(item, { + sync: forceSyncFocusRef.current, + preventScroll: true, + }); + } + } + + const initialItem = listRef.current[indexRef.current]; + const forceScrollIntoView = forceScrollIntoViewRef.current; + + if (initialItem) { + runFocus(initialItem); + } + + const scheduler = forceSyncFocusRef.current + ? (callback: () => void) => callback() + : (callback: () => void) => focusFrame.request(callback); + + scheduler(() => { + const waitedItem = listRef.current[indexRef.current] || initialItem; + + if (!waitedItem) { + return; + } + + if (!initialItem) { + runFocus(waitedItem); + } + + const shouldScrollIntoView = forceScrollIntoView || !isPointerModalityRef.current; + + if (shouldScrollIntoView) { + // JSDOM doesn't support `.scrollIntoView()` but it's widely supported + // by all browsers. + waitedItem.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); + } + }); + }); + + useIsoLayoutEffect(() => { + dataRef.current.orientation = orientation; + }, [dataRef, orientation]); + + // Sync `selectedIndex` to be the `activeIndex` upon opening the floating + // element. Also, reset `activeIndex` upon closing the floating element. + useIsoLayoutEffect(() => { + if (!enabled) { + return; + } + + if (open && floatingElement) { + indexRef.current = selectedIndex ?? -1; + if (focusItemOnOpenRef.current && selectedIndex != null) { + // Regardless of the pointer modality, we want to ensure the selected + // item comes into view when the floating element is opened. + forceScrollIntoViewRef.current = true; + onNavigate(); + } + } else if (previousMountedRef.current) { + // Since the user can specify `onNavigate` conditionally + // (onNavigate: open ? setActiveIndex : setSelectedIndex), + // we store and call the previous function. + indexRef.current = -1; + previousOnNavigateRef.current(); + } + }, [enabled, open, floatingElement, selectedIndex, onNavigate]); + + // Sync `activeIndex` to be the focused item while the floating element is + // open. + useIsoLayoutEffect(() => { + if (!enabled) { + return; + } + if (!open) { + forceSyncFocusRef.current = false; + return; + } + if (!floatingElement) { + return; + } + + if (activeIndex == null) { + forceSyncFocusRef.current = false; + + if (selectedIndexRef.current != null) { + return; + } + + // Reset while the floating element was open (e.g. the list changed). + if (previousMountedRef.current) { + indexRef.current = -1; + focusItem(); + } + + // Initial sync. + if ( + (!previousOpenRef.current || !previousMountedRef.current) && + focusItemOnOpenRef.current && + (keyRef.current != null || (focusItemOnOpenRef.current === true && keyRef.current == null)) + ) { + let runs = 0; + const waitForListPopulated = () => { + if (listRef.current[0] == null) { + // Avoid letting the browser paint if possible on the first try, + // otherwise use rAF. Don't try more than twice, since something + // is wrong otherwise. + if (runs < 2) { + const scheduler = runs + ? (callback: () => void) => waitForListPopulatedFrame.request(callback) + : queueMicrotask; + scheduler(waitForListPopulated); + } + runs += 1; + } else { + // initially focus the first non-disabled item + indexRef.current = + keyRef.current == null || + isMainOrientationToEndKey(keyRef.current, orientation, rtl) || + nested + ? getMinListIndex(listRef) + : getMaxListIndex(listRef); + keyRef.current = null; + onNavigate(); + } + }; + + waitForListPopulated(); + } + } else if (!isIndexOutOfListBounds(listRef.current, activeIndex)) { + indexRef.current = activeIndex; + focusItem(); + forceScrollIntoViewRef.current = false; + } + }, [ + enabled, + open, + floatingElement, + activeIndex, + selectedIndexRef, + nested, + listRef, + orientation, + rtl, + onNavigate, + focusItem, + waitForListPopulatedFrame, + ]); + + // Ensure the parent floating element has focus when a nested child closes + // to allow arrow key navigation to work after the pointer leaves the child. + useIsoLayoutEffect(() => { + if (!enabled || floatingElement || !tree || virtual || !previousMountedRef.current) { + return; + } + + const nodes = tree.nodesRef.current; + const parent = nodes.find((node) => node.id === parentId)?.context?.elements.floating; + const activeEl = activeElement(ownerDocument(floatingElement)); + const treeContainsActiveEl = nodes.some( + (node) => node.context && contains(node.context.elements.floating, activeEl), + ); + + if (parent && !treeContainsActiveEl && isPointerModalityRef.current) { + parent.focus({ preventScroll: true }); + } + }, [enabled, floatingElement, tree, parentId, virtual]); + + useIsoLayoutEffect(() => { + previousOnNavigateRef.current = onNavigate; + previousOpenRef.current = open; + previousMountedRef.current = !!floatingElement; + }); + + useIsoLayoutEffect(() => { + if (!open) { + keyRef.current = null; + focusItemOnOpenRef.current = focusItemOnOpen; + } + }, [open, focusItemOnOpen]); + + const hasActiveIndex = activeIndex != null; + + const syncCurrentTarget = useStableCallback((event: React.SyntheticEvent) => { + if (!latestOpenRef.current) { + return; + } + + const index = listRef.current.indexOf(event.currentTarget); + if (index !== -1 && (indexRef.current !== index || activeIndex !== index)) { + indexRef.current = index; + onNavigate(event); + } + }); + + const getParentOrientation = useStableCallback(() => { + return ( + parentOrientation ?? + (tree?.nodesRef.current.find((node) => node.id === parentId)?.context?.dataRef?.current + .orientation as UseListNavigationProps['orientation']) + ); + }); + + const getMinEnabledIndex = useStableCallback(() => { + return getMinListIndex(listRef, disabledIndicesRef.current); + }); + + const commonOnKeyDown = useStableCallback((event: React.KeyboardEvent) => { + isPointerModalityRef.current = false; + forceSyncFocusRef.current = true; + + // When composing a character, Chrome fires ArrowDown twice. Firefox/Safari + // don't appear to suffer from this. `event.isComposing` is avoided due to + // Safari not supporting it properly (although it's not needed in the first + // place for Safari, just avoiding any possible issues). + if (event.which === 229) { + return; + } + + // If the floating element is animating out, ignore navigation. Otherwise, + // the `activeIndex` gets set to 0 despite not being open so the next time + // the user ArrowDowns, the first item won't be focused. + if (!latestOpenRef.current && event.currentTarget === floatingFocusElementRef.current) { + return; + } + + if (nested && isCrossOrientationCloseKey(event.key, orientation, rtl, cols)) { + // If the nested list's close key is also the parent navigation key, + // let the parent navigate. Otherwise, stop propagating the event. + if (!isMainOrientationKey(event.key, getParentOrientation())) { + stopEvent(event); + } + + store.setOpen(false, createChangeEventDetails(REASONS.listNavigation, event.nativeEvent)); + + if (isHTMLElement(domReferenceElement)) { + if (virtual) { + tree?.events.emit('virtualfocus', domReferenceElement); + } else { + domReferenceElement.focus(); + } + } + + return; + } + + const currentIndex = indexRef.current; + const minIndex = getMinListIndex(listRef, disabledIndices); + const maxIndex = getMaxListIndex(listRef, disabledIndices); + + if (!typeableComboboxReference) { + if (event.key === 'Home') { + stopEvent(event); + indexRef.current = minIndex; + onNavigate(event); + } + + if (event.key === 'End') { + stopEvent(event); + indexRef.current = maxIndex; + onNavigate(event); + } + } + + // Grid navigation. + if (gridNavigator && cols > 1) { + const index = gridNavigator(listRef.current, { + event, + orientation, + loopFocus, + rtl, + cols, + disabledIndices, + minIndex, + maxIndex, + prevIndex: indexRef.current > maxIndex ? minIndex : indexRef.current, + stopEvent: true, + }); + + if (index != null) { + indexRef.current = index; + onNavigate(event); + } + + if (orientation === 'both') { + return; + } + } + + if (isMainOrientationKey(event.key, orientation)) { + stopEvent(event); + + // Reset the index if no item is focused. + if ( + open && + !virtual && + activeElement(event.currentTarget.ownerDocument) === event.currentTarget + ) { + indexRef.current = isMainOrientationToEndKey(event.key, orientation, rtl) + ? minIndex + : maxIndex; + onNavigate(event); + return; + } + + if (isMainOrientationToEndKey(event.key, orientation, rtl)) { + if (loopFocus) { + if (currentIndex >= maxIndex) { + if (allowEscape && currentIndex !== listRef.current.length) { + indexRef.current = -1; + } else { + // Give time for virtualizers to update the listRef. + forceSyncFocusRef.current = false; + indexRef.current = minIndex; + } + } else { + indexRef.current = findNonDisabledListIndex(listRef.current, { + startingIndex: currentIndex, + disabledIndices, + }); + } + } else { + indexRef.current = Math.min( + maxIndex, + findNonDisabledListIndex(listRef.current, { + startingIndex: currentIndex, + disabledIndices, + }), + ); + } + } else if (loopFocus) { + if (currentIndex <= minIndex) { + if (allowEscape && currentIndex !== -1) { + indexRef.current = listRef.current.length; + } else { + // Give time for virtualizers to update the listRef. + forceSyncFocusRef.current = false; + indexRef.current = maxIndex; + } + } else { + indexRef.current = findNonDisabledListIndex(listRef.current, { + startingIndex: currentIndex, + decrement: true, + disabledIndices, + }); + } + } else { + indexRef.current = Math.max( + minIndex, + findNonDisabledListIndex(listRef.current, { + startingIndex: currentIndex, + decrement: true, + disabledIndices, + }), + ); + } + + if (isIndexOutOfListBounds(listRef.current, indexRef.current)) { + indexRef.current = -1; + } + + onNavigate(event); + } + }); + + const item = React.useMemo(() => { + const itemProps: ElementProps['item'] = { + onFocus(event) { + forceSyncFocusRef.current = true; + syncCurrentTarget(event); + }, + onClick: ({ currentTarget }) => currentTarget.focus({ preventScroll: true }), // Safari + onMouseMove(event) { + forceSyncFocusRef.current = true; + forceScrollIntoViewRef.current = false; + if (focusItemOnHover) { + syncCurrentTarget(event); + } + }, + onPointerLeave(event) { + if ( + !latestOpenRef.current || + !isPointerModalityRef.current || + event.pointerType === 'touch' + ) { + return; + } + + forceSyncFocusRef.current = true; + + const relatedTarget = event.relatedTarget as HTMLElement | null; + + if (!focusItemOnHover || listRef.current.includes(relatedTarget)) { + return; + } + + if (!resetOnPointerLeaveRef.current) { + return; + } + + cancelQueuedFocusRef.current?.(); + cancelQueuedFocusRef.current = null; + + indexRef.current = -1; + onNavigate(event); + + if (!virtual) { + const floatingFocusEl = floatingFocusElementRef.current; + const activeEl = activeElement(ownerDocument(floatingFocusEl)); + if (floatingFocusEl && contains(floatingFocusEl, activeEl)) { + floatingFocusEl.focus({ preventScroll: true }); + } + } + }, + }; + + return itemProps; + }, [ + syncCurrentTarget, + latestOpenRef, + floatingFocusElementRef, + focusItemOnHover, + listRef, + onNavigate, + resetOnPointerLeaveRef, + virtual, + ]); + + const ariaActiveDescendantProp = React.useMemo(() => { + return ( + virtual && + open && + hasActiveIndex && { + 'aria-activedescendant': `${id}-${activeIndex}`, + } + ); + }, [virtual, open, hasActiveIndex, id, activeIndex]); + + const floating: ElementProps['floating'] = React.useMemo(() => { + return { + 'aria-orientation': orientation === 'both' ? undefined : orientation, + ...(!typeableComboboxReference ? ariaActiveDescendantProp : {}), + onKeyDown(event: React.KeyboardEvent) { + // Close submenu on Shift+Tab + if (event.key === 'Tab' && event.shiftKey && open && !virtual) { + // If the event originated from within a nested element (e.g., a Dialog opened from + // within the menu), don't close the menu. The nested element has its own focus + // management and should handle the Tab key. + const target = getTarget(event.nativeEvent) as Element | null; + if (target && !contains(floatingFocusElementRef.current, target)) { + return; + } + + stopEvent(event); + store.setOpen(false, createChangeEventDetails(REASONS.focusOut, event.nativeEvent)); + + if (isHTMLElement(domReferenceElement)) { + domReferenceElement.focus(); + } + + return; + } + + commonOnKeyDown(event); + }, + onPointerMove() { + isPointerModalityRef.current = true; + }, + }; + }, [ + ariaActiveDescendantProp, + commonOnKeyDown, + floatingFocusElementRef, + orientation, + typeableComboboxReference, + store, + open, + virtual, + domReferenceElement, + ]); + + const trigger: ElementProps['trigger'] = React.useMemo(() => { + function openOnNavigationKeyDown(event: React.KeyboardEvent) { + store.setOpen( + true, + createChangeEventDetails( + REASONS.listNavigation, + event.nativeEvent, + event.currentTarget as HTMLElement, + ), + ); + } + + function checkVirtualMouse(event: React.PointerEvent) { + if (focusItemOnOpen === 'auto' && isVirtualClick(event.nativeEvent)) { + focusItemOnOpenRef.current = !virtual; + } + } + + function checkVirtualPointer(event: React.PointerEvent) { + // `pointerdown` fires first, reset the state then perform the checks. + focusItemOnOpenRef.current = focusItemOnOpen; + if (focusItemOnOpen === 'auto' && isVirtualPointerEvent(event.nativeEvent)) { + focusItemOnOpenRef.current = true; + } + } + + return { + onKeyDown(event) { + // non-reactive open state (to prevent re-creation of the handler) + const currentOpen = store.select('open'); + isPointerModalityRef.current = false; + + const isArrowKey = event.key.startsWith('Arrow'); + const isParentCrossOpenKey = isCrossOrientationOpenKey( + event.key, + getParentOrientation(), + rtl, + ); + const isMainKey = isMainOrientationKey(event.key, orientation); + const isNavigationKey = + (nested ? isParentCrossOpenKey : isMainKey) || + event.key === 'Enter' || + event.key.trim() === ''; + + if (virtual && currentOpen) { + return commonOnKeyDown(event); + } + + // If a floating element should not open on arrow key down, avoid + // setting `activeIndex` while it's closed. + if (!currentOpen && !openOnArrowKeyDown && isArrowKey) { + return undefined; + } + + if (isNavigationKey) { + const isParentMainKey = isMainOrientationKey(event.key, getParentOrientation()); + keyRef.current = nested && isParentMainKey ? null : event.key; + } + + if (nested) { + if (isParentCrossOpenKey) { + stopEvent(event); + + if (currentOpen) { + indexRef.current = getMinEnabledIndex(); + onNavigate(event); + } else { + openOnNavigationKeyDown(event); + } + } + + return undefined; + } + + if (isMainKey) { + if (selectedIndexRef.current != null) { + indexRef.current = selectedIndexRef.current; + } + + stopEvent(event); + + if (!currentOpen && openOnArrowKeyDown) { + openOnNavigationKeyDown(event); + } else { + commonOnKeyDown(event); + } + + if (currentOpen) { + onNavigate(event); + } + } + + return undefined; + }, + onFocus(event) { + if (store.select('open') && !virtual) { + indexRef.current = -1; + onNavigate(event); + } + }, + onPointerDown: checkVirtualPointer, + onPointerEnter: checkVirtualPointer, + onMouseDown: checkVirtualMouse, + onClick: checkVirtualMouse, + }; + }, [ + commonOnKeyDown, + focusItemOnOpen, + getMinEnabledIndex, + nested, + onNavigate, + store, + openOnArrowKeyDown, + orientation, + getParentOrientation, + rtl, + selectedIndexRef, + virtual, + ]); + + const reference: ElementProps['reference'] = React.useMemo(() => { + return { + ...ariaActiveDescendantProp, + ...trigger, + }; + }, [ariaActiveDescendantProp, trigger]); + + return React.useMemo( + () => (enabled ? { reference, floating, item, trigger } : {}), + [enabled, reference, floating, trigger, item], + ); +} diff --git a/packages/react/src/floating-ui-react/hooks/useSyncedFloatingRootContext.ts b/packages/react/src/floating-ui-react/hooks/useSyncedFloatingRootContext.ts index 2f1916ac81d..734165ee98d 100644 --- a/packages/react/src/floating-ui-react/hooks/useSyncedFloatingRootContext.ts +++ b/packages/react/src/floating-ui-react/hooks/useSyncedFloatingRootContext.ts @@ -1,10 +1,10 @@ 'use client'; import * as React from 'react'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; -import { ReactStore } from '@base-ui/utils/store'; +import { ReactStore } from '@base-ui/utils/store/core'; import { isElement } from '@floating-ui/utils/dom'; import { BaseUIChangeEventDetails } from '../../types'; -import { PopupStoreContext, PopupStoreSelectors, PopupStoreState } from '../../utils/popups'; +import { PopupStoreContext, PopupStoreSelectors, PopupStoreState } from '../../utils/popups/store'; import { FloatingRootState, FloatingRootStore } from '../components/FloatingRootStore'; export interface UseSyncedFloatingRootContextOptions< diff --git a/packages/react/src/floating-ui-react/hooks/useTypeahead.ts b/packages/react/src/floating-ui-react/hooks/useTypeahead.ts index cfdf1fc6a61..5b229a0a49c 100644 --- a/packages/react/src/floating-ui-react/hooks/useTypeahead.ts +++ b/packages/react/src/floating-ui-react/hooks/useTypeahead.ts @@ -3,7 +3,7 @@ import * as React from 'react'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { useTimeout } from '@base-ui/utils/useTimeout'; -import { isElementVisible } from '../utils/composite'; +import { isElementVisible } from '../utils/visibility'; import type { ElementProps, FloatingContext, FloatingRootContext } from '../types'; import { contains } from '../utils/element'; import { stopEvent } from '../utils/event'; diff --git a/packages/react/src/floating-ui-react/index.ts b/packages/react/src/floating-ui-react/index.ts index a2b3f5778f2..29be0d7ff15 100644 --- a/packages/react/src/floating-ui-react/index.ts +++ b/packages/react/src/floating-ui-react/index.ts @@ -1,6 +1,7 @@ export { FloatingDelayGroup, useDelayGroup } from './components/FloatingDelayGroup'; export { FloatingFocusManager } from './components/FloatingFocusManager'; -export { FloatingPortal, useFloatingPortalNode } from './components/FloatingPortal'; +export { FloatingPortal } from './components/FloatingPortal'; +export { useFloatingPortalNode } from './components/useFloatingPortalNode'; export { FloatingNode, FloatingTree, diff --git a/packages/react/src/floating-ui-react/safePolygon.ts b/packages/react/src/floating-ui-react/safePolygon.ts index 446998679b4..a0f21c79ceb 100644 --- a/packages/react/src/floating-ui-react/safePolygon.ts +++ b/packages/react/src/floating-ui-react/safePolygon.ts @@ -88,7 +88,6 @@ export interface SafePolygonOptions extends HandleCloseOptions {} * @see https://floating-ui.com/docs/useHover#safepolygon */ export function safePolygon(options: SafePolygonOptions = {}) { - const { blockPointerEvents = false } = options; const timeout = new Timeout(); const fn: HandleClose = ({ x, y, placement, elements, onClose, nodeId, tree }) => { @@ -126,13 +125,23 @@ export function safePolygon(options: SafePolygonOptions = {}) { onClose(); } + function hasOpenChildNode() { + return Boolean(tree && getNodeChildren(tree.nodesRef.current, nodeId).length > 0); + } + + function closeIfNoOpenChild() { + if (!hasOpenChildNode()) { + close(); + } + } + return function onMouseMove(event: MouseEvent) { timeout.clear(); const domReference = elements.domReference; const floating = elements.floating; if (!domReference || !floating || side == null || x == null || y == null) { - return undefined; + return; } const { clientX, clientY } = event; @@ -145,7 +154,7 @@ export function safePolygon(options: SafePolygonOptions = {}) { hasLanded = true; if (!isLeave) { - return undefined; + return; } } @@ -154,29 +163,19 @@ export function safePolygon(options: SafePolygonOptions = {}) { if (!isLeave) { hasLanded = true; - return undefined; + return; } } // Prevent overlapping floating element from being stuck in an open-close // loop: https://github.com/floating-ui/floating-ui/issues/1910 if (isLeave && isElement(event.relatedTarget) && contains(floating, event.relatedTarget)) { - return undefined; - } - - function hasOpenChildNode() { - return Boolean(tree && getNodeChildren(tree.nodesRef.current, nodeId).length > 0); - } - - function closeIfNoOpenChild() { - if (!hasOpenChildNode()) { - close(); - } + return; } // If any nested child is open, abort. if (hasOpenChildNode()) { - return undefined; + return; } const refRect = domReference.getBoundingClientRect(); @@ -201,7 +200,7 @@ export function safePolygon(options: SafePolygonOptions = {}) { (side === 'right' && x <= refRect.left + 1) ) { closeIfNoOpenChild(); - return undefined; + return; } // Ignore when the cursor is within the rectangular trough between the @@ -209,227 +208,133 @@ export function safePolygon(options: SafePolygonOptions = {}) { // which can start beyond the ref element's edge, traversing back and // forth from the ref to the floating element can cause it to close. This // ensures it always remains open in that case. - let isInsideTroughRect = false; - - switch (side) { - case 'top': - isInsideTroughRect = isInsideAxisAlignedRect( - clientX, - clientY, - left, - refRect.top + 1, - right, - rect.bottom - 1, - ); - break; - case 'bottom': - isInsideTroughRect = isInsideAxisAlignedRect( + const isVerticalSide = side === 'top' || side === 'bottom'; + const isInsideTroughRect = isVerticalSide + ? isInsideAxisAlignedRect( clientX, clientY, left, - rect.top + 1, + side === 'top' ? refRect.top + 1 : rect.top + 1, right, - refRect.bottom - 1, - ); - break; - case 'left': - isInsideTroughRect = isInsideAxisAlignedRect( - clientX, - clientY, - rect.right - 1, - bottom, - refRect.left + 1, - top, - ); - break; - case 'right': - isInsideTroughRect = isInsideAxisAlignedRect( + side === 'top' ? rect.bottom - 1 : refRect.bottom - 1, + ) + : isInsideAxisAlignedRect( clientX, clientY, - refRect.right - 1, + side === 'left' ? rect.right - 1 : refRect.right - 1, bottom, - rect.left + 1, + side === 'left' ? refRect.left + 1 : rect.left + 1, top, ); - break; - default: - } if (isInsideTroughRect) { - return undefined; + return; } if (hasLanded && !isInsideRect(clientX, clientY, refRect)) { closeIfNoOpenChild(); - return undefined; + return; } if (!isLeave && isCursorMovingSlowly(clientX, clientY)) { closeIfNoOpenChild(); - return undefined; + return; } - let isInsidePolygon = false; - - switch (side) { - case 'top': { - const cursorXOffset = isFloatingWider ? POLYGON_BUFFER / 2 : POLYGON_BUFFER * 4; - const cursorPointOneX = isFloatingWider - ? x + cursorXOffset - : cursorLeaveFromRight + const isInsidePolygon = isVerticalSide + ? (() => { + const cursorXOffset = isFloatingWider ? POLYGON_BUFFER / 2 : POLYGON_BUFFER * 4; + const cursorPointOneX = isFloatingWider ? x + cursorXOffset - : x - cursorXOffset; - const cursorPointTwoX = isFloatingWider - ? x - cursorXOffset - : cursorLeaveFromRight - ? x + cursorXOffset - : x - cursorXOffset; - const cursorPointY = y + POLYGON_BUFFER + 1; - - const commonYLeft = cursorLeaveFromRight - ? rect.bottom - POLYGON_BUFFER - : isFloatingWider - ? rect.bottom - POLYGON_BUFFER - : rect.top; - const commonYRight = cursorLeaveFromRight - ? isFloatingWider - ? rect.bottom - POLYGON_BUFFER - : rect.top - : rect.bottom - POLYGON_BUFFER; - - isInsidePolygon = isPointInQuadrilateral( - clientX, - clientY, - cursorPointOneX, - cursorPointY, - cursorPointTwoX, - cursorPointY, - rect.left, - commonYLeft, - rect.right, - commonYRight, - ); - break; - } - case 'bottom': { - const cursorXOffset = isFloatingWider ? POLYGON_BUFFER / 2 : POLYGON_BUFFER * 4; - const cursorPointOneX = isFloatingWider - ? x + cursorXOffset - : cursorLeaveFromRight - ? x + cursorXOffset - : x - cursorXOffset; - const cursorPointTwoX = isFloatingWider - ? x - cursorXOffset - : cursorLeaveFromRight - ? x + cursorXOffset - : x - cursorXOffset; - const cursorPointY = y - POLYGON_BUFFER; - - const commonYLeft = cursorLeaveFromRight - ? rect.top + POLYGON_BUFFER - : isFloatingWider - ? rect.top + POLYGON_BUFFER - : rect.bottom; - const commonYRight = cursorLeaveFromRight - ? isFloatingWider - ? rect.top + POLYGON_BUFFER - : rect.bottom - : rect.top + POLYGON_BUFFER; - - isInsidePolygon = isPointInQuadrilateral( - clientX, - clientY, - cursorPointOneX, - cursorPointY, - cursorPointTwoX, - cursorPointY, - rect.left, - commonYLeft, - rect.right, - commonYRight, - ); - break; - } - case 'left': { - const cursorYOffset = isFloatingTaller ? POLYGON_BUFFER / 2 : POLYGON_BUFFER * 4; - const cursorPointOneY = isFloatingTaller - ? y + cursorYOffset - : cursorLeaveFromBottom - ? y + cursorYOffset - : y - cursorYOffset; - const cursorPointTwoY = isFloatingTaller - ? y - cursorYOffset - : cursorLeaveFromBottom + : cursorLeaveFromRight + ? x + cursorXOffset + : x - cursorXOffset; + const cursorPointTwoX = isFloatingWider + ? x - cursorXOffset + : cursorLeaveFromRight + ? x + cursorXOffset + : x - cursorXOffset; + const cursorPointY = side === 'top' ? y + POLYGON_BUFFER + 1 : y - POLYGON_BUFFER; + const commonY = + side === 'top' ? rect.bottom - POLYGON_BUFFER : rect.top + POLYGON_BUFFER; + const oppositeY = side === 'top' ? rect.top : rect.bottom; + const commonYLeft = cursorLeaveFromRight + ? commonY + : isFloatingWider + ? commonY + : oppositeY; + const commonYRight = cursorLeaveFromRight + ? isFloatingWider + ? commonY + : oppositeY + : commonY; + + return isPointInQuadrilateral( + clientX, + clientY, + cursorPointOneX, + cursorPointY, + cursorPointTwoX, + cursorPointY, + rect.left, + commonYLeft, + rect.right, + commonYRight, + ); + })() + : (() => { + const cursorYOffset = isFloatingTaller ? POLYGON_BUFFER / 2 : POLYGON_BUFFER * 4; + const cursorPointOneY = isFloatingTaller ? y + cursorYOffset - : y - cursorYOffset; - const cursorPointX = x + POLYGON_BUFFER + 1; - - const commonXTop = cursorLeaveFromBottom - ? rect.right - POLYGON_BUFFER - : isFloatingTaller - ? rect.right - POLYGON_BUFFER - : rect.left; - const commonXBottom = cursorLeaveFromBottom - ? isFloatingTaller - ? rect.right - POLYGON_BUFFER - : rect.left - : rect.right - POLYGON_BUFFER; - - isInsidePolygon = isPointInQuadrilateral( - clientX, - clientY, - commonXTop, - rect.top, - commonXBottom, - rect.bottom, - cursorPointX, - cursorPointOneY, - cursorPointX, - cursorPointTwoY, - ); - break; - } - case 'right': { - const cursorYOffset = isFloatingTaller ? POLYGON_BUFFER / 2 : POLYGON_BUFFER * 4; - const cursorPointOneY = isFloatingTaller - ? y + cursorYOffset - : cursorLeaveFromBottom - ? y + cursorYOffset - : y - cursorYOffset; - const cursorPointTwoY = isFloatingTaller - ? y - cursorYOffset - : cursorLeaveFromBottom - ? y + cursorYOffset - : y - cursorYOffset; - const cursorPointX = x - POLYGON_BUFFER; - - const commonXTop = cursorLeaveFromBottom - ? rect.left + POLYGON_BUFFER - : isFloatingTaller - ? rect.left + POLYGON_BUFFER - : rect.right; - const commonXBottom = cursorLeaveFromBottom - ? isFloatingTaller - ? rect.left + POLYGON_BUFFER - : rect.right - : rect.left + POLYGON_BUFFER; - - isInsidePolygon = isPointInQuadrilateral( - clientX, - clientY, - cursorPointX, - cursorPointOneY, - cursorPointX, - cursorPointTwoY, - commonXTop, - rect.top, - commonXBottom, - rect.bottom, - ); - break; - } - default: - } + : cursorLeaveFromBottom + ? y + cursorYOffset + : y - cursorYOffset; + const cursorPointTwoY = isFloatingTaller + ? y - cursorYOffset + : cursorLeaveFromBottom + ? y + cursorYOffset + : y - cursorYOffset; + const cursorPointX = side === 'left' ? x + POLYGON_BUFFER + 1 : x - POLYGON_BUFFER; + const commonX = + side === 'left' ? rect.right - POLYGON_BUFFER : rect.left + POLYGON_BUFFER; + const oppositeX = side === 'left' ? rect.left : rect.right; + const commonXTop = cursorLeaveFromBottom + ? commonX + : isFloatingTaller + ? commonX + : oppositeX; + const commonXBottom = cursorLeaveFromBottom + ? isFloatingTaller + ? commonX + : oppositeX + : commonX; + + return side === 'left' + ? isPointInQuadrilateral( + clientX, + clientY, + commonXTop, + rect.top, + commonXBottom, + rect.bottom, + cursorPointX, + cursorPointOneY, + cursorPointX, + cursorPointTwoY, + ) + : isPointInQuadrilateral( + clientX, + clientY, + cursorPointX, + cursorPointOneY, + cursorPointX, + cursorPointTwoY, + commonXTop, + rect.top, + commonXBottom, + rect.bottom, + ); + })(); if (!isInsidePolygon) { closeIfNoOpenChild(); @@ -437,15 +342,12 @@ export function safePolygon(options: SafePolygonOptions = {}) { timeout.start(40, closeIfNoOpenChild); } - return undefined; + return; }; }; // eslint-disable-next-line no-underscore-dangle - fn.__options = { - ...options, - blockPointerEvents, - }; + fn.__options = options; return fn; } diff --git a/packages/react/src/floating-ui-react/types.ts b/packages/react/src/floating-ui-react/types.ts index 63a036a45ed..84a44e34ec1 100644 --- a/packages/react/src/floating-ui-react/types.ts +++ b/packages/react/src/floating-ui-react/types.ts @@ -12,7 +12,7 @@ import type { FloatingRootStore } from './components/FloatingRootStore'; export * from '.'; export type { FloatingDelayGroupProps } from './components/FloatingDelayGroup'; export type { FloatingFocusManagerProps } from './components/FloatingFocusManager'; -export type { UseFloatingPortalNodeProps } from './components/FloatingPortal'; +export type { UseFloatingPortalNodeProps } from './components/useFloatingPortalNode'; export type { UseClientPointProps } from './hooks/useClientPoint'; export type { UseDismissProps } from './hooks/useDismiss'; export type { UseFocusProps } from './hooks/useFocus'; diff --git a/packages/react/src/floating-ui-react/utils.ts b/packages/react/src/floating-ui-react/utils.ts index c185bdd5f07..b9d86e31239 100644 --- a/packages/react/src/floating-ui-react/utils.ts +++ b/packages/react/src/floating-ui-react/utils.ts @@ -2,4 +2,5 @@ export * from './utils/element'; export * from './utils/nodes'; export * from './utils/event'; export * from './utils/composite'; +export * from './utils/visibility'; export * from './utils/tabbable'; diff --git a/packages/react/src/floating-ui-react/utils/composite.test.ts b/packages/react/src/floating-ui-react/utils/composite.test.ts index 66eec32fe9d..fbae4b41ab0 100644 --- a/packages/react/src/floating-ui-react/utils/composite.test.ts +++ b/packages/react/src/floating-ui-react/utils/composite.test.ts @@ -1,4 +1,4 @@ -import { isElementVisible, isHiddenByStyles } from './composite'; +import { isElementVisible, isHiddenByStyles } from './visibility'; afterEach(() => { document.body.innerHTML = ''; diff --git a/packages/react/src/floating-ui-react/utils/composite.ts b/packages/react/src/floating-ui-react/utils/composite.ts index f07f5c7acd8..81098d2c1b8 100644 --- a/packages/react/src/floating-ui-react/utils/composite.ts +++ b/packages/react/src/floating-ui-react/utils/composite.ts @@ -1,9 +1,11 @@ import { floor } from '@floating-ui/utils'; -import { getComputedStyle } from '@floating-ui/utils/dom'; import type { Dimensions } from '../types'; import { stopEvent } from './event'; import { ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, ARROW_UP } from './constants'; +import { isElementVisible, isHiddenByStyles } from './visibility'; + +export { isElementVisible, isHiddenByStyles }; type DisabledIndices = ReadonlyArray | ((index: number) => boolean); @@ -497,22 +499,3 @@ export function isListIndexDisabled( (element.hasAttribute('disabled') || element.getAttribute('aria-disabled') === 'true') ); } - -export function isHiddenByStyles(styles: CSSStyleDeclaration) { - return styles.visibility === 'hidden' || styles.visibility === 'collapse'; -} - -export function isElementVisible( - element: Element | null, - styles: CSSStyleDeclaration | null = element ? getComputedStyle(element) : null, -) { - if (!element || !element.isConnected || !styles || isHiddenByStyles(styles)) { - return false; - } - - if (typeof element.checkVisibility === 'function') { - return element.checkVisibility(); - } - - return styles.display !== 'none' && styles.display !== 'contents'; -} diff --git a/packages/react/src/floating-ui-react/utils/element.ts b/packages/react/src/floating-ui-react/utils/element.ts index 7e3268b5420..871d92b5256 100644 --- a/packages/react/src/floating-ui-react/utils/element.ts +++ b/packages/react/src/floating-ui-react/utils/element.ts @@ -1,7 +1,7 @@ import { isElement, isHTMLElement } from '@floating-ui/utils/dom'; import { isJSDOM } from '@base-ui/utils/detectBrowser'; import { FOCUSABLE_ATTRIBUTE, TYPEABLE_SELECTOR } from './constants'; -import { type PopupTriggerMap } from '../../utils/popups'; +import { type PopupTriggerMap } from '../../utils/popups/popupTriggerMap'; import { activeElement, contains, getTarget } from '../../internals/shadowDom'; export { activeElement, contains, getTarget }; diff --git a/packages/react/src/floating-ui-react/utils/getEmptyRootContext.ts b/packages/react/src/floating-ui-react/utils/getEmptyRootContext.ts index 0c1e0e6ef02..aaaed779bd7 100644 --- a/packages/react/src/floating-ui-react/utils/getEmptyRootContext.ts +++ b/packages/react/src/floating-ui-react/utils/getEmptyRootContext.ts @@ -1,4 +1,4 @@ -import { PopupTriggerMap } from '../../utils/popups'; +import { PopupTriggerMap } from '../../utils/popups/popupTriggerMap'; import { FloatingRootStore } from '../components/FloatingRootStore'; import type { FloatingRootContext } from '../types'; diff --git a/packages/react/src/floating-ui-react/utils/markOthers.ts b/packages/react/src/floating-ui-react/utils/markOthers.ts index 13fc6cd89ae..07e8db5ae4e 100644 --- a/packages/react/src/floating-ui-react/utils/markOthers.ts +++ b/packages/react/src/floating-ui-react/utils/markOthers.ts @@ -27,13 +27,6 @@ const uncontrolledElementsSets: Record> = { let markerCounterMap = new WeakMap(); let lockCount = 0; -function getUncontrolledElementsSet(controlAttribute: ControlAttribute) { - return uncontrolledElementsSets[controlAttribute]; -} - -export const supportsInert = (): boolean => - typeof HTMLElement !== 'undefined' && 'inert' in HTMLElement.prototype; - function unwrapHost(node: Node | null): Element | null { if (!node) { return null; @@ -129,7 +122,7 @@ function applyAttributeToOthers( if (controlAttribute) { const map = counters[controlAttribute]; - const currentUncontrolledElementsSet = getUncontrolledElementsSet(controlAttribute); + const currentUncontrolledElementsSet = uncontrolledElementsSets[controlAttribute]; uncontrolledElementsSet = currentUncontrolledElementsSet; counterMap = map; const ariaLiveElements = correctElements( diff --git a/packages/react/src/floating-ui-react/utils/tabbable.ts b/packages/react/src/floating-ui-react/utils/tabbable.ts index 6bdddc7f42d..913673e851b 100644 --- a/packages/react/src/floating-ui-react/utils/tabbable.ts +++ b/packages/react/src/floating-ui-react/utils/tabbable.ts @@ -1,7 +1,7 @@ import { getComputedStyle, getNodeName, isHTMLElement, isShadowRoot } from '@floating-ui/utils/dom'; import { ownerDocument } from '@base-ui/utils/owner'; import { activeElement, contains } from './element'; -import { isElementVisible } from './composite'; +import { isElementVisible } from './visibility'; export type FocusableElement = HTMLElement | SVGElement; diff --git a/packages/react/src/floating-ui-react/utils/visibility.ts b/packages/react/src/floating-ui-react/utils/visibility.ts new file mode 100644 index 00000000000..53df735a6b0 --- /dev/null +++ b/packages/react/src/floating-ui-react/utils/visibility.ts @@ -0,0 +1,20 @@ +import { getComputedStyle } from '@floating-ui/utils/dom'; + +export function isHiddenByStyles(styles: CSSStyleDeclaration) { + return styles.visibility === 'hidden' || styles.visibility === 'collapse'; +} + +export function isElementVisible( + element: Element | null, + styles: CSSStyleDeclaration | null = element ? getComputedStyle(element) : null, +) { + if (!element || !element.isConnected || !styles || isHiddenByStyles(styles)) { + return false; + } + + if (typeof element.checkVisibility === 'function') { + return element.checkVisibility(); + } + + return styles.display !== 'none' && styles.display !== 'contents'; +} diff --git a/packages/react/src/internals/composite/list/useCompositeListItem.ts b/packages/react/src/internals/composite/list/useCompositeListItem.ts index 7b2cff79812..9345aaf8ffa 100644 --- a/packages/react/src/internals/composite/list/useCompositeListItem.ts +++ b/packages/react/src/internals/composite/list/useCompositeListItem.ts @@ -19,10 +19,12 @@ interface UseCompositeListItemReturnValue { index: number; } -export enum IndexGuessBehavior { - None, - GuessFromOrder, -} +export const IndexGuessBehavior = { + None: 0, + GuessFromOrder: 1, +} as const; + +export type IndexGuessBehavior = (typeof IndexGuessBehavior)[keyof typeof IndexGuessBehavior]; /** * Used to register a list item and its index (DOM position) in the `CompositeList`. diff --git a/packages/react/src/internals/useRenderElement.tsx b/packages/react/src/internals/useRenderElement.tsx index ab904e55ce8..7fa18270bd4 100644 --- a/packages/react/src/internals/useRenderElement.tsx +++ b/packages/react/src/internals/useRenderElement.tsx @@ -6,8 +6,6 @@ import { warn } from '@base-ui/utils/warn'; import { EMPTY_OBJECT } from '@base-ui/utils/empty'; import type { BaseUIComponentProps, ComponentRenderFn, HTMLProps } from './types'; import { getStateAttributesProps, StateAttributesMapping } from './getStateAttributesProps'; -import { resolveClassName } from '../utils/resolveClassName'; -import { resolveStyle } from '../utils/resolveStyle'; import { mergeProps, mergePropsN, mergeClassNames } from '../merge-props'; type IntrinsicTagName = keyof React.JSX.IntrinsicElements; @@ -63,8 +61,18 @@ function useRenderElementProps< enabled = true, } = params; - const className = enabled ? resolveClassName(classNameProp, state) : undefined; - const style = enabled ? resolveStyle(styleProp, state) : undefined; + // eslint-disable-next-line no-nested-ternary + const className = enabled + ? typeof classNameProp === 'function' + ? classNameProp(state) + : classNameProp + : undefined; + // eslint-disable-next-line no-nested-ternary + const style = enabled + ? typeof styleProp === 'function' + ? styleProp(state) + : styleProp + : undefined; const stateProps = enabled ? getStateAttributesProps(state, stateAttributesMapping) diff --git a/packages/react/src/menu/item/useMenuItem.ts b/packages/react/src/menu/item/useMenuItem.ts index 866664ba9b0..6e75a0bd3f6 100644 --- a/packages/react/src/menu/item/useMenuItem.ts +++ b/packages/react/src/menu/item/useMenuItem.ts @@ -3,7 +3,7 @@ import * as React from 'react'; import { useMergedRefs } from '@base-ui/utils/useMergedRefs'; import { useButton } from '../../internals/use-button'; import { mergeProps } from '../../merge-props'; -import { HTMLProps } from '../../internals/types'; +import type { HTMLProps } from '../../internals/types'; import { MenuStore } from '../store/MenuStore'; import { useMenuItemCommonProps } from './useMenuItemCommonProps'; diff --git a/packages/react/src/menu/item/useMenuItemCommonProps.ts b/packages/react/src/menu/item/useMenuItemCommonProps.ts index 946d861e523..956f15cbd66 100644 --- a/packages/react/src/menu/item/useMenuItemCommonProps.ts +++ b/packages/react/src/menu/item/useMenuItemCommonProps.ts @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; import { isMac } from '@base-ui/utils/detectBrowser'; -import { HTMLProps } from '../../internals/types'; +import type { HTMLProps } from '../../internals/types'; import { MenuStore } from '../store/MenuStore'; import { REASONS } from '../../internals/reasons'; import { useContextMenuRootContext } from '../../context-menu/root/ContextMenuRootContext'; diff --git a/packages/react/src/menu/popup/MenuPopup.tsx b/packages/react/src/menu/popup/MenuPopup.tsx index 0bf5ff6c3e8..4f1e4d1ce7e 100644 --- a/packages/react/src/menu/popup/MenuPopup.tsx +++ b/packages/react/src/menu/popup/MenuPopup.tsx @@ -1,7 +1,8 @@ 'use client'; import * as React from 'react'; import type { InteractionType } from '@base-ui/utils/useEnhancedClickHandler'; -import { FloatingFocusManager, useHoverFloatingInteraction } from '../../floating-ui-react'; +import { FloatingFocusManager } from '../../floating-ui-react/components/FloatingFocusManager'; +import { useHoverFloatingInteraction } from '../../floating-ui-react/hooks/useHoverFloatingInteraction'; 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 eed8f8fd8c5..f0296763fe3 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/components/FloatingPortal'; 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 74b3e84f806..e82f106d75b 100644 --- a/packages/react/src/menu/positioner/MenuPositioner.tsx +++ b/packages/react/src/menu/positioner/MenuPositioner.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { inertValue } from '@base-ui/utils/inertValue'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { useTimeout } from '@base-ui/utils/useTimeout'; -import { FloatingNode } from '../../floating-ui-react'; +import { FloatingNode } from '../../floating-ui-react/components/FloatingTree'; import { MenuPositionerContext } from './MenuPositionerContext'; import { useMenuRootContext } from '../root/MenuRootContext'; import type { MenuRoot } from '../root/MenuRoot'; diff --git a/packages/react/src/menu/root/MenuRoot.tsx b/packages/react/src/menu/root/MenuRoot.tsx index ead61132115..da08d6e3966 100644 --- a/packages/react/src/menu/root/MenuRoot.tsx +++ b/packages/react/src/menu/root/MenuRoot.tsx @@ -9,13 +9,13 @@ import { EMPTY_ARRAY, EMPTY_OBJECT } from '@base-ui/utils/empty'; import { fastComponent } from '@base-ui/utils/fastHooks'; import { FloatingTree, - useDismiss, useFloatingNodeId, useFloatingParentNodeId, - useListNavigation, - useTypeahead, - useSyncedFloatingRootContext, -} from '../../floating-ui-react'; +} from '../../floating-ui-react/components/FloatingTree'; +import { useDismissCore as useDismiss } from '../../floating-ui-react/hooks/useDismissCore'; +import { useListNavigationNoGrid } from '../../floating-ui-react/hooks/useListNavigation'; +import { useSyncedFloatingRootContext } from '../../floating-ui-react/hooks/useSyncedFloatingRootContext'; +import { useTypeahead } from '../../floating-ui-react/hooks/useTypeahead'; import { MenuRootContext, useMenuRootContext } from './MenuRootContext'; import { MenubarContext, useMenubarContext } from '../../menubar/MenubarContext'; import { TYPEAHEAD_RESET_MS } from '../../internals/constants'; @@ -35,11 +35,11 @@ import { MenuStore, type State as MenuStoreState } from '../store/MenuStore'; import { MenuHandle } from '../store/MenuHandle'; import { FOCUSABLE_POPUP_PROPS, - PayloadChildRenderFunction, useImplicitActiveTrigger, useOpenStateTransitions, usePopupInteractionProps, -} from '../../utils/popups'; + type PayloadChildRenderFunction, +} from '../../utils/popups/popupStoreUtils'; import { useMenuSubmenuRootContext } from '../submenu-root/MenuSubmenuRootContext'; /** @@ -409,7 +409,7 @@ export const MenuRoot = fastComponent(function MenuRoot(props: MenuRoot [store], ); - const listNavigation = useListNavigation(floatingRootContext, { + const listNavigation = useListNavigationNoGrid(floatingRootContext, { enabled: !disabled, listRef: store.context.itemDomElements, activeIndex, diff --git a/packages/react/src/menu/store/MenuStore.ts b/packages/react/src/menu/store/MenuStore.ts index 6c7e6177339..0a31668958c 100644 --- a/packages/react/src/menu/store/MenuStore.ts +++ b/packages/react/src/menu/store/MenuStore.ts @@ -1,18 +1,18 @@ import * as React from 'react'; -import { createSelector, ReactStore } from '@base-ui/utils/store'; +import { createSelector, ReactStore } from '@base-ui/utils/store/core'; import { EMPTY_OBJECT } from '@base-ui/utils/empty'; import type { InteractionType } from '@base-ui/utils/useEnhancedClickHandler'; import { useRefWithInit } from '@base-ui/utils/useRefWithInit'; import { MenuParent, MenuRoot } from '../root/MenuRoot'; import { FloatingTreeStore } from '../../floating-ui-react/components/FloatingTreeStore'; -import { HTMLProps } from '../../internals/types'; +import type { HTMLProps } from '../../internals/types'; import { createInitialPopupStoreState, PopupStoreContext, popupStoreSelectors, PopupStoreState, - PopupTriggerMap, -} from '../../utils/popups'; +} from '../../utils/popups/store'; +import { PopupTriggerMap } from '../../utils/popups/popupTriggerMap'; export type State = PopupStoreState & { disabled: boolean; @@ -127,16 +127,19 @@ export class MenuStore extends ReactStore< selectors, ); - // Set up propagation of state from parent menu if applicable. - this.unsubscribeParentListener = this.observe('parent', (parent) => { - this.unsubscribeParentListener?.(); + let parentStoreCleanup: (() => void) | null = null; + let observedParent = this.state.parent; + + const syncParent = (parent: MenuParent) => { + parentStoreCleanup?.(); + parentStoreCleanup = null; if (parent.type === 'menu') { let rootId = parent.store.select('rootId'); let floatingTreeRoot = parent.store.select('floatingTreeRoot'); let keyboardEventRelay = parent.store.select('keyboardEventRelay'); - this.unsubscribeParentListener = parent.store.subscribe(() => { + parentStoreCleanup = parent.store.subscribe(() => { const nextRootId = parent.store.select('rootId'); const nextFloatingTreeRoot = parent.store.select('floatingTreeRoot'); const nextKeyboardEventRelay = parent.store.select('keyboardEventRelay'); @@ -162,8 +165,17 @@ export class MenuStore extends ReactStore< if (parent.type !== undefined) { this.context.allowMouseUpTriggerRef = parent.context.allowMouseUpTriggerRef; } + }; + + syncParent(observedParent); + + this.subscribe((state) => { + if (state.parent === observedParent) { + return; + } - this.unsubscribeParentListener = null; + observedParent = state.parent; + syncParent(observedParent); }); } @@ -182,8 +194,6 @@ export class MenuStore extends ReactStore< return externalStore ?? internalStore; } - - private unsubscribeParentListener: (() => void) | null = null; } function createInitialState(): State { diff --git a/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.tsx b/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.tsx index df8a3a0ad63..25a532e85bc 100644 --- a/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.tsx +++ b/packages/react/src/menu/submenu-trigger/MenuSubmenuTrigger.tsx @@ -5,7 +5,8 @@ import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { warn } from '@base-ui/utils/warn'; import { SafeReact } from '@base-ui/utils/safeReact'; import { EMPTY_OBJECT } from '@base-ui/utils/empty'; -import { safePolygon, useClick, useHoverReferenceInteraction } from '../../floating-ui-react'; +import { useHoverReferenceInteraction } from '../../floating-ui-react/hooks/useHoverReferenceInteraction'; +import { safePolygon } from '../../floating-ui-react/safePolygon'; import { BaseUIComponentProps, NonNativeButtonProps } from '../../internals/types'; import { useMenuRootContext } from '../root/MenuRootContext'; import { useBaseUiId } from '../../internals/useBaseUiId'; @@ -14,7 +15,8 @@ import { useCompositeListItem } from '../../internals/composite/list/useComposit import { useMenuItem } from '../item/useMenuItem'; import { useRenderElement } from '../../internals/useRenderElement'; import { useMenuPositionerContext } from '../positioner/MenuPositionerContext'; -import { useTriggerRegistration } from '../../utils/popups'; +import { useTriggerRegistration } from '../../utils/popups/popupStoreUtils'; +import { useTriggerPress } from '../../utils/popups/useTriggerPress'; import { useMenuSubmenuRootContext } from '../submenu-root/MenuSubmenuRootContext'; /** @@ -144,7 +146,7 @@ export const MenuSubmenuTrigger = React.forwardRef(function MenuSubmenuTrigger( isClosing: () => store.select('transitionStatus') === 'ending', }); - const click = useClick(floatingRootContext, { + const click = useTriggerPress(floatingRootContext, { enabled: !disabled, event: 'mousedown', toggle: !openOnHover, diff --git a/packages/react/src/menu/trigger/MenuTrigger.tsx b/packages/react/src/menu/trigger/MenuTrigger.tsx index 9a2863121b4..58e64fd8e21 100644 --- a/packages/react/src/menu/trigger/MenuTrigger.tsx +++ b/packages/react/src/menu/trigger/MenuTrigger.tsx @@ -7,16 +7,15 @@ import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { EMPTY_OBJECT } from '@base-ui/utils/empty'; import { - safePolygon, - useClick, - useFloatingTree, - useFocus, - useHoverReferenceInteraction, useFloatingNodeId, useFloatingParentNodeId, -} from '../../floating-ui-react'; + useFloatingTree, +} from '../../floating-ui-react/components/FloatingTree'; +import { useFocus } from '../../floating-ui-react/hooks/useFocus'; +import { useHoverReferenceInteraction } from '../../floating-ui-react/hooks/useHoverReferenceInteraction'; +import { safePolygon } from '../../floating-ui-react/safePolygon'; import { FloatingTreeStore } from '../../floating-ui-react/components/FloatingTreeStore'; -import { contains } from '../../floating-ui-react/utils'; +import { contains } from '../../internals/shadowDom'; import { useMenuRootContext } from '../root/MenuRootContext'; import { pressableTriggerOpenStateMapping } from '../../utils/popupStateMapping'; import { useRenderElement } from '../../internals/useRenderElement'; @@ -26,7 +25,8 @@ import { getPseudoElementBounds } from '../../utils/getPseudoElementBounds'; import { CompositeItem } from '../../internals/composite/item/CompositeItem'; import { useCompositeRootContext } from '../../internals/composite/root/CompositeRootContext'; import { findRootOwnerId } from '../utils/findRootOwnerId'; -import { useTriggerDataForwarding } from '../../utils/popups'; +import { useTriggerDataForwarding } from '../../utils/popups/popupStoreUtils'; +import { useTriggerPress } from '../../utils/popups/useTriggerPress'; import { useTriggerFocusGuards } from '../../utils/popups/useTriggerFocusGuards'; import { useBaseUiId } from '../../internals/useBaseUiId'; import { REASONS } from '../../internals/reasons'; @@ -194,7 +194,7 @@ export const MenuTrigger = fastComponentRef(function MenuTrigger( // only when `isOpenedByThisTrigger` changes. const stickIfOpen = useStickIfOpen(isOpenedByThisTrigger, store.select('lastOpenChangeReason')); - const click = useClick(floatingRootContext, { + const click = useTriggerPress(floatingRootContext, { enabled: !disabled && parent.type !== 'context-menu', event: isOpenedByThisTrigger && isInMenubar ? 'click' : 'mousedown', toggle: true, diff --git a/packages/react/src/menubar/Menubar.tsx b/packages/react/src/menubar/Menubar.tsx index 1c6fa026b5a..66ee0a2622f 100644 --- a/packages/react/src/menubar/Menubar.tsx +++ b/packages/react/src/menubar/Menubar.tsx @@ -5,7 +5,7 @@ import { FloatingTree, useFloatingNodeId, useFloatingTree, -} from '../floating-ui-react'; +} from '../floating-ui-react/components/FloatingTree'; import { type MenuRoot } from '../menu/root/MenuRoot'; import { BaseUIComponentProps } from '../internals/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 46438b8d98d..a1e778fde96 100644 --- a/packages/react/src/navigation-menu/content/NavigationMenuContent.tsx +++ b/packages/react/src/navigation-menu/content/NavigationMenuContent.tsx @@ -4,7 +4,7 @@ import * as ReactDOM from 'react-dom'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { inertValue } from '@base-ui/utils/inertValue'; import { EMPTY_OBJECT } from '@base-ui/utils/empty'; -import { FloatingNode } from '../../floating-ui-react'; +import { FloatingNode } from '../../floating-ui-react/components/FloatingTree'; import { contains, getTarget } from '../../floating-ui-react/utils'; import type { BaseUIComponentProps, HTMLProps } from '../../internals/types'; import { diff --git a/packages/react/src/navigation-menu/link/NavigationMenuLink.tsx b/packages/react/src/navigation-menu/link/NavigationMenuLink.tsx index 49ed79abb15..fbb2d21253c 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/components/FloatingTree'; import type { BaseUIComponentProps, HTMLProps } from '../../internals/types'; import { useNavigationMenuRootContext, diff --git a/packages/react/src/navigation-menu/list/NavigationMenuDismissContext.ts b/packages/react/src/navigation-menu/list/NavigationMenuDismissContext.ts index de78f2c630e..86f23173b02 100644 --- a/packages/react/src/navigation-menu/list/NavigationMenuDismissContext.ts +++ b/packages/react/src/navigation-menu/list/NavigationMenuDismissContext.ts @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import type { ElementProps } from '../../floating-ui-react'; +import type { ElementProps } from '../../floating-ui-react/types'; export const NavigationMenuDismissContext = React.createContext( undefined, diff --git a/packages/react/src/navigation-menu/list/NavigationMenuList.tsx b/packages/react/src/navigation-menu/list/NavigationMenuList.tsx index abf4572862e..6ff5a2a9fb6 100644 --- a/packages/react/src/navigation-menu/list/NavigationMenuList.tsx +++ b/packages/react/src/navigation-menu/list/NavigationMenuList.tsx @@ -1,7 +1,8 @@ 'use client'; import * as React from 'react'; import { EMPTY_OBJECT } from '@base-ui/utils/empty'; -import { useDismiss, useHoverFloatingInteraction } from '../../floating-ui-react'; +import { useDismissCore as useDismiss } from '../../floating-ui-react/hooks/useDismissCore'; +import { useHoverFloatingInteraction } from '../../floating-ui-react/hooks/useHoverFloatingInteraction'; import { getTarget } from '../../floating-ui-react/utils'; import type { BaseUIComponentProps, HTMLProps } from '../../internals/types'; import { CompositeRoot } from '../../internals/composite/root/CompositeRoot'; diff --git a/packages/react/src/navigation-menu/portal/NavigationMenuPortal.tsx b/packages/react/src/navigation-menu/portal/NavigationMenuPortal.tsx index 8a4b5b68422..84ba7a7ade6 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/components/FloatingPortal'; import { useNavigationMenuRootContext } from '../root/NavigationMenuRootContext'; import { NavigationMenuPortalContext } from './NavigationMenuPortalContext'; diff --git a/packages/react/src/navigation-menu/root/NavigationMenuRoot.tsx b/packages/react/src/navigation-menu/root/NavigationMenuRoot.tsx index 929adfa34be..80526f44634 100644 --- a/packages/react/src/navigation-menu/root/NavigationMenuRoot.tsx +++ b/packages/react/src/navigation-menu/root/NavigationMenuRoot.tsx @@ -9,8 +9,8 @@ import { FloatingTree, useFloatingNodeId, useFloatingParentNodeId, - type FloatingRootContext, -} from '../../floating-ui-react'; +} from '../../floating-ui-react/components/FloatingTree'; +import type { FloatingRootContext } from '../../floating-ui-react/types'; import { activeElement, contains } from '../../floating-ui-react/utils'; import { useRenderElement } from '../../internals/useRenderElement'; import { diff --git a/packages/react/src/navigation-menu/root/NavigationMenuRootContext.ts b/packages/react/src/navigation-menu/root/NavigationMenuRootContext.ts index 93ed4737755..9038c58db14 100644 --- a/packages/react/src/navigation-menu/root/NavigationMenuRootContext.ts +++ b/packages/react/src/navigation-menu/root/NavigationMenuRootContext.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/types'; import type { TransitionStatus } from '../../internals/useTransitionStatus'; import type { NavigationMenuRoot } from './NavigationMenuRoot'; diff --git a/packages/react/src/navigation-menu/trigger/NavigationMenuTrigger.tsx b/packages/react/src/navigation-menu/trigger/NavigationMenuTrigger.tsx index a4c7323fb09..c680e337c17 100644 --- a/packages/react/src/navigation-menu/trigger/NavigationMenuTrigger.tsx +++ b/packages/react/src/navigation-menu/trigger/NavigationMenuTrigger.tsx @@ -10,13 +10,10 @@ import { useTimeout } from '@base-ui/utils/useTimeout'; import { useAnimationFrame } from '@base-ui/utils/useAnimationFrame'; import { useValueAsRef } from '@base-ui/utils/useValueAsRef'; import { EMPTY_ARRAY } from '@base-ui/utils/empty'; -import { - safePolygon, - useClick, - useFloatingRootContext, - useFloatingTree, - useHoverReferenceInteraction, -} from '../../floating-ui-react'; +import { useFloatingTree } from '../../floating-ui-react/components/FloatingTree'; +import { useFloatingRootContext } from '../../floating-ui-react/hooks/useFloatingRootContext'; +import { useHoverReferenceInteraction } from '../../floating-ui-react/hooks/useHoverReferenceInteraction'; +import { safePolygon } from '../../floating-ui-react/safePolygon'; import { applySafePolygonPointerEventsMutation, clearSafePolygonPointerEventsMutation, @@ -54,6 +51,7 @@ import { useNavigationMenuDismissContext } from '../list/NavigationMenuDismissCo import { NavigationMenuPopupCssVars } from '../popup/NavigationMenuPopupCssVars'; import { NavigationMenuPositionerCssVars } from '../positioner/NavigationMenuPositionerCssVars'; import { mergeProps } from '../../merge-props'; +import { useTriggerPress } from '../../utils/popups/useTriggerPress'; const DEFAULT_SIZE = { width: 0, height: 0 }; @@ -622,7 +620,7 @@ export const NavigationMenuTrigger = React.forwardRef(function NavigationMenuTri [hoverProps], ); - const click = useClick(context, { + const click = useTriggerPress(context, { enabled: interactionsEnabled, stickIfOpen, toggle: isActiveItem, diff --git a/packages/react/src/navigation-menu/utils/isOutsideMenuEvent.ts b/packages/react/src/navigation-menu/utils/isOutsideMenuEvent.ts index cc4400be2fc..dd7b7355c47 100644 --- a/packages/react/src/navigation-menu/utils/isOutsideMenuEvent.ts +++ b/packages/react/src/navigation-menu/utils/isOutsideMenuEvent.ts @@ -1,4 +1,4 @@ -import { FloatingTreeType } from '../../floating-ui-react'; +import type { FloatingTreeType } from '../../floating-ui-react/types'; import { contains, getNodeChildren } from '../../floating-ui-react/utils'; interface Targets { diff --git a/packages/react/src/popover/popup/PopoverPopup.tsx b/packages/react/src/popover/popup/PopoverPopup.tsx index 669e58b872d..dfa5157cbeb 100644 --- a/packages/react/src/popover/popup/PopoverPopup.tsx +++ b/packages/react/src/popover/popup/PopoverPopup.tsx @@ -2,7 +2,8 @@ import * as React from 'react'; import { InteractionType } from '@base-ui/utils/useEnhancedClickHandler'; import { isHTMLElement } from '@floating-ui/utils/dom'; -import { FloatingFocusManager, useHoverFloatingInteraction } from '../../floating-ui-react'; +import { FloatingFocusManager } from '../../floating-ui-react/components/FloatingFocusManager'; +import { useHoverFloatingInteraction } from '../../floating-ui-react/hooks/useHoverFloatingInteraction'; import { usePopoverRootContext } from '../root/PopoverRootContext'; import { usePopoverPositionerContext } from '../positioner/PopoverPositionerContext'; import type { Side, Align } from '../../utils/useAnchorPositioning'; @@ -18,7 +19,7 @@ import { COMPOSITE_KEYS } from '../../internals/composite/composite'; import { useToolbarRootContext } from '../../toolbar/root/ToolbarRootContext'; import { getDisabledMountTransitionStyles } from '../../utils/getDisabledMountTransitionStyles'; import { ClosePartProvider, useClosePartCount } from '../../utils/closePart'; -import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups'; +import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups/popupStoreUtils'; const stateAttributesMapping: StateAttributesMapping = { ...baseMapping, diff --git a/packages/react/src/popover/portal/PopoverPortal.tsx b/packages/react/src/popover/portal/PopoverPortal.tsx index afefb2236d6..646cb39d70c 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/components/FloatingPortal'; 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 8d7f7ccb141..c234dda9bdc 100644 --- a/packages/react/src/popover/positioner/PopoverPositioner.tsx +++ b/packages/react/src/popover/positioner/PopoverPositioner.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { inertValue } from '@base-ui/utils/inertValue'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; -import { FloatingNode, useFloatingNodeId } from '../../floating-ui-react'; +import { FloatingNode, useFloatingNodeId } from '../../floating-ui-react/components/FloatingTree'; import { usePopoverRootContext } from '../root/PopoverRootContext'; import { PopoverPositionerContext } from './PopoverPositionerContext'; import { diff --git a/packages/react/src/popover/positioner/PopoverPositionerContext.ts b/packages/react/src/popover/positioner/PopoverPositionerContext.ts index 591ac9e849b..7792e4fa91d 100644 --- a/packages/react/src/popover/positioner/PopoverPositionerContext.ts +++ b/packages/react/src/popover/positioner/PopoverPositionerContext.ts @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; import type { Side, Align } from '../../utils/useAnchorPositioning'; -import type { FloatingContext } from '../../floating-ui-react'; +import type { FloatingContext } from '../../floating-ui-react/types'; export interface PopoverPositionerContext { side: Side; diff --git a/packages/react/src/popover/root/PopoverRoot.tsx b/packages/react/src/popover/root/PopoverRoot.tsx index 5394c46d766..a4cc787550c 100644 --- a/packages/react/src/popover/root/PopoverRoot.tsx +++ b/packages/react/src/popover/root/PopoverRoot.tsx @@ -2,7 +2,11 @@ import * as React from 'react'; import { EMPTY_OBJECT } from '@base-ui/utils/empty'; import { useOnFirstRender } from '@base-ui/utils/useOnFirstRender'; -import { useDismiss, FloatingTree, useFloatingParentNodeId } from '../../floating-ui-react'; +import { + FloatingTree, + useFloatingParentNodeId, +} from '../../floating-ui-react/components/FloatingTree'; +import { useDismissCore as useDismiss } from '../../floating-ui-react/hooks/useDismissCore'; import { PopoverRootContext, usePopoverRootContext } from './PopoverRootContext'; import { PopoverStore } from '../store/PopoverStore'; import { PopoverHandle } from '../store/PopoverHandle'; @@ -18,7 +22,7 @@ import { usePopupInteractionProps, usePopupRootSync, type PayloadChildRenderFunction, -} from '../../utils/popups'; +} from '../../utils/popups/popupStoreUtils'; import { mergeProps } from '../../merge-props'; function PopoverRootComponent({ props }: { props: PopoverRoot.Props }) { diff --git a/packages/react/src/popover/store/PopoverStore.ts b/packages/react/src/popover/store/PopoverStore.ts index 0e1c6157a6b..f1863e8aa17 100644 --- a/packages/react/src/popover/store/PopoverStore.ts +++ b/packages/react/src/popover/store/PopoverStore.ts @@ -2,7 +2,7 @@ 'use client'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import { ReactStore, createSelector } from '@base-ui/utils/store'; +import { ReactStore, createSelector } from '@base-ui/utils/store/core'; import { Timeout } from '@base-ui/utils/useTimeout'; import { type InteractionType } from '@base-ui/utils/useEnhancedClickHandler'; import { type PopoverRoot } from '../root/PopoverRoot'; @@ -13,10 +13,9 @@ import { PopupStoreContext, popupStoreSelectors, PopupStoreState, - PopupTriggerMap, - setOpenTriggerState, - usePopupStore, -} from '../../utils/popups'; +} from '../../utils/popups/store'; +import { PopupTriggerMap } from '../../utils/popups/popupTriggerMap'; +import { setOpenTriggerState, usePopupStore } from '../../utils/popups/popupStoreUtils'; import { PATIENT_CLICK_THRESHOLD } from '../../internals/constants'; export type State = PopupStoreState & { diff --git a/packages/react/src/popover/trigger/PopoverTrigger.tsx b/packages/react/src/popover/trigger/PopoverTrigger.tsx index 0907358d92e..576cbad233d 100644 --- a/packages/react/src/popover/trigger/PopoverTrigger.tsx +++ b/packages/react/src/popover/trigger/PopoverTrigger.tsx @@ -10,13 +10,15 @@ import { import { StateAttributesMapping } from '../../internals/getStateAttributesProps'; import { useRenderElement } from '../../internals/useRenderElement'; import { CLICK_TRIGGER_IDENTIFIER } from '../../internals/constants'; -import { safePolygon, useClick, useHoverReferenceInteraction } from '../../floating-ui-react'; +import { useHoverReferenceInteraction } from '../../floating-ui-react/hooks/useHoverReferenceInteraction'; +import { safePolygon } from '../../floating-ui-react/safePolygon'; import { OPEN_DELAY } from '../utils/constants'; import { PopoverHandle } from '../store/PopoverHandle'; import { useBaseUiId } from '../../internals/useBaseUiId'; import { FocusGuard } from '../../utils/FocusGuard'; import { REASONS } from '../../internals/reasons'; -import { useTriggerDataForwarding } from '../../utils/popups'; +import { useTriggerDataForwarding } from '../../utils/popups/popupStoreUtils'; +import { useClickTriggerPress } from '../../utils/popups/useTriggerPress'; import { useTriggerFocusGuards } from '../../utils/popups/useTriggerFocusGuards'; import { useOpenMethodTriggerProps } from '../../utils/useOpenInteractionType'; @@ -95,7 +97,7 @@ export const PopoverTrigger = React.forwardRef(function PopoverTrigger( isClosing: () => store.select('transitionStatus') === 'ending', }); - const click = useClick(floatingContext, { enabled: floatingContext != null, stickIfOpen }); + const click = useClickTriggerPress(floatingContext, stickIfOpen); const interactionTypeProps = useOpenMethodTriggerProps( () => store.select('open'), (interactionType) => { diff --git a/packages/react/src/preview-card/popup/PreviewCardPopup.tsx b/packages/react/src/preview-card/popup/PreviewCardPopup.tsx index 309377b645d..915fa166d12 100644 --- a/packages/react/src/preview-card/popup/PreviewCardPopup.tsx +++ b/packages/react/src/preview-card/popup/PreviewCardPopup.tsx @@ -12,7 +12,7 @@ import { transitionStatusMapping } from '../../internals/stateAttributesMapping' import { useOpenChangeComplete } from '../../internals/useOpenChangeComplete'; import { useRenderElement } from '../../internals/useRenderElement'; import { getDisabledMountTransitionStyles } from '../../utils/getDisabledMountTransitionStyles'; -import { useHoverFloatingInteraction } from '../../floating-ui-react'; +import { useHoverFloatingInteraction } from '../../floating-ui-react/hooks/useHoverFloatingInteraction'; const stateAttributesMapping: StateAttributesMapping = { ...baseMapping, diff --git a/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx b/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx index 94e0b0bfd81..1194905f760 100644 --- a/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx +++ b/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { usePreviewCardRootContext } from '../root/PreviewCardContext'; import { PreviewCardPositionerContext } from './PreviewCardPositionerContext'; -import { FloatingNode, useFloatingNodeId } from '../../floating-ui-react'; +import { FloatingNode, useFloatingNodeId } from '../../floating-ui-react/components/FloatingTree'; import { type Side, type Align, @@ -15,7 +15,7 @@ import { usePreviewCardPortalContext } from '../portal/PreviewCardPortalContext' import { POPUP_COLLISION_AVOIDANCE } from '../../internals/constants'; import { adaptiveOrigin } from '../../utils/adaptiveOriginMiddleware'; import { usePositioner } from '../../utils/usePositioner'; -import { createInlineMiddleware } from '../../utils/popups'; +import { createInlineMiddleware } from '../../utils/popups/inlineRect'; /** * Positions the popup against the trigger. diff --git a/packages/react/src/preview-card/root/PreviewCardRoot.tsx b/packages/react/src/preview-card/root/PreviewCardRoot.tsx index 4477d3c33e9..6bc418e838e 100644 --- a/packages/react/src/preview-card/root/PreviewCardRoot.tsx +++ b/packages/react/src/preview-card/root/PreviewCardRoot.tsx @@ -4,7 +4,8 @@ import { fastComponent } from '@base-ui/utils/fastHooks'; import { EMPTY_OBJECT } from '@base-ui/utils/empty'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { useOnFirstRender } from '@base-ui/utils/useOnFirstRender'; -import { useDismiss, FloatingTree } from '../../floating-ui-react'; +import { FloatingTree } from '../../floating-ui-react/components/FloatingTree'; +import { useDismissCore as useDismiss } from '../../floating-ui-react/hooks/useDismissCore'; import { PreviewCardRootContext, usePreviewCardRootContext } from './PreviewCardContext'; import { createChangeEventDetails, @@ -14,11 +15,11 @@ import { REASONS } from '../../internals/reasons'; import { PreviewCardStore } from '../store/PreviewCardStore'; import { FOCUSABLE_POPUP_PROPS, - PayloadChildRenderFunction, useImplicitActiveTrigger, useOpenStateTransitions, usePopupInteractionProps, -} from '../../utils/popups'; + type PayloadChildRenderFunction, +} from '../../utils/popups/popupStoreUtils'; import { PreviewCardHandle } from '../store/PreviewCardHandle'; import { mergeProps } from '../../merge-props'; diff --git a/packages/react/src/preview-card/store/PreviewCardStore.ts b/packages/react/src/preview-card/store/PreviewCardStore.ts index d268004ddb4..6032d005dba 100644 --- a/packages/react/src/preview-card/store/PreviewCardStore.ts +++ b/packages/react/src/preview-card/store/PreviewCardStore.ts @@ -1,18 +1,16 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import { createSelector, ReactStore } from '@base-ui/utils/store'; +import { createSelector, ReactStore } from '@base-ui/utils/store/core'; import { createPopupFloatingRootContext, createInitialPopupStoreState, - InlineRectCoords, PopupStoreContext, popupStoreSelectors, PopupStoreState, - PopupTriggerMap, - setOpenTriggerState, - updateInlineRectCoords, - usePopupStore, -} from '../../utils/popups'; +} from '../../utils/popups/store'; +import { InlineRectCoords, updateInlineRectCoords } from '../../utils/popups/inlineRect'; +import { PopupTriggerMap } from '../../utils/popups/popupTriggerMap'; +import { setOpenTriggerState, usePopupStore } from '../../utils/popups/popupStoreUtils'; import { type PreviewCardRoot } from '../root/PreviewCardRoot'; import { REASONS } from '../../internals/reasons'; import { CLOSE_DELAY } from '../utils/constants'; diff --git a/packages/react/src/preview-card/trigger/PreviewCardTrigger.tsx b/packages/react/src/preview-card/trigger/PreviewCardTrigger.tsx index 7f1854066a1..42b8c342816 100644 --- a/packages/react/src/preview-card/trigger/PreviewCardTrigger.tsx +++ b/packages/react/src/preview-card/trigger/PreviewCardTrigger.tsx @@ -8,9 +8,12 @@ import { triggerOpenStateMapping } from '../../utils/popupStateMapping'; import { useRenderElement } from '../../internals/useRenderElement'; import { useBaseUiId } from '../../internals/useBaseUiId'; import { PreviewCardHandle } from '../store/PreviewCardHandle'; -import { getInlineRectTriggerProps, useTriggerDataForwarding } from '../../utils/popups'; +import { getInlineRectTriggerProps } from '../../utils/popups/inlineRect'; +import { useTriggerDataForwarding } from '../../utils/popups/popupStoreUtils'; import { CLOSE_DELAY, OPEN_DELAY } from '../utils/constants'; -import { safePolygon, useFocus, useHoverReferenceInteraction } from '../../floating-ui-react'; +import { useFocus } from '../../floating-ui-react/hooks/useFocus'; +import { useHoverReferenceInteraction } from '../../floating-ui-react/hooks/useHoverReferenceInteraction'; +import { safePolygon } from '../../floating-ui-react/safePolygon'; /** * A link that opens the preview card. diff --git a/packages/react/src/scroll-area/viewport/ScrollAreaViewport.tsx b/packages/react/src/scroll-area/viewport/ScrollAreaViewport.tsx index ad85997d7e6..22b0605bac1 100644 --- a/packages/react/src/scroll-area/viewport/ScrollAreaViewport.tsx +++ b/packages/react/src/scroll-area/viewport/ScrollAreaViewport.tsx @@ -245,7 +245,9 @@ export const ScrollAreaViewport = React.forwardRef(function ScrollAreaViewport( thumbXEl.style.transform = `translate3d(${thumbOffsetX}px,0,0)`; } - const overflowMetricsPx: Array<[ScrollAreaViewportCssVars, number]> = [ + const overflowMetricsPx: Array< + [(typeof ScrollAreaViewportCssVars)[keyof typeof ScrollAreaViewportCssVars], number] + > = [ [ScrollAreaViewportCssVars.scrollAreaOverflowXStart, scrollLeftFromStart], [ScrollAreaViewportCssVars.scrollAreaOverflowXEnd, scrollLeftFromEnd], [ScrollAreaViewportCssVars.scrollAreaOverflowYStart, scrollTopFromStart], diff --git a/packages/react/src/select/arrow/SelectArrow.tsx b/packages/react/src/select/arrow/SelectArrow.tsx index 00ec8eb3931..c8367862c10 100644 --- a/packages/react/src/select/arrow/SelectArrow.tsx +++ b/packages/react/src/select/arrow/SelectArrow.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { useSelectPositionerContext } from '../positioner/SelectPositionerContext'; import { useSelectRootContext } from '../root/SelectRootContext'; import type { BaseUIComponentProps } from '../../internals/types'; diff --git a/packages/react/src/select/backdrop/SelectBackdrop.tsx b/packages/react/src/select/backdrop/SelectBackdrop.tsx index bceaf51aeba..18b21cb8542 100644 --- a/packages/react/src/select/backdrop/SelectBackdrop.tsx +++ b/packages/react/src/select/backdrop/SelectBackdrop.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import type { BaseUIComponentProps } from '../../internals/types'; import { useSelectRootContext } from '../root/SelectRootContext'; import { popupStateMapping } from '../../utils/popupStateMapping'; diff --git a/packages/react/src/select/icon/SelectIcon.tsx b/packages/react/src/select/icon/SelectIcon.tsx index 0c5d8017ffd..5b9bb03e4c3 100644 --- a/packages/react/src/select/icon/SelectIcon.tsx +++ b/packages/react/src/select/icon/SelectIcon.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import type { BaseUIComponentProps } from '../../internals/types'; import { useRenderElement } from '../../internals/useRenderElement'; import { useSelectRootContext } from '../root/SelectRootContext'; diff --git a/packages/react/src/select/item/SelectItem.tsx b/packages/react/src/select/item/SelectItem.tsx index 42f652ca71d..5fb90f1d8fa 100644 --- a/packages/react/src/select/item/SelectItem.tsx +++ b/packages/react/src/select/item/SelectItem.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { useSelectRootContext } from '../root/SelectRootContext'; import { useCompositeListItem, diff --git a/packages/react/src/select/label/SelectLabel.tsx b/packages/react/src/select/label/SelectLabel.tsx index f5c09e6b6c5..6afbea05cbd 100644 --- a/packages/react/src/select/label/SelectLabel.tsx +++ b/packages/react/src/select/label/SelectLabel.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import type { BaseUIComponentProps } from '../../internals/types'; import { useRenderElement } from '../../internals/useRenderElement'; import type { FieldRoot } from '../../field/root/FieldRoot'; diff --git a/packages/react/src/select/list/SelectList.tsx b/packages/react/src/select/list/SelectList.tsx index 118a01055e7..f491f5a736e 100644 --- a/packages/react/src/select/list/SelectList.tsx +++ b/packages/react/src/select/list/SelectList.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import type { BaseUIComponentProps, HTMLProps } from '../../internals/types'; import { useSelectRootContext } from '../root/SelectRootContext'; import { useSelectPositionerContext } from '../positioner/SelectPositionerContext'; diff --git a/packages/react/src/select/popup/SelectPopup.tsx b/packages/react/src/select/popup/SelectPopup.tsx index 8f3a78d3323..e088eb7b288 100644 --- a/packages/react/src/select/popup/SelectPopup.tsx +++ b/packages/react/src/select/popup/SelectPopup.tsx @@ -6,11 +6,12 @@ import { isWebKit } from '@base-ui/utils/detectBrowser'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { ownerDocument, ownerWindow } from '@base-ui/utils/owner'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { useAnimationFrame } from '@base-ui/utils/useAnimationFrame'; import type { InteractionType } from '@base-ui/utils/useEnhancedClickHandler'; -import { FloatingFocusManager, platform as floatingPlatform } from '../../floating-ui-react'; -import type { ClientRectObject } from '../../floating-ui-react'; +import { platform as floatingPlatform } from '@floating-ui/react-dom'; +import type { ClientRectObject } from '@floating-ui/react-dom'; +import { FloatingFocusManager } from '../../floating-ui-react/components/FloatingFocusManager'; import type { BaseUIComponentProps, HTMLProps } from '../../internals/types'; import { useSelectFloatingContext, 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 499d667bd1b..0a5897298a7 100644 --- a/packages/react/src/select/portal/SelectPortal.tsx +++ b/packages/react/src/select/portal/SelectPortal.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui/utils/store'; -import { FloatingPortal } from '../../floating-ui-react'; +import { useStore } from '@base-ui/utils/store/core'; +import { FloatingPortal } from '../../floating-ui-react/components/FloatingPortal'; import { SelectPortalContext } from './SelectPortalContext'; import { useSelectRootContext } from '../root/SelectRootContext'; import { selectors } from '../store'; diff --git a/packages/react/src/select/positioner/SelectPositioner.tsx b/packages/react/src/select/positioner/SelectPositioner.tsx index a9aa1a06332..3cede20d3d4 100644 --- a/packages/react/src/select/positioner/SelectPositioner.tsx +++ b/packages/react/src/select/positioner/SelectPositioner.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { inertValue } from '@base-ui/utils/inertValue'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { useSelectRootContext, useSelectFloatingContext } from '../root/SelectRootContext'; import { CompositeList } from '../../internals/composite/list/CompositeList'; import type { BaseUIComponentProps } from '../../internals/types'; diff --git a/packages/react/src/select/root/SelectRoot.tsx b/packages/react/src/select/root/SelectRoot.tsx index 159fc5f639e..47999d4bf84 100644 --- a/packages/react/src/select/root/SelectRoot.tsx +++ b/packages/react/src/select/root/SelectRoot.tsx @@ -9,15 +9,12 @@ import { useControlled } from '@base-ui/utils/useControlled'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { useValueAsRef } from '@base-ui/utils/useValueAsRef'; -import { useStore, Store } from '@base-ui/utils/store'; +import { useStore, Store } from '@base-ui/utils/store/core'; import { EMPTY_ARRAY, EMPTY_OBJECT } from '@base-ui/utils/empty'; -import { - useClick, - useDismiss, - useFloatingRootContext, - useListNavigation, - useTypeahead, -} from '../../floating-ui-react'; +import { useDismissCore as useDismiss } from '../../floating-ui-react/hooks/useDismissCore'; +import { useFloatingRootContext } from '../../floating-ui-react/hooks/useFloatingRootContext'; +import { useListNavigationNoGrid } from '../../floating-ui-react/hooks/useListNavigation'; +import { useTypeahead } from '../../floating-ui-react/hooks/useTypeahead'; import { SelectRootContext, SelectFloatingContext } from './SelectRootContext'; import { useFieldRootContext } from '../../internals/field-root-context/FieldRootContext'; import { useRegisterFieldControl } from '../../internals/field-register-control/useRegisterFieldControl'; @@ -36,7 +33,8 @@ import { defaultItemEquality, findItemIndex } from '../../internals/itemEquality import { useValueChanged } from '../../internals/useValueChanged'; import { useOpenInteractionType } from '../../utils/useOpenInteractionType'; import { getMaxScrollOffset, normalizeScrollOffset } from '../../utils/scrollEdges'; -import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups'; +import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups/popupStoreUtils'; +import { useTriggerPress } from '../../utils/popups/useTriggerPress'; import { mergeProps } from '../../merge-props'; /** @@ -148,8 +146,8 @@ export function SelectRoot( openMethod: null, activeIndex: null, selectedIndex: null, - popupProps: {}, - triggerProps: {}, + popupProps: EMPTY_OBJECT, + triggerProps: EMPTY_OBJECT, triggerElement: null, positionerElement: null, listElement: null, @@ -350,7 +348,7 @@ export function SelectRoot( }, }); - const click = useClick(floatingContext, { + const click = useTriggerPress(floatingContext, { enabled: !readOnly && !disabled, event: 'mousedown', }); @@ -359,7 +357,7 @@ export function SelectRoot( bubbles: false, }); - const listNavigation = useListNavigation(floatingContext, { + const listNavigation = useListNavigationNoGrid(floatingContext, { enabled: !readOnly && !disabled, listRef, activeIndex, diff --git a/packages/react/src/select/root/SelectRootContext.ts b/packages/react/src/select/root/SelectRootContext.ts index b5fea935541..56fd4d9f5bc 100644 --- a/packages/react/src/select/root/SelectRootContext.ts +++ b/packages/react/src/select/root/SelectRootContext.ts @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { type FloatingEvents, type FloatingRootContext } from '../../floating-ui-react'; +import type { FloatingEvents, FloatingRootContext } from '../../floating-ui-react/types'; import type { SelectStore } from '../store'; import type { UseFieldValidationReturnValue } from '../../field/root/useFieldValidation'; import type { HTMLProps } from '../../internals/types'; diff --git a/packages/react/src/select/scroll-arrow/SelectScrollArrow.tsx b/packages/react/src/select/scroll-arrow/SelectScrollArrow.tsx index 2e6706b1054..7f7c7fd21b7 100644 --- a/packages/react/src/select/scroll-arrow/SelectScrollArrow.tsx +++ b/packages/react/src/select/scroll-arrow/SelectScrollArrow.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; import { useTimeout } from '@base-ui/utils/useTimeout'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import type { BaseUIComponentProps } from '../../internals/types'; import { useSelectRootContext } from '../root/SelectRootContext'; diff --git a/packages/react/src/select/store.ts b/packages/react/src/select/store.ts index 30154d57cb0..f39bcffaec9 100644 --- a/packages/react/src/select/store.ts +++ b/packages/react/src/select/store.ts @@ -1,4 +1,4 @@ -import { Store, createSelector } from '@base-ui/utils/store'; +import { Store, createSelector } from '@base-ui/utils/store/core'; import { type InteractionType } from '@base-ui/utils/useEnhancedClickHandler'; import type { TransitionStatus } from '../internals/useTransitionStatus'; import type { HTMLProps } from '../internals/types'; diff --git a/packages/react/src/select/trigger/SelectTrigger.tsx b/packages/react/src/select/trigger/SelectTrigger.tsx index 251924356b6..109bbc06c7f 100644 --- a/packages/react/src/select/trigger/SelectTrigger.tsx +++ b/packages/react/src/select/trigger/SelectTrigger.tsx @@ -5,18 +5,19 @@ import { useTimeout } from '@base-ui/utils/useTimeout'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { useMergedRefs } from '@base-ui/utils/useMergedRefs'; import { useValueAsRef } from '@base-ui/utils/useValueAsRef'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import { useSelectRootContext } from '../root/SelectRootContext'; -import { BaseUIComponentProps, HTMLProps, NativeButtonProps } from '../../internals/types'; +import type { BaseUIComponentProps, HTMLProps, NativeButtonProps } from '../../internals/types'; import { useFieldRootContext } from '../../internals/field-root-context/FieldRootContext'; import { useLabelableContext } from '../../internals/labelable-provider/LabelableContext'; import { pressableTriggerOpenStateMapping } from '../../utils/popupStateMapping'; import { fieldValidityMapping } from '../../internals/field-constants/constants'; import { useRenderElement } from '../../internals/useRenderElement'; -import { StateAttributesMapping } from '../../internals/getStateAttributesProps'; +import type { StateAttributesMapping } from '../../internals/getStateAttributesProps'; import { selectors } from '../store'; import { getPseudoElementBounds } from '../../utils/getPseudoElementBounds'; -import { contains, getFloatingFocusElement } from '../../floating-ui-react/utils'; +import { getFloatingFocusElement } from '../../floating-ui-react/utils/element'; +import { contains } from '../../internals/shadowDom'; import { mergeProps } from '../../merge-props'; import { useButton } from '../../internals/use-button'; import type { FieldRootState } from '../../field/root/FieldRoot'; diff --git a/packages/react/src/select/value/SelectValue.tsx b/packages/react/src/select/value/SelectValue.tsx index 9df05cafedf..e6513723856 100644 --- a/packages/react/src/select/value/SelectValue.tsx +++ b/packages/react/src/select/value/SelectValue.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { useStore } from '@base-ui/utils/store'; +import { useStore } from '@base-ui/utils/store/core'; import type { BaseUIComponentProps } from '../../internals/types'; import { useRenderElement } from '../../internals/useRenderElement'; import { useSelectRootContext } from '../root/SelectRootContext'; diff --git a/packages/react/src/toast/positioner/ToastPositioner.tsx b/packages/react/src/toast/positioner/ToastPositioner.tsx index b72c14e8dc2..8005408db28 100644 --- a/packages/react/src/toast/positioner/ToastPositioner.tsx +++ b/packages/react/src/toast/positioner/ToastPositioner.tsx @@ -11,7 +11,7 @@ import { import type { BaseUIComponentProps } from '../../internals/types'; import { POPUP_COLLISION_AVOIDANCE } from '../../internals/constants'; import { ToastPositionerContext } from './ToastPositionerContext'; -import { useFloatingRootContext } from '../../floating-ui-react'; +import { useFloatingRootContext } from '../../floating-ui-react/hooks/useFloatingRootContext'; import { NOOP } from '../../internals/noop'; import type { ToastObject } from '../useToastManager'; import { ToastRootCssVars } from '../root/ToastRootCssVars'; diff --git a/packages/react/src/toast/store.ts b/packages/react/src/toast/store.ts index ad7234844cf..7c18d73c840 100644 --- a/packages/react/src/toast/store.ts +++ b/packages/react/src/toast/store.ts @@ -1,4 +1,4 @@ -import { ReactStore, createSelector } from '@base-ui/utils/store'; +import { ReactStore, createSelector } from '@base-ui/utils/store/core'; import { generateId } from '@base-ui/utils/generateId'; import { ownerDocument } from '@base-ui/utils/owner'; import { Timeout } from '@base-ui/utils/useTimeout'; @@ -9,7 +9,7 @@ import { ToastObject, } from './useToastManager'; import { resolvePromiseOptions } from './utils/resolvePromiseOptions'; -import { activeElement, contains, getTarget } from '../floating-ui-react/utils'; +import { activeElement, contains, getTarget } from '../internals/shadowDom'; import { isFocusVisible } from './utils/focusVisible'; type ToastInternalUpdateOptions = Partial, 'id'>>; @@ -66,26 +66,18 @@ function createToastMetadata(toasts: ToastObject[]) { return metadata; } -const toastMetadataSelector = (state: State) => state.toastMetadata; - export const selectors = { toasts: createSelector((state: State) => state.toasts), isEmpty: createSelector((state: State) => state.toasts.length === 0), - toast: createSelector( - toastMetadataSelector, - (toastMetadata, id: string) => toastMetadata.get(id)?.value, - ), + toast: createSelector((state: State, id: string) => state.toastMetadata.get(id)?.value), toastIndex: createSelector( - toastMetadataSelector, - (toastMetadata, id: string) => toastMetadata.get(id)?.domIndex ?? -1, + (state: State, id: string) => state.toastMetadata.get(id)?.domIndex ?? -1, ), toastOffsetY: createSelector( - toastMetadataSelector, - (toastMetadata, id: string) => toastMetadata.get(id)?.offsetY ?? 0, + (state: State, id: string) => state.toastMetadata.get(id)?.offsetY ?? 0, ), toastVisibleIndex: createSelector( - toastMetadataSelector, - (toastMetadata, id: string) => toastMetadata.get(id)?.visibleIndex ?? -1, + (state: State, id: string) => state.toastMetadata.get(id)?.visibleIndex ?? -1, ), hovering: createSelector((state: State) => state.hovering), focused: createSelector((state: State) => state.focused), diff --git a/packages/react/src/toast/utils/focusVisible.ts b/packages/react/src/toast/utils/focusVisible.ts index efb95ba2abe..f26fd22fcab 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/element'; diff --git a/packages/react/src/toast/viewport/ToastViewport.tsx b/packages/react/src/toast/viewport/ToastViewport.tsx index d1df51e7b8f..9f191cfa338 100644 --- a/packages/react/src/toast/viewport/ToastViewport.tsx +++ b/packages/react/src/toast/viewport/ToastViewport.tsx @@ -5,7 +5,7 @@ import { mergeCleanups } from '@base-ui/utils/mergeCleanups'; import { ownerDocument, ownerWindow } from '@base-ui/utils/owner'; import { visuallyHidden } from '@base-ui/utils/visuallyHidden'; import { useTimeout } from '@base-ui/utils/useTimeout'; -import { activeElement, contains, getTarget } from '../../floating-ui-react/utils'; +import { activeElement, contains, getTarget } from '../../internals/shadowDom'; import { FocusGuard } from '../../utils/FocusGuard'; import type { BaseUIComponentProps, HTMLProps } from '../../internals/types'; import { useToastProviderContext } from '../provider/ToastProviderContext'; diff --git a/packages/react/src/tooltip/popup/TooltipPopup.tsx b/packages/react/src/tooltip/popup/TooltipPopup.tsx index 61ae1105342..f462f809f00 100644 --- a/packages/react/src/tooltip/popup/TooltipPopup.tsx +++ b/packages/react/src/tooltip/popup/TooltipPopup.tsx @@ -11,7 +11,7 @@ import { transitionStatusMapping } from '../../internals/stateAttributesMapping' import { useOpenChangeComplete } from '../../internals/useOpenChangeComplete'; import { useRenderElement } from '../../internals/useRenderElement'; import { getDisabledMountTransitionStyles } from '../../utils/getDisabledMountTransitionStyles'; -import { useHoverFloatingInteraction } from '../../floating-ui-react'; +import { useHoverFloatingInteraction } from '../../floating-ui-react/hooks/useHoverFloatingInteraction'; const stateAttributesMapping: StateAttributesMapping = { ...baseMapping, diff --git a/packages/react/src/tooltip/provider/TooltipProvider.tsx b/packages/react/src/tooltip/provider/TooltipProvider.tsx index bd07461bfa5..4fe70810833 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 { FloatingDelayGroup } from '../../floating-ui-react'; +import { FloatingDelayGroup } from '../../floating-ui-react/components/FloatingDelayGroup'; import { TooltipProviderContext } from './TooltipProviderContext'; /** diff --git a/packages/react/src/tooltip/root/TooltipRoot.tsx b/packages/react/src/tooltip/root/TooltipRoot.tsx index 20fd13998d4..6694740ce74 100644 --- a/packages/react/src/tooltip/root/TooltipRoot.tsx +++ b/packages/react/src/tooltip/root/TooltipRoot.tsx @@ -3,8 +3,10 @@ import * as React from 'react'; import { fastComponent } from '@base-ui/utils/fastHooks'; import { useOnFirstRender } from '@base-ui/utils/useOnFirstRender'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; +import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { TooltipRootContext } from './TooltipRootContext'; -import { useClientPoint, useDismiss } from '../../floating-ui-react'; +import { useClientPoint } from '../../floating-ui-react/hooks/useClientPoint'; +import { useDismissCore } from '../../floating-ui-react/hooks/useDismissCore'; import { type BaseUIChangeEventDetails, createChangeEventDetails, @@ -15,7 +17,7 @@ import { useOpenStateTransitions, usePopupInteractionProps, type PayloadChildRenderFunction, -} from '../../utils/popups'; +} from '../../utils/popups/popupStoreUtils'; import { mergeProps } from '../../merge-props'; import { TooltipStore } from '../store/TooltipStore'; import { type TooltipHandle } from '../store/TooltipHandle'; @@ -257,22 +259,44 @@ function TooltipInteractions({ }) { const floatingRootContext = store.useState('floatingRootContext'); - const dismiss = useDismiss(floatingRootContext, { + const dismiss = useDismissCore(floatingRootContext, { enabled: !disabled, - referencePress: () => store.select('closeOnClick'), }); const clientPoint = useClientPoint(floatingRootContext, { enabled: !disabled && trackCursorAxis !== 'none', axis: trackCursorAxis === 'none' ? undefined : trackCursorAxis, }); + const closeOnReferencePress = useStableCallback((event: React.SyntheticEvent) => { + if (!store.select('closeOnClick')) { + return; + } + + store.setOpen( + false, + createChangeEventDetails( + REASONS.triggerPress, + event.nativeEvent as MouseEvent | PointerEvent | TouchEvent | KeyboardEvent, + ), + ); + }); + + const dismissReference = React.useMemo( + () => ({ + ...dismiss.reference, + onPointerDown: closeOnReferencePress, + onClick: closeOnReferencePress, + }), + [dismiss.reference, closeOnReferencePress], + ); + const activeTriggerProps = React.useMemo( - () => mergeProps(clientPoint.reference, dismiss.reference), - [clientPoint.reference, dismiss.reference], + () => mergeProps(clientPoint.reference, dismissReference), + [clientPoint.reference, dismissReference], ); const inactiveTriggerProps = React.useMemo( - () => mergeProps(clientPoint.trigger, dismiss.trigger), - [clientPoint.trigger, dismiss.trigger], + () => mergeProps(clientPoint.trigger, dismissReference), + [clientPoint.trigger, dismissReference], ); const popupProps = React.useMemo( () => mergeProps(FOCUSABLE_POPUP_PROPS, clientPoint.floating, dismiss.floating), diff --git a/packages/react/src/tooltip/store/TooltipStore.ts b/packages/react/src/tooltip/store/TooltipStore.ts index d0abf096110..b1f70e82dfb 100644 --- a/packages/react/src/tooltip/store/TooltipStore.ts +++ b/packages/react/src/tooltip/store/TooltipStore.ts @@ -1,6 +1,6 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import { createSelector, ReactStore } from '@base-ui/utils/store'; +import { createSelector, ReactStore } from '@base-ui/utils/store/core'; import { type TooltipRoot } from '../root/TooltipRoot'; import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; import { REASONS } from '../../internals/reasons'; @@ -10,10 +10,9 @@ import { PopupStoreContext, popupStoreSelectors, PopupStoreState, - PopupTriggerMap, - setOpenTriggerState, - usePopupStore, -} from '../../utils/popups'; +} from '../../utils/popups/store'; +import { PopupTriggerMap } from '../../utils/popups/popupTriggerMap'; +import { setOpenTriggerState, usePopupStore } from '../../utils/popups/popupStoreUtils'; export type State = PopupStoreState & { disabled: boolean; diff --git a/packages/react/src/tooltip/trigger/TooltipTrigger.tsx b/packages/react/src/tooltip/trigger/TooltipTrigger.tsx index 02323fbd303..1ebf6042e2e 100644 --- a/packages/react/src/tooltip/trigger/TooltipTrigger.tsx +++ b/packages/react/src/tooltip/trigger/TooltipTrigger.tsx @@ -8,16 +8,14 @@ import { useTooltipRootContext } from '../root/TooltipRootContext'; import type { BaseUIComponentProps, BaseUIEvent } from '../../internals/types'; import { triggerOpenStateMapping } from '../../utils/popupStateMapping'; import { useRenderElement } from '../../internals/useRenderElement'; -import { useTriggerDataForwarding } from '../../utils/popups'; +import { useTriggerDataForwarding } from '../../utils/popups/popupStoreUtils'; import { useBaseUiId } from '../../internals/useBaseUiId'; import { TooltipHandle } from '../store/TooltipHandle'; import { useTooltipProviderContext } from '../provider/TooltipProviderContext'; -import { - safePolygon, - useDelayGroup, - useFocus, - useHoverReferenceInteraction, -} from '../../floating-ui-react'; +import { useDelayGroup } from '../../floating-ui-react/components/FloatingDelayGroup'; +import { useFocus } from '../../floating-ui-react/hooks/useFocus'; +import { useHoverReferenceInteraction } from '../../floating-ui-react/hooks/useHoverReferenceInteraction'; +import { safePolygon } from '../../floating-ui-react/safePolygon'; import { contains } from '../../floating-ui-react/utils/element'; import { isMouseLikePointerType } from '../../floating-ui-react/utils/event'; import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; diff --git a/packages/react/src/utils/FloatingPortalLite.tsx b/packages/react/src/utils/FloatingPortalLite.tsx index 38f3a008cb5..b3cdbb70dbe 100644 --- a/packages/react/src/utils/FloatingPortalLite.tsx +++ b/packages/react/src/utils/FloatingPortalLite.tsx @@ -1,7 +1,11 @@ 'use client'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import { useFloatingPortalNode, type FloatingPortal } from '../floating-ui-react'; +import type { BaseUIComponentProps } from '../internals/types'; +import { + useFloatingPortalNode, + type UseFloatingPortalNodeProps, +} from '../floating-ui-react/components/useFloatingPortalNode'; /** * `FloatingPortal` includes tabbable logic handling for focus management. @@ -21,10 +25,6 @@ export const FloatingPortalLite = React.forwardRef(function FloatingPortalLite( elementProps, }); - if (!portalSubtree && !portalNode) { - return null; - } - return ( {portalSubtree} @@ -35,7 +35,12 @@ export const FloatingPortalLite = React.forwardRef(function FloatingPortalLite( export interface FloatingPortalLiteState {} -export interface FloatingPortalLiteProps extends FloatingPortal.Props {} +export interface FloatingPortalLiteProps extends BaseUIComponentProps<'div', TState> { + /** + * A parent element to render the portal element into. + */ + container?: UseFloatingPortalNodeProps['container'] | undefined; +} export namespace FloatingPortalLite { export type State = FloatingPortalLiteState; diff --git a/packages/react/src/utils/adaptiveOriginMiddleware.ts b/packages/react/src/utils/adaptiveOriginMiddleware.ts index 46f26e0950b..d92de9f3081 100644 --- a/packages/react/src/utils/adaptiveOriginMiddleware.ts +++ b/packages/react/src/utils/adaptiveOriginMiddleware.ts @@ -1,6 +1,6 @@ import { ownerDocument, ownerWindow } from '@base-ui/utils/owner'; import { getSide } from '@floating-ui/utils'; -import { Middleware } from '../floating-ui-react'; +import type { Middleware } from '@floating-ui/react-dom'; export const DEFAULT_SIDES = { sideX: 'left', diff --git a/packages/react/src/utils/popups/popupStoreUtils.test.tsx b/packages/react/src/utils/popups/popupStoreUtils.test.tsx index ed4de521256..235172cbc68 100644 --- a/packages/react/src/utils/popups/popupStoreUtils.test.tsx +++ b/packages/react/src/utils/popups/popupStoreUtils.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import * as React from 'react'; import { act, render } from '@testing-library/react'; -import { ReactStore } from '@base-ui/utils/store'; +import { ReactStore } from '@base-ui/utils/store/core'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { createInitialPopupStoreState, diff --git a/packages/react/src/utils/popups/popupStoreUtils.ts b/packages/react/src/utils/popups/popupStoreUtils.ts index 26c34e042dc..6e4471b1e1e 100644 --- a/packages/react/src/utils/popups/popupStoreUtils.ts +++ b/packages/react/src/utils/popups/popupStoreUtils.ts @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { ReactStore } from '@base-ui/utils/store'; +import { ReactStore } from '@base-ui/utils/store/core'; import { EMPTY_OBJECT } from '@base-ui/utils/empty'; import type { InteractionType } from '@base-ui/utils/useEnhancedClickHandler'; import { useId } from '@base-ui/utils/useId'; diff --git a/packages/react/src/utils/popups/store.ts b/packages/react/src/utils/popups/store.ts index 4f6b4562a5c..32590d20d19 100644 --- a/packages/react/src/utils/popups/store.ts +++ b/packages/react/src/utils/popups/store.ts @@ -1,6 +1,6 @@ -import { createSelector } from '@base-ui/utils/store'; +import { createSelector } from '@base-ui/utils/store/core'; import { EMPTY_OBJECT } from '@base-ui/utils/empty'; -import { FloatingRootContext } from '../../floating-ui-react'; +import type { FloatingRootContext } from '../../floating-ui-react/types'; import { FloatingRootStore } from '../../floating-ui-react/components/FloatingRootStore'; import { getEmptyRootContext } from '../../floating-ui-react/utils/getEmptyRootContext'; import { TransitionStatus } from '../../internals/useTransitionStatus'; diff --git a/packages/react/src/utils/popups/useTriggerFocusGuards.ts b/packages/react/src/utils/popups/useTriggerFocusGuards.ts index c2f86cf6f30..8095262b003 100644 --- a/packages/react/src/utils/popups/useTriggerFocusGuards.ts +++ b/packages/react/src/utils/popups/useTriggerFocusGuards.ts @@ -2,13 +2,13 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { - contains, type FocusableElement, getNextTabbable, getTabbableAfterElement, getTabbableBeforeElement, isOutsideEvent, -} from '../../floating-ui-react/utils'; +} from '../../floating-ui-react/utils/tabbable'; +import { contains } from '../../internals/shadowDom'; import { type BaseUIChangeEventDetails, createChangeEventDetails, diff --git a/packages/react/src/utils/popups/useTriggerPress.ts b/packages/react/src/utils/popups/useTriggerPress.ts new file mode 100644 index 00000000000..d51fd2e4692 --- /dev/null +++ b/packages/react/src/utils/popups/useTriggerPress.ts @@ -0,0 +1,234 @@ +'use client'; +import * as React from 'react'; +import { EMPTY_OBJECT } from '@base-ui/utils/empty'; +import { useAnimationFrame } from '@base-ui/utils/useAnimationFrame'; +import { useTimeout } from '@base-ui/utils/useTimeout'; +import type { + ElementProps, + FloatingContext, + FloatingRootContext, +} from '../../floating-ui-react/types'; +import { getTarget, isTypeableElement } from '../../floating-ui-react/utils/element'; +import { isMouseLikePointerType } from '../../floating-ui-react/utils/event'; +import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails'; +import { REASONS } from '../../internals/reasons'; + +interface UseTriggerPressProps { + enabled?: boolean | undefined; + event?: 'click' | 'mousedown' | undefined; + toggle?: boolean | undefined; + ignoreMouse?: boolean | undefined; + stickIfOpen?: boolean | undefined; +} + +export function useTriggerPress( + context: FloatingRootContext | FloatingContext, + props: UseTriggerPressProps = {}, +): ElementProps { + const { + enabled = true, + event: eventOption = 'click', + toggle = true, + ignoreMouse = false, + stickIfOpen = true, + } = props; + + const store = 'rootStore' in context ? context.rootStore : context; + const { dataRef } = store.context; + const pointerTypeRef = React.useRef<'mouse' | 'pen' | 'touch'>(undefined); + const frame = useAnimationFrame(); + + const reference: ElementProps['reference'] = React.useMemo(() => { + function setOpen(nextOpen: boolean, nativeEvent: MouseEvent, target: HTMLElement) { + store.setOpen(nextOpen, createChangeEventDetails(REASONS.triggerPress, nativeEvent, target)); + } + + function getNextOpen( + open: boolean, + currentTarget: EventTarget | null, + isRelevantOpenEvent: (eventType: string | undefined) => boolean, + ) { + const openEvent = dataRef.current.openEvent; + + if (open && store.select('domReferenceElement') !== currentTarget) { + return true; + } + + if (!open || !toggle) { + return true; + } + + return openEvent && stickIfOpen ? !isRelevantOpenEvent(openEvent.type) : false; + } + + return { + onPointerDown(event) { + pointerTypeRef.current = event.pointerType; + }, + onMouseDown(event) { + const pointerType = pointerTypeRef.current; + const nativeEvent = event.nativeEvent; + const open = store.select('open'); + + if ( + event.button !== 0 || + eventOption === 'click' || + (isMouseLikePointerType(pointerType, true) && ignoreMouse) + ) { + return; + } + + const nextOpen = getNextOpen( + open, + event.currentTarget, + (openEventType) => openEventType === 'click' || openEventType === 'mousedown', + ); + + const target = getTarget(nativeEvent); + + if (isTypeableElement(target)) { + setOpen(nextOpen, nativeEvent, target as HTMLElement); + return; + } + + const eventCurrentTarget = event.currentTarget as HTMLElement; + + frame.request(() => { + setOpen(nextOpen, nativeEvent, eventCurrentTarget); + }); + }, + onClick(event) { + const pointerType = pointerTypeRef.current; + + if (eventOption === 'mousedown' && pointerType) { + pointerTypeRef.current = undefined; + return; + } + + if (isMouseLikePointerType(pointerType, true) && ignoreMouse) { + return; + } + + const open = store.select('open'); + const nextOpen = getNextOpen( + open, + event.currentTarget, + (openEventType) => + openEventType === 'click' || + openEventType === 'mousedown' || + openEventType === 'keydown' || + openEventType === 'keyup', + ); + setOpen(nextOpen, event.nativeEvent, event.currentTarget as HTMLElement); + }, + onKeyDown() { + pointerTypeRef.current = undefined; + }, + }; + }, [dataRef, eventOption, frame, ignoreMouse, store, stickIfOpen, toggle]); + + return React.useMemo(() => (enabled ? { reference } : EMPTY_OBJECT), [enabled, reference]); +} + +export function useClickTriggerPress( + context: FloatingRootContext | FloatingContext | null, + stickIfOpen = true, +): ElementProps { + const store = context && ('rootStore' in context ? context.rootStore : context); + const dataRef = store?.context.dataRef; + + const reference: ElementProps['reference'] = React.useMemo( + () => ({ + onClick(event) { + if (!store || !dataRef) { + return; + } + + const open = store.select('open'); + const openEvent = dataRef.current.openEvent; + /* eslint-disable no-nested-ternary */ + const nextOpen = + !open || store.select('domReferenceElement') !== event.currentTarget + ? true + : openEvent && stickIfOpen + ? !isClickLikeOpenEvent(openEvent.type) + : false; + /* eslint-enable no-nested-ternary */ + + store.setOpen( + nextOpen, + createChangeEventDetails( + REASONS.triggerPress, + event.nativeEvent, + event.currentTarget as HTMLElement, + ), + ); + }, + }), + [dataRef, store, stickIfOpen], + ); + + return React.useMemo(() => (store ? { reference } : EMPTY_OBJECT), [reference, store]); +} + +function isClickLikeOpenEvent(eventType: string | undefined) { + return eventType === 'click' || eventType === 'keydown' || eventType === 'keyup'; +} + +interface UseInputPressProps { + enabled?: boolean | undefined; + touchOpenDelay?: number | undefined; +} + +export function useInputPress( + context: FloatingRootContext | FloatingContext, + props: UseInputPressProps = {}, +): ElementProps { + const { enabled = true, touchOpenDelay = 0 } = props; + + const store = 'rootStore' in context ? context.rootStore : context; + const pointerTypeRef = React.useRef<'mouse' | 'pen' | 'touch'>(undefined); + const frame = useAnimationFrame(); + const touchOpenTimeout = useTimeout(); + + const reference: ElementProps['reference'] = React.useMemo( + () => ({ + onPointerDown(event) { + pointerTypeRef.current = event.pointerType; + }, + onMouseDown(event) { + if (event.button !== 0) { + return; + } + + const nativeEvent = event.nativeEvent; + const pointerType = pointerTypeRef.current; + const target = getTarget(nativeEvent); + + const setOpen = (element: HTMLElement) => { + const details = createChangeEventDetails(REASONS.inputPress, nativeEvent, element); + + if (pointerType === 'touch' && touchOpenDelay > 0) { + touchOpenTimeout.start(touchOpenDelay, () => store.setOpen(true, details)); + } else { + store.setOpen(true, details); + } + }; + + if (isTypeableElement(target)) { + setOpen(target as HTMLElement); + return; + } + + const eventCurrentTarget = event.currentTarget as HTMLElement; + frame.request(() => setOpen(eventCurrentTarget)); + }, + onKeyDown() { + pointerTypeRef.current = undefined; + }, + }), + [frame, store, touchOpenDelay, touchOpenTimeout], + ); + + return React.useMemo(() => (enabled ? { reference } : EMPTY_OBJECT), [enabled, reference]); +} diff --git a/packages/react/src/utils/useAnchorPositioning.ts b/packages/react/src/utils/useAnchorPositioning.ts index 0122f771cd1..db21bd2b076 100644 --- a/packages/react/src/utils/useAnchorPositioning.ts +++ b/packages/react/src/utils/useAnchorPositioning.ts @@ -11,37 +11,37 @@ import { limitShift, offset, shift, - useFloating, size, - type UseFloatingOptions, type Placement, - type FloatingRootContext, type VirtualElement, type Padding, - type FloatingContext, type Side as PhysicalSide, type MiddlewareState, type AutoUpdateOptions, type Middleware, - type FloatingTreeStore, -} from '../floating-ui-react'; +} from '@floating-ui/react-dom'; +import type { + FloatingRootContext, + FloatingContext, + UseFloatingOptions, +} from '../floating-ui-react/types'; +import type { FloatingTreeStore } from '../floating-ui-react/components/FloatingTreeStore'; +import { useFloating } from '../floating-ui-react/hooks/useFloating'; import { useDirection } from '../internals/direction-context/DirectionContext'; import { arrow } from '../floating-ui-react/middleware/arrow'; import { hide } from './hideMiddleware'; import { DEFAULT_SIDES } from './adaptiveOriginMiddleware'; function getLogicalSide(sideParam: Side, renderedSide: PhysicalSide, isRtl: boolean): Side { - const isLogicalSideParam = sideParam === 'inline-start' || sideParam === 'inline-end'; - const logicalRight = isRtl ? 'inline-start' : 'inline-end'; - const logicalLeft = isRtl ? 'inline-end' : 'inline-start'; - return ( - { - top: 'top', - right: isLogicalSideParam ? logicalRight : 'right', - bottom: 'bottom', - left: isLogicalSideParam ? logicalLeft : 'left', - } satisfies Record - )[renderedSide]; + if ( + (sideParam === 'inline-start' || sideParam === 'inline-end') && + renderedSide !== 'top' && + renderedSide !== 'bottom' + ) { + return (renderedSide === 'right') === isRtl ? 'inline-start' : 'inline-end'; + } + + return renderedSide; } function getOffsetData(state: MiddlewareState, sideParam: Side, isRtl: boolean) { @@ -157,27 +157,27 @@ export function useAnchorPositioning( const collisionAvoidanceAlign = collisionAvoidance.align || 'flip'; const collisionAvoidanceFallbackAxisSide = collisionAvoidance.fallbackAxisSide || 'end'; - const anchorFn = typeof anchor === 'function' ? anchor : undefined; - const anchorFnCallback = useStableCallback(anchorFn); - const anchorDep = anchorFn ? anchorFnCallback : anchor; + const anchorFnCallback = useStableCallback(typeof anchor === 'function' ? anchor : undefined); + const anchorDep = typeof anchor === 'function' ? anchorFnCallback : anchor; const anchorValueRef = useValueAsRef(anchor); const mountedRef = useValueAsRef(mounted); const direction = useDirection(); const isRtl = direction === 'rtl'; + /* eslint-disable no-nested-ternary */ const side = mountSide || - ( - { - top: 'top', - right: 'right', - bottom: 'bottom', - left: 'left', - 'inline-end': isRtl ? 'left' : 'right', - 'inline-start': isRtl ? 'right' : 'left', - } satisfies Record - )[sideParam]; + (sideParam === 'inline-end' + ? isRtl + ? 'left' + : 'right' + : sideParam === 'inline-start' + ? isRtl + ? 'right' + : 'left' + : sideParam); + /* eslint-enable no-nested-ternary */ const placement = align === 'center' ? side : (`${side}-${align}` as Placement); @@ -191,25 +191,19 @@ export function useAnchorPositioning( // Create a bias to the preferred side. // On iOS, when the mobile software keyboard opens, the input is exactly centered // in the viewport, but this can cause it to flip to the top undesirably. - const bias = 1; - const biasTop = sideParam === 'bottom' ? bias : 0; - const biasBottom = sideParam === 'top' ? bias : 0; - const biasLeft = sideParam === 'right' ? bias : 0; - const biasRight = sideParam === 'left' ? bias : 0; - if (typeof collisionPadding === 'number') { collisionPadding = { - top: collisionPadding + biasTop, - right: collisionPadding + biasRight, - bottom: collisionPadding + biasBottom, - left: collisionPadding + biasLeft, + top: collisionPadding + (sideParam === 'bottom' ? 1 : 0), + right: collisionPadding + (sideParam === 'left' ? 1 : 0), + bottom: collisionPadding + (sideParam === 'top' ? 1 : 0), + left: collisionPadding + (sideParam === 'right' ? 1 : 0), }; } else if (collisionPadding) { collisionPadding = { - top: (collisionPadding.top || 0) + biasTop, - right: (collisionPadding.right || 0) + biasRight, - bottom: (collisionPadding.bottom || 0) + biasBottom, - left: (collisionPadding.left || 0) + biasLeft, + top: (collisionPadding.top || 0) + (sideParam === 'bottom' ? 1 : 0), + right: (collisionPadding.right || 0) + (sideParam === 'left' ? 1 : 0), + bottom: (collisionPadding.bottom || 0) + (sideParam === 'top' ? 1 : 0), + left: (collisionPadding.left || 0) + (sideParam === 'right' ? 1 : 0), }; } @@ -271,10 +265,10 @@ export function useAnchorPositioning( // Ensure the popup flips if it's been limited by its --available-height and it resizes. // Since the size() padding is smaller than the flip() padding, flip() will take precedence. padding: { - top: collisionPadding.top + bias, - right: collisionPadding.right + bias, - bottom: collisionPadding.bottom + bias, - left: collisionPadding.left + bias, + top: collisionPadding.top + 1, + right: collisionPadding.right + 1, + bottom: collisionPadding.bottom + 1, + left: collisionPadding.left + 1, }, mainAxis: !shiftCrossAxis && collisionAvoidanceSide === 'flip', crossAxis: collisionAvoidanceAlign === 'flip' ? 'alignment' : false, @@ -381,12 +375,16 @@ export function useAnchorPositioning( : sideOffset; const isOverlappingAnchor = shiftY > sideOffsetValue; - const adjacentTransformOrigin = { - top: `${transformX}px calc(100% + ${sideOffsetValue}px)`, - bottom: `${transformX}px ${-sideOffsetValue}px`, - left: `calc(100% + ${sideOffsetValue}px) ${transformY}px`, - right: `${-sideOffsetValue}px ${transformY}px`, - }[currentRenderedSide]; + /* eslint-disable no-nested-ternary */ + const adjacentTransformOrigin = + currentRenderedSide === 'top' + ? `${transformX}px calc(100% + ${sideOffsetValue}px)` + : currentRenderedSide === 'bottom' + ? `${transformX}px ${-sideOffsetValue}px` + : currentRenderedSide === 'left' + ? `calc(100% + ${sideOffsetValue}px) ${transformY}px` + : `${-sideOffsetValue}px ${transformY}px`; + /* eslint-enable no-nested-ternary */ const overlapTransformOrigin = `${transformX}px ${rects.reference.y + halfAnchorHeight - y}px`; elements.floating.style.setProperty( diff --git a/packages/react/src/utils/usePopupViewport.tsx b/packages/react/src/utils/usePopupViewport.tsx index 9587996bfa5..45687fcc688 100644 --- a/packages/react/src/utils/usePopupViewport.tsx +++ b/packages/react/src/utils/usePopupViewport.tsx @@ -3,11 +3,10 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { inertValue } from '@base-ui/utils/inertValue'; import { useAnimationFrame } from '@base-ui/utils/useAnimationFrame'; -import { usePreviousValue } from '@base-ui/utils/usePreviousValue'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { ownerDocument } from '@base-ui/utils/owner'; -import type { ReactStore } from '@base-ui/utils/store'; +import type { ReactStore } from '@base-ui/utils/store/core'; import { useAnimationsFinished } from '../internals/useAnimationsFinished'; import { usePopupAutoResize } from './usePopupAutoResize'; import { Dimensions } from '../floating-ui-react/types'; @@ -85,7 +84,7 @@ export function usePopupViewport(parameters: UsePopupViewportParameters): UsePop const popupElement = store.useState('popupElement'); const positionerElement = store.useState('positionerElement'); - const previousActiveTrigger = usePreviousValue(open ? activeTrigger : null); + const previousActiveTriggerRef = React.useRef(null); // Remount current content on trigger changes (and once more when payload lags) to avoid DOM reuse flashes. // The key bumps immediately on trigger switches, then again if the payload arrives on a later render. const currentContentKey = usePopupContentKey(activeTriggerId, payload); @@ -136,6 +135,9 @@ export function usePopupViewport(parameters: UsePopupViewportParameters): UsePop const lastHandledTriggerRef = React.useRef(null); useIsoLayoutEffect(() => { + const previousActiveTrigger = previousActiveTriggerRef.current; + previousActiveTriggerRef.current = open ? activeTrigger : null; + // When a trigger changes, set the captured children HTML to state, // so we can render both new and old content. if ( @@ -166,13 +168,7 @@ export function usePopupViewport(parameters: UsePopupViewportParameters): UsePop lastHandledTriggerRef.current = activeTrigger; } - }, [ - activeTrigger, - previousActiveTrigger, - previousContentNode, - onAnimationsFinished, - cleanupFrame, - ]); + }, [activeTrigger, open, previousContentNode, onAnimationsFinished, cleanupFrame]); // Capture a clone of the current content DOM subtree when not transitioning. // We can't store previous React nodes as they may be stateful; instead we capture DOM clones for visual continuity. @@ -319,18 +315,9 @@ function calculateRelativePosition(from: Element, to: Element): Offset { const fromRect = from.getBoundingClientRect(); const toRect = to.getBoundingClientRect(); - const fromCenter = { - x: fromRect.left + fromRect.width / 2, - y: fromRect.top + fromRect.height / 2, - }; - const toCenter = { - x: toRect.left + toRect.width / 2, - y: toRect.top + toRect.height / 2, - }; - return { - horizontal: toCenter.x - fromCenter.x, - vertical: toCenter.y - fromCenter.y, + horizontal: toRect.left + toRect.width / 2 - fromRect.left - fromRect.width / 2, + vertical: toRect.top + toRect.height / 2 - fromRect.top - fromRect.height / 2, }; } diff --git a/packages/utils/package.json b/packages/utils/package.json index 540f6bae5f8..754616c30ad 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -11,6 +11,7 @@ }, "exports": { "./store": "./src/store/index.ts", + "./store/core": "./src/store/core.ts", "./*": "./src/*.ts" }, "imports": { diff --git a/packages/utils/src/detectBrowser.ts b/packages/utils/src/detectBrowser.ts index 5cfe1dfaa99..8cbfcffd63b 100644 --- a/packages/utils/src/detectBrowser.ts +++ b/packages/utils/src/detectBrowser.ts @@ -6,28 +6,32 @@ interface NavigatorUAData { const hasNavigator = typeof navigator !== 'undefined'; -const nav = getNavigatorData(); -const platform = getPlatform(); -const userAgent = getUserAgent(); - export const isWebKit = typeof CSS === 'undefined' || !CSS.supports ? false : CSS.supports('-webkit-backdrop-filter:none'); -export const isIOS = - // iPads can claim to be MacIntel - nav.platform === 'MacIntel' && nav.maxTouchPoints > 1 - ? true - : /iP(hone|ad|od)|iOS/.test(nav.platform); - -export const isFirefox = hasNavigator && /firefox/i.test(userAgent); +export const isIOS = /* @__PURE__ */ (() => { + const nav = getNavigatorData(); + return ( + // iPads can claim to be MacIntel + nav.platform === 'MacIntel' && nav.maxTouchPoints > 1 + ? true + : /iP(hone|ad|od)|iOS/.test(nav.platform) + ); +})(); + +export const isFirefox = /* @__PURE__ */ (() => hasNavigator && /firefox/i.test(getUserAgent()))(); export const isSafari = hasNavigator && /apple/i.test(navigator.vendor); -export const isEdge = hasNavigator && /Edg/i.test(userAgent); -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/'); +export const isEdge = /* @__PURE__ */ (() => hasNavigator && /Edg/i.test(getUserAgent()))(); +export const isAndroid = /* @__PURE__ */ (() => { + const userAgent = getUserAgent(); + return (hasNavigator && /android/i.test(getPlatform())) || /android/i.test(userAgent); +})(); +export const isMac = /* @__PURE__ */ (() => { + return hasNavigator && getPlatform().toLowerCase().startsWith('mac') && !navigator.maxTouchPoints; +})(); +export const isJSDOM = /* @__PURE__ */ (() => getUserAgent().includes('jsdom/'))(); // Avoid Chrome DevTools blue warning. function getNavigatorData(): { platform: string; maxTouchPoints: number } { diff --git a/packages/utils/src/store/ReactStore.ts b/packages/utils/src/store/ReactStore.ts index 1ebc5be3d2d..7e84d9aa580 100644 --- a/packages/utils/src/store/ReactStore.ts +++ b/packages/utils/src/store/ReactStore.ts @@ -2,7 +2,7 @@ /* eslint-disable react-hooks/rules-of-hooks */ 'use client'; import * as React from 'react'; -import { Store } from './Store'; +import { StoreCore } from './Store'; import { useStore } from './useStore'; import { useStableCallback } from '../useStableCallback'; import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; @@ -11,11 +11,11 @@ import { NOOP } from '../empty'; /** * A Store that supports controlled state keys, non-reactive values and provides utility methods for React. */ -export class ReactStore< +export class ReactStoreCore< State extends object, Context = Record, Selectors extends Record> = Record, -> extends Store { +> extends StoreCore { /** * Creates a new ReactStore instance. * @@ -34,7 +34,7 @@ export class ReactStore< */ readonly context: Context; - private selectors: Selectors | undefined; + protected selectors: Selectors | undefined; /** * Synchronizes a single external value into the store. @@ -46,7 +46,9 @@ export class ReactStore< key: keyof State, value: Value, ) { - React.useDebugValue(key); + if (process.env.NODE_ENV !== 'production') { + React.useDebugValue(key); + } // eslint-disable-next-line consistent-this const store = this; useIsoLayoutEffect(() => { @@ -120,7 +122,9 @@ export class ReactStore< key: keyof State, controlled: Value | undefined, ): void { - React.useDebugValue(key); + if (process.env.NODE_ENV !== 'production') { + React.useDebugValue(key); + } // eslint-disable-next-line consistent-this const store = this; const isControlled = controlled !== undefined; @@ -176,7 +180,9 @@ export class ReactStore< ): ReturnType; useState(key: keyof Selectors, a1?: unknown, a2?: unknown, a3?: unknown) { - React.useDebugValue(key); + if (process.env.NODE_ENV !== 'production') { + React.useDebugValue(key); + } return useStore(this, this.selectors![key], a1, a2, a3); } @@ -191,7 +197,9 @@ export class ReactStore< key: Key, fn: ContextFunction | undefined, ) { - React.useDebugValue(key); + if (process.env.NODE_ENV !== 'production') { + React.useDebugValue(key); + } const stableFunction = useStableCallback(fn ?? (NOOP as ContextFunction)); (this.context as Record>)[key] = stableFunction; } @@ -211,7 +219,13 @@ export class ReactStore< } return ref.current; } +} +export class ReactStore< + State extends object, + Context = Record, + Selectors extends Record> = Record, +> extends ReactStoreCore { /** * Observes changes derived from the store's selectors and calls the listener when the selected value changes. * diff --git a/packages/utils/src/store/Store.ts b/packages/utils/src/store/Store.ts index b53f4cc70fe..b0a3a246edd 100644 --- a/packages/utils/src/store/Store.ts +++ b/packages/utils/src/store/Store.ts @@ -6,7 +6,7 @@ type Listener = (state: T) => void; * A data store implementation that allows subscribing to state changes and updating the state. * It uses an observer pattern to notify subscribers when the state changes. */ -export class Store { +export class StoreCore { /** * The current state of the store. * This property is updated immediately when the state changes as a result of calling {@link setState}, {@link update}, or {@link set}. @@ -105,7 +105,9 @@ export class Store { const newState = { ...this.state }; this.setState(newState); } +} +export class Store extends StoreCore { use any>(selector: F, ...args: SelectorArgs): ReturnType; use(selector: any, a1?: unknown, a2?: unknown, a3?: unknown) { @@ -114,7 +116,7 @@ export class Store { } } -export type ReadonlyStore = Pick, 'getSnapshot' | 'subscribe' | 'state'>; +export type ReadonlyStore = Pick, 'getSnapshot' | 'subscribe' | 'state'>; type SelectorArgs = Selector extends (...params: infer Params) => any ? Tail diff --git a/packages/utils/src/store/StoreInspector.tsx b/packages/utils/src/store/StoreInspector.tsx index de0fb95520b..739d867ec7a 100644 --- a/packages/utils/src/store/StoreInspector.tsx +++ b/packages/utils/src/store/StoreInspector.tsx @@ -5,7 +5,7 @@ import { isElement } from '@floating-ui/utils/dom'; import { mergeCleanups } from '../mergeCleanups'; import { ownerDocument, ownerWindow } from '../owner'; import { addEventListener } from '../addEventListener'; -import { Store } from './Store'; +import type { ReadonlyStore } from './Store'; import { useForcedRerendering } from '../useForcedRerendering'; import { useStableCallback } from '../useStableCallback'; import { useAnimationFrame } from '../useAnimationFrame'; @@ -127,7 +127,7 @@ export interface StoreInspectorProps { /** * Instance of the store to inspect. */ - store: Store; + store: ReadonlyStore; /** * Additional data to display in the inspector. */ @@ -185,7 +185,7 @@ export function StoreInspector(props: StoreInspectorProps) { interface PanelProps { anchorElement: HTMLElement | null; - store: Store; + store: ReadonlyStore; title?: string | undefined; additionalData?: any; open: boolean; diff --git a/packages/utils/src/store/core.ts b/packages/utils/src/store/core.ts new file mode 100644 index 00000000000..d158a29a5b9 --- /dev/null +++ b/packages/utils/src/store/core.ts @@ -0,0 +1,7 @@ +export * from './useStore'; +export { StoreCore as Store } from './Store'; +export { ReactStoreCore as ReactStore } from './ReactStore'; + +export function createSelector any>(selector: Selector) { + return selector; +} diff --git a/packages/utils/src/useScrollLock.ts b/packages/utils/src/useScrollLock.ts index 66c69517a6f..e6ccdeec756 100644 --- a/packages/utils/src/useScrollLock.ts +++ b/packages/utils/src/useScrollLock.ts @@ -13,19 +13,15 @@ let originalBodyStyles: Partial = {}; let originalHtmlScrollBehavior = ''; function hasInsetScrollbars(referenceElement: Element | null) { - if (typeof document === 'undefined') { - return false; - } const doc = ownerDocument(referenceElement); const win = ownerWindow(doc); return win.innerWidth - doc.documentElement.clientWidth > 0; } function supportsStableScrollbarGutter(referenceElement: Element | null) { - const supported = - typeof CSS !== 'undefined' && CSS.supports && CSS.supports('scrollbar-gutter', 'stable'); + const supported = typeof CSS !== 'undefined' && CSS.supports?.('scrollbar-gutter', 'stable'); - if (!supported || typeof document === 'undefined') { + if (!supported) { return false; } @@ -90,7 +86,7 @@ function preventScrollInsetScrollbars(referenceElement: Element | null) { // Pinch-zoom in Safari causes a shift. Just don't lock scroll if there's any pinch-zoom. if (isWebKit && (win.visualViewport?.scale ?? 1) !== 1) { - return () => {}; + return NOOP; } function lockScroll() { @@ -214,65 +210,61 @@ function preventScrollInsetScrollbars(referenceElement: Element | null) { }; } -class ScrollLocker { - lockCount = 0; - restore = null as (() => void) | null; - timeoutLock = Timeout.create(); - timeoutUnlock = Timeout.create(); +let lockCount = 0; +let restore: (() => void) | null = null; +const timeoutLock = Timeout.create(); +const timeoutUnlock = Timeout.create(); - acquire(referenceElement: Element | null) { - this.lockCount += 1; - if (this.lockCount === 1 && this.restore === null) { - this.timeoutLock.start(0, () => this.lock(referenceElement)); - } - return this.release; +function unlock() { + if (lockCount === 0 && restore) { + restore(); + restore = null; } +} - release = () => { - this.lockCount -= 1; - if (this.lockCount === 0 && this.restore) { - this.timeoutUnlock.start(0, this.unlock); - } - }; +function release() { + lockCount -= 1; + if (lockCount === 0 && restore) { + timeoutUnlock.start(0, unlock); + } +} - private unlock = () => { - if (this.lockCount === 0 && this.restore) { - this.restore?.(); - this.restore = null; - } - }; +function lock(referenceElement: Element | null) { + if (lockCount === 0 || restore !== null) { + return; + } - private lock(referenceElement: Element | null) { - if (this.lockCount === 0 || this.restore !== null) { - return; - } + const doc = ownerDocument(referenceElement); + const html = doc.documentElement; + const htmlOverflowY = ownerWindow(html).getComputedStyle(html).overflowY; - const doc = ownerDocument(referenceElement); - const html = doc.documentElement; - const htmlOverflowY = ownerWindow(html).getComputedStyle(html).overflowY; + // If the site author already hid overflow on , respect it and bail out. + if (htmlOverflowY === 'hidden' || htmlOverflowY === 'clip') { + restore = NOOP; + return; + } - // If the site author already hid overflow on , respect it and bail out. - if (htmlOverflowY === 'hidden' || htmlOverflowY === 'clip') { - this.restore = NOOP; - return; - } + const hasOverlayScrollbars = isIOS || !hasInsetScrollbars(referenceElement); + + // On iOS, scroll locking does not work if the navbar is collapsed. Due to numerous + // side effects and bugs that arise on iOS, it must be researched extensively before + // being enabled to ensure it doesn't cause the following issues: + // - Textboxes must scroll into view when focused, nor cause a glitchy scroll animation. + // - The navbar must not force itself into view and cause layout shift. + // - Scroll containers must not flicker upon closing a popup when it has an exit animation. + restore = hasOverlayScrollbars + ? preventScrollOverlayScrollbars(referenceElement) + : preventScrollInsetScrollbars(referenceElement); +} - const hasOverlayScrollbars = isIOS || !hasInsetScrollbars(referenceElement); - - // On iOS, scroll locking does not work if the navbar is collapsed. Due to numerous - // side effects and bugs that arise on iOS, it must be researched extensively before - // being enabled to ensure it doesn't cause the following issues: - // - Textboxes must scroll into view when focused, nor cause a glitchy scroll animation. - // - The navbar must not force itself into view and cause layout shift. - // - Scroll containers must not flicker upon closing a popup when it has an exit animation. - this.restore = hasOverlayScrollbars - ? preventScrollOverlayScrollbars(referenceElement) - : preventScrollInsetScrollbars(referenceElement); +function acquire(referenceElement: Element | null) { + lockCount += 1; + if (lockCount === 1 && restore === null) { + timeoutLock.start(0, () => lock(referenceElement)); } + return release; } -const SCROLL_LOCKER = new ScrollLocker(); - /** * Locks the scroll of the document when enabled. * @@ -284,6 +276,6 @@ export function useScrollLock(enabled: boolean = true, referenceElement: Element if (!enabled) { return undefined; } - return SCROLL_LOCKER.acquire(referenceElement); + return acquire(referenceElement); }, [enabled, referenceElement]); }