feat: add tooltip core (#212)

This commit is contained in:
Wesley Luyten
2025-12-05 20:18:54 -06:00
committed by GitHub
parent 99fef78f63
commit cbf41ce4c7
16 changed files with 534 additions and 633 deletions
+33 -9
View File
@@ -1,11 +1,11 @@
import type { PopoverState as CorePopoverState } from '@videojs/core';
import type { Prettify } from '../types';
import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory';
import { Popover as CorePopover } from '@videojs/core';
import { getDocumentOrShadowRoot } from '@videojs/utils/dom';
import { getCoreState, getPropsFromAttrs, toConnectedHTMLComponent } from '../utils/component-factory';
type Placement = 'top' | 'top-start' | 'top-end';
type Placement = CorePopoverState['placement'];
export type PopoverState = Prettify<ReturnType<CorePopover['getState']>>;
@@ -21,30 +21,46 @@ export const getPopoverProps: PropsHook<Popover, PopoverState> = (element, state
state._setPopoverElement(element);
}
const triggerElement = getDocumentOrShadowRoot(element)?.querySelector(`[commandfor="${element.id}"]`) as HTMLElement | null;
const triggerElement = getDocumentOrShadowRoot(element)?.querySelector(
`[commandfor="${element.id}"]`,
) as HTMLElement | null;
if (state._triggerElement !== triggerElement) {
state._setTriggerElement(triggerElement);
}
const [side, alignment] = element.side.split('-');
const mediaContainer = element.closest(element.collisionBoundary
? `#${element.collisionBoundary}`
: 'media-container') as HTMLElement | null;
if (state._collisionBoundaryElement !== mediaContainer) {
state._setCollisionBoundaryElement(mediaContainer);
}
return {
'data-side': state.placement,
'data-starting-style': state._transitionStatus === 'initial',
'data-open': state._transitionStatus === 'initial' || state._transitionStatus === 'open',
'data-ending-style': state._transitionStatus === 'close' || state._transitionStatus === 'unmounted',
'data-closed': state._transitionStatus === 'close' || state._transitionStatus === 'unmounted',
style: {
...(element.id ? { 'position-anchor': `--${element.id}` } : {}),
top: `calc(anchor(${side}) - ${element.sideOffset}px)`,
translate: '0 -100%',
'justify-self': alignment === 'start' ? 'anchor-start' : alignment === 'end' ? 'anchor-end' : 'anchor-center',
...state._popoverStyle,
},
};
};
export class Popover extends HTMLElement {
static get observedAttributes(): string[] {
return ['id', 'open-on-hover', 'delay', 'close-delay', 'side', 'side-offset'];
return [
'id',
'open-on-hover',
'delay',
'close-delay',
'side',
'side-offset',
'collision-padding',
'collision-boundary',
];
}
get openOnHover(): boolean {
@@ -66,6 +82,14 @@ export class Popover extends HTMLElement {
get sideOffset(): number {
return Number.parseInt(this.getAttribute('side-offset') ?? '0', 10);
}
get collisionPadding(): number {
return Number.parseInt(this.getAttribute('collision-padding') ?? '0', 10);
}
get collisionBoundary(): string | null {
return this.getAttribute('collision-boundary');
}
}
export const PopoverElement: ConnectedComponentConstructor<Popover, PopoverState> = toConnectedHTMLComponent(
+1 -1
View File
@@ -51,7 +51,7 @@ export const getTimeSliderRootProps: PropsHook<TimeSliderRoot, TimeSliderStateWi
'aria-valuetext': `${state._currentTimeText} of ${state._durationText}`,
'aria-orientation': element.orientation || 'horizontal',
style: {
...(element.hasAttribute('commandfor') ? { 'anchor-name': `--${element.getAttribute('commandfor')}` } : {}),
...(element.hasAttribute('commandfor') ? { anchorName: `--${element.getAttribute('commandfor')}` } : {}),
'--slider-fill': `${state._fillWidth.toFixed(3)}%`,
'--slider-pointer': `${(state._pointerWidth * 100).toFixed(3)}%`,
},
+28 -218
View File
@@ -1,227 +1,37 @@
import type { MediaContainerElement } from '@/media/media-container';
import type { Prettify } from '../types';
import type { ConnectedComponentConstructor, PropsHook, StateHook } from '../utils/component-factory';
import { Tooltip as CoreTooltip } from '@videojs/core';
import { getCoreState, getPropsFromAttrs, toConnectedHTMLComponent } from '../utils/component-factory';
import { getPopoverProps, Popover } from './popover';
import {
getBoundingClientRectWithoutTransform,
getDocumentOrShadowRoot,
getInBoundsAdjustments,
} from '@videojs/utils/dom';
export type TooltipState = Prettify<ReturnType<CoreTooltip['getState']>>;
type Placement = 'top' | 'top-start' | 'top-end';
export const getTooltipState: StateHook<Tooltip, TooltipState> = (element, _mediaStore) => {
const coreState = getCoreState(CoreTooltip, getPropsFromAttrs(element));
return {
...coreState,
};
};
export class TooltipElement extends HTMLElement {
export const getTooltipProps: PropsHook<Tooltip, TooltipState> = getPopoverProps;
export class Tooltip extends Popover {
static get observedAttributes(): string[] {
return ['id', 'delay', 'close-delay', 'track-cursor-axis', 'side', 'side-offset', 'collision-padding'];
return [
...super.observedAttributes,
'track-cursor-axis',
];
}
#open = false;
#hoverTimeout: ReturnType<typeof setTimeout> | 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}`);
}
const [side, alignment] = this.side.split('-');
this.style.setProperty('top', `calc(anchor(${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', alignment === 'start'
? 'anchor-start'
: alignment === 'end'
? 'anchor-end'
: 'anchor-center');
}
}
connectedCallback(): void {
this.setAttribute('role', 'tooltip');
this.#abortController ??= new AbortController();
const { signal } = this.#abortController;
const trigger = this.#triggerElement as HTMLElement;
if (trigger) {
if (globalThis.matchMedia?.('(hover: hover)')?.matches) {
trigger.addEventListener('pointerenter', this, { signal });
trigger.addEventListener('pointerleave', this, { signal });
trigger.addEventListener('pointermove', this, { signal });
}
}
this.#updateVisibility();
}
disconnectedCallback(): void {
this.#clearHoverTimeout();
this.#abortController?.abort();
this.#abortController = null;
this.#transitionStatus = 'unmounted';
this.#updateVisibility();
}
handleEvent(event: Event): void {
switch (event.type) {
case 'pointerenter':
this.#handlePointerEnter();
break;
case 'pointerleave':
this.#handlePointerLeave(event as PointerEvent);
break;
case 'pointermove':
this.#handlePointerMove(event as PointerEvent);
break;
default:
break;
}
}
get delay(): number {
return Number.parseInt(this.getAttribute('delay') ?? '0', 10);
}
get closeDelay(): number {
return Number.parseInt(this.getAttribute('close-delay') ?? '0', 10);
}
get trackCursorAxis(): 'x' | 'y' | 'both' | undefined {
get trackCursorAxis(): 'x' | null {
const value = this.getAttribute('track-cursor-axis');
return value === 'x' || value === 'y' || value === 'both' ? value : undefined;
}
get side(): Placement {
return (this.getAttribute('side') as Placement) ?? 'top';
}
get sideOffset(): number {
return Number.parseInt(this.getAttribute('side-offset') ?? '0', 10);
}
get collisionPadding(): number {
return Number.parseInt(this.getAttribute('collision-padding') ?? '0', 10);
}
get #triggerElement(): HTMLElement | null {
return getDocumentOrShadowRoot(this)?.querySelector(`[commandfor="${this.id}"]`) as HTMLElement | null;
}
#setOpen(open: boolean): void {
if (this.#open === open) return;
this.#open = open;
if (open) {
this.#transitionStatus = 'initial';
this.#updateVisibility();
this.showPopover();
requestAnimationFrame(() => {
this.#transitionStatus = 'open';
this.#updateVisibility();
this.#checkCollision();
});
} else {
this.#transitionStatus = 'close';
this.#updateVisibility();
const transitions = this.getAnimations().filter(anim => anim instanceof CSSTransition);
if (transitions.length > 0) {
Promise.all(transitions.map(t => t.finished))
.then(() => this.hidePopover())
.catch(() => this.hidePopover());
} else {
this.hidePopover();
}
}
}
#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-closed', this.#transitionStatus === 'close' || this.#transitionStatus === 'unmounted');
const triggerElement = this.#triggerElement as HTMLElement;
if (triggerElement) {
triggerElement.toggleAttribute('data-popup-open', this.#open);
}
}
#updatePosition(): void {
if (this.#open && this.trackCursorAxis) {
this.style.setProperty('left', `${this.#pointerPosition.x}px`);
this.#checkCollision();
}
}
#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%');
}
}
}
#clearHoverTimeout(): void {
if (this.#hoverTimeout) {
globalThis.clearTimeout(this.#hoverTimeout);
this.#hoverTimeout = null;
}
}
#handlePointerEnter(): void {
this.#clearHoverTimeout();
this.#hoverTimeout = globalThis.setTimeout(() => {
this.#setOpen(true);
}, this.delay);
}
#handlePointerLeave(_event: PointerEvent): void {
this.#clearHoverTimeout();
this.#hoverTimeout = globalThis.setTimeout(() => {
this.#setOpen(false);
}, this.closeDelay);
}
#handlePointerMove(event: PointerEvent): void {
if (this.trackCursorAxis) {
this.#pointerPosition = { x: event.clientX, y: event.clientY };
if (this.#open) {
this.#updatePosition();
}
}
return value === 'x' ? value : null;
}
}
export const TooltipElement: ConnectedComponentConstructor<Tooltip, TooltipState> = toConnectedHTMLComponent(
Tooltip,
getTooltipState,
getTooltipProps,
'Tooltip',
);
+1 -1
View File
@@ -53,7 +53,7 @@ export const getVolumeSliderRootProps: PropsHook<VolumeSliderRoot, VolumeSliderS
'aria-valuetext': volumeText,
'aria-orientation': element.orientation || 'horizontal',
style: {
...(element.hasAttribute('commandfor') ? { 'anchor-name': `--${element.getAttribute('commandfor')}` } : {}),
...(element.hasAttribute('commandfor') ? { anchorName: `--${element.getAttribute('commandfor')}` } : {}),
'--slider-fill': `${state._fillWidth.toFixed(3)}%`,
},
};