UNPKG

@base-ui/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.

253 lines (249 loc) 10.2 kB
'use client'; import _formatErrorMessage from "@base-ui/utils/formatErrorMessage"; import * as React from 'react'; import { isElement } from '@floating-ui/utils/dom'; import { fastComponentRef } from '@base-ui/utils/fastHooks'; import { useTimeout } from '@base-ui/utils/useTimeout'; import { useValueAsRef } from '@base-ui/utils/useValueAsRef'; import { useTooltipRootContext } from "../root/TooltipRootContext.js"; import { triggerOpenStateMapping } from "../../utils/popupStateMapping.js"; import { useRenderElement } from "../../internals/useRenderElement.js"; import { useTriggerDataForwarding } from "../../utils/popups/index.js"; import { useBaseUiId } from "../../internals/useBaseUiId.js"; import { useTooltipProviderContext } from "../provider/TooltipProviderContext.js"; import { safePolygon, useDelayGroup, useFocus, useHoverReferenceInteraction } from "../../floating-ui-react/index.js"; import { contains } from "../../floating-ui-react/utils/element.js"; import { isMouseLikePointerType } from "../../floating-ui-react/utils/event.js"; import { createChangeEventDetails } from "../../internals/createBaseUIEventDetails.js"; import { REASONS } from "../../internals/reasons.js"; import { TooltipTriggerDataAttributes } from "./TooltipTriggerDataAttributes.js"; import { useHoverInteractionSharedState } from "../../floating-ui-react/hooks/useHoverInteractionSharedState.js"; import { OPEN_DELAY } from "../utils/constants.js"; const TOOLTIP_TRIGGER_IDENTIFIER = 'data-base-ui-tooltip-trigger'; function getTargetElement(event) { if ('composedPath' in event) { const path = event.composedPath(); for (let i = 0; i < path.length; i += 1) { const element = path[i]; if (isElement(element)) { return element; } } } const target = event.target; if (isElement(target)) { return target; } return null; } function closestEnabledTooltipTrigger(element) { let current = element; while (current) { if (current.hasAttribute(TOOLTIP_TRIGGER_IDENTIFIER)) { return current; } const parentElement = current.parentElement; if (parentElement) { current = parentElement; continue; } const root = current.getRootNode(); current = 'host' in root && isElement(root.host) ? root.host : null; } return null; } /** * An element to attach the tooltip to. * Renders a `<button>` element. * * Documentation: [Base UI Tooltip](https://base-ui.com/react/components/tooltip) */ export const TooltipTrigger = fastComponentRef(function TooltipTrigger(componentProps, forwardedRef) { const { render, className, style, handle, payload, disabled: disabledProp, delay, closeOnClick = true, closeDelay, id: idProp, ...elementProps } = componentProps; const rootContext = useTooltipRootContext(true); const store = handle?.store ?? rootContext; if (!store) { throw new Error(process.env.NODE_ENV !== "production" ? 'Base UI: <Tooltip.Trigger> must be either used within a <Tooltip.Root> component or provided with a handle.' : _formatErrorMessage(82)); } const thisTriggerId = useBaseUiId(idProp); const isTriggerActive = store.useState('isTriggerActive', thisTriggerId); const isOpenedByThisTrigger = store.useState('isOpenedByTrigger', thisTriggerId); const floatingRootContext = store.useState('floatingRootContext'); const triggerElementRef = React.useRef(null); const delayWithDefault = delay ?? OPEN_DELAY; const closeDelayWithDefault = closeDelay ?? 0; const { registerTrigger, isMountedByThisTrigger } = useTriggerDataForwarding(thisTriggerId, triggerElementRef, store, { payload, closeOnClick, closeDelay: closeDelayWithDefault }); const providerContext = useTooltipProviderContext(); const { delayRef, isInstantPhase, hasProvider } = useDelayGroup(floatingRootContext, { open: isOpenedByThisTrigger }); const hoverInteraction = useHoverInteractionSharedState(floatingRootContext); store.useSyncedValue('isInstantPhase', isInstantPhase); const rootDisabled = store.useState('disabled'); const disabled = disabledProp ?? rootDisabled; const disabledRef = useValueAsRef(disabled); const trackCursorAxis = store.useState('trackCursorAxis'); const disableHoverablePopup = store.useState('disableHoverablePopup'); const isNestedTriggerHoveredRef = React.useRef(false); const nestedTriggerOpenTimeout = useTimeout(); // Local copy so it can be cleared on mouseLeave without resetting the hover hook's own pointerType. const pointerTypeRef = React.useRef(undefined); function getOpenDelay() { const providerDelay = providerContext?.delay; const groupOpenValue = typeof delayRef.current === 'object' ? delayRef.current.open : undefined; let computedOpenDelay = delayWithDefault; if (hasProvider) { if (groupOpenValue !== 0) { computedOpenDelay = delay ?? providerDelay ?? delayWithDefault; } else { computedOpenDelay = 0; } } return computedOpenDelay; } function isEnabledNestedTriggerTarget(target) { const triggerEl = triggerElementRef.current; if (!triggerEl || !target) { return false; } const nearestTrigger = closestEnabledTooltipTrigger(target); return nearestTrigger !== null && nearestTrigger !== triggerEl && contains(triggerEl, nearestTrigger); } function detectNestedTriggerHover(target) { const nestedTriggerHovered = isEnabledNestedTriggerTarget(target); isNestedTriggerHoveredRef.current = nestedTriggerHovered; if (nestedTriggerHovered) { hoverInteraction.openChangeTimeout.clear(); hoverInteraction.restTimeout.clear(); hoverInteraction.restTimeoutPending = false; nestedTriggerOpenTimeout.clear(); } return nestedTriggerHovered; } const hoverProps = useHoverReferenceInteraction(floatingRootContext, { enabled: !disabled, mouseOnly: true, move: false, handleClose: !disableHoverablePopup && trackCursorAxis !== 'both' ? safePolygon() : null, restMs: getOpenDelay, delay() { const closeValue = typeof delayRef.current === 'object' ? delayRef.current.close : undefined; let computedCloseDelay = closeDelayWithDefault; if (closeDelay == null && hasProvider) { computedCloseDelay = closeValue; } return { close: computedCloseDelay }; }, triggerElementRef, isActiveTrigger: isTriggerActive, isClosing: () => store.select('transitionStatus') === 'ending', shouldOpen() { return !isNestedTriggerHoveredRef.current; } }); const focusProps = useFocus(floatingRootContext, { enabled: !disabled }).reference; const handleNestedTriggerHover = event => { const wasNestedTriggerHovered = isNestedTriggerHoveredRef.current; const target = getTargetElement(event); const nestedTriggerHovered = detectNestedTriggerHover(target); const triggerEl = triggerElementRef.current; const targetInsideTrigger = triggerEl && target && contains(triggerEl, target); // Only close hover-opened parents. Focus/click-like opens remain owned by // their original interaction and should not be clobbered by nested hover. if (nestedTriggerHovered && store.select('open') && store.select('lastOpenChangeReason') === REASONS.triggerHover) { store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event)); return; } if (wasNestedTriggerHovered && !nestedTriggerHovered && targetInsideTrigger && !disabledRef.current && !store.select('open') && triggerEl && // Match the hover hook's non-strict mouse fallback for mouse-only event sequences. isMouseLikePointerType(pointerTypeRef.current)) { const open = () => { if (!isNestedTriggerHoveredRef.current && !disabledRef.current && !store.select('open')) { store.setOpen(true, createChangeEventDetails(REASONS.triggerHover, event, triggerEl)); } }; const openDelay = getOpenDelay(); // With `move: false`, the hover hook only listens to mouseenter/mouseleave // on the parent trigger. Leaving a nested child for the parent area fires // no event the hook can react to, so reopen locally. if (openDelay === 0) { nestedTriggerOpenTimeout.clear(); open(); } else { nestedTriggerOpenTimeout.start(openDelay, open); } } }; const rootTriggerProps = store.useState('triggerProps', isMountedByThisTrigger); const shouldApplyRootTriggerProps = isMountedByThisTrigger || trackCursorAxis !== 'none'; const state = { open: isOpenedByThisTrigger }; const element = useRenderElement('button', componentProps, { state, ref: [forwardedRef, registerTrigger, triggerElementRef], props: [hoverProps, focusProps, shouldApplyRootTriggerProps ? rootTriggerProps : undefined, { onMouseOver(event) { handleNestedTriggerHover(event.nativeEvent); }, onFocus(event) { if (isEnabledNestedTriggerTarget(getTargetElement(event.nativeEvent))) { event.preventBaseUIHandler(); } }, onMouseLeave() { isNestedTriggerHoveredRef.current = false; nestedTriggerOpenTimeout.clear(); pointerTypeRef.current = undefined; }, onPointerEnter(event) { pointerTypeRef.current = event.pointerType; }, onPointerDown(event) { pointerTypeRef.current = event.pointerType; store.set('closeOnClick', closeOnClick); if (closeOnClick && !store.select('open')) { store.cancelPendingOpen(event.nativeEvent); } }, onClick(event) { if (closeOnClick && !store.select('open')) { store.cancelPendingOpen(event.nativeEvent); } }, id: thisTriggerId, [TooltipTriggerDataAttributes.triggerDisabled]: disabled ? '' : undefined, [TOOLTIP_TRIGGER_IDENTIFIER]: disabled ? undefined : '' }, elementProps], stateAttributesMapping: triggerOpenStateMapping }); return element; }); if (process.env.NODE_ENV !== "production") TooltipTrigger.displayName = "TooltipTrigger";