From ee76ae64b1c9eac11a6ceade7e650a43e1f385e6 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 30 Dec 2024 20:13:16 +1100 Subject: [PATCH] [core] Refactor data passing --- docs/reference/generated/menu-positioner.json | 11 +- .../generated/popover-positioner.json | 9 +- .../generated/preview-card-positioner.json | 9 +- .../generated/select-positioner.json | 8 +- .../generated/tooltip-positioner.json | 7 +- .../experiments/anchor-positioning.tsx | 2 +- packages/react/src/dialog/root/DialogRoot.tsx | 4 +- .../react/src/dialog/root/useDialogRoot.ts | 8 +- .../src/menu/positioner/MenuPositioner.tsx | 35 +-- .../src/menu/positioner/useMenuPositioner.ts | 168 ++------------ .../react/src/popover/popup/PopoverPopup.tsx | 2 +- .../popover/positioner/PopoverPositioner.tsx | 19 +- .../positioner/usePopoverPositioner.tsx | 176 ++------------- .../react/src/popover/root/PopoverRoot.tsx | 83 ++----- .../positioner/PreviewCardPositioner.tsx | 14 +- .../positioner/usePreviewCardPositioner.ts | 167 +------------- .../src/preview-card/root/PreviewCardRoot.tsx | 52 +---- .../react/src/select/popup/SelectPopup.tsx | 2 +- .../select/positioner/SelectPositioner.tsx | 89 ++------ .../positioner/SelectPositionerContext.ts | 2 +- .../select/positioner/useSelectPositioner.ts | 210 +----------------- .../tooltip/positioner/TooltipPositioner.tsx | 19 +- .../positioner/useTooltipPositioner.ts | 166 ++------------ .../react/src/tooltip/root/TooltipRoot.tsx | 68 ++---- .../react/src/utils/useAnchorPositioning.ts | 169 +++++++++----- 25 files changed, 335 insertions(+), 1164 deletions(-) diff --git a/docs/reference/generated/menu-positioner.json b/docs/reference/generated/menu-positioner.json index dc8fc9f0ac..d8889b3028 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." @@ -56,7 +63,7 @@ "keepMounted": { "type": "boolean", "default": "false", - "description": "Whether to keep the HTML element in the DOM while the menu is hidden." + "description": "Whether to keep the popup mounted in the DOM while it's hidden." }, "render": { "type": "React.ReactElement | (props, state) => React.ReactElement", diff --git a/docs/reference/generated/popover-positioner.json b/docs/reference/generated/popover-positioner.json index 9a155f18dd..6087872cea 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." @@ -58,7 +63,7 @@ "keepMounted": { "type": "boolean", "default": "false", - "description": "Whether to keep the HTML element in the DOM while the popover is hidden." + "description": "Whether to keep the popup mounted in the DOM while it's hidden." }, "render": { "type": "React.ReactElement | (props, state) => React.ReactElement", diff --git a/docs/reference/generated/preview-card-positioner.json b/docs/reference/generated/preview-card-positioner.json index 5fea11a83e..c79c636cb0 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." @@ -58,7 +63,7 @@ "keepMounted": { "type": "boolean", "default": "false", - "description": "Whether to keep the HTML element in the DOM while the preview card is hidden." + "description": "Whether to keep the popup mounted in the DOM while it's hidden." }, "render": { "type": "React.ReactElement | (props, state) => React.ReactElement", 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 538059bb6a..e654920558 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." @@ -58,7 +63,7 @@ "keepMounted": { "type": "boolean", "default": "false", - "description": "Whether to keep the HTML element in the DOM while the tooltip is hidden." + "description": "Whether to keep the popup mounted in the DOM while it's hidden." }, "render": { "type": "React.ReactElement | (props, state) => React.ReactElement", diff --git a/docs/src/app/(private)/experiments/anchor-positioning.tsx b/docs/src/app/(private)/experiments/anchor-positioning.tsx index 8c3a4107a3..9a6c334c7b 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 236a9d091e..f681d2939d 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'; import { PortalContext } from '../../portal/PortalContext'; /** @@ -50,7 +50,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 0f373c8e92..1122c5c029 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, @@ -167,7 +167,7 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo ]); } -export interface CommonParameters { +export interface SharedParameters { /** * Whether the dialog is currently open. */ @@ -200,7 +200,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 1552afa781..785a06e506 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'; /** * Positions the menu popup against the trigger. @@ -43,6 +38,7 @@ const MenuPositioner = React.forwardRef(function MenuPositioner( collisionPadding = 5, arrowPadding = 5, sticky = false, + trackAnchor = true, ...otherProps } = props; @@ -54,12 +50,9 @@ const MenuPositioner = React.forwardRef(function MenuPositioner( itemLabels, mounted, nested, - setOpen, modal, } = useMenuRootContext(); - const { events: menuEvents } = useFloatingTree()!; - const nodeId = useFloatingNodeId(); const parentNodeId = useFloatingParentNodeId(); @@ -88,8 +81,8 @@ const MenuPositioner = React.forwardRef(function MenuPositioner( sticky, nodeId, parentNodeId, - menuEvents, - setOpen, + keepMounted, + trackAnchor, }); const state: MenuPositioner.State = React.useMemo( @@ -110,7 +103,7 @@ const MenuPositioner = React.forwardRef(function MenuPositioner( arrowRef: positioner.arrowRef, arrowUncentered: positioner.arrowUncentered, arrowStyles: positioner.arrowStyles, - floatingContext: positioner.floatingContext, + floatingContext: positioner.context, }), [ positioner.side, @@ -118,7 +111,7 @@ const MenuPositioner = React.forwardRef(function MenuPositioner( positioner.arrowRef, positioner.arrowUncentered, positioner.arrowStyles, - positioner.floatingContext, + positioner.context, ], ); @@ -175,6 +168,7 @@ MenuPositioner.propTypes /* remove-proptypes */ = { // └─────────────────────────────────────────────────────────────────────┘ /** * How to align the popup relative to the specified side. + * @default 'center' */ align: PropTypes.oneOf(['center', 'end', 'start']), /** @@ -188,6 +182,7 @@ MenuPositioner.propTypes /* remove-proptypes */ = { */ anchor: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ HTMLElementType, + refType, PropTypes.object, PropTypes.func, ]), @@ -236,7 +231,7 @@ MenuPositioner.propTypes /* remove-proptypes */ = { }), ]), /** - * Whether to keep the HTML element in the DOM while the menu is hidden. + * Whether to keep the popup mounted in the DOM while it's hidden. * @default false */ keepMounted: PropTypes.bool, @@ -255,6 +250,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']), /** @@ -263,11 +259,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 7823fb8479..288b72b93b 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 { keepMounted, mounted, menuEvents, nodeId, parentNodeId, setOpen } = params; + const { keepMounted, 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, }, }); }, - [keepMounted, open, positionerStyles, mounted], + [keepMounted, 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,107 +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 keep the HTML element in the DOM while the menu is hidden. - * @default false - */ - keepMounted?: boolean; - /** - * 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 Menu is mounted. - */ - mounted: boolean; - /** - * The Menu root context. - */ - floatingRootContext?: FloatingRootContext; + floatingRootContext: FloatingRootContext; /** * Floating node id. */ @@ -181,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 to keep the HTML element in the DOM while the popover is hidden. - * @default false - */ - keepMounted?: boolean; - /** - * 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 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 1feeb4dd2c..9a9b942478 100644 --- a/packages/react/src/popover/root/PopoverRoot.tsx +++ b/packages/react/src/popover/root/PopoverRoot.tsx @@ -13,93 +13,40 @@ import { PortalContext } from '../../portal/PortalContext'; * 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 ( - {props.children} + {props.children} ); }; diff --git a/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx b/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx index 6f69caae0e..f56fb7efaa 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'; /** * Positions the popup against the trigger. @@ -35,6 +35,7 @@ const PreviewCardPositioner = React.forwardRef(function PreviewCardPositioner( arrowPadding = 5, sticky = false, keepMounted = false, + trackAnchor = true, ...otherProps } = props; @@ -55,6 +56,7 @@ const PreviewCardPositioner = React.forwardRef(function PreviewCardPositioner( collisionBoundary, collisionPadding, sticky, + trackAnchor, }); const state: PreviewCardPositioner.State = React.useMemo( @@ -145,6 +147,7 @@ PreviewCardPositioner.propTypes /* remove-proptypes */ = { */ anchor: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ HTMLElementType, + refType, PropTypes.object, PropTypes.func, ]), @@ -193,7 +196,7 @@ PreviewCardPositioner.propTypes /* remove-proptypes */ = { }), ]), /** - * Whether to keep the HTML element in the DOM while the preview card is hidden. + * Whether to keep the popup mounted in the DOM while it's hidden. * @default false */ keepMounted: PropTypes.bool, @@ -222,10 +225,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 82bc387025..5cd9b83eb5 100644 --- a/packages/react/src/preview-card/positioner/usePreviewCardPositioner.ts +++ b/packages/react/src/preview-card/positioner/usePreviewCardPositioner.ts @@ -1,32 +1,17 @@ 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 { keepMounted, mounted } = params; + const { keepMounted } = params; - const { open } = usePreviewCardRootContext(); + const { open, mounted } = usePreviewCardRootContext(); - const { - positionerStyles, - arrowStyles, - anchorHidden, - arrowRef, - arrowUncentered, - renderedSide, - renderedAlign, - positionerContext, - } = useAnchorPositioning(params); + const positioning = useAnchorPositioning(params); const getPositionerProps: usePreviewCardPositioner.ReturnValue['getPositionerProps'] = React.useCallback( @@ -41,159 +26,29 @@ export function usePreviewCardPositioner( role: 'presentation', hidden: !mounted, style: { - ...positionerStyles, + ...positioning.positionerStyles, ...hiddenStyles, }, }); }, - [positionerStyles, open, keepMounted, mounted], + [keepMounted, 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 to keep the HTML element in the DOM while the preview card is hidden. - * @default false - */ - keepMounted?: boolean; - /** - * 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 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 00055bc04a..cea213cbdf 100644 --- a/packages/react/src/preview-card/root/PreviewCardRoot.tsx +++ b/packages/react/src/preview-card/root/PreviewCardRoot.tsx @@ -18,21 +18,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, @@ -42,44 +28,18 @@ 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 ( - {props.children} + + {props.children} + ); }; 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 d672929ac6..ef38a50c1d 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 { 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,194 +43,21 @@ 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, getPositionerProps, - positioner, }), - [getPositionerProps, positioner], + [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 keep the HTML element in the DOM while the select menu is hidden. - * @default true - */ - keepMounted?: boolean; - /** - * 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 useAnchorPositioning.ReturnValue { 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; - }; } } diff --git a/packages/react/src/tooltip/positioner/TooltipPositioner.tsx b/packages/react/src/tooltip/positioner/TooltipPositioner.tsx index 8d9b5e5eb9..42320685f6 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'; /** * Positions the tooltip against the trigger. @@ -35,17 +35,17 @@ 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 positioner = useTooltipPositioner({ anchor, - floatingRootContext, positionMethod, - open, + floatingRootContext, + trackAnchor, mounted, keepMounted, side, @@ -55,7 +55,6 @@ const TooltipPositioner = React.forwardRef(function TooltipPositioner( collisionBoundary, collisionPadding, sticky, - trackCursorAxis, arrowPadding, }); @@ -140,6 +139,7 @@ TooltipPositioner.propTypes /* remove-proptypes */ = { */ anchor: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ HTMLElementType, + refType, PropTypes.object, PropTypes.func, ]), @@ -188,7 +188,7 @@ TooltipPositioner.propTypes /* remove-proptypes */ = { }), ]), /** - * Whether to keep the HTML element in the DOM while the tooltip is hidden. + * Whether to keep the popup mounted in the DOM while it's hidden. * @default false */ keepMounted: PropTypes.bool, @@ -221,6 +221,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 4bfa1e1a16..57852623a5 100644 --- a/packages/react/src/tooltip/positioner/useTooltipPositioner.ts +++ b/packages/react/src/tooltip/positioner/useTooltipPositioner.ts @@ -1,30 +1,21 @@ 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 { keepMounted, mounted } = params; + const { keepMounted } = params; - const { open, trackCursorAxis } = useTooltipRootContext(); + const { open, trackCursorAxis, mounted } = 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 (keepMounted && !open) { @@ -39,166 +30,41 @@ export function useTooltipPositioner( role: 'presentation', hidden: !mounted, style: { - ...positionerStyles, + ...positioning.positionerStyles, ...hiddenStyles, }, }); }, - [keepMounted, open, trackCursorAxis, mounted, positionerStyles], + [keepMounted, 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 to keep the HTML element in the DOM while the tooltip is hidden. - * @default false - */ - keepMounted?: boolean; - /** - * 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 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 ed29bc7304..cbf467747f 100644 --- a/packages/react/src/tooltip/root/TooltipRoot.tsx +++ b/packages/react/src/tooltip/root/TooltipRoot.tsx @@ -13,77 +13,43 @@ import { PortalContext } from '../../portal/PortalContext'; * 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 ( - {props.children} + {props.children} ); }; diff --git a/packages/react/src/utils/useAnchorPositioning.ts b/packages/react/src/utils/useAnchorPositioning.ts index 5fc6b328c6..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; + } +}