mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
refactor(html): extract PositionController from tooltip/popover (#1282)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user