{"version":3,"file":"useOverlay.mjs","names":[],"sources":["../../../../package/common/hooks/useOverlay.ts"],"sourcesContent":["import { useEffect, useRef, type RefObject } from 'react';\nimport { tabbablesIn } from './useFocusBoundary';\n\nexport interface UseOverlayArgs {\n  /** Whether the overlay is currently visible. Everything below is set up while this is true. */\n  isActive: boolean;\n  /** The overlay panel. Focus is trapped inside it and Escape is scoped to it. */\n  containerRef: RefObject<HTMLElement | null>;\n  /** Called when Escape is pressed while this is the topmost overlay. Omit to not handle Escape. */\n  onEscape?: () => void;\n  /**\n   * Keep Tab inside the panel and restore focus to the previously focused element on close, unless\n   * an overlay opened after this one is still open.\n   */\n  trapFocus?: boolean;\n  /** Prevent the page behind the overlay from scrolling. */\n  lockScroll?: boolean;\n}\n\n/**\n * The three behaviours every blocking overlay needs: Escape to dismiss, a focus trap, and a\n * scroll lock on the page behind it.\n *\n * They share one module-level stack because they share one question: overlays nest (a\n * ConfirmModal opened from inside a Modal), every instance listens on `document`, and only the\n * innermost one may react. Without the stack, Escape would close every open layer at once and\n * the outer trap would yank focus out of the inner panel on the next Tab.\n *\n * The scroll lock is reference-counted for the same reason: the page stays locked until the last\n * overlay closes, and the body's original inline styles are restored rather than cleared.\n */\nexport function useOverlay({ isActive, containerRef, onEscape, trapFocus = true, lockScroll = true }: UseOverlayArgs) {\n  const onEscapeRef = useRef(onEscape);\n  useEffect(() => {\n    onEscapeRef.current = onEscape;\n  });\n\n  // The layer's position in the stack is claimed during render, not in the effect below, because\n  // effects run child-first: an overlay opened from *inside* another one (a menu in a modal) has\n  // its effect run before its own parent's and would come out as the lower layer. Render runs\n  // parent-first, which is the order the layers are actually nested in. DOM order is no help\n  // either — React commits the inner portal ahead of the outer one.\n  const orderRef = useRef(0);\n  if (isActive && orderRef.current === 0) orderRef.current = nextLayerOrder++;\n  if (!isActive && orderRef.current !== 0) orderRef.current = 0;\n\n  useEffect(() => {\n    if (!isActive) return;\n\n    const order = orderRef.current;\n    activeLayers.set(order, { trapFocus, containerRef });\n    const isTopmost = () => order === Math.max(...activeLayers.keys());\n    const layersAbove = () => [...activeLayers].filter(([other]) => other > order).map(([, layer]) => layer);\n\n    const previouslyFocused = document.activeElement as HTMLElement | null;\n    if (trapFocus) {\n      const container = containerRef.current;\n      (tabbablesIn(container)[0] ?? container)?.focus();\n    }\n\n    function onKeyDown(event: KeyboardEvent) {\n      if (event.key === 'Escape' && onEscapeRef.current && isTopmost()) {\n        event.preventDefault();\n        onEscapeRef.current();\n        return;\n      }\n\n      if (event.key !== 'Tab' || !trapFocus || event.defaultPrevented) return;\n      // A layer that does not trap, a Popover, leaves Tab to the trap below it: with nothing to\n      // focus in its panel, Tab otherwise left the Modal for the browser's own controls. Tab\n      // inside it stays its own, and so does the move it already made out of either end.\n      const above = layersAbove();\n      if (above.some((layer) => layer.trapFocus)) return;\n      if (above.some((layer) => layer.containerRef.current?.contains(document.activeElement))) return;\n      const focusables = tabbablesIn(containerRef.current);\n      // Nothing to move to inside the panel, but Tab must still not escape it.\n      if (focusables.length === 0) {\n        event.preventDefault();\n        return;\n      }\n      const first = focusables[0];\n      const last = focusables[focusables.length - 1];\n      const active = document.activeElement;\n      if (!containerRef.current?.contains(active)) {\n        event.preventDefault();\n        first.focus();\n      } else if (!event.shiftKey && active === last) {\n        event.preventDefault();\n        first.focus();\n      } else if (event.shiftKey && active === first) {\n        event.preventDefault();\n        last.focus();\n      }\n    }\n\n    // A key held as this opened would repeat into what it focused: a held Enter pressed Close.\n    // Outside the panel it still repeats, since suggestions open while the user types.\n    const pressedSinceOpen = new Set<string>();\n    const keyId = (event: KeyboardEvent) => event.code || event.key;\n    function onKeyDownCapture(event: KeyboardEvent) {\n      if (!event.repeat) pressedSinceOpen.add(keyId(event));\n      else if (!pressedSinceOpen.has(keyId(event)) && containerRef.current?.contains(event.target as Node)) {\n        event.preventDefault();\n        event.stopPropagation();\n      }\n    }\n    function onKeyUp(event: KeyboardEvent) {\n      pressedSinceOpen.delete(keyId(event));\n    }\n\n    // `window`, not `document`: a key event on a focused element reaches both, but a synthetic\n    // one dispatched straight at `window` (as tests and some consumers do) never reaches\n    // `document`, since it sits above it in the propagation path.\n    window.addEventListener('keydown', onKeyDown);\n    window.addEventListener('keydown', onKeyDownCapture, true);\n    window.addEventListener('keyup', onKeyUp, true);\n    if (lockScroll) acquireScrollLock();\n\n    return () => {\n      window.removeEventListener('keydown', onKeyDown);\n      window.removeEventListener('keydown', onKeyDownCapture, true);\n      window.removeEventListener('keyup', onKeyUp, true);\n      if (lockScroll) releaseScrollLock();\n      // Modal closes a commit late, so a Popover opened by the same click already holds the focus.\n      const coveredByLaterLayer = layersAbove().length > 0;\n      activeLayers.delete(order);\n      if (trapFocus && !coveredByLaterLayer) previouslyFocused?.focus();\n    };\n  }, [isActive, trapFocus, lockScroll, containerRef]);\n}\n\n/** Monotonic, so a layer opened later always outranks one opened earlier. */\nlet nextLayerOrder = 1;\nconst activeLayers = new Map<number, { trapFocus: boolean; containerRef: RefObject<HTMLElement | null> }>();\n\nlet scrollLockCount = 0;\nlet previousOverflow = '';\nlet previousPaddingRight = '';\n\nfunction acquireScrollLock() {\n  const { body } = document;\n  if (scrollLockCount === 0) {\n    previousOverflow = body.style.overflow;\n    previousPaddingRight = body.style.paddingRight;\n    // Removing the page's scrollbar reclaims its width, so everything behind the overlay would\n    // shift right by that amount without the compensating padding.\n    const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;\n    if (scrollbarWidth > 0) {\n      const current = parseFloat(window.getComputedStyle(body).paddingRight) || 0;\n      body.style.paddingRight = `${current + scrollbarWidth}px`;\n    }\n    body.style.overflow = 'hidden';\n  }\n  scrollLockCount += 1;\n}\n\nfunction releaseScrollLock() {\n  scrollLockCount -= 1;\n  if (scrollLockCount > 0) return;\n  scrollLockCount = 0;\n  document.body.style.overflow = previousOverflow;\n  document.body.style.paddingRight = previousPaddingRight;\n}\n"],"mappings":"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"}