diff --git a/packages/html/src/ui/popover/popover-element.ts b/packages/html/src/ui/popover/popover-element.ts index ad6a1d0b..e64d4d82 100644 --- a/packages/html/src/ui/popover/popover-element.ts +++ b/packages/html/src/ui/popover/popover-element.ts @@ -16,6 +16,7 @@ import { SnapshotController } from '@videojs/store/html'; import { applyStyles, supportsAnchorPositioning, tryHidePopover, tryShowPopover } from '@videojs/utils/dom'; import { MediaElement } from '../media-element'; +import { PositionController } from '../position-controller'; export class PopoverElement extends MediaElement { static readonly tagName = 'media-popover'; @@ -45,6 +46,7 @@ export class PopoverElement extends MediaElement { closeDelay = PopoverCore.defaultProps.closeDelay; readonly #core = new PopoverCore(); + readonly #position = new PositionController(this); #popover: PopoverApi | null = null; #snapshot: SnapshotController | null = null; @@ -52,10 +54,6 @@ export class PopoverElement extends MediaElement { #disconnect: AbortController | null = null; #triggerAbort: AbortController | null = null; #currentTrigger: HTMLElement | null = null; - #positionAbort: AbortController | null = null; - #positionFrame = 0; - #resizeObserver: ResizeObserver | null = null; - #positionTrigger: HTMLElement | null = null; override connectedCallback(): void { super.connectedCallback(); @@ -104,13 +102,11 @@ export class PopoverElement extends MediaElement { override disconnectedCallback(): void { super.disconnectedCallback(); - this.#cleanupPositioning(); this.#disconnect?.abort(); this.#disconnect = null; } override destroyCallback(): void { - this.#cleanupPositioning(); this.#cleanupTrigger(); this.#popover?.destroy(); super.destroyCallback(); @@ -138,7 +134,7 @@ export class PopoverElement extends MediaElement { if (!this.#popover) return; // Discover trigger via commandfor linkage. - const triggerEl = this.#findTrigger(); + const triggerEl = this.#position.findTrigger(); this.#syncTrigger(triggerEl); // Derive state from core + input. @@ -166,7 +162,7 @@ export class PopoverElement extends MediaElement { // Skip positioning when closed — no rects to measure. if (!state.open) { - this.#cleanupPositioning(); + this.#position.cleanup(); return; } @@ -185,21 +181,15 @@ export class PopoverElement extends MediaElement { applyStyles(this, getAnchorPositionStyle(this.id, posOpts, triggerRect, selfRect, boundaryRect, offsets)); } - this.#syncPositioning(); + this.#position.sync(this.#currentTrigger); } - // --- Trigger discovery --- - - #findTrigger(): HTMLElement | null { - if (!this.id) return null; - const root = this.getRootNode() as Document | ShadowRoot; - return root.querySelector(`[commandfor="${this.id}"]`); - } + // --- Trigger management --- #syncTrigger(triggerEl: HTMLElement | null): void { if (triggerEl === this.#currentTrigger) return; - this.#cleanupPositioning(); + this.#position.cleanup(); this.#cleanupTrigger(); this.#currentTrigger = triggerEl; this.#popover?.setTriggerElement(triggerEl); @@ -225,49 +215,4 @@ export class PopoverElement extends MediaElement { this.#triggerAbort = null; this.#currentTrigger = null; } - - #syncPositioning(): void { - if (supportsAnchorPositioning()) return; - - const triggerEl = this.#currentTrigger; - - if (!triggerEl) return; - if (this.#positionAbort && this.#positionTrigger === triggerEl) return; - - this.#cleanupPositioning(); - this.#positionAbort = new AbortController(); - this.#positionTrigger = triggerEl; - const { signal } = this.#positionAbort; - - const reposition = () => { - cancelAnimationFrame(this.#positionFrame); - this.#positionFrame = requestAnimationFrame(() => { - if (signal.aborted) return; - this.requestUpdate(); - }); - }; - - window.addEventListener('scroll', reposition, { capture: true, passive: true, signal }); - window.addEventListener('resize', reposition, { signal }); - - if (typeof ResizeObserver === 'function') { - this.#resizeObserver = new ResizeObserver(() => { - reposition(); - }); - this.#resizeObserver.observe(triggerEl); - this.#resizeObserver.observe(this); - } - - reposition(); - } - - #cleanupPositioning(): void { - this.#positionAbort?.abort(); - this.#positionAbort = null; - this.#positionTrigger = null; - cancelAnimationFrame(this.#positionFrame); - this.#positionFrame = 0; - this.#resizeObserver?.disconnect(); - this.#resizeObserver = null; - } } diff --git a/packages/html/src/ui/position-controller.ts b/packages/html/src/ui/position-controller.ts new file mode 100644 index 00000000..443009ce --- /dev/null +++ b/packages/html/src/ui/position-controller.ts @@ -0,0 +1,84 @@ +import type { ReactiveController, ReactiveControllerHost } from '@videojs/element'; +import { supportsAnchorPositioning } from '@videojs/utils/dom'; + +export type PositionControllerHost = ReactiveControllerHost & HTMLElement; + +/** + * Reactive controller that manages JS-fallback positioning for floating + * popup elements (tooltips, popovers). Tracks scroll, resize, and + * ResizeObserver events to keep the popup aligned with its trigger. + * + * When native CSS Anchor Positioning is supported, `sync()` is a no-op. + */ +export class PositionController implements ReactiveController { + readonly #host: PositionControllerHost; + + #abort: AbortController | null = null; + #frame = 0; + #resizeObserver: ResizeObserver | null = null; + #trigger: HTMLElement | null = null; + + constructor(host: PositionControllerHost) { + this.#host = host; + host.addController(this); + } + + /** Discover a trigger element linked via `commandfor` attribute. */ + findTrigger(): HTMLElement | null { + if (!this.#host.id) return null; + const root = this.#host.getRootNode() as Document | ShadowRoot; + return root.querySelector(`[commandfor="${this.#host.id}"]`); + } + + /** Start or update position tracking for the given trigger. */ + sync(trigger: HTMLElement | null): void { + if (supportsAnchorPositioning()) return; + if (!trigger) return; + if (this.#abort && this.#trigger === trigger) return; + + this.cleanup(); + this.#abort = new AbortController(); + this.#trigger = trigger; + const { signal } = this.#abort; + + const reposition = () => { + cancelAnimationFrame(this.#frame); + this.#frame = requestAnimationFrame(() => { + if (signal.aborted) return; + this.#host.requestUpdate(); + }); + }; + + window.addEventListener('scroll', reposition, { capture: true, passive: true, signal }); + window.addEventListener('resize', reposition, { signal }); + + if (typeof ResizeObserver === 'function') { + this.#resizeObserver = new ResizeObserver(() => { + reposition(); + }); + this.#resizeObserver.observe(trigger); + this.#resizeObserver.observe(this.#host); + } + + reposition(); + } + + /** Stop all position tracking. */ + cleanup(): void { + this.#abort?.abort(); + this.#abort = null; + this.#trigger = null; + cancelAnimationFrame(this.#frame); + this.#frame = 0; + this.#resizeObserver?.disconnect(); + this.#resizeObserver = null; + } + + hostDisconnected(): void { + this.cleanup(); + } + + hostDestroyed(): void { + this.cleanup(); + } +} diff --git a/packages/html/src/ui/tooltip/tooltip-element.ts b/packages/html/src/ui/tooltip/tooltip-element.ts index 0c484a02..204fdfc7 100644 --- a/packages/html/src/ui/tooltip/tooltip-element.ts +++ b/packages/html/src/ui/tooltip/tooltip-element.ts @@ -18,6 +18,7 @@ import { SnapshotController } from '@videojs/store/html'; import { applyStyles, supportsAnchorPositioning, tryHidePopover, tryShowPopover } from '@videojs/utils/dom'; import { MediaElement } from '../media-element'; +import { PositionController } from '../position-controller'; import { tooltipGroupContext } from './context'; type TriggerElement = HTMLElement & { @@ -54,6 +55,7 @@ export class TooltipElement extends MediaElement { readonly #core = new TooltipCore(); readonly #groupConsumer = new ContextConsumer(this, { context: tooltipGroupContext }); + readonly #position = new PositionController(this); #tooltip: TooltipApi | null = null; #snapshot: SnapshotController | null = null; @@ -61,13 +63,11 @@ export class TooltipElement extends MediaElement { #disconnect: AbortController | null = null; #triggerAbort: AbortController | null = null; #currentTrigger: HTMLElement | null = null; - #positionAbort: AbortController | null = null; - #positionFrame = 0; - #resizeObserver: ResizeObserver | null = null; - #positionTrigger: HTMLElement | null = null; override connectedCallback(): void { super.connectedCallback(); + if (this.destroyed) return; + this.#disconnect = new AbortController(); this.#tooltip = createTooltip({ @@ -110,7 +110,6 @@ export class TooltipElement extends MediaElement { override disconnectedCallback(): void { super.disconnectedCallback(); - this.#cleanupPositioning(); this.#cleanupTrigger(); this.#tooltip?.destroy(); this.#tooltip = null; @@ -140,7 +139,7 @@ export class TooltipElement extends MediaElement { if (!this.#tooltip) return; // Discover trigger via commandfor linkage. - const triggerEl = this.#findTrigger(); + const triggerEl = this.#position.findTrigger(); this.#syncTrigger(triggerEl); // Derive state from core + input. @@ -167,7 +166,7 @@ export class TooltipElement extends MediaElement { // Skip positioning when closed — no rects to measure. if (!state.open) { - this.#cleanupPositioning(); + this.#position.cleanup(); return; } @@ -192,21 +191,15 @@ export class TooltipElement extends MediaElement { ); } - this.#syncPositioning(); + this.#position.sync(this.#currentTrigger); } - // --- Trigger discovery --- - - #findTrigger(): HTMLElement | null { - if (!this.id) return null; - const root = this.getRootNode() as Document | ShadowRoot; - return root.querySelector(`[commandfor="${this.id}"]`); - } + // --- Trigger management --- #syncTrigger(triggerEl: HTMLElement | null): void { if (triggerEl === this.#currentTrigger) return; - this.#cleanupPositioning(); + this.#position.cleanup(); this.#cleanupTrigger(); this.#currentTrigger = triggerEl; this.#tooltip?.setTriggerElement(triggerEl); @@ -237,49 +230,4 @@ export class TooltipElement extends MediaElement { this.#triggerAbort = null; this.#currentTrigger = null; } - - #syncPositioning(): void { - if (supportsAnchorPositioning()) return; - - const triggerEl = this.#currentTrigger; - - if (!triggerEl) return; - if (this.#positionAbort && this.#positionTrigger === triggerEl) return; - - this.#cleanupPositioning(); - this.#positionAbort = new AbortController(); - this.#positionTrigger = triggerEl; - const { signal } = this.#positionAbort; - - const reposition = () => { - cancelAnimationFrame(this.#positionFrame); - this.#positionFrame = requestAnimationFrame(() => { - if (signal.aborted) return; - this.requestUpdate(); - }); - }; - - window.addEventListener('scroll', reposition, { capture: true, passive: true, signal }); - window.addEventListener('resize', reposition, { signal }); - - if (typeof ResizeObserver === 'function') { - this.#resizeObserver = new ResizeObserver(() => { - reposition(); - }); - this.#resizeObserver.observe(triggerEl); - this.#resizeObserver.observe(this); - } - - reposition(); - } - - #cleanupPositioning(): void { - this.#positionAbort?.abort(); - this.#positionAbort = null; - this.#positionTrigger = null; - cancelAnimationFrame(this.#positionFrame); - this.#positionFrame = 0; - this.#resizeObserver?.disconnect(); - this.#resizeObserver = null; - } }