import { type CSSProperties, type MouseEvent, type ReactNode, type RefCallback, useCallback, useEffect, useMemo, useRef, } from 'react'; import { arrow, autoPlacement, autoUpdate, flip, type Placement as FloatingPlacement, type MiddlewareData, offset, shift, useFloating, } from '@floating-ui/react-dom'; import { LIFECYCLE } from '~/literals'; import { getScrollParent, hasCustomScrollParent, hasPosition } from '~/modules/dom'; import { sortObjectKeys } from '~/modules/helpers'; import type { StoreState } from '~/modules/store'; import type { Controls, Lifecycle, PositionData, StepMerged } from '~/types'; import Arrow from './Arrow'; import Beacon from './Beacon'; import Portal from './Portal'; import Tooltip from './Tooltip'; interface FloaterProps { continuous: boolean; controls: Controls; index: number; lifecycle: Lifecycle; nonce?: string; open: boolean; portalElement: HTMLElement | null; setPositionData: (name: 'beacon' | 'tooltip', data: PositionData) => void; setTooltipRef: RefCallback; shouldScroll: boolean; size: number; step: StepMerged; target: Element; updateState: (state: Partial) => void; } function getFallbackPlacements(placement: FloatingPlacement): FloatingPlacement[] | undefined { if (placement.startsWith('left')) return ['top', 'bottom']; if (placement.startsWith('right')) return ['bottom', 'top']; return undefined; } function getFlipMiddleware(isAuto: boolean, step: StepMerged, tooltipPlacement: FloatingPlacement) { if (isAuto) { return [autoPlacement()]; } if (step.floatingOptions?.flipOptions === false) { return []; } return [ flip({ crossAxis: false, fallbackPlacements: getFallbackPlacements(tooltipPlacement), padding: 20, ...step.floatingOptions?.flipOptions, }), ]; } export default function JoyrideFloater(props: FloaterProps) { const { continuous, controls, index, lifecycle, nonce, open, portalElement, setPositionData, setTooltipRef, shouldScroll, size, step, target, updateState, } = props; const arrowRef = useRef(null); const beaconMiddlewareRef = useRef({}); const tooltipMiddlewareRef = useRef({}); const isCenter = step.placement === 'center'; const isAuto = step.placement === 'auto'; const centerReference = useMemo( () => ({ getBoundingClientRect: () => ({ x: window.innerWidth / 2, y: window.innerHeight / 2, top: window.innerHeight / 2, left: window.innerWidth / 2, bottom: window.innerHeight / 2, right: window.innerWidth / 2, width: 0, height: 0, }), }), [], ); const scrollParent = useMemo( () => hasCustomScrollParent(target as HTMLElement) ? getScrollParent(target as HTMLElement) : undefined, [target], ); const isFixedTarget = useMemo(() => hasPosition(target), [target]); const boundaryOptions = useMemo( () => scrollParent ? { boundary: scrollParent as Element, rootBoundary: 'viewport' as const } : {}, [scrollParent], ); const tooltipPlacement = isCenter || isAuto ? 'bottom' : (step.placement as FloatingPlacement); const strategy = isCenter ? 'fixed' : (step.floatingOptions?.strategy ?? (step.isFixed || isFixedTarget ? 'fixed' : 'absolute')); const tooltipMiddleware = useMemo( () => isCenter ? [ { name: 'center', fn: ({ rects }: { rects: { floating: { height: number; width: number } } }) => ({ x: (window.innerWidth - rects.floating.width) / 2, y: (window.innerHeight - rects.floating.height) / 2, }), }, ] : [ offset( ({ placement: currentPlacement }) => { let side: 'top' | 'bottom' | 'left' | 'right' = 'right'; if (currentPlacement.startsWith('top')) side = 'top'; else if (currentPlacement.startsWith('bottom')) side = 'bottom'; else if (currentPlacement.startsWith('left')) side = 'left'; const padding = step.spotlightTarget ? 0 : step.spotlightPadding[side]; return ( step.offset + padding + (step.floatingOptions?.hideArrow ? 0 : step.arrowSize) ); }, [ step.offset, step.spotlightPadding, step.spotlightTarget, step.arrowSize, step.floatingOptions?.hideArrow, ], ), ...getFlipMiddleware(isAuto, step, tooltipPlacement), shift({ padding: 10, ...boundaryOptions, ...step.floatingOptions?.shiftOptions, }), ...(step.floatingOptions?.hideArrow ? [] : [ arrow({ element: arrowRef, padding: step.arrowSpacing }, [ step.arrowSpacing, step.arrowBase, ]), ]), ...(step.floatingOptions?.middleware ?? []), ], [isCenter, step, isAuto, tooltipPlacement, boundaryOptions], ); const tooltipFloating = useFloating({ ...(isCenter ? { elements: { reference: centerReference } } : {}), placement: tooltipPlacement, strategy, middleware: tooltipMiddleware, }); const beaconPlacement = step.beaconPlacement ?? (isAuto || isCenter ? 'bottom' : (step.placement as FloatingPlacement)); const beaconMiddleware = useMemo( () => [offset(step.floatingOptions?.beaconOptions?.offset ?? -18)], [step.floatingOptions?.beaconOptions?.offset], ); const beaconFloating = useFloating({ strategy, placement: beaconPlacement, middleware: beaconMiddleware, whileElementsMounted: autoUpdate, }); tooltipMiddlewareRef.current = tooltipFloating.middlewareData; beaconMiddlewareRef.current = beaconFloating.middlewareData; useEffect(() => { const { floating, reference } = tooltipFloating.elements; if (!reference || !floating || lifecycle !== LIFECYCLE.TOOLTIP) { return undefined; } return autoUpdate( reference, floating, tooltipFloating.update, step.floatingOptions?.autoUpdate, ); }, [ lifecycle, tooltipFloating.update, step.floatingOptions?.autoUpdate, step.target, tooltipFloating.elements, ]); // Wire reference element to both floating instances useEffect(() => { if (!isCenter && target) { tooltipFloating.refs.setReference(target); } if (target) { beaconFloating.refs.setReference(target); } }, [beaconFloating.refs, isCenter, target, tooltipFloating.refs]); // Sync tooltip position data to store useEffect(() => { if (tooltipFloating.isPositioned) { setPositionData('tooltip', { placement: tooltipFloating.placement, x: tooltipFloating.x ?? 0, y: tooltipFloating.y ?? 0, middlewareData: tooltipMiddlewareRef.current, }); } }, [ setPositionData, tooltipFloating.isPositioned, tooltipFloating.placement, tooltipFloating.x, tooltipFloating.y, ]); // Sync beacon position data to store useEffect(() => { if (beaconFloating.isPositioned) { setPositionData('beacon', { placement: beaconFloating.placement, x: beaconFloating.x ?? 0, y: beaconFloating.y ?? 0, middlewareData: beaconMiddlewareRef.current, }); } }, [ setPositionData, beaconFloating.isPositioned, beaconFloating.placement, beaconFloating.x, beaconFloating.y, ]); const zIndex = step.zIndex + 1; const handleBeaconInteraction = useCallback( (event: MouseEvent) => { if (event.type === 'mouseenter' && step.beaconTrigger !== 'hover') { return; } updateState({ lifecycle: LIFECYCLE.TOOLTIP_BEFORE, positioned: false }); }, [step.beaconTrigger, updateState], ); const floaterRef = useCallback( (node: HTMLElement | null) => { if (node) { tooltipFloating.refs.setFloating(node); setTooltipRef(node); } }, [tooltipFloating.refs, setTooltipRef], ); const { arrow: arrowStyles, floater: floaterStyles } = step.styles; let content: ReactNode = null; if (lifecycle === LIFECYCLE.TOOLTIP || lifecycle === LIFECYCLE.TOOLTIP_BEFORE) { const styles: CSSProperties = sortObjectKeys({ ...floaterStyles, ...tooltipFloating.floatingStyles, zIndex, opacity: open && tooltipFloating.isPositioned ? 1 : 0, ...(!open && { transition: 'none' }), }); content = (
{!isCenter && !step.floatingOptions?.hideArrow && ( )}
); } else if (lifecycle === LIFECYCLE.BEACON || lifecycle === LIFECYCLE.BEACON_BEFORE) { content = (
); } return {content}; }