diff --git a/docs/reference/generated/menu-positioner.json b/docs/reference/generated/menu-positioner.json index 105a5f811a..51ebc6966f 100644 --- a/docs/reference/generated/menu-positioner.json +++ b/docs/reference/generated/menu-positioner.json @@ -4,6 +4,7 @@ "props": { "align": { "type": "'start' | 'center' | 'end'", + "default": "'center'", "description": "How to align the popup relative to the specified side." }, "alignOffset": { @@ -13,6 +14,7 @@ }, "side": { "type": "'bottom' | 'inline-end' | 'inline-start' | 'left' | 'right' | 'top'", + "default": "'bottom'", "description": "Which side of the anchor element to align the popup against.\nMay automatically change to avoid collisions." }, "sideOffset": { @@ -42,13 +44,18 @@ "sticky": { "type": "boolean", "default": "false", - "description": "Whether to maintain the menu in the viewport after\nthe anchor element is scrolled out of view." + "description": "Whether to maintain the popup in the viewport after\nthe anchor element was scrolled out of view." }, "positionMethod": { "type": "'absolute' | 'fixed'", "default": "'absolute'", "description": "Determines which CSS `position` property to use." }, + "trackAnchor": { + "type": "boolean", + "default": "true", + "description": "Whether the popup tracks any layout shift of its positioning anchor." + }, "className": { "type": "string | (state) => string", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." diff --git a/docs/reference/generated/popover-positioner.json b/docs/reference/generated/popover-positioner.json index 5d7d915bcc..9d50c7487f 100644 --- a/docs/reference/generated/popover-positioner.json +++ b/docs/reference/generated/popover-positioner.json @@ -44,13 +44,18 @@ "sticky": { "type": "boolean", "default": "false", - "description": "Whether to maintain the popup in the viewport after\nthe anchor element is scrolled out of view." + "description": "Whether to maintain the popup in the viewport after\nthe anchor element was scrolled out of view." }, "positionMethod": { "type": "'absolute' | 'fixed'", "default": "'absolute'", "description": "Determines which CSS `position` property to use." }, + "trackAnchor": { + "type": "boolean", + "default": "true", + "description": "Whether the popup tracks any layout shift of its positioning anchor." + }, "className": { "type": "string | (state) => string", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." diff --git a/docs/reference/generated/preview-card-positioner.json b/docs/reference/generated/preview-card-positioner.json index 8ec325c32e..0159de294f 100644 --- a/docs/reference/generated/preview-card-positioner.json +++ b/docs/reference/generated/preview-card-positioner.json @@ -44,13 +44,18 @@ "sticky": { "type": "boolean", "default": "false", - "description": "Whether to maintain the popup in the viewport after\nthe anchor element is scrolled out of view." + "description": "Whether to maintain the popup in the viewport after\nthe anchor element was scrolled out of view." }, "positionMethod": { "type": "'absolute' | 'fixed'", "default": "'absolute'", "description": "Determines which CSS `position` property to use." }, + "trackAnchor": { + "type": "boolean", + "default": "true", + "description": "Whether the popup tracks any layout shift of its positioning anchor." + }, "className": { "type": "string | (state) => string", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." diff --git a/docs/reference/generated/select-positioner.json b/docs/reference/generated/select-positioner.json index 97c2deae61..9abdaa0889 100644 --- a/docs/reference/generated/select-positioner.json +++ b/docs/reference/generated/select-positioner.json @@ -4,7 +4,7 @@ "props": { "align": { "type": "'start' | 'center' | 'end'", - "default": "'start'", + "default": "'center'", "description": "How to align the popup relative to the specified side." }, "alignOffset": { @@ -44,17 +44,17 @@ "sticky": { "type": "boolean", "default": "false", - "description": "Whether to maintain the select menu in the viewport after\nthe anchor element is scrolled out of view." + "description": "Whether to maintain the popup in the viewport after\nthe anchor element was scrolled out of view." }, "positionMethod": { "type": "'absolute' | 'fixed'", "default": "'absolute'", - "description": "The CSS position method for positioning the Select popup element." + "description": "Determines which CSS `position` property to use." }, "trackAnchor": { "type": "boolean", "default": "true", - "description": "Whether the select popup continuously tracks its anchor after the initial positioning upon mount." + "description": "Whether the popup tracks any layout shift of its positioning anchor." }, "className": { "type": "string | (state) => string", diff --git a/docs/reference/generated/tooltip-positioner.json b/docs/reference/generated/tooltip-positioner.json index 5ca373df6f..5dae700806 100644 --- a/docs/reference/generated/tooltip-positioner.json +++ b/docs/reference/generated/tooltip-positioner.json @@ -51,6 +51,11 @@ "default": "'absolute'", "description": "Determines which CSS `position` property to use." }, + "trackAnchor": { + "type": "boolean", + "default": "true", + "description": "Whether the popup tracks any layout shift of its positioning anchor." + }, "className": { "type": "string | (state) => string", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." diff --git a/docs/src/app/(private)/experiments/anchor-positioning.tsx b/docs/src/app/(private)/experiments/anchor-positioning.tsx index bad594ce66..a0f45b9e0e 100644 --- a/docs/src/app/(private)/experiments/anchor-positioning.tsx +++ b/docs/src/app/(private)/experiments/anchor-positioning.tsx @@ -37,7 +37,7 @@ export default function AnchorPositioning() { positionerStyles, arrowStyles, arrowRef, - renderedSide, + side: renderedSide, arrowUncentered, } = useAnchorPositioning({ side, diff --git a/packages/react/src/dialog/root/DialogRoot.tsx b/packages/react/src/dialog/root/DialogRoot.tsx index faa1303c22..66930017c6 100644 --- a/packages/react/src/dialog/root/DialogRoot.tsx +++ b/packages/react/src/dialog/root/DialogRoot.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { DialogRootContext, useOptionalDialogRootContext } from './DialogRootContext'; import { DialogContext } from '../utils/DialogContext'; -import { type CommonParameters, useDialogRoot } from './useDialogRoot'; +import { type SharedParameters, useDialogRoot } from './useDialogRoot'; /** * Groups all parts of the dialog. @@ -48,7 +48,7 @@ const DialogRoot = function DialogRoot(props: DialogRoot.Props) { }; namespace DialogRoot { - export interface Props extends CommonParameters { + export interface Props extends SharedParameters { children?: React.ReactNode; } } diff --git a/packages/react/src/dialog/root/useDialogRoot.ts b/packages/react/src/dialog/root/useDialogRoot.ts index b6da8e8cde..052c0b5bf1 100644 --- a/packages/react/src/dialog/root/useDialogRoot.ts +++ b/packages/react/src/dialog/root/useDialogRoot.ts @@ -21,7 +21,7 @@ import { translateOpenChangeReason, } from '../../utils/translateOpenChangeReason'; -export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRoot.ReturnValue { +export function useDialogRoot(params: useDialogRoot.Parameters): useDialogRoot.ReturnValue { const { defaultOpen, dismissible, @@ -30,7 +30,7 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo onNestedDialogOpen, onOpenChange: onOpenChangeParameter, open: openParam, - } = parameters; + } = params; const [open, setOpenUnwrapped] = useControlled({ controlled: openParam, @@ -163,7 +163,7 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo ]); } -export interface CommonParameters { +export interface SharedParameters { /** * Whether the dialog is currently open. */ @@ -196,7 +196,7 @@ export interface CommonParameters { } export namespace useDialogRoot { - export interface Parameters extends RequiredExcept { + export interface Parameters extends RequiredExcept { /** * Callback to invoke when a nested dialog is opened. */ diff --git a/packages/react/src/menu/positioner/MenuPositioner.tsx b/packages/react/src/menu/positioner/MenuPositioner.tsx index 4a7bfc628c..037af3412b 100644 --- a/packages/react/src/menu/positioner/MenuPositioner.tsx +++ b/packages/react/src/menu/positioner/MenuPositioner.tsx @@ -1,23 +1,18 @@ 'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; -import { - FloatingNode, - useFloatingNodeId, - useFloatingParentNodeId, - useFloatingTree, -} from '@floating-ui/react'; +import { FloatingNode, useFloatingNodeId, useFloatingParentNodeId } from '@floating-ui/react'; import { MenuPositionerContext } from './MenuPositionerContext'; import { useMenuRootContext } from '../root/MenuRootContext'; import type { Align, Side } from '../../utils/useAnchorPositioning'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; import { useForkRef } from '../../utils/useForkRef'; import { useMenuPositioner } from './useMenuPositioner'; -import { HTMLElementType } from '../../utils/proptypes'; import { BaseUIComponentProps } from '../../utils/types'; import { popupStateMapping } from '../../utils/popupStateMapping'; import { CompositeList } from '../../composite/list/CompositeList'; import { InternalBackdrop } from '../../utils/InternalBackdrop'; +import { HTMLElementType, refType } from '../../utils/proptypes'; import { useMenuPortalContext } from '../portal/MenuPortalContext'; /** @@ -43,6 +38,7 @@ const MenuPositioner = React.forwardRef(function MenuPositioner( collisionPadding = 5, arrowPadding = 5, sticky = false, + trackAnchor = true, ...otherProps } = props; @@ -54,13 +50,10 @@ const MenuPositioner = React.forwardRef(function MenuPositioner( itemLabels, mounted, nested, - setOpen, modal, } = useMenuRootContext(); const keepMounted = useMenuPortalContext(); - const { events: menuEvents } = useFloatingTree()!; - const nodeId = useFloatingNodeId(); const parentNodeId = useFloatingParentNodeId(); @@ -89,9 +82,8 @@ const MenuPositioner = React.forwardRef(function MenuPositioner( sticky, nodeId, parentNodeId, - menuEvents, - setOpen, keepMounted, + trackAnchor, }); const state: MenuPositioner.State = React.useMemo( @@ -112,7 +104,7 @@ const MenuPositioner = React.forwardRef(function MenuPositioner( arrowRef: positioner.arrowRef, arrowUncentered: positioner.arrowUncentered, arrowStyles: positioner.arrowStyles, - floatingContext: positioner.floatingContext, + floatingContext: positioner.context, }), [ positioner.side, @@ -120,7 +112,7 @@ const MenuPositioner = React.forwardRef(function MenuPositioner( positioner.arrowRef, positioner.arrowUncentered, positioner.arrowStyles, - positioner.floatingContext, + positioner.context, ], ); @@ -172,6 +164,7 @@ MenuPositioner.propTypes /* remove-proptypes */ = { // └─────────────────────────────────────────────────────────────────────┘ /** * How to align the popup relative to the specified side. + * @default 'center' */ align: PropTypes.oneOf(['center', 'end', 'start']), /** @@ -185,6 +178,7 @@ MenuPositioner.propTypes /* remove-proptypes */ = { */ anchor: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ HTMLElementType, + refType, PropTypes.object, PropTypes.func, ]), @@ -247,6 +241,7 @@ MenuPositioner.propTypes /* remove-proptypes */ = { /** * Which side of the anchor element to align the popup against. * May automatically change to avoid collisions. + * @default 'bottom' */ side: PropTypes.oneOf(['bottom', 'inline-end', 'inline-start', 'left', 'right', 'top']), /** @@ -255,11 +250,16 @@ MenuPositioner.propTypes /* remove-proptypes */ = { */ sideOffset: PropTypes.number, /** - * Whether to maintain the menu in the viewport after - * the anchor element is scrolled out of view. + * Whether to maintain the popup in the viewport after + * the anchor element was scrolled out of view. * @default false */ sticky: PropTypes.bool, + /** + * Whether the popup tracks any layout shift of its positioning anchor. + * @default true + */ + trackAnchor: PropTypes.bool, } as any; export { MenuPositioner }; diff --git a/packages/react/src/menu/positioner/useMenuPositioner.ts b/packages/react/src/menu/positioner/useMenuPositioner.ts index 46c5c89aae..4efa9e6b99 100644 --- a/packages/react/src/menu/positioner/useMenuPositioner.ts +++ b/packages/react/src/menu/positioner/useMenuPositioner.ts @@ -1,34 +1,21 @@ 'use client'; import * as React from 'react'; -import type { - Padding, - VirtualElement, - FloatingContext, - FloatingRootContext, - FloatingEvents, -} from '@floating-ui/react'; +import { useFloatingTree, type FloatingRootContext } from '@floating-ui/react'; import { mergeReactProps } from '../../utils/mergeReactProps'; -import { type Boundary, type Side, useAnchorPositioning } from '../../utils/useAnchorPositioning'; +import { useAnchorPositioning } from '../../utils/useAnchorPositioning'; import type { GenericHTMLProps } from '../../utils/types'; import { useMenuRootContext } from '../root/MenuRootContext'; export function useMenuPositioner( params: useMenuPositioner.Parameters, ): useMenuPositioner.ReturnValue { - const { mounted, menuEvents, nodeId, parentNodeId, setOpen } = params; + const { nodeId, parentNodeId } = params; - const { open } = useMenuRootContext(); + const { open, setOpen, mounted } = useMenuRootContext(); - const { - positionerStyles, - arrowStyles, - anchorHidden, - arrowRef, - arrowUncentered, - renderedSide, - renderedAlign, - positionerContext: floatingContext, - } = useAnchorPositioning(params); + const positioning = useAnchorPositioning(params); + + const { events: menuEvents } = useFloatingTree()!; const getPositionerProps: useMenuPositioner.ReturnValue['getPositionerProps'] = React.useCallback( (externalProps = {}) => { @@ -42,18 +29,18 @@ export function useMenuPositioner( role: 'presentation', hidden: !mounted, style: { - ...positionerStyles, + ...positioning.positionerStyles, ...hiddenStyles, }, }); }, - [open, positionerStyles, mounted], + [open, mounted, positioning.positionerStyles], ); React.useEffect(() => { function onMenuOpened(event: { nodeId: string; parentNodeId: string }) { if (event.nodeId !== nodeId && event.parentNodeId === parentNodeId) { - setOpen(false); + setOpen(false, undefined); } } @@ -72,106 +59,19 @@ export function useMenuPositioner( return React.useMemo( () => ({ + ...positioning, getPositionerProps, - arrowRef, - arrowUncentered, - arrowStyles, - side: renderedSide, - align: renderedAlign, - floatingContext, - anchorHidden, }), - [ - getPositionerProps, - arrowRef, - arrowUncentered, - arrowStyles, - renderedSide, - renderedAlign, - floatingContext, - anchorHidden, - ], + [positioning, getPositionerProps], ); } export namespace useMenuPositioner { - export interface SharedParameters { - /** - * Whether the menu is currently open. - */ - open?: boolean; - /** - * An element to position the popup against. - * By default, the popup will be positioned against the trigger. - */ - anchor?: - | Element - | null - | VirtualElement - | React.MutableRefObject - | (() => Element | VirtualElement | null); - /** - * Determines which CSS `position` property to use. - * @default 'absolute' - */ - positionMethod?: 'absolute' | 'fixed'; - /** - * Which side of the anchor element to align the popup against. - * May automatically change to avoid collisions. - */ - side?: Side; - /** - * Distance between the anchor and the popup. - * @default 0 - */ - sideOffset?: number; + export interface Parameters extends useAnchorPositioning.Parameters { /** - * How to align the popup relative to the specified side. + * The menu root context. */ - align?: 'start' | 'end' | 'center'; - /** - * Additional offset along the alignment axis of the element. - * @default 0 - */ - alignOffset?: number; - /** - * An element or a rectangle that delimits the area that the popup is confined to. - * @default 'clipping-ancestors' - */ - collisionBoundary?: Boundary; - /** - * Additional space to maintain from the edge of the collision boundary. - * @default 5 - */ - collisionPadding?: Padding; - /** - * Whether to maintain the menu in the viewport after - * the anchor element is scrolled out of view. - * @default false - */ - sticky?: boolean; - /** - * Minimum distance to maintain between the arrow and the edges of the popup. - * - * Use it to prevent the arrow element from hanging out of the rounded corners of a popup. - * @default 5 - */ - arrowPadding?: number; - } - - export interface Parameters extends SharedParameters { - /** - * Whether the portal is kept mounted in the DOM while the popup is closed. - */ - keepMounted: boolean; - /** - * Whether the Menu is mounted. - */ - mounted: boolean; - /** - * The Menu root context. - */ - floatingRootContext?: FloatingRootContext; + floatingRootContext: FloatingRootContext; /** * Floating node id. */ @@ -180,42 +80,11 @@ export namespace useMenuPositioner { * The parent floating node id. */ parentNodeId: string | null; - menuEvents: FloatingEvents; - setOpen: (open: boolean, event?: Event) => void; } - export interface ReturnValue { - /** - * Props to spread on the Menu positioner element. - */ + export interface SharedParameters extends useAnchorPositioning.SharedParameters {} + + export interface ReturnValue extends useAnchorPositioning.ReturnValue { getPositionerProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; - /** - * The ref of the Menu arrow element. - */ - arrowRef: React.MutableRefObject; - /** - * Determines if the arrow cannot be centered. - */ - arrowUncentered: boolean; - /** - * The rendered side of the Menu element. - */ - side: Side; - /** - * The rendered align of the Menu element. - */ - align: 'start' | 'end' | 'center'; - /** - * The styles to apply to the Menu arrow element. - */ - arrowStyles: React.CSSProperties; - /** - * The floating context. - */ - floatingContext: FloatingContext; - /** - * Determines if the anchor element is hidden. - */ - anchorHidden: boolean; } } diff --git a/packages/react/src/popover/popup/PopoverPopup.tsx b/packages/react/src/popover/popup/PopoverPopup.tsx index 5dec881b1c..b305c352d6 100644 --- a/packages/react/src/popover/popup/PopoverPopup.tsx +++ b/packages/react/src/popover/popup/PopoverPopup.tsx @@ -84,7 +84,7 @@ const PopoverPopup = React.forwardRef(function PopoverPopup( return ( ({ getPositionerProps, - arrowRef, - arrowUncentered, - arrowStyles, - side: renderedSide, - align: renderedAlign, - positionerContext, - anchorHidden, + ...positioning, }), - [ - getPositionerProps, - arrowRef, - arrowUncentered, - arrowStyles, - renderedSide, - renderedAlign, - positionerContext, - anchorHidden, - ], + [getPositionerProps, positioning], ); } export namespace usePopoverPositioner { - export interface SharedParameters { - /** - * An element to position the popup against. - * By default, the popup will be positioned against the trigger. - */ - anchor?: - | Element - | null - | VirtualElement - | React.MutableRefObject - | (() => Element | VirtualElement | null); - /** - * Determines which CSS `position` property to use. - * @default 'absolute' - */ - positionMethod?: 'absolute' | 'fixed'; - /** - * Which side of the anchor element to align the popup against. - * May automatically change to avoid collisions. - * @default 'bottom' - */ - side?: Side; - /** - * Distance between the anchor and the popup. - * @default 0 - */ - sideOffset?: number; - /** - * How to align the popup relative to the specified side. - * @default 'center' - */ - align?: 'start' | 'end' | 'center'; - /** - * Additional offset along the alignment axis of the element. - * @default 0 - */ - alignOffset?: number; - /** - * An element or a rectangle that delimits the area that the popup is confined to. - * @default 'clipping-ancestors' - */ - collisionBoundary?: Boundary; - /** - * Additional space to maintain from the edge of the collision boundary. - * @default 5 - */ - collisionPadding?: Padding; - /** - * Whether to maintain the popup in the viewport after - * the anchor element is scrolled out of view. - * @default false - */ - sticky?: boolean; - /** - * Minimum distance to maintain between the arrow and the edges of the popup. - * - * Use it to prevent the arrow element from hanging out of the rounded corners of a popup. - * @default 5 - */ - arrowPadding?: number; - /** - * Whether the popover continuously tracks its anchor after the initial positioning upon mount. - * @default true - */ - trackAnchor?: boolean; - } + export interface Parameters extends useAnchorPositioning.Parameters {} - export interface Parameters extends SharedParameters { - /** - * Whether the portal is kept mounted in the DOM while the popup is closed. - */ - keepMounted: boolean; - /** - * Whether the popover is mounted. - */ - mounted: boolean; - /** - * Whether the popover is currently open. - */ - open?: boolean; - /** - * The floating root context. - */ - floatingRootContext?: FloatingRootContext; - /** - * Method used to open the popover. - */ - openMethod: InteractionType | null; - /** - * The ref to the popup element. - */ - popupRef: React.RefObject; - } + export interface SharedParameters extends useAnchorPositioning.SharedParameters {} - export interface ReturnValue { - /** - * Props to spread on the popover positioner element. - */ + export interface ReturnValue extends useAnchorPositioning.ReturnValue { getPositionerProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; - /** - * The ref of the popover arrow element. - */ - arrowRef: React.MutableRefObject; - /** - * Determines if the arrow cannot be centered. - */ - arrowUncentered: boolean; - /** - * The rendered side of the popover element. - */ - side: Side; - /** - * The rendered align of the popover element. - */ - align: 'start' | 'end' | 'center'; - /** - * The styles to apply to the popover arrow element. - */ - arrowStyles: React.CSSProperties; - /** - * The floating context. - */ - positionerContext: FloatingContext; - /** - * Determines if the anchor element is hidden. - */ - anchorHidden: boolean; } } diff --git a/packages/react/src/popover/root/PopoverRoot.tsx b/packages/react/src/popover/root/PopoverRoot.tsx index 9fedbbf161..7a03624101 100644 --- a/packages/react/src/popover/root/PopoverRoot.tsx +++ b/packages/react/src/popover/root/PopoverRoot.tsx @@ -12,88 +12,35 @@ import { OPEN_DELAY } from '../utils/constants'; * Documentation: [Base UI Popover](https://base-ui.com/react/components/popover) */ const PopoverRoot: React.FC = function PopoverRoot(props) { - const { openOnHover = false, delay, closeDelay = 0 } = props; + const { + defaultOpen = false, + onOpenChange, + open, + openOnHover = false, + delay, + closeDelay = 0, + } = props; const delayWithDefault = delay ?? OPEN_DELAY; - const { + const popoverRoot = usePopoverRoot({ + ...props, + defaultOpen, + onOpenChange, open, - setOpen, - mounted, - setMounted, - setTriggerElement, - positionerElement, - setPositionerElement, - popupRef, - instantType, - transitionStatus, - floatingRootContext, - getRootTriggerProps, - getRootPopupProps, - titleId, - setTitleId, - descriptionId, - setDescriptionId, - openMethod, - openReason, - } = usePopoverRoot({ openOnHover, delay: delayWithDefault, closeDelay, - open: props.open, - onOpenChange: props.onOpenChange, - defaultOpen: props.defaultOpen, }); const contextValue: PopoverRootContext = React.useMemo( () => ({ + ...popoverRoot, openOnHover, delay: delayWithDefault, closeDelay, - open, - setOpen, - setTriggerElement, - positionerElement, - setPositionerElement, - popupRef, - mounted, - setMounted, - instantType, - transitionStatus, - titleId, - setTitleId, - descriptionId, - setDescriptionId, - floatingRootContext, - getRootPopupProps, - getRootTriggerProps, - openMethod, - openReason, }), - [ - openOnHover, - delayWithDefault, - closeDelay, - open, - setOpen, - setTriggerElement, - positionerElement, - setPositionerElement, - popupRef, - mounted, - setMounted, - instantType, - transitionStatus, - titleId, - setTitleId, - descriptionId, - setDescriptionId, - floatingRootContext, - getRootPopupProps, - getRootTriggerProps, - openMethod, - openReason, - ], + [popoverRoot, openOnHover, delayWithDefault, closeDelay], ); return ( diff --git a/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx b/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx index 08f9dc095f..a8c56bace8 100644 --- a/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx +++ b/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx @@ -6,10 +6,10 @@ import { usePreviewCardRootContext } from '../root/PreviewCardContext'; import { usePreviewCardPositioner } from './usePreviewCardPositioner'; import { PreviewCardPositionerContext } from './PreviewCardPositionerContext'; import { useForkRef } from '../../utils/useForkRef'; -import { HTMLElementType } from '../../utils/proptypes'; import type { Side, Align } from '../../utils/useAnchorPositioning'; import type { BaseUIComponentProps } from '../../utils/types'; import { popupStateMapping } from '../../utils/popupStateMapping'; +import { HTMLElementType, refType } from '../../utils/proptypes'; import { usePreviewCardPortalContext } from '../portal/PreviewCardPortalContext'; /** @@ -35,6 +35,7 @@ const PreviewCardPositioner = React.forwardRef(function PreviewCardPositioner( collisionPadding = 5, arrowPadding = 5, sticky = false, + trackAnchor = true, ...otherProps } = props; @@ -55,6 +56,7 @@ const PreviewCardPositioner = React.forwardRef(function PreviewCardPositioner( collisionBoundary, collisionPadding, sticky, + trackAnchor, keepMounted, }); @@ -141,6 +143,7 @@ PreviewCardPositioner.propTypes /* remove-proptypes */ = { */ anchor: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ HTMLElementType, + refType, PropTypes.object, PropTypes.func, ]), @@ -213,10 +216,15 @@ PreviewCardPositioner.propTypes /* remove-proptypes */ = { sideOffset: PropTypes.number, /** * Whether to maintain the popup in the viewport after - * the anchor element is scrolled out of view. + * the anchor element was scrolled out of view. * @default false */ sticky: PropTypes.bool, + /** + * Whether the popup tracks any layout shift of its positioning anchor. + * @default true + */ + trackAnchor: PropTypes.bool, } as any; export { PreviewCardPositioner }; diff --git a/packages/react/src/preview-card/positioner/usePreviewCardPositioner.ts b/packages/react/src/preview-card/positioner/usePreviewCardPositioner.ts index 2228d1e050..f9aa8506a3 100644 --- a/packages/react/src/preview-card/positioner/usePreviewCardPositioner.ts +++ b/packages/react/src/preview-card/positioner/usePreviewCardPositioner.ts @@ -1,32 +1,15 @@ import * as React from 'react'; -import type { - Padding, - FloatingContext, - VirtualElement, - FloatingRootContext, -} from '@floating-ui/react'; import { mergeReactProps } from '../../utils/mergeReactProps'; -import { Boundary, useAnchorPositioning, type Side } from '../../utils/useAnchorPositioning'; +import { useAnchorPositioning } from '../../utils/useAnchorPositioning'; import type { GenericHTMLProps } from '../../utils/types'; import { usePreviewCardRootContext } from '../root/PreviewCardContext'; export function usePreviewCardPositioner( params: usePreviewCardPositioner.Parameters, ): usePreviewCardPositioner.ReturnValue { - const { mounted } = params; + const { open, mounted } = usePreviewCardRootContext(); - const { open } = usePreviewCardRootContext(); - - const { - positionerStyles, - arrowStyles, - anchorHidden, - arrowRef, - arrowUncentered, - renderedSide, - renderedAlign, - positionerContext, - } = useAnchorPositioning(params); + const positioning = useAnchorPositioning(params); const getPositionerProps: usePreviewCardPositioner.ReturnValue['getPositionerProps'] = React.useCallback( @@ -41,158 +24,29 @@ export function usePreviewCardPositioner( role: 'presentation', hidden: !mounted, style: { - ...positionerStyles, + ...positioning.positionerStyles, ...hiddenStyles, }, }); }, - [positionerStyles, open, mounted], + [open, mounted, positioning.positionerStyles], ); return React.useMemo( () => ({ getPositionerProps, - arrowRef, - arrowUncentered, - arrowStyles, - side: renderedSide, - align: renderedAlign, - positionerContext, - anchorHidden, + ...positioning, }), - [ - getPositionerProps, - arrowRef, - arrowUncentered, - arrowStyles, - renderedSide, - renderedAlign, - positionerContext, - anchorHidden, - ], + [getPositionerProps, positioning], ); } export namespace usePreviewCardPositioner { - export interface SharedParameters { - /** - * An element to position the popup against. - * By default, the popup will be positioned against the trigger. - */ - anchor?: - | Element - | null - | VirtualElement - | React.MutableRefObject - | (() => Element | VirtualElement | null); - /** - * Determines which CSS `position` property to use. - * @default 'absolute' - */ - positionMethod?: 'absolute' | 'fixed'; - /** - * Which side of the anchor element to align the popup against. - * May automatically change to avoid collisions. - * @default 'bottom' - */ - side?: Side; - /** - * Distance between the anchor and the popup. - * @default 0 - */ - sideOffset?: number; - /** - * How to align the popup relative to the specified side. - * @default 'center' - */ - align?: 'start' | 'end' | 'center'; - /** - * Additional offset along the alignment axis of the element. - * @default 0 - */ - alignOffset?: number; - /** - * An element or a rectangle that delimits the area that the popup is confined to. - * @default 'clipping-ancestors' - */ - collisionBoundary?: Boundary; - /** - * Additional space to maintain from the edge of the collision boundary. - * @default 5 - */ - collisionPadding?: Padding; - /** - * Whether to maintain the popup in the viewport after - * the anchor element is scrolled out of view. - * @default false - */ - sticky?: boolean; - /** - * Minimum distance to maintain between the arrow and the edges of the popup. - * - * Use it to prevent the arrow element from hanging out of the rounded corners of a popup. - * @default 5 - */ - arrowPadding?: number; - /** - * Whether the preview card popup continuously tracks its anchor after the initial positioning - * upon mount. - * @default true - */ - trackAnchor?: boolean; - } + export interface Parameters extends useAnchorPositioning.Parameters {} - export interface Parameters extends SharedParameters { - /** - * Whether the portal is kept mounted in the DOM while the popup is closed. - */ - keepMounted: boolean; - /** - * Whether the preview card is mounted. - */ - mounted: boolean; - /** - * Whether the preview card is currently open. - */ - open?: boolean; - /** - * The floating root context. - */ - floatingRootContext?: FloatingRootContext; - } + export interface SharedParameters extends useAnchorPositioning.SharedParameters {} - export interface ReturnValue { - /** - * Props to spread on the preview card positioner element. - */ + export interface ReturnValue extends useAnchorPositioning.ReturnValue { getPositionerProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; - /** - * The ref of the preview card arrow element. - */ - arrowRef: React.MutableRefObject; - /** - * Determines if the arrow cannot be centered. - */ - arrowUncentered: boolean; - /** - * The rendered side of the preview card element. - */ - side: Side; - /** - * The rendered align of the preview card element. - */ - align: 'start' | 'end' | 'center'; - /** - * The styles to apply to the preview card arrow element. - */ - arrowStyles: React.CSSProperties; - /** - * The floating context. - */ - positionerContext: FloatingContext; - /** - * Determines if the anchor element is hidden. - */ - anchorHidden: boolean; } } diff --git a/packages/react/src/preview-card/root/PreviewCardRoot.tsx b/packages/react/src/preview-card/root/PreviewCardRoot.tsx index 0cc3e40816..347e735f16 100644 --- a/packages/react/src/preview-card/root/PreviewCardRoot.tsx +++ b/packages/react/src/preview-card/root/PreviewCardRoot.tsx @@ -17,21 +17,7 @@ const PreviewCardRoot: React.FC = function PreviewCardRoo const delayWithDefault = delay ?? OPEN_DELAY; const closeDelayWithDefault = closeDelay ?? CLOSE_DELAY; - const { - open, - setOpen, - mounted, - setMounted, - setTriggerElement, - positionerElement, - setPositionerElement, - popupRef, - instantType, - getRootTriggerProps, - getRootPopupProps, - floatingRootContext, - transitionStatus, - } = usePreviewCardRoot({ + const previewCardRoot = usePreviewCardRoot({ delay, closeDelay, open: props.open, @@ -41,39 +27,11 @@ const PreviewCardRoot: React.FC = function PreviewCardRoo const contextValue = React.useMemo( () => ({ + ...previewCardRoot, delay: delayWithDefault, closeDelay: closeDelayWithDefault, - open, - setOpen, - setTriggerElement, - positionerElement, - setPositionerElement, - popupRef, - mounted, - setMounted, - instantType, - getRootTriggerProps, - getRootPopupProps, - floatingRootContext, - transitionStatus, }), - [ - delayWithDefault, - closeDelayWithDefault, - open, - setOpen, - setTriggerElement, - positionerElement, - setPositionerElement, - popupRef, - mounted, - setMounted, - instantType, - getRootTriggerProps, - getRootPopupProps, - floatingRootContext, - transitionStatus, - ], + [closeDelayWithDefault, delayWithDefault, previewCardRoot], ); return ( diff --git a/packages/react/src/select/popup/SelectPopup.tsx b/packages/react/src/select/popup/SelectPopup.tsx index a7c9caec9e..634469a005 100644 --- a/packages/react/src/select/popup/SelectPopup.tsx +++ b/packages/react/src/select/popup/SelectPopup.tsx @@ -84,7 +84,7 @@ const SelectPopup = React.forwardRef(function SelectPopup( /> )} { - if (props[propName] == null) { - return new Error(`Prop '${propName}' is required but wasn't specified`); - } - if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) { - return new Error(`Expected prop '${propName}' to be of type Element`); - } - return null; - }, + anchor: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + HTMLElementType, + refType, + PropTypes.object, PropTypes.func, - PropTypes.shape({ - contextElement: (props, propName) => { - if (props[propName] == null) { - return null; - } - if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) { - return new Error(`Expected prop '${propName}' to be of type Element`); - } - return null; - }, - getBoundingClientRect: PropTypes.func.isRequired, - getClientRects: PropTypes.func, - }), - PropTypes.shape({ - current: (props, propName) => { - if (props[propName] == null) { - return null; - } - if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) { - return new Error(`Expected prop '${propName}' to be of type Element`); - } - return null; - }, - }), ]), /** * Minimum distance to maintain between the arrow and the edges of the popup. @@ -179,31 +150,15 @@ SelectPositioner.propTypes /* remove-proptypes */ = { * An element or a rectangle that delimits the area that the popup is confined to. * @default 'clipping-ancestors' */ - collisionBoundary: PropTypes.oneOfType([ - PropTypes.oneOf(['clipping-ancestors']), - PropTypes.arrayOf((props, propName) => { - if (props[propName] == null) { - return new Error(`Prop '${propName}' is required but wasn't specified`); - } - if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) { - return new Error(`Expected prop '${propName}' to be of type Element`); - } - return null; - }), - (props, propName) => { - if (props[propName] == null) { - return new Error(`Prop '${propName}' is required but wasn't specified`); - } - if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) { - return new Error(`Expected prop '${propName}' to be of type Element`); - } - return null; - }, + collisionBoundary: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + HTMLElementType, + PropTypes.arrayOf(HTMLElementType), + PropTypes.string, PropTypes.shape({ - height: PropTypes.number.isRequired, - width: PropTypes.number.isRequired, - x: PropTypes.number.isRequired, - y: PropTypes.number.isRequired, + height: PropTypes.number, + width: PropTypes.number, + x: PropTypes.number, + y: PropTypes.number, }), ]), /** @@ -220,7 +175,7 @@ SelectPositioner.propTypes /* remove-proptypes */ = { }), ]), /** - * The CSS position method for positioning the Select popup element. + * Determines which CSS `position` property to use. * @default 'absolute' */ positionMethod: PropTypes.oneOf(['absolute', 'fixed']), @@ -243,13 +198,13 @@ SelectPositioner.propTypes /* remove-proptypes */ = { */ sideOffset: PropTypes.number, /** - * Whether to maintain the select menu in the viewport after - * the anchor element is scrolled out of view. + * Whether to maintain the popup in the viewport after + * the anchor element was scrolled out of view. * @default false */ sticky: PropTypes.bool, /** - * Whether the select popup continuously tracks its anchor after the initial positioning upon mount. + * Whether the popup tracks any layout shift of its positioning anchor. * @default true */ trackAnchor: PropTypes.bool, diff --git a/packages/react/src/select/positioner/SelectPositionerContext.ts b/packages/react/src/select/positioner/SelectPositionerContext.ts index 6ce0e77de7..f19bcf2e7b 100644 --- a/packages/react/src/select/positioner/SelectPositionerContext.ts +++ b/packages/react/src/select/positioner/SelectPositionerContext.ts @@ -1,7 +1,7 @@ import * as React from 'react'; import { useSelectPositioner } from './useSelectPositioner'; -type SelectPositionerContext = ReturnType['positioner']; +type SelectPositionerContext = ReturnType; export const SelectPositionerContext = React.createContext(null); diff --git a/packages/react/src/select/positioner/useSelectPositioner.ts b/packages/react/src/select/positioner/useSelectPositioner.ts index e0aa0be52d..071a8421bf 100644 --- a/packages/react/src/select/positioner/useSelectPositioner.ts +++ b/packages/react/src/select/positioner/useSelectPositioner.ts @@ -1,13 +1,6 @@ import * as React from 'react'; -import type { - VirtualElement, - Padding, - FloatingRootContext, - FloatingContext, - Middleware, -} from '@floating-ui/react'; import type { GenericHTMLProps } from '../../utils/types'; -import { type Boundary, type Side, useAnchorPositioning } from '../../utils/useAnchorPositioning'; +import { Side, useAnchorPositioning } from '../../utils/useAnchorPositioning'; import { mergeReactProps } from '../../utils/mergeReactProps'; import { useSelectRootContext } from '../root/SelectRootContext'; import { useScrollLock } from '../../utils/useScrollLock'; @@ -19,26 +12,14 @@ export function useSelectPositioner( useScrollLock((alignItemToTrigger || modal) && open, triggerElement); - const { - positionerStyles: enabledPositionerStyles, - arrowStyles, - anchorHidden, - arrowRef, - arrowUncentered, - renderedSide, - renderedAlign, - positionerContext, - isPositioned, - } = useAnchorPositioning({ + const positioning = useAnchorPositioning({ ...params, - keepMounted: true, trackAnchor: params.trackAnchor ?? !alignItemToTrigger, - mounted, }); const positionerStyles: React.CSSProperties = React.useMemo( - () => (alignItemToTrigger ? { position: 'fixed' } : enabledPositionerStyles), - [alignItemToTrigger, enabledPositionerStyles], + () => (alignItemToTrigger ? { position: 'fixed' } : positioning.positionerStyles), + [alignItemToTrigger, positioning.positionerStyles], ); const getPositionerProps: useSelectPositioner.ReturnValue['getPositionerProps'] = @@ -62,189 +43,23 @@ export function useSelectPositioner( [open, mounted, positionerStyles], ); - const positioner = React.useMemo( - () => - ({ - arrowRef, - arrowUncentered, - arrowStyles, - side: alignItemToTrigger ? 'none' : renderedSide, - align: renderedAlign, - positionerContext, - isPositioned, - anchorHidden, - }) as const, - [ - alignItemToTrigger, - arrowRef, - arrowStyles, - arrowUncentered, - isPositioned, - positionerContext, - renderedAlign, - renderedSide, - anchorHidden, - ], - ); - return React.useMemo( () => ({ + ...positioning, + side: alignItemToTrigger ? 'none' : positioning.side, getPositionerProps, - positioner, }), - [getPositionerProps, positioner], + [alignItemToTrigger, getPositionerProps, positioning], ); } export namespace useSelectPositioner { - export interface SharedParameters { - /** - * An element to position the popup against. - * By default, the popup will be positioned against the trigger. - */ - anchor?: - | Element - | null - | VirtualElement - | React.MutableRefObject - | (() => Element | VirtualElement | null); - /** - * The CSS position method for positioning the Select popup element. - * @default 'absolute' - */ - positionMethod?: 'absolute' | 'fixed'; - /** - * Which side of the anchor element to align the popup against. - * May automatically change to avoid collisions. - * @default 'bottom' - */ - side?: Side; - /** - * Distance between the anchor and the popup. - * @default 0 - */ - sideOffset?: number; - /** - * How to align the popup relative to the specified side. - * @default 'start' - */ - align?: 'start' | 'end' | 'center'; - /** - * Additional offset along the alignment axis of the element. - * @default 0 - */ - alignOffset?: number; - /** - * An element or a rectangle that delimits the area that the popup is confined to. - * @default 'clipping-ancestors' - */ - collisionBoundary?: Boundary; - /** - * Additional space to maintain from the edge of the collision boundary. - * @default 5 - */ - collisionPadding?: Padding; - /** - * Whether to maintain the select menu in the viewport after - * the anchor element is scrolled out of view. - * @default false - */ - sticky?: boolean; - /** - * Minimum distance to maintain between the arrow and the edges of the popup. - * - * Use it to prevent the arrow element from hanging out of the rounded corners of a popup. - * @default 5 - */ - arrowPadding?: number; - /** - * Whether the select popup continuously tracks its anchor after the initial positioning upon mount. - * @default true - */ - trackAnchor?: boolean; - } + export interface Parameters extends useAnchorPositioning.Parameters {} - export interface Parameters extends SharedParameters { - /** - * Whether the Select is mounted. - */ - mounted: boolean; - /** - * Whether the select menu is currently open. - */ - open?: boolean; - /** - * The Select root context. - */ - floatingRootContext?: FloatingRootContext; - /** - * Floating node id. - */ - nodeId?: string; - /** - * If specified, positions the popup relative to the selected item inside it. - */ - inner?: Middleware; - /** - * Whether the floating element can flip to the perpendicular axis if it cannot fit in the - * viewport. - * @default true - */ - allowAxisFlip?: boolean; - /** - * Whether to use fallback anchor postioning because anchoring to an inner item results in poor - * UX. - * @default false - */ - innerFallback?: boolean; - /** - * Whether the user's current modality is touch. - * @default false - */ - touchModality?: boolean; - } + export interface SharedParameters extends useAnchorPositioning.SharedParameters {} - export interface ReturnValue { - /** - * Props to spread on the Select positioner element. - */ + export interface ReturnValue extends Omit { getPositionerProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; - /** - * The Select positioner context. - */ - positioner: { - /** - * The ref of the Select arrow element. - */ - arrowRef: React.MutableRefObject; - /** - * Determines if the arrow cannot be centered. - */ - arrowUncentered: boolean; - /** - * The rendered side of the Select element. - */ - side: Side | 'none'; - /** - * The rendered align of the Select element. - */ - align: 'start' | 'end' | 'center'; - /** - * The styles to apply to the Select arrow element. - */ - arrowStyles: React.CSSProperties; - /** - * The floating context. - */ - positionerContext: FloatingContext; - /** - * Whether the Select popup has been positioned. - */ - isPositioned: boolean; - /** - * Determines if the anchor element is hidden. - */ - anchorHidden: boolean; - }; + side: Side | 'none'; } } diff --git a/packages/react/src/tooltip/positioner/TooltipPositioner.tsx b/packages/react/src/tooltip/positioner/TooltipPositioner.tsx index 4a08765820..9a27f68e67 100644 --- a/packages/react/src/tooltip/positioner/TooltipPositioner.tsx +++ b/packages/react/src/tooltip/positioner/TooltipPositioner.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; -import { HTMLElementType } from '../../utils/proptypes'; import { useForkRef } from '../../utils/useForkRef'; import { useTooltipRootContext } from '../root/TooltipRootContext'; import { TooltipPositionerContext } from './TooltipPositionerContext'; @@ -10,6 +9,7 @@ import { useTooltipPositioner } from './useTooltipPositioner'; import type { BaseUIComponentProps } from '../../utils/types'; import type { Side, Align } from '../../utils/useAnchorPositioning'; import { popupStateMapping } from '../../utils/popupStateMapping'; +import { HTMLElementType, refType } from '../../utils/proptypes'; import { useTooltipPortalContext } from '../portal/TooltipPortalContext'; /** @@ -35,18 +35,18 @@ const TooltipPositioner = React.forwardRef(function TooltipPositioner( collisionPadding = 5, arrowPadding = 5, sticky = false, + trackAnchor = true, ...otherProps } = props; - const { open, setPositionerElement, mounted, floatingRootContext, trackCursorAxis } = - useTooltipRootContext(); + const { open, setPositionerElement, mounted, floatingRootContext } = useTooltipRootContext(); const keepMounted = useTooltipPortalContext(); const positioner = useTooltipPositioner({ anchor, - floatingRootContext, positionMethod, - open, + floatingRootContext, + trackAnchor, mounted, side, sideOffset, @@ -55,7 +55,6 @@ const TooltipPositioner = React.forwardRef(function TooltipPositioner( collisionBoundary, collisionPadding, sticky, - trackCursorAxis, arrowPadding, keepMounted, }); @@ -136,6 +135,7 @@ TooltipPositioner.propTypes /* remove-proptypes */ = { */ anchor: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ HTMLElementType, + refType, PropTypes.object, PropTypes.func, ]), @@ -212,6 +212,11 @@ TooltipPositioner.propTypes /* remove-proptypes */ = { * @default false */ sticky: PropTypes.bool, + /** + * Whether the popup tracks any layout shift of its positioning anchor. + * @default true + */ + trackAnchor: PropTypes.bool, } as any; export { TooltipPositioner }; diff --git a/packages/react/src/tooltip/positioner/useTooltipPositioner.ts b/packages/react/src/tooltip/positioner/useTooltipPositioner.ts index 59ca294b7f..d58f3227e1 100644 --- a/packages/react/src/tooltip/positioner/useTooltipPositioner.ts +++ b/packages/react/src/tooltip/positioner/useTooltipPositioner.ts @@ -1,30 +1,19 @@ import * as React from 'react'; -import type { Padding, VirtualElement, FloatingRootContext } from '@floating-ui/react'; import { mergeReactProps } from '../../utils/mergeReactProps'; -import { type Boundary, type Side, useAnchorPositioning } from '../../utils/useAnchorPositioning'; +import { Side, useAnchorPositioning } from '../../utils/useAnchorPositioning'; import type { GenericHTMLProps } from '../../utils/types'; import { useTooltipRootContext } from '../root/TooltipRootContext'; export function useTooltipPositioner( params: useTooltipPositioner.Parameters, ): useTooltipPositioner.ReturnValue { - const { mounted } = params; + const { open, trackCursorAxis, mounted } = useTooltipRootContext(); - const { open, trackCursorAxis } = useTooltipRootContext(); - - const { - positionerStyles, - arrowStyles, - anchorHidden, - arrowRef, - arrowUncentered, - renderedSide, - renderedAlign, - } = useAnchorPositioning(params); + const positioning = useAnchorPositioning(params); const getPositionerProps: useTooltipPositioner.ReturnValue['getPositionerProps'] = React.useCallback( - (externalProps = {}) => { + (externalProps) => { const hiddenStyles: React.CSSProperties = {}; if (!open) { @@ -39,165 +28,41 @@ export function useTooltipPositioner( role: 'presentation', hidden: !mounted, style: { - ...positionerStyles, + ...positioning.positionerStyles, ...hiddenStyles, }, }); }, - [open, trackCursorAxis, mounted, positionerStyles], + [open, trackCursorAxis, mounted, positioning.positionerStyles], ); return React.useMemo( () => ({ getPositionerProps, - arrowStyles, - arrowRef, - arrowUncentered, - side: renderedSide, - align: renderedAlign, - anchorHidden, + ...positioning, }), - [ - getPositionerProps, - arrowStyles, - arrowRef, - arrowUncentered, - renderedSide, - renderedAlign, - anchorHidden, - ], + [getPositionerProps, positioning], ); } export namespace useTooltipPositioner { - export interface SharedParameters { - /** - * An element to position the popup against. - * By default, the popup will be positioned against the trigger. - */ - anchor?: - | Element - | null - | VirtualElement - | React.MutableRefObject - | (() => Element | VirtualElement | null); - /** - * Whether the tooltip is currently open. - */ - open?: boolean; + export interface Parameters extends useAnchorPositioning.Parameters {} + + export interface SharedParameters extends useAnchorPositioning.SharedParameters { /** - * Determines which CSS `position` property to use. - * @default 'absolute' + * Determines which axis the tooltip should track the cursor on. + * @default 'none' */ - positionMethod?: 'absolute' | 'fixed'; + trackCursorAxis?: 'none' | 'x' | 'y' | 'both'; /** * Which side of the anchor element to align the popup against. * May automatically change to avoid collisions. * @default 'top' */ side?: Side; - /** - * Distance between the anchor and the popup. - * @default 0 - */ - sideOffset?: number; - /** - * How to align the popup relative to the specified side. - * @default 'center' - */ - align?: 'start' | 'end' | 'center'; - /** - * Additional offset along the alignment axis of the element. - * @default 0 - */ - alignOffset?: number; - /** - * An element or a rectangle that delimits the area that the popup is confined to. - * @default 'clipping-ancestors' - */ - collisionBoundary?: Boundary; - /** - * Additional space to maintain from the edge of the collision boundary. - * @default 5 - */ - collisionPadding?: Padding; - /** - * Whether to maintain the popup in the viewport after - * the anchor element was scrolled out of view. - * @default false - */ - sticky?: boolean; - /** - * Minimum distance to maintain between the arrow and the edges of the popup. - * - * Use it to prevent the arrow element from hanging out of the rounded corners of a popup. - * @default 5 - */ - arrowPadding?: number; - /** - * Whether the tooltip continuously tracks its anchor after the initial positioning upon - * mount. - * @default true - */ - trackAnchor?: boolean; - /** - * The tooltip root context. - */ - floatingRootContext?: FloatingRootContext; - /** - * Determines which axis the tooltip should track the cursor on. - * @default 'none' - */ - trackCursorAxis?: 'none' | 'x' | 'y' | 'both'; - } - - export interface Parameters extends SharedParameters { - /** - * Whether the portal is kept mounted in the DOM while the popup is closed. - */ - keepMounted: boolean; - /** - * Whether the tooltip is mounted. - */ - mounted: boolean; - /** - * Whether the tooltip is currently open. - */ - open?: boolean; - /** - * The tooltip root context. - */ - floatingRootContext?: FloatingRootContext; } - export interface ReturnValue { - /** - * Props to spread on the positioner element. - */ + export interface ReturnValue extends useAnchorPositioning.ReturnValue { getPositionerProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; - /** - * The ref for the arrow element. - */ - arrowRef: React.MutableRefObject; - /** - * Determines if the arrow cannot be centered. - */ - arrowUncentered: boolean; - /** - * Styles to apply to the arrow element. - */ - arrowStyles: React.CSSProperties; - /** - * The rendered side of the tooltip element. - */ - side: Side; - /** - * The rendered align of the tooltip element. - */ - align: 'start' | 'end' | 'center'; - /** - * Determines if the anchor element is hidden. - */ - anchorHidden: boolean; } } diff --git a/packages/react/src/tooltip/root/TooltipRoot.tsx b/packages/react/src/tooltip/root/TooltipRoot.tsx index 6f5789f710..dc48a0fae3 100644 --- a/packages/react/src/tooltip/root/TooltipRoot.tsx +++ b/packages/react/src/tooltip/root/TooltipRoot.tsx @@ -12,72 +12,38 @@ import { OPEN_DELAY } from '../utils/constants'; * Documentation: [Base UI Tooltip](https://base-ui.com/react/components/tooltip) */ const TooltipRoot: React.FC = function TooltipRoot(props) { - const { delay, closeDelay, hoverable = true, trackCursorAxis = 'none' } = props; + const { + defaultOpen = false, + onOpenChange, + open, + delay, + closeDelay, + hoverable = true, + trackCursorAxis = 'none', + } = props; const delayWithDefault = delay ?? OPEN_DELAY; const closeDelayWithDefault = closeDelay ?? 0; - const { + const tooltipRoot = useTooltipRoot({ + ...props, + defaultOpen, + onOpenChange, open, - setOpen, - mounted, - setMounted, - setTriggerElement, - positionerElement, - setPositionerElement, - popupRef, - instantType, - getRootTriggerProps, - getRootPopupProps, - floatingRootContext, - transitionStatus, - } = useTooltipRoot({ hoverable, trackCursorAxis, delay, closeDelay, - open: props.open, - onOpenChange: props.onOpenChange, - defaultOpen: props.defaultOpen, }); - const contextValue = React.useMemo( + const contextValue: TooltipRootContext = React.useMemo( () => ({ + ...tooltipRoot, delay: delayWithDefault, closeDelay: closeDelayWithDefault, - open, - setOpen, - setTriggerElement, - positionerElement, - setPositionerElement, - popupRef, - mounted, - setMounted, - instantType, - getRootTriggerProps, - getRootPopupProps, - floatingRootContext, trackCursorAxis, - transitionStatus, }), - [ - delayWithDefault, - closeDelayWithDefault, - open, - setOpen, - setTriggerElement, - positionerElement, - setPositionerElement, - popupRef, - mounted, - setMounted, - instantType, - getRootTriggerProps, - getRootPopupProps, - floatingRootContext, - trackCursorAxis, - transitionStatus, - ], + [tooltipRoot, delayWithDefault, closeDelayWithDefault, trackCursorAxis], ); return ( diff --git a/packages/react/src/utils/useAnchorPositioning.ts b/packages/react/src/utils/useAnchorPositioning.ts index a3c51b09e7..5d7bd1eca1 100644 --- a/packages/react/src/utils/useAnchorPositioning.ts +++ b/packages/react/src/utils/useAnchorPositioning.ts @@ -26,69 +26,31 @@ export type Side = 'top' | 'bottom' | 'left' | 'right' | 'inline-end' | 'inline- export type Align = 'start' | 'center' | 'end'; export type Boundary = 'clipping-ancestors' | Element | Element[] | Rect; -interface UseAnchorPositioningParameters { - anchor?: - | Element - | VirtualElement - | (() => Element | VirtualElement | null) - | React.MutableRefObject - | null; - positionMethod?: 'absolute' | 'fixed'; - side?: Side; - sideOffset?: number; - align?: Align; - alignOffset?: number; - fallbackAxisSideDirection?: 'start' | 'end' | 'none'; - collisionBoundary?: Boundary; - collisionPadding?: Padding; - sticky?: boolean; - keepMounted: boolean; - arrowPadding?: number; - floatingRootContext?: FloatingRootContext; - mounted: boolean; - trackAnchor?: boolean; - nodeId?: string; - allowAxisFlip?: boolean; -} - -interface UseAnchorPositioningReturnValue { - positionerStyles: React.CSSProperties; - arrowStyles: React.CSSProperties; - arrowRef: React.MutableRefObject; - arrowUncentered: boolean; - renderedSide: Side; - renderedAlign: Align; - anchorHidden: boolean; - refs: ReturnType['refs']; - positionerContext: FloatingContext; - isPositioned: boolean; -} - /** * Provides standardized anchor positioning behavior for floating elements. Wraps Floating UI's * `useFloating` hook. * @ignore - internal hook. */ export function useAnchorPositioning( - params: UseAnchorPositioningParameters, -): UseAnchorPositioningReturnValue { + params: useAnchorPositioning.Parameters, +): useAnchorPositioning.ReturnValue { const { + // Public parameters anchor, - floatingRootContext, positionMethod = 'absolute', - side: sideParam = 'top', + side: sideParam = 'bottom', sideOffset = 0, align = 'center', alignOffset = 0, collisionBoundary, collisionPadding = 5, - fallbackAxisSideDirection = 'none', sticky = false, - keepMounted = false, arrowPadding = 5, + keepMounted = false, + // Private parameters + floatingRootContext, mounted, trackAnchor = true, - allowAxisFlip = true, nodeId, } = params; @@ -126,10 +88,7 @@ export function useAnchorPositioning( }), ]; - const flipMiddleware = flip({ - ...commonCollisionProps, - fallbackAxisSideDirection: allowAxisFlip ? fallbackAxisSideDirection : 'none', - }); + const flipMiddleware = flip(commonCollisionProps); const shiftMiddleware = shift({ ...commonCollisionProps, crossAxis: sticky, @@ -215,9 +174,6 @@ export function useAnchorPositioning( const autoUpdateOptions = React.useMemo( () => ({ - // Keep `ancestorResize` for window resizing. TODO: determine the best configuration, or - // if we need to allow options. - ancestorScroll: trackAnchor, elementResize: trackAnchor && typeof ResizeObserver !== 'undefined', layoutShift: trackAnchor && typeof IntersectionObserver !== 'undefined', }), @@ -231,7 +187,7 @@ export function useAnchorPositioning( middlewareData, update, placement: renderedPlacement, - context: positionerContext, + context, isPositioned, } = useFloating({ rootContext, @@ -316,11 +272,11 @@ export function useAnchorPositioning( arrowStyles, arrowRef, arrowUncentered, - renderedSide: logicalRenderedSide, - renderedAlign, + side: logicalRenderedSide, + align: renderedAlign, anchorHidden, refs, - positionerContext, + context, isPositioned, }), [ @@ -332,7 +288,7 @@ export function useAnchorPositioning( renderedAlign, anchorHidden, refs, - positionerContext, + context, isPositioned, ], ); @@ -343,3 +299,102 @@ function isRef( ): param is React.RefObject { return param != null && 'current' in param; } + +export namespace useAnchorPositioning { + export interface SharedParameters { + /** + * An element to position the popup against. + * By default, the popup will be positioned against the trigger. + */ + anchor?: + | Element + | null + | VirtualElement + | React.RefObject + | (() => Element | VirtualElement | null); + /** + * Whether the popup is currently open. + */ + open?: boolean; + /** + * Determines which CSS `position` property to use. + * @default 'absolute' + */ + positionMethod?: 'absolute' | 'fixed'; + /** + * Which side of the anchor element to align the popup against. + * May automatically change to avoid collisions. + * @default 'bottom' + */ + side?: Side; + /** + * Distance between the anchor and the popup. + * @default 0 + */ + sideOffset?: number; + /** + * How to align the popup relative to the specified side. + * @default 'center' + */ + align?: 'start' | 'end' | 'center'; + /** + * Additional offset along the alignment axis of the element. + * @default 0 + */ + alignOffset?: number; + /** + * An element or a rectangle that delimits the area that the popup is confined to. + * @default 'clipping-ancestors' + */ + collisionBoundary?: Boundary; + /** + * Additional space to maintain from the edge of the collision boundary. + * @default 5 + */ + collisionPadding?: Padding; + /** + * Whether to maintain the popup in the viewport after + * the anchor element was scrolled out of view. + * @default false + */ + sticky?: boolean; + /** + * Minimum distance to maintain between the arrow and the edges of the popup. + * + * Use it to prevent the arrow element from hanging out of the rounded corners of a popup. + * @default 5 + */ + arrowPadding?: number; + /** + * Whether the popup tracks any layout shift of its positioning anchor. + * @default true + */ + trackAnchor?: boolean; + /** + * Whether to keep the popup mounted in the DOM while it's hidden. + * @default false + */ + keepMounted?: boolean; + } + + export interface Parameters extends SharedParameters { + trackCursorAxis?: 'none' | 'x' | 'y' | 'both'; + floatingRootContext?: FloatingRootContext; + mounted: boolean; + trackAnchor: boolean; + nodeId?: string; + } + + export interface ReturnValue { + positionerStyles: React.CSSProperties; + arrowStyles: React.CSSProperties; + arrowRef: React.RefObject; + arrowUncentered: boolean; + side: Side; + align: Align; + anchorHidden: boolean; + refs: ReturnType['refs']; + context: FloatingContext; + isPositioned: boolean; + } +}