useAnchoredPosition.ts 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
  1. /**
  2. * Keep a fixed-position floating element anchored to a trigger.
  3. *
  4. * A portaled panel is positioned from its anchor's viewport rect, which stops
  5. * being true the moment anything scrolls or the window resizes. This owns that
  6. * one concern: measure the anchor, offset the panel below it, clamp the result
  7. * inside the viewport, and re-run on scroll (capture phase, so scrollers nested
  8. * inside the page are caught too), on resize, and on the panel's own size
  9. * changes while the element is open.
  10. * @module @deepseek-ai/dsh-client-ui-primitives/useAnchoredPosition
  11. */
  12. import { useLayoutEffect, useState, type CSSProperties, type RefObject } from 'react'
  13. /** Inputs for {@link useAnchoredPosition}. */
  14. export interface AnchoredPositionOptions {
  15. /** Whether the floating element is mounted and should track its anchor. */
  16. open: boolean
  17. /** The element the panel is placed from. */
  18. anchorRef: RefObject<HTMLElement | null>
  19. /** The floating element, measured so the clamp uses real dimensions. */
  20. panelRef: RefObject<HTMLElement | null>
  21. /** Distance kept between the anchor's bottom edge and the panel's top. */
  22. gap: number
  23. /** Distance kept between the panel and each viewport edge. */
  24. margin: number
  25. }
  26. /**
  27. * Track an anchor and return the panel's fixed coordinates.
  28. * @param options - the open state, the two refs, and the gap/margin distances.
  29. * @returns `left`/`top` for the panel, or `null` before the first measurement.
  30. */
  31. export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProperties | null {
  32. const { open, anchorRef, panelRef, gap, margin } = options
  33. const [position, setPosition] = useState<CSSProperties | null>(null)
  34. useLayoutEffect(() => {
  35. if (!open) {
  36. setPosition(null)
  37. return
  38. }
  39. const place = () => {
  40. /* v8 ignore start -- geometry read from real layout: jsdom reports zero
  41. offset sizes, so the positive-size clamp arms are exercised by browser
  42. scenarios rather than unit tests. */
  43. const rect = anchorRef.current?.getBoundingClientRect()
  44. if (rect === undefined) return
  45. const panel = panelRef.current
  46. const width = panel?.offsetWidth ?? 0
  47. const height = panel?.offsetHeight ?? 0
  48. let left = rect.left
  49. let top = rect.bottom + gap
  50. if (width > 0) left = Math.min(Math.max(left, margin), window.innerWidth - width - margin)
  51. if (height > 0) top = Math.min(Math.max(top, margin), window.innerHeight - height - margin)
  52. /* v8 ignore stop */
  53. setPosition({ left, top })
  54. }
  55. // The first run measures the panel in the same commit that opened it, so
  56. // the clamp uses real dimensions before anything paints.
  57. place()
  58. window.addEventListener('scroll', place, true)
  59. window.addEventListener('resize', place)
  60. // The panel's own height changes without either event — a status line
  61. // appearing inside it, or a `resize: vertical` textarea dragged taller —
  62. // and a stale clamp would let a panel near the bottom edge cross the
  63. // margin it is supposed to respect. The guard keeps the hook usable where
  64. // `ResizeObserver` is absent, which is how jsdom runs.
  65. const panel = panelRef.current
  66. let observer: ResizeObserver | null = null
  67. if (typeof ResizeObserver !== 'undefined' && panel !== null) {
  68. observer = new ResizeObserver(place)
  69. observer.observe(panel)
  70. }
  71. return () => {
  72. observer?.disconnect()
  73. window.removeEventListener('scroll', place, true)
  74. window.removeEventListener('resize', place)
  75. }
  76. }, [open, anchorRef, panelRef, gap, margin])
  77. return position
  78. }