{"version":3,"file":"useAnchorTracking.mjs","names":[],"sources":["../../../../package/common/hooks/useAnchorTracking.ts"],"sourcesContent":["import { useLayoutEffect, useRef, type RefObject } from 'react';\nimport { getScrollableAncestors } from 'components/common/utils/scrollAncestors';\n\nexport interface UseAnchorTrackingArgs {\n  isOpen: boolean;\n  /** The element the popup is anchored to. Its scrollable ancestors are what get tracked. */\n  triggerRef: RefObject<HTMLElement | null>;\n  /**\n   * Every element the positioning maths needs. Tracking starts only once all of them, plus the\n   * trigger, are mounted. Read fresh on each setup, so an inline array literal is fine.\n   */\n  elementRefs: RefObject<HTMLElement | null>[];\n  /**\n   * Positions the popup. Called once on setup and then on every scroll, resize, tracked size\n   * change and end of a CSS animation that moved the trigger, plus whenever it calls `rerun` for\n   * an end none of those report. Reads the caller's own refs for everything else.\n   */\n  update: (trigger: HTMLElement, ancestors: HTMLElement[], rerun: () => void) => void;\n  /**\n   * Elements whose own size changes must re-run `update`: a popup whose content changes size\n   * while open (a filtered list shrinking, an async list loading in) otherwise keeps coordinates\n   * computed for its old height and ends up detached from the trigger. Re-read on every update,\n   * since a panel can mount a commit later than the element it hangs off.\n   */\n  resizeRefs?: RefObject<HTMLElement | null>[];\n  /** Hands the popup's elements back as they were found. Runs when tracking stops. */\n  teardown?: () => void;\n  /** Values that must restart tracking, and so re-run `update`, when they change. */\n  deps?: readonly unknown[];\n}\n\n/**\n * The lifecycle half of anchoring a portaled, `position: fixed` popup to a trigger: waits for the\n * elements to mount, resolves the trigger's scrolling ancestors once, and re-runs the caller's\n * positioning on every scroll, viewport resize and tracked size change until it closes.\n *\n * Callers supply only the geometry (`update`) and the inline styles to clear again (`teardown`);\n * `usePopoverPosition` is built on this.\n *\n * Position is deliberately written straight to the DOM by the caller rather than through React\n * state: going through setState -> re-render -> commit adds a round-trip that lags behind the\n * browser's own scroll painting by at least a frame.\n */\nexport function useAnchorTracking({\n  isOpen,\n  triggerRef,\n  elementRefs,\n  update,\n  resizeRefs,\n  teardown,\n  deps = [],\n}: UseAnchorTrackingArgs) {\n  // Latest-value refs, so the effect restarts only for `deps` the caller names, never merely\n  // because these were passed as fresh array/function literals this render.\n  const latest = useRef({ elementRefs, update, resizeRefs, teardown });\n  latest.current = { elementRefs, update, resizeRefs, teardown };\n\n  useLayoutEffect(() => {\n    if (!isOpen) return;\n\n    let rafId: number | undefined;\n    let stop: (() => void) | undefined;\n\n    // The elements may not be mounted yet on the very first invocation of this effect: a portal\n    // target can still be missing (a `#bodyEnd` the app renders, or any while hydrating), and then\n    // the portaled content and its ref commits one render later. Since refs never change\n    // identity, that later commit doesn't re-trigger this effect on its own, so retry each frame.\n    function trySetup() {\n      const trigger = triggerRef.current;\n      const elements = latest.current.elementRefs;\n      if (!trigger || elements.some((ref) => !ref.current)) {\n        rafId = requestAnimationFrame(trySetup);\n        return;\n      }\n      const ancestors = getScrollableAncestors(trigger);\n\n      const resizeObserver = new ResizeObserver(() => runUpdate());\n      const observed = new Set<Element>();\n\n      let active = true;\n      function runUpdate() {\n        // `rerun` may be called after tracking stopped, from a promise that settled late.\n        if (!active) return;\n        latest.current.update(trigger!, ancestors, runUpdate);\n        // After the update, so an element that only mounts during it is picked up too. Once\n        // observed this is a handful of Set lookups, so it costs nothing on the scroll path.\n        latest.current.resizeRefs?.forEach(({ current }) => {\n          if (!current || observed.has(current)) return;\n          observed.add(current);\n          resizeObserver.observe(current);\n        });\n      }\n\n      runUpdate();\n\n      const scrollTargets: (Window | HTMLElement)[] = [window, ...ancestors];\n      scrollTargets.forEach((t) => t.addEventListener('scroll', runUpdate, { passive: true }));\n      window.addEventListener('resize', runUpdate);\n      // `window.resize` doesn't reliably fire when a mobile keyboard opens/closes — visualViewport\n      // does, and is what the callers' flip/clamp maths is measured against.\n      window.visualViewport?.addEventListener('resize', runUpdate);\n      window.visualViewport?.addEventListener('scroll', runUpdate);\n      // A transform on an ancestor moves the trigger without any of the events above: a popup\n      // opened as a Modal scales in was placed, and hidden, against rects collapsed at scale(0).\n      function onAncestorMotionEnd(event: Event) {\n        if ((event.target as Node).contains(trigger)) runUpdate();\n      }\n      document.addEventListener('transitionend', onAncestorMotionEnd, true);\n      document.addEventListener('animationend', onAncestorMotionEnd, true);\n\n      stop = () => {\n        active = false;\n        scrollTargets.forEach((t) => t.removeEventListener('scroll', runUpdate));\n        window.removeEventListener('resize', runUpdate);\n        window.visualViewport?.removeEventListener('resize', runUpdate);\n        window.visualViewport?.removeEventListener('scroll', runUpdate);\n        document.removeEventListener('transitionend', onAncestorMotionEnd, true);\n        document.removeEventListener('animationend', onAncestorMotionEnd, true);\n        resizeObserver.disconnect();\n        latest.current.teardown?.();\n      };\n    }\n\n    trySetup();\n\n    return () => {\n      if (rafId !== undefined) cancelAnimationFrame(rafId);\n      stop?.();\n    };\n    // Spread rather than listed: `deps` is the caller's own set of geometry inputs, and changing\n    // any of them has to restart tracking so the new values get applied immediately.\n    // oxlint-disable-next-line react-hooks/exhaustive-deps\n  }, [isOpen, triggerRef, ...deps]);\n}\n"],"mappings":"uIA2CA,SAAgB,EAAkB,CAChC,SACA,aACA,cACA,SACA,aACA,WACA,OAAO,CAAC,GACgB,CAGxB,IAAM,EAAS,EAAO,CAAE,cAAa,SAAQ,aAAY,UAAS,CAAC,EACnE,EAAO,QAAU,CAAE,cAAa,SAAQ,aAAY,UAAS,EAE7D,MAAsB,CACpB,GAAI,CAAC,EAAQ,OAEb,IAAI,EACA,EAMJ,SAAS,GAAW,CAClB,IAAM,EAAU,EAAW,QACrB,EAAW,EAAO,QAAQ,YAChC,GAAI,CAAC,GAAW,EAAS,KAAM,GAAQ,CAAC,EAAI,OAAO,EAAG,CACpD,EAAQ,sBAAsB,CAAQ,EACtC,MACF,CACA,IAAM,EAAY,EAAuB,CAAO,EAE1C,EAAiB,IAAI,mBAAqB,EAAU,CAAC,EACrD,EAAW,IAAI,IAEjB,EAAS,GACb,SAAS,GAAY,CAEd,IACL,EAAO,QAAQ,OAAO,EAAU,EAAW,CAAS,EAGpD,EAAO,QAAQ,YAAY,SAAS,CAAE,aAAc,CAC7C,GAAW,GAAS,IAAI,CAAO,IACpC,EAAS,IAAI,CAAO,EACpB,EAAe,QAAQ,CAAO,EAChC,CAAC,EACH,CAEA,EAAU,EAEV,IAAM,EAA0C,CAAC,OAAQ,GAAG,CAAS,EACrE,EAAc,QAAS,GAAM,EAAE,iBAAiB,SAAU,EAAW,CAAE,QAAS,EAAK,CAAC,CAAC,EACvF,OAAO,iBAAiB,SAAU,CAAS,EAG3C,OAAO,gBAAgB,iBAAiB,SAAU,CAAS,EAC3D,OAAO,gBAAgB,iBAAiB,SAAU,CAAS,EAG3D,SAAS,EAAoB,EAAc,CACpC,EAAM,OAAgB,SAAS,CAAO,GAAG,EAAU,CAC1D,CACA,SAAS,iBAAiB,gBAAiB,EAAqB,EAAI,EACpE,SAAS,iBAAiB,eAAgB,EAAqB,EAAI,EAEnE,MAAa,CACX,EAAS,GACT,EAAc,QAAS,GAAM,EAAE,oBAAoB,SAAU,CAAS,CAAC,EACvE,OAAO,oBAAoB,SAAU,CAAS,EAC9C,OAAO,gBAAgB,oBAAoB,SAAU,CAAS,EAC9D,OAAO,gBAAgB,oBAAoB,SAAU,CAAS,EAC9D,SAAS,oBAAoB,gBAAiB,EAAqB,EAAI,EACvE,SAAS,oBAAoB,eAAgB,EAAqB,EAAI,EACtE,EAAe,WAAW,EAC1B,EAAO,QAAQ,WAAW,CAC5B,CACF,CAIA,OAFA,EAAS,MAEI,CACP,IAAU,IAAA,IAAW,qBAAqB,CAAK,EACnD,IAAO,CACT,CAIF,EAAG,CAAC,EAAQ,EAAY,GAAG,CAAI,CAAC,CAClC"}