refactor(html): extract PositionController from tooltip/popover (#1282)

This commit is contained in:
rahim
2026-04-08 17:47:54 +10:00
committed by GitHub
parent 1c937da470
commit 998b87198c
3 changed files with 100 additions and 123 deletions
@@ -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<PopoverInput> | 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<HTMLElement>(`[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;
}
}
@@ -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<HTMLElement>(`[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();
}
}
@@ -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<TooltipInput> | 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<HTMLElement>(`[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;
}
}