UNPKG

@base-ui-components/react

Version:

Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.

385 lines (377 loc) 15.2 kB
'use client'; import * as React from 'react'; import { getSide, getAlignment, getSideAxis } from '@floating-ui/utils'; import { ownerDocument } from '@base-ui-components/utils/owner'; import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect'; import { useValueAsRef } from '@base-ui-components/utils/useValueAsRef'; import { useStableCallback } from '@base-ui-components/utils/useStableCallback'; import { autoUpdate, flip, limitShift, offset, shift, useFloating, size } from "../floating-ui-react/index.js"; import { useDirection } from "../direction-provider/DirectionContext.js"; import { arrow } from "../floating-ui-react/middleware/arrow.js"; import { hide } from "./hideMiddleware.js"; import { DEFAULT_SIDES } from "./adaptiveOriginMiddleware.js"; function getLogicalSide(sideParam, renderedSide, isRtl) { const isLogicalSideParam = sideParam === 'inline-start' || sideParam === 'inline-end'; const logicalRight = isRtl ? 'inline-start' : 'inline-end'; const logicalLeft = isRtl ? 'inline-end' : 'inline-start'; return { top: 'top', right: isLogicalSideParam ? logicalRight : 'right', bottom: 'bottom', left: isLogicalSideParam ? logicalLeft : 'left' }[renderedSide]; } function getOffsetData(state, sideParam, isRtl) { const { rects, placement } = state; const data = { side: getLogicalSide(sideParam, getSide(placement), isRtl), align: getAlignment(placement) || 'center', anchor: { width: rects.reference.width, height: rects.reference.height }, positioner: { width: rects.floating.width, height: rects.floating.height } }; return data; } /** * Provides standardized anchor positioning behavior for floating elements. Wraps Floating UI's * `useFloating` hook. */ export function useAnchorPositioning(params) { const { // Public parameters anchor, positionMethod = 'absolute', side: sideParam = 'bottom', sideOffset = 0, align = 'center', alignOffset = 0, collisionBoundary, collisionPadding: collisionPaddingParam = 5, sticky = false, arrowPadding = 5, disableAnchorTracking = false, // Private parameters keepMounted = false, floatingRootContext, mounted, collisionAvoidance, shiftCrossAxis = false, nodeId, adaptiveOrigin, lazyFlip = false, externalTree } = params; const [mountSide, setMountSide] = React.useState(null); if (!mounted && mountSide !== null) { setMountSide(null); } const collisionAvoidanceSide = collisionAvoidance.side || 'flip'; const collisionAvoidanceAlign = collisionAvoidance.align || 'flip'; const collisionAvoidanceFallbackAxisSide = collisionAvoidance.fallbackAxisSide || 'end'; const anchorFn = typeof anchor === 'function' ? anchor : undefined; const anchorFnCallback = useStableCallback(anchorFn); const anchorDep = anchorFn ? anchorFnCallback : anchor; const anchorValueRef = useValueAsRef(anchor); const direction = useDirection(); const isRtl = direction === 'rtl'; const side = mountSide || { top: 'top', right: 'right', bottom: 'bottom', left: 'left', 'inline-end': isRtl ? 'left' : 'right', 'inline-start': isRtl ? 'right' : 'left' }[sideParam]; const placement = align === 'center' ? side : `${side}-${align}`; let collisionPadding = collisionPaddingParam; // Create a bias to the preferred side. // On iOS, when the mobile software keyboard opens, the input is exactly centered // in the viewport, but this can cause it to flip to the top undesirably. const bias = 1; const biasTop = sideParam === 'bottom' ? bias : 0; const biasBottom = sideParam === 'top' ? bias : 0; const biasLeft = sideParam === 'right' ? bias : 0; const biasRight = sideParam === 'left' ? bias : 0; if (typeof collisionPadding === 'number') { collisionPadding = { top: collisionPadding + biasTop, right: collisionPadding + biasRight, bottom: collisionPadding + biasBottom, left: collisionPadding + biasLeft }; } else if (collisionPadding) { collisionPadding = { top: (collisionPadding.top || 0) + biasTop, right: (collisionPadding.right || 0) + biasRight, bottom: (collisionPadding.bottom || 0) + biasBottom, left: (collisionPadding.left || 0) + biasLeft }; } const commonCollisionProps = { boundary: collisionBoundary === 'clipping-ancestors' ? 'clippingAncestors' : collisionBoundary, padding: collisionPadding }; // Using a ref assumes that the arrow element is always present in the DOM for the lifetime of the // popup. If this assumption ends up being false, we can switch to state to manage the arrow's // presence. const arrowRef = React.useRef(null); // Keep these reactive if they're not functions const sideOffsetRef = useValueAsRef(sideOffset); const alignOffsetRef = useValueAsRef(alignOffset); const sideOffsetDep = typeof sideOffset !== 'function' ? sideOffset : 0; const alignOffsetDep = typeof alignOffset !== 'function' ? alignOffset : 0; const middleware = [offset(state => { const data = getOffsetData(state, sideParam, isRtl); const sideAxis = typeof sideOffsetRef.current === 'function' ? sideOffsetRef.current(data) : sideOffsetRef.current; const alignAxis = typeof alignOffsetRef.current === 'function' ? alignOffsetRef.current(data) : alignOffsetRef.current; return { mainAxis: sideAxis, crossAxis: alignAxis, alignmentAxis: alignAxis }; }, [sideOffsetDep, alignOffsetDep, isRtl, sideParam])]; const shiftDisabled = collisionAvoidanceAlign === 'none' && collisionAvoidanceSide !== 'shift'; const crossAxisShiftEnabled = !shiftDisabled && (sticky || shiftCrossAxis || collisionAvoidanceSide === 'shift'); const flipMiddleware = collisionAvoidanceSide === 'none' ? null : flip({ ...commonCollisionProps, // Ensure the popup flips if it's been limited by its --available-height and it resizes. // Since the size() padding is smaller than the flip() padding, flip() will take precedence. padding: { top: collisionPadding.top + bias, right: collisionPadding.right + bias, bottom: collisionPadding.bottom + bias, left: collisionPadding.left + bias }, mainAxis: !shiftCrossAxis && collisionAvoidanceSide === 'flip', crossAxis: collisionAvoidanceAlign === 'flip' ? 'alignment' : false, fallbackAxisSideDirection: collisionAvoidanceFallbackAxisSide }); const shiftMiddleware = shiftDisabled ? null : shift(data => { const html = ownerDocument(data.elements.floating).documentElement; return { ...commonCollisionProps, // Use the Layout Viewport to avoid shifting around when pinch-zooming // for context menus. rootBoundary: shiftCrossAxis ? { x: 0, y: 0, width: html.clientWidth, height: html.clientHeight } : undefined, mainAxis: collisionAvoidanceAlign !== 'none', crossAxis: crossAxisShiftEnabled, limiter: sticky || shiftCrossAxis ? undefined : limitShift(limitData => { if (!arrowRef.current) { return {}; } const { width, height } = arrowRef.current.getBoundingClientRect(); const sideAxis = getSideAxis(getSide(limitData.placement)); const arrowSize = sideAxis === 'y' ? width : height; const offsetAmount = sideAxis === 'y' ? collisionPadding.left + collisionPadding.right : collisionPadding.top + collisionPadding.bottom; return { offset: arrowSize / 2 + offsetAmount / 2 }; }) }; }, [commonCollisionProps, sticky, shiftCrossAxis, collisionPadding, collisionAvoidanceAlign]); // https://floating-ui.com/docs/flip#combining-with-shift if (collisionAvoidanceSide === 'shift' || collisionAvoidanceAlign === 'shift' || align === 'center') { middleware.push(shiftMiddleware, flipMiddleware); } else { middleware.push(flipMiddleware, shiftMiddleware); } middleware.push(size({ ...commonCollisionProps, apply({ elements: { floating }, rects: { reference }, availableWidth, availableHeight }) { Object.entries({ '--available-width': `${availableWidth}px`, '--available-height': `${availableHeight}px`, '--anchor-width': `${reference.width}px`, '--anchor-height': `${reference.height}px` }).forEach(([key, value]) => { floating.style.setProperty(key, value); }); } }), arrow(() => ({ // `transform-origin` calculations rely on an element existing. If the arrow hasn't been set, // we'll create a fake element. element: arrowRef.current || document.createElement('div'), padding: arrowPadding, offsetParent: 'floating' }), [arrowPadding]), { name: 'transformOrigin', fn(state) { const { elements, middlewareData, placement: renderedPlacement, rects, y } = state; const currentRenderedSide = getSide(renderedPlacement); const currentRenderedAxis = getSideAxis(currentRenderedSide); const arrowEl = arrowRef.current; const arrowX = middlewareData.arrow?.x || 0; const arrowY = middlewareData.arrow?.y || 0; const arrowWidth = arrowEl?.clientWidth || 0; const arrowHeight = arrowEl?.clientHeight || 0; const transformX = arrowX + arrowWidth / 2; const transformY = arrowY + arrowHeight / 2; const shiftY = Math.abs(middlewareData.shift?.y || 0); const halfAnchorHeight = rects.reference.height / 2; const sideOffsetValue = typeof sideOffset === 'function' ? sideOffset(getOffsetData(state, sideParam, isRtl)) : sideOffset; const isOverlappingAnchor = shiftY > sideOffsetValue; const adjacentTransformOrigin = { top: `${transformX}px calc(100% + ${sideOffsetValue}px)`, bottom: `${transformX}px ${-sideOffsetValue}px`, left: `calc(100% + ${sideOffsetValue}px) ${transformY}px`, right: `${-sideOffsetValue}px ${transformY}px` }[currentRenderedSide]; const overlapTransformOrigin = `${transformX}px ${rects.reference.y + halfAnchorHeight - y}px`; elements.floating.style.setProperty('--transform-origin', crossAxisShiftEnabled && currentRenderedAxis === 'y' && isOverlappingAnchor ? overlapTransformOrigin : adjacentTransformOrigin); return {}; } }, hide, adaptiveOrigin); useIsoLayoutEffect(() => { // Ensure positioning doesn't run initially for `keepMounted` elements that // aren't initially open. if (!mounted && floatingRootContext) { floatingRootContext.update({ referenceElement: null, floatingElement: null, domReferenceElement: null }); } }, [mounted, floatingRootContext]); const autoUpdateOptions = React.useMemo(() => ({ elementResize: !disableAnchorTracking && typeof ResizeObserver !== 'undefined', layoutShift: !disableAnchorTracking && typeof IntersectionObserver !== 'undefined' }), [disableAnchorTracking]); const { refs, elements, x, y, middlewareData, update, placement: renderedPlacement, context, isPositioned, floatingStyles: originalFloatingStyles } = useFloating({ rootContext: floatingRootContext, placement, middleware, strategy: positionMethod, whileElementsMounted: keepMounted ? undefined : (...args) => autoUpdate(...args, autoUpdateOptions), nodeId, externalTree }); const { sideX, sideY } = middlewareData.adaptiveOrigin || DEFAULT_SIDES; // Default to `fixed` when not positioned to prevent `autoFocus` scroll jumps. // This ensures the popup is inside the viewport initially before it gets positioned. const resolvedPosition = isPositioned ? positionMethod : 'fixed'; const floatingStyles = React.useMemo(() => adaptiveOrigin ? { position: resolvedPosition, [sideX]: x, [sideY]: y } : { position: resolvedPosition, ...originalFloatingStyles }, [adaptiveOrigin, resolvedPosition, sideX, x, sideY, y, originalFloatingStyles]); const registeredPositionReferenceRef = React.useRef(null); useIsoLayoutEffect(() => { if (!mounted) { return; } const anchorValue = anchorValueRef.current; const resolvedAnchor = typeof anchorValue === 'function' ? anchorValue() : anchorValue; const unwrappedElement = (isRef(resolvedAnchor) ? resolvedAnchor.current : resolvedAnchor) || null; const finalAnchor = unwrappedElement || null; if (finalAnchor !== registeredPositionReferenceRef.current) { refs.setPositionReference(finalAnchor); registeredPositionReferenceRef.current = finalAnchor; } }, [mounted, refs, anchorDep, anchorValueRef]); React.useEffect(() => { if (!mounted) { return; } const anchorValue = anchorValueRef.current; // Refs from parent components are set after useLayoutEffect runs and are available in useEffect. // Therefore, if the anchor is a ref, we need to update the position reference in useEffect. if (typeof anchorValue === 'function') { return; } if (isRef(anchorValue) && anchorValue.current !== registeredPositionReferenceRef.current) { refs.setPositionReference(anchorValue.current); registeredPositionReferenceRef.current = anchorValue.current; } }, [mounted, refs, anchorDep, anchorValueRef]); React.useEffect(() => { if (keepMounted && mounted && elements.domReference && elements.floating) { return autoUpdate(elements.domReference, elements.floating, update, autoUpdateOptions); } return undefined; }, [keepMounted, mounted, elements, update, autoUpdateOptions]); const renderedSide = getSide(renderedPlacement); const logicalRenderedSide = getLogicalSide(sideParam, renderedSide, isRtl); const renderedAlign = getAlignment(renderedPlacement) || 'center'; const anchorHidden = Boolean(middlewareData.hide?.referenceHidden); /** * Locks the flip (makes it "sticky") so it doesn't prefer a given placement * and flips back lazily, not eagerly. Ideal for filtered lists that change * the size of the popup dynamically to avoid unwanted flipping when typing. */ useIsoLayoutEffect(() => { if (lazyFlip && mounted && isPositioned) { setMountSide(renderedSide); } }, [lazyFlip, mounted, isPositioned, renderedSide]); const arrowStyles = React.useMemo(() => ({ position: 'absolute', top: middlewareData.arrow?.y, left: middlewareData.arrow?.x }), [middlewareData.arrow]); const arrowUncentered = middlewareData.arrow?.centerOffset !== 0; return React.useMemo(() => ({ positionerStyles: floatingStyles, arrowStyles, arrowRef, arrowUncentered, side: logicalRenderedSide, align: renderedAlign, physicalSide: renderedSide, anchorHidden, refs, context, isPositioned, update }), [floatingStyles, arrowStyles, arrowRef, arrowUncentered, logicalRenderedSide, renderedAlign, renderedSide, anchorHidden, refs, context, isPositioned, update]); } function isRef(param) { return param != null && 'current' in param; }