feat: use anchor API for html elements (#174)

This commit is contained in:
Wesley Luyten
2025-11-07 14:33:10 -06:00
committed by GitHub
parent 8d477a33a4
commit 4a2d580bb3
16 changed files with 191 additions and 215 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
import './media-container';
// be sure to import video-provider first for proper context initialization
import './video-provider';
import './media-container';
import './media-play-button';
import './media-mute-button';
import './media-volume-slider';
+8
View File
@@ -27,6 +27,8 @@ export class ButtonElement extends HTMLElement {
static getTemplateHTML: typeof getTemplateHTML = getTemplateHTML;
static observedAttributes: string[] = ['commandfor'];
constructor() {
super();
@@ -46,6 +48,12 @@ export class ButtonElement extends HTMLElement {
this.addEventListener('keydown', this);
}
attributeChangedCallback(name: string, _oldValue: string, newValue: string): void {
if (name === 'commandfor') {
this.style.setProperty('anchor-name', `--${newValue}`);
}
}
handleEvent(event: Event): void {
const { type } = event;
if (type === 'keydown') {
+21 -54
View File
@@ -1,26 +1,27 @@
import type { ComputePositionReturn } from '@floating-ui/core';
import type { Placement } from '@floating-ui/dom';
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
import { contains, getDocument, getDocumentOrShadowRoot, safePolygon } from '@videojs/utils/dom';
type Prettify<T> = {
[K in keyof T]: T[K];
};
type FloatingContext = Prettify<ComputePositionReturn> & {
elements: {
domReference: HTMLElement;
floating: HTMLElement;
};
};
type Placement = 'top' | 'bottom' | 'left' | 'right';
export class PopoverElement extends HTMLElement {
static get observedAttributes(): string[] {
return ['id', 'open-on-hover', 'delay', 'close-delay', 'side', 'side-offset'];
}
#open = false;
#transitionStatus: 'initial' | 'open' | 'close' | 'unmounted' = 'initial';
#hoverTimeout: ReturnType<typeof setTimeout> | null = null;
#cleanup: (() => void) | null = null;
#abortController: AbortController | null = null;
#floatingContext: FloatingContext | null = null;
attributeChangedCallback(name: string, _oldValue: string, newValue: string): void {
if (name === 'id') {
this.style.setProperty('position-anchor', `--${newValue}`);
}
this.style.setProperty('top', `calc(anchor(${this.side}) - ${this.sideOffset}px)`);
this.style.setProperty('translate', `0 -100%`);
this.style.setProperty('justify-self', 'anchor-center');
}
connectedCallback(): void {
this.#abortController ??= new AbortController();
@@ -71,10 +72,6 @@ export class PopoverElement extends HTMLElement {
}
}
static get observedAttributes(): string[] {
return ['open-on-hover', 'delay', 'close-delay', 'side', 'side-offset'];
}
get openOnHover(): boolean {
return this.hasAttribute('open-on-hover');
}
@@ -105,8 +102,6 @@ export class PopoverElement extends HTMLElement {
this.#open = open;
if (open) {
this.#setupFloating();
this.#transitionStatus = 'initial';
this.#updateVisibility();
@@ -141,38 +136,6 @@ export class PopoverElement extends HTMLElement {
this.toggleAttribute('data-closed', this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted');
}
#setupFloating(): void {
const trigger = this.#triggerElement as HTMLElement;
if (!trigger) return;
const placement = this.side ?? 'top';
const sideOffset = this.sideOffset;
const updatePosition = () => {
computePosition(trigger, this, {
placement,
middleware: [offset(sideOffset), flip(), shift()],
strategy: 'fixed',
}).then((data: ComputePositionReturn) => {
this.#floatingContext = {
...data,
elements: {
domReference: trigger,
floating: this,
},
};
Object.assign(this.style, {
left: `${data.x}px`,
top: `${data.y}px`,
});
});
};
updatePosition();
this.#cleanup = autoUpdate(trigger, this, updatePosition);
}
#clearHoverTimeout(): void {
if (this.#hoverTimeout) {
globalThis.clearTimeout(this.#hoverTimeout);
@@ -210,10 +173,14 @@ export class PopoverElement extends HTMLElement {
}
#handlePointerMove(event: PointerEvent): void {
if (!this.openOnHover || !this.#floatingContext) return;
if (!this.openOnHover || !this.#triggerElement) return;
const close = safePolygon({ blockPointerEvents: true })({
...this.#floatingContext,
placement: this.side,
elements: {
domReference: this.#triggerElement,
floating: this,
},
x: event.clientX,
y: event.clientY,
onClose: () => {
+4 -2
View File
@@ -50,7 +50,7 @@ export const getTimeSliderRootProps: PropsHook<{
};
export class TimeSliderRoot extends HTMLElement {
static readonly observedAttributes: readonly string[] = ['orientation'];
static readonly observedAttributes: readonly string[] = ['commandfor', 'orientation'];
_state: TimeSliderRootState | undefined;
_core: CoreTimeSlider | null = null;
@@ -67,9 +67,11 @@ export class TimeSliderRoot extends HTMLElement {
return (this.getAttribute('orientation') as 'horizontal' | 'vertical') || 'horizontal';
}
attributeChangedCallback(name: string, _oldValue: string | null, _newValue: string | null): void {
attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void {
if (name === 'orientation' && this._state) {
this._render(getTimeSliderRootProps(this._state, this), this._state);
} else if (name === 'commandfor') {
this.style.setProperty('anchor-name', `--${newValue}`);
}
}
+63 -112
View File
@@ -1,18 +1,46 @@
import type { Placement } from '@floating-ui/dom';
import type { MediaContainerElement } from '@/media/media-container';
import { arrow, autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
import { getDocumentOrShadowRoot } from '@videojs/utils/dom';
import {
getBoundingClientRectWithoutTransform,
getDocumentOrShadowRoot,
getInBoundsAdjustments,
} from '@videojs/utils/dom';
type Placement = 'top' | 'bottom' | 'left' | 'right';
export class TooltipElement extends HTMLElement {
static get observedAttributes(): string[] {
return ['id', 'delay', 'close-delay', 'track-cursor-axis', 'side', 'side-offset', 'collision-padding'];
}
#open = false;
#hoverTimeout: ReturnType<typeof setTimeout> | null = null;
#cleanup: (() => void) | null = null;
#arrowElement: HTMLElement | null = null;
#pointerPosition = { x: 0, y: 0 };
#transitionStatus: 'initial' | 'open' | 'close' | 'unmounted' = 'initial';
#abortController: AbortController | null = null;
constructor() {
super();
const resizeObserver = new ResizeObserver(() => this.#checkCollision());
resizeObserver.observe(this);
}
attributeChangedCallback(name: string, _oldValue: string, newValue: string): void {
if (name === 'id') {
this.style.setProperty('position-anchor', `--${newValue}`);
}
this.style.setProperty('top', `calc(anchor(${this.side}) - ${this.sideOffset}px)`);
if (this.trackCursorAxis) {
this.style.setProperty('translate', `-50% -100%`);
} else {
this.style.setProperty('translate', `0 -100%`);
this.style.setProperty('justify-self', 'anchor-center');
}
}
connectedCallback(): void {
this.setAttribute('role', 'tooltip');
@@ -33,7 +61,6 @@ export class TooltipElement extends HTMLElement {
disconnectedCallback(): void {
this.#clearHoverTimeout();
this.#cleanup?.();
this.#abortController?.abort();
this.#abortController = null;
@@ -57,10 +84,6 @@ export class TooltipElement extends HTMLElement {
}
}
static get observedAttributes(): string[] {
return ['delay', 'close-delay', 'track-cursor-axis', 'side', 'side-offset', 'collision-padding'];
}
get delay(): number {
return Number.parseInt(this.getAttribute('delay') ?? '0', 10);
}
@@ -96,8 +119,6 @@ export class TooltipElement extends HTMLElement {
this.#open = open;
if (open) {
this.#setupFloating();
this.#transitionStatus = 'initial';
this.#updateVisibility();
@@ -106,6 +127,7 @@ export class TooltipElement extends HTMLElement {
requestAnimationFrame(() => {
this.#transitionStatus = 'open';
this.#updateVisibility();
this.#checkCollision();
});
} else {
this.#transitionStatus = 'close';
@@ -119,16 +141,16 @@ export class TooltipElement extends HTMLElement {
} else {
this.hidePopover();
}
this.#cleanup?.();
this.#cleanup = null;
}
}
#updateVisibility(): void {
this.toggleAttribute('data-starting-style', this.#transitionStatus === 'initial');
this.toggleAttribute('data-open', this.#transitionStatus === 'initial' || this.#transitionStatus === 'open');
this.toggleAttribute('data-ending-style', this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted');
this.toggleAttribute(
'data-ending-style',
this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted',
);
this.toggleAttribute('data-closed', this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted');
const triggerElement = this.#triggerElement as HTMLElement;
@@ -137,105 +159,34 @@ export class TooltipElement extends HTMLElement {
}
}
#setupFloating(): void {
const trigger = this.#triggerElement as HTMLElement;
if (!trigger) return;
const placement = this.side;
const sideOffset = this.sideOffset;
const collisionPadding = this.collisionPadding;
const mediaContainer = this.closest('media-container') as MediaContainerElement;
this.#arrowElement = this.querySelector('media-tooltip-arrow') as HTMLElement;
const updatePosition = () => {
const middleware = [
offset(sideOffset),
flip(),
shift({
boundary: mediaContainer,
padding: collisionPadding,
}),
];
if (this.#arrowElement) {
middleware.push(arrow({ element: this.#arrowElement }));
}
const referenceElement = this.trackCursorAxis
? {
getBoundingClientRect: () => {
const triggerRect = trigger.getBoundingClientRect();
if (this.trackCursorAxis === 'x') {
return {
width: 0,
height: 0,
top: triggerRect.top,
right: this.#pointerPosition.x,
bottom: triggerRect.bottom,
left: this.#pointerPosition.x,
x: this.#pointerPosition.x,
y: triggerRect.top,
};
} else if (this.trackCursorAxis === 'y') {
return {
width: 0,
height: 0,
top: this.#pointerPosition.y,
right: triggerRect.right,
bottom: this.#pointerPosition.y,
left: triggerRect.left,
x: triggerRect.left,
y: this.#pointerPosition.y,
};
} else {
// Track both axes (trackCursorAxis === 'both')
return {
width: 0,
height: 0,
top: this.#pointerPosition.y,
right: this.#pointerPosition.x,
bottom: this.#pointerPosition.y,
left: this.#pointerPosition.x,
x: this.#pointerPosition.x,
y: this.#pointerPosition.y,
};
}
},
}
: trigger;
computePosition(referenceElement, this, {
placement,
middleware,
strategy: 'fixed',
}).then(({ x, y, middlewareData }: { x: number; y: number; middlewareData: any }) => {
Object.assign(this.style, {
left: `${x}px`,
top: `${y}px`,
});
if (this.#arrowElement && middlewareData.arrow) {
const { x: arrowX, y: arrowY } = middlewareData.arrow;
Object.assign(this.#arrowElement.style, {
left: arrowX != null ? `${arrowX}px` : undefined,
top: arrowY != null ? `${arrowY}px` : undefined,
});
}
});
};
updatePosition();
if (!this.trackCursorAxis) {
this.#cleanup = autoUpdate(trigger, this, updatePosition);
#updatePosition(): void {
if (this.#open && this.trackCursorAxis) {
this.style.setProperty('left', `${this.#pointerPosition.x}px`);
this.#checkCollision();
}
}
#updatePosition(): void {
if (this.#open && this.trackCursorAxis) {
this.#setupFloating();
#checkCollision(): void {
const mediaContainer = this.closest('media-container') as MediaContainerElement;
if (!mediaContainer || !this.#open) return;
const popupRect = getBoundingClientRectWithoutTransform(this);
const boundsRect = getBoundingClientRectWithoutTransform(mediaContainer);
const { x } = getInBoundsAdjustments(popupRect, boundsRect, this.collisionPadding);
if (x !== 0) {
if (this.trackCursorAxis) {
const currentLeft = Number.parseFloat(this.style.left || '0');
this.style.setProperty('left', `${currentLeft + x}px`);
} else {
this.style.setProperty('translate', `${x}px -100%`);
}
} else {
if (this.trackCursorAxis) {
this.style.setProperty('translate', '-50% -100%');
} else {
this.style.setProperty('translate', '0 -100%');
}
}
}
+5 -5
View File
@@ -132,7 +132,7 @@ export class VolumeSliderTrack extends HTMLElement {
/**
* VolumeSlider Progress component - Shows current progress
*/
export class VolumeSliderIndicatorElement extends HTMLElement {
export class VolumeSliderIndicator extends HTMLElement {
constructor() {
super();
this.style.position = 'absolute';
@@ -255,8 +255,8 @@ export const VolumeSliderTrackElement: ConnectedComponentConstructor<any> = toCo
/**
* Connected VolumeSlider Progress component
*/
export const VolumeSliderProgressElement: ConnectedComponentConstructor<any> = toConnectedHTMLComponent(
VolumeSliderIndicatorElement,
export const VolumeSliderIndicatorElement: ConnectedComponentConstructor<any> = toConnectedHTMLComponent(
VolumeSliderIndicator,
{ keys: [], transform: () => ({}) },
getVolumeSliderProgressProps,
'VolumeSliderProgress',
@@ -280,12 +280,12 @@ export const VolumeSliderElement = Object.assign(
{
Root: VolumeSliderRootElement,
Track: VolumeSliderTrackElement,
Progress: VolumeSliderProgressElement,
Indicator: VolumeSliderIndicatorElement,
Thumb: VolumeSliderThumbElement,
},
) as {
Root: typeof VolumeSliderRootElement;
Track: typeof VolumeSliderTrackElement;
Progress: typeof VolumeSliderProgressElement;
Indicator: typeof VolumeSliderIndicatorElement;
Thumb: typeof VolumeSliderThumbElement;
};
+2 -1
View File
@@ -1,8 +1,9 @@
import { MediaSkinElement } from '@/media/media-skin';
import { defineCustomElement } from '@/utils/custom-element';
import styles from './styles.css';
import '@/define/media-container';
// be sure to import video-provider first for proper context initialization
import '@/define/video-provider';
import '@/define/media-container';
import '@/define/media-play-button';
import '@/define/media-mute-button';
import '@/define/media-volume-slider';
+2 -1
View File
@@ -1,8 +1,9 @@
import { MediaSkinElement } from '@/media/media-skin';
import { defineCustomElement } from '@/utils/custom-element';
import styles from './styles.css';
import '@/define/media-container';
// be sure to import video-provider first for proper context initialization
import '@/define/video-provider';
import '@/define/media-container';
import '@/define/media-play-button';
import '@/define/media-mute-button';
import '@/define/media-volume-slider';