mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat: add tooltip core (#212)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)}%`,
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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)}%`,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user