{"version":3,"file":"stickyStack.mjs","names":[],"sources":["../../../../package/common/sticky/stickyStack.ts"],"sourcesContent":["/**\n * Coordinates every mounted `Sticky` so ones that overlap horizontally stack\n * underneath each other (in DOM order) instead of pinning to the same spot.\n * Non-overlapping ones (e.g. side-by-side halves under a full-width one) land\n * at the same top instead of being pushed down by each other.\n */\n\nexport interface StickyStackMetrics {\n  /** Current (unstuck-flow) top of the root element, from getBoundingClientRect. */\n  top: number;\n  left: number;\n  width: number;\n  contentHeight: number;\n  /** The element this entry sticks relative to (its scroll parent), or null for the viewport.\n   *  Stacking only considers overlap between entries sharing the same container — two Stickies\n   *  in unrelated scroll panels should never push each other down just because they happen to\n   *  share horizontal DOM coordinates. */\n  container: HTMLElement | null;\n  /** The element the entry is confined to, or null when nothing confines it.\n   *  Entries sharing one leave together. */\n  boundary: HTMLElement | null;\n  /** Bottom edge of that element (less its inset), in the same coordinate space as `top`. */\n  boundaryBottom: number | null;\n}\n\nexport interface StickyStackEntry {\n  node: HTMLElement;\n  offsetTop: number;\n  disabled: boolean;\n  getMetrics: () => StickyStackMetrics | null;\n}\n\nexport interface StickyStackResult {\n  isStuck: boolean;\n  /** Effective `top` to pin at while stuck — offsetTop plus the height of any stuck, overlapping entries above it. */\n  top: number;\n}\n\ntype ResultListener = (result: StickyStackResult) => void;\n\nfunction rangesOverlap(aLeft: number, aRight: number, bLeft: number, bRight: number): boolean {\n  return aLeft < bRight && bLeft < aRight;\n}\n\nfunction compareDomOrder(a: HTMLElement, b: HTMLElement): number {\n  if (a === b) return 0;\n  const position = a.compareDocumentPosition(b);\n  if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1;\n  if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1;\n  return 0;\n}\n\nclass StickyStack {\n  private entries = new Map<symbol, StickyStackEntry>();\n  private listeners = new Map<symbol, ResultListener>();\n  private results = new Map<symbol, StickyStackResult>();\n  private rafId = 0;\n  private listenerCount = 0;\n\n  schedule = (): void => {\n    cancelAnimationFrame(this.rafId);\n    this.rafId = requestAnimationFrame(this.recompute);\n  };\n\n  register(entry: StickyStackEntry, onChange: ResultListener): symbol {\n    const id = Symbol('sticky');\n    this.entries.set(id, entry);\n    this.listeners.set(id, onChange);\n    this.startListening();\n    return id;\n  }\n\n  updateEntry(id: symbol, patch: Partial<Pick<StickyStackEntry, 'offsetTop' | 'disabled'>>): void {\n    const entry = this.entries.get(id);\n    if (entry) Object.assign(entry, patch);\n  }\n\n  unregister(id: symbol): void {\n    if (!this.entries.has(id)) return;\n    this.entries.delete(id);\n    this.listeners.delete(id);\n    this.results.delete(id);\n    this.stopListening();\n  }\n\n  recompute = (): void => {\n    const ordered = [...this.entries.entries()].sort(([, a], [, b]) => compareDomOrder(a.node, b.node));\n    const placedByContainer = new Map<HTMLElement | null, { left: number; right: number; bottom: number }[]>();\n    const rows: { id: symbol; metrics: StickyStackMetrics; baseTop: number; isStuck: boolean }[] = [];\n\n    for (const [id, entry] of ordered) {\n      const metrics = entry.getMetrics();\n      if (!metrics) continue;\n\n      let placed = placedByContainer.get(metrics.container);\n      if (!placed) {\n        placed = [];\n        placedByContainer.set(metrics.container, placed);\n      }\n\n      const overlapping = placed.filter((p) =>\n        rangesOverlap(p.left, p.right, metrics.left, metrics.left + metrics.width),\n      );\n      const baseTop = overlapping.reduce((max, p) => Math.max(max, p.bottom), entry.offsetTop);\n      const isStuck = !entry.disabled && metrics.top <= baseTop;\n\n      if (isStuck) {\n        // Never hold space below the boundary this entry is confined to: the\n        // shared shift below has pulled it up to that edge, and an entry under it\n        // must stack on where it sits, not on where it would sit unconfined.\n        const bottom = baseTop + metrics.contentHeight;\n        placed.push({\n          left: metrics.left,\n          right: metrics.left + metrics.width,\n          bottom: metrics.boundaryBottom == null ? bottom : Math.min(bottom, metrics.boundaryBottom),\n        });\n      }\n      rows.push({ id, metrics, baseTop: isStuck ? baseTop : entry.offsetTop, isStuck });\n    }\n\n    // A boundary pulls everything confined to it up by one shared amount, the\n    // largest any of them needs. Letting each clamp its own bottom edge instead\n    // would collapse a stacked group onto that edge, sliding the lower ones up\n    // through the ones above.\n    const shiftByBoundary = new Map<HTMLElement, number>();\n    for (const { metrics, baseTop, isStuck } of rows) {\n      const { boundary, boundaryBottom, contentHeight } = metrics;\n      if (!isStuck || boundary == null || boundaryBottom == null) continue;\n      const needed = Math.min(0, boundaryBottom - (baseTop + contentHeight));\n      const current = shiftByBoundary.get(boundary);\n      if (current == null || needed < current) shiftByBoundary.set(boundary, needed);\n    }\n\n    // An entry on its way out stays `isStuck`, so its placeholder keeps holding\n    // the flow slot and the exit isn't a jump.\n    for (const { id, metrics, baseTop, isStuck } of rows) {\n      const shift = isStuck && metrics.boundary ? (shiftByBoundary.get(metrics.boundary) ?? 0) : 0;\n      const result: StickyStackResult = { isStuck, top: baseTop + shift };\n      const prev = this.results.get(id);\n      if (prev && prev.isStuck === result.isStuck && prev.top === result.top) continue;\n      this.results.set(id, result);\n      this.listeners.get(id)?.(result);\n    }\n  };\n\n  private startListening(): void {\n    if (this.listenerCount === 0) {\n      window.addEventListener('scroll', this.schedule, { passive: true, capture: true });\n      window.addEventListener('resize', this.schedule);\n    }\n    this.listenerCount++;\n  }\n\n  private stopListening(): void {\n    this.listenerCount--;\n    if (this.listenerCount === 0) {\n      window.removeEventListener('scroll', this.schedule, { capture: true } as EventListenerOptions);\n      window.removeEventListener('resize', this.schedule);\n    }\n  }\n}\n\nexport const stickyStack = new StickyStack();\n"],"mappings":"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"}