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,5 +1,12 @@
|
||||
import { shallowEqual } from '@videojs/utils';
|
||||
import { contains, getDocument, safePolygon } from '@videojs/utils/dom';
|
||||
import {
|
||||
addTranslateToBoundingRect,
|
||||
contains,
|
||||
getDocument,
|
||||
getInBoundsAdjustments,
|
||||
getUntransformedBoundingRect,
|
||||
safePolygon,
|
||||
} from '@videojs/utils/dom';
|
||||
import { map } from 'nanostores';
|
||||
|
||||
type Placement = 'top' | 'top-start' | 'top-end';
|
||||
@@ -10,32 +17,47 @@ export interface PopoverState {
|
||||
closeDelay: number;
|
||||
placement: Placement;
|
||||
sideOffset: number;
|
||||
collisionPadding: number;
|
||||
disableHoverablePopover: boolean;
|
||||
trackCursorAxis?: 'x';
|
||||
_open: boolean;
|
||||
_setTriggerElement: (element: HTMLElement | null) => void;
|
||||
_triggerElement: HTMLElement | null;
|
||||
_setPopoverElement: (element: HTMLElement | null) => void;
|
||||
_popoverElement: HTMLElement | null;
|
||||
_transitionStatus: 'initial' | 'open' | 'close' | 'unmounted';
|
||||
_collisionOffset: { x: number };
|
||||
_pointerPosition: { x: number };
|
||||
_setTriggerElement: (element: HTMLElement | null) => void;
|
||||
_triggerElement?: HTMLElement | null;
|
||||
_setPopoverElement: (element: HTMLElement | null) => void;
|
||||
_popoverElement?: HTMLElement | null;
|
||||
_setCollisionBoundaryElement: (element: HTMLElement | null) => void;
|
||||
_collisionBoundaryElement?: HTMLElement | null;
|
||||
_popoverStyle?: Partial<CSSStyleDeclaration>;
|
||||
_minLeft?: number;
|
||||
_maxLeft?: number;
|
||||
}
|
||||
|
||||
export class Popover {
|
||||
#hoverTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
#resizeObserver: ResizeObserver | null = null;
|
||||
#state = map<PopoverState>({
|
||||
_open: false,
|
||||
openOnHover: false,
|
||||
delay: 0,
|
||||
closeDelay: 0,
|
||||
placement: 'top',
|
||||
sideOffset: 5,
|
||||
_setTriggerElement: this._setTriggerElement.bind(this),
|
||||
_triggerElement: null,
|
||||
_setPopoverElement: this._setPopoverElement.bind(this),
|
||||
_popoverElement: null,
|
||||
collisionPadding: 0,
|
||||
disableHoverablePopover: false,
|
||||
_open: false,
|
||||
_transitionStatus: 'initial',
|
||||
_collisionOffset: { x: 0 },
|
||||
_pointerPosition: { x: 0 },
|
||||
_setTriggerElement: this._setTriggerElement.bind(this),
|
||||
_setPopoverElement: this._setPopoverElement.bind(this),
|
||||
_setCollisionBoundaryElement: this._setCollisionBoundaryElement.bind(this),
|
||||
});
|
||||
|
||||
_setPopoverElement(element: HTMLElement | null): void {
|
||||
if (!element) {
|
||||
this.#resizeObserver?.disconnect();
|
||||
this.#clearHoverTimeout();
|
||||
this.#popoverElement?.removeEventListener('pointerenter', this);
|
||||
this.#popoverElement?.removeEventListener('focusout', this);
|
||||
@@ -45,6 +67,9 @@ export class Popover {
|
||||
|
||||
this.setState({ _popoverElement: element });
|
||||
|
||||
this.#resizeObserver = new ResizeObserver(() => this.#checkCollision());
|
||||
this.#resizeObserver.observe(element);
|
||||
|
||||
element.addEventListener('pointerenter', this);
|
||||
element.addEventListener('focusout', this);
|
||||
}
|
||||
@@ -69,6 +94,10 @@ export class Popover {
|
||||
element.addEventListener('focusout', this);
|
||||
}
|
||||
|
||||
_setCollisionBoundaryElement(element: HTMLElement | null): void {
|
||||
this.setState({ _collisionBoundaryElement: element });
|
||||
}
|
||||
|
||||
subscribe(callback: (state: PopoverState) => void): () => void {
|
||||
return this.#state.subscribe(callback);
|
||||
}
|
||||
@@ -79,7 +108,37 @@ export class Popover {
|
||||
}
|
||||
|
||||
getState(): PopoverState {
|
||||
return this.#state.get();
|
||||
const baseState = this.#state.get();
|
||||
const {
|
||||
placement,
|
||||
sideOffset,
|
||||
trackCursorAxis,
|
||||
_popoverElement,
|
||||
_pointerPosition,
|
||||
_collisionOffset,
|
||||
_minLeft,
|
||||
_maxLeft,
|
||||
} = baseState;
|
||||
const [side, alignment] = placement.split('-');
|
||||
|
||||
const _popoverStyle: Partial<CSSStyleDeclaration> = {
|
||||
...(_popoverElement ? { positionAnchor: `--${_popoverElement.id}` } : {}),
|
||||
top: `calc(anchor(${side}) - ${sideOffset}px)`,
|
||||
};
|
||||
|
||||
if (trackCursorAxis === 'x') {
|
||||
_popoverStyle.translate = `-50% -100%`;
|
||||
_popoverStyle.left = `clamp(${_minLeft}px, ${_pointerPosition.x}px, ${_maxLeft}px)`;
|
||||
} else {
|
||||
_popoverStyle.translate = `${_collisionOffset.x}px -100%`;
|
||||
_popoverStyle.justifySelf
|
||||
= alignment === 'start' ? 'anchor-start' : alignment === 'end' ? 'anchor-end' : 'anchor-center';
|
||||
}
|
||||
|
||||
return {
|
||||
...baseState,
|
||||
_popoverStyle,
|
||||
};
|
||||
}
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
@@ -104,11 +163,11 @@ export class Popover {
|
||||
}
|
||||
}
|
||||
|
||||
get #popoverElement(): HTMLElement | null {
|
||||
get #popoverElement(): HTMLElement | undefined | null {
|
||||
return this.getState()._popoverElement;
|
||||
}
|
||||
|
||||
get #triggerElement(): HTMLElement | null {
|
||||
get #triggerElement(): HTMLElement | undefined | null {
|
||||
return this.getState()._triggerElement;
|
||||
}
|
||||
|
||||
@@ -128,6 +187,9 @@ export class Popover {
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.setState({ _transitionStatus: 'open' });
|
||||
|
||||
// This requestAnimationFrame is required for React because it renders async.
|
||||
requestAnimationFrame(() => this.#checkCollision());
|
||||
});
|
||||
} else {
|
||||
this.setState({ _transitionStatus: 'close' });
|
||||
@@ -154,11 +216,12 @@ export class Popover {
|
||||
}
|
||||
|
||||
#handlePointerEnter(event: PointerEvent): void {
|
||||
if (!this.getState().openOnHover) return;
|
||||
const { openOnHover, trackCursorAxis } = this.getState();
|
||||
if (!openOnHover) return;
|
||||
|
||||
this.#clearHoverTimeout();
|
||||
|
||||
if (event.currentTarget === this.#popoverElement) {
|
||||
if (event.currentTarget === this.#popoverElement || trackCursorAxis === 'x') {
|
||||
this.#addPointerMoveListener();
|
||||
}
|
||||
|
||||
@@ -172,7 +235,18 @@ export class Popover {
|
||||
}
|
||||
|
||||
#handlePointerLeave(_event: PointerEvent): void {
|
||||
this.#addPointerMoveListener();
|
||||
const { disableHoverablePopover, closeDelay } = this.getState();
|
||||
|
||||
if (!disableHoverablePopover) {
|
||||
this.#addPointerMoveListener();
|
||||
}
|
||||
|
||||
if (disableHoverablePopover) {
|
||||
this.#clearHoverTimeout();
|
||||
this.#hoverTimeout = globalThis.setTimeout(() => {
|
||||
this.#setOpen(false);
|
||||
}, closeDelay);
|
||||
}
|
||||
}
|
||||
|
||||
#addPointerMoveListener(): void {
|
||||
@@ -182,7 +256,19 @@ export class Popover {
|
||||
}
|
||||
|
||||
#handlePointerMove(event: PointerEvent): void {
|
||||
if (!this.getState().openOnHover || !this.#triggerElement || !this.#popoverElement) return;
|
||||
const { disableHoverablePopover, openOnHover, trackCursorAxis } = this.getState();
|
||||
if (!openOnHover || !this.#triggerElement || !this.#popoverElement) return;
|
||||
|
||||
if (trackCursorAxis === 'x') {
|
||||
this.setState({ _pointerPosition: { x: event.clientX } });
|
||||
|
||||
// This requestAnimationFrame is required for React because it renders async.
|
||||
requestAnimationFrame(() => this.#checkCollision());
|
||||
}
|
||||
|
||||
if (disableHoverablePopover) {
|
||||
return;
|
||||
}
|
||||
|
||||
const close = safePolygon({ blockPointerEvents: true })({
|
||||
placement: this.getState().placement,
|
||||
@@ -213,5 +299,21 @@ export class Popover {
|
||||
if (relatedTarget && contains(this.#popoverElement, relatedTarget)) return;
|
||||
|
||||
this.#setOpen(false);
|
||||
};
|
||||
}
|
||||
|
||||
#checkCollision(): void {
|
||||
const { _collisionBoundaryElement, _open, _popoverElement, collisionPadding } = this.getState();
|
||||
|
||||
if (!_open || !_collisionBoundaryElement || !_popoverElement) return;
|
||||
|
||||
const popoverRect = getUntransformedBoundingRect(_popoverElement);
|
||||
const boundsRect = getUntransformedBoundingRect(_collisionBoundaryElement);
|
||||
const translatedPopoverRect = addTranslateToBoundingRect(popoverRect, _popoverElement);
|
||||
|
||||
const _minLeft = boundsRect.left + translatedPopoverRect.width / 2 + collisionPadding;
|
||||
const _maxLeft = boundsRect.right - translatedPopoverRect.width / 2 - collisionPadding;
|
||||
const _collisionOffset = getInBoundsAdjustments(popoverRect, boundsRect, collisionPadding);
|
||||
|
||||
this.setState({ _minLeft, _maxLeft, _collisionOffset });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { shallowEqual } from '@videojs/utils';
|
||||
|
||||
import { getPointProgressOnLine, shallowEqual } from '@videojs/utils';
|
||||
import { map } from 'nanostores';
|
||||
|
||||
export interface SliderState {
|
||||
@@ -175,31 +174,3 @@ export class Slider {
|
||||
this.setState({ _stepSize: Math.max(0.001, Math.min(1, stepSize)) });
|
||||
}
|
||||
}
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress ratio of a point on a line segment.
|
||||
* @param x - The x coordinate of the point.
|
||||
* @param y - The y coordinate of the point.
|
||||
* @param p1 - The first point of the line segment.
|
||||
* @param p2 - The second point of the line segment.
|
||||
*/
|
||||
export function getPointProgressOnLine(x: number, y: number, p1: Point, p2: Point): number {
|
||||
const dx = p2.x - p1.x;
|
||||
const dy = p2.y - p1.y;
|
||||
const lengthSquared = dx * dx + dy * dy;
|
||||
|
||||
if (lengthSquared === 0) return 0; // Avoid division by zero if p1 === p2
|
||||
|
||||
const projection = ((x - p1.x) * dx + (y - p1.y) * dy) / lengthSquared;
|
||||
|
||||
return Math.max(0, Math.min(1, projection)); // Clamp between 0 and 1
|
||||
}
|
||||
|
||||
export function distance(p1: Point, p2: Point): number {
|
||||
return Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { PopoverState } from './popover';
|
||||
import { Popover } from './popover';
|
||||
|
||||
export interface TooltipState extends PopoverState {}
|
||||
|
||||
export class Tooltip extends Popover {
|
||||
constructor() {
|
||||
super();
|
||||
this.setState({
|
||||
openOnHover: true,
|
||||
disableHoverablePopover: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { Popover, type PopoverState } from './components/popover';
|
||||
export { TimeSlider, type TimeSliderState } from './components/time-slider';
|
||||
export { Tooltip, type TooltipState } from './components/tooltip';
|
||||
export { VolumeSlider, type VolumeSliderState } from './components/volume-slider';
|
||||
|
||||
Reference in New Issue
Block a user