diff --git a/packages/core/src/components/popover.ts b/packages/core/src/components/popover.ts index 3f569025..91859a76 100644 --- a/packages/core/src/components/popover.ts +++ b/packages/core/src/components/popover.ts @@ -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; + _minLeft?: number; + _maxLeft?: number; } export class Popover { #hoverTimeout: ReturnType | null = null; + #resizeObserver: ResizeObserver | null = null; #state = map({ - _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 = { + ...(_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 }); + } } diff --git a/packages/core/src/components/slider.ts b/packages/core/src/components/slider.ts index 5d671f64..d133ea48 100644 --- a/packages/core/src/components/slider.ts +++ b/packages/core/src/components/slider.ts @@ -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); -} diff --git a/packages/core/src/components/tooltip.ts b/packages/core/src/components/tooltip.ts new file mode 100644 index 00000000..9135c744 --- /dev/null +++ b/packages/core/src/components/tooltip.ts @@ -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, + }); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e8f849e9..2709088f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/html/src/elements/popover.ts b/packages/html/src/elements/popover.ts index b0b182f1..a5f46307 100644 --- a/packages/html/src/elements/popover.ts +++ b/packages/html/src/elements/popover.ts @@ -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>; @@ -21,30 +21,46 @@ export const getPopoverProps: PropsHook = (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 = toConnectedHTMLComponent( diff --git a/packages/html/src/elements/time-slider.ts b/packages/html/src/elements/time-slider.ts index 34b92c9b..5af0e7b3 100644 --- a/packages/html/src/elements/time-slider.ts +++ b/packages/html/src/elements/time-slider.ts @@ -51,7 +51,7 @@ export const getTimeSliderRootProps: PropsHook>; -type Placement = 'top' | 'top-start' | 'top-end'; +export const getTooltipState: StateHook = (element, _mediaStore) => { + const coreState = getCoreState(CoreTooltip, getPropsFromAttrs(element)); + return { + ...coreState, + }; +}; -export class TooltipElement extends HTMLElement { +export const getTooltipProps: PropsHook = 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 | 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 = toConnectedHTMLComponent( + Tooltip, + getTooltipState, + getTooltipProps, + 'Tooltip', +); diff --git a/packages/html/src/elements/volume-slider.ts b/packages/html/src/elements/volume-slider.ts index 6264cf1d..539cff7d 100644 --- a/packages/html/src/elements/volume-slider.ts +++ b/packages/html/src/elements/volume-slider.ts @@ -53,7 +53,7 @@ export const getVolumeSliderRootProps: PropsHook void; + updatePositioning: (placement: Placement, sideOffset: number, collisionPadding: number) => void; } >; @@ -20,7 +21,7 @@ export type PopoverState = Prettify< // ROOT COMPONENT // ============================================================================ -interface PopoverRootProps { +export interface PopoverRootProps { openOnHover?: boolean; delay?: number; closeDelay?: number; @@ -31,6 +32,7 @@ export function usePopoverRootState(props: PopoverRootProps): PopoverState { const { openOnHover = false, delay = 0, closeDelay = 0 } = props; const [placement, setPlacement] = useState('top'); const [sideOffset, setSideOffset] = useState(5); + const [collisionPadding, setCollisionPadding] = useState(0); const uniqueId = useId(); const popupId = uniqueId.replace(/^:([^:]+):$/, '«$1»'); @@ -40,12 +42,17 @@ export function usePopoverRootState(props: PopoverRootProps): PopoverState { closeDelay, placement, sideOffset, + collisionPadding, }); - const updatePositioning = useCallback((newPlacement: Placement, newSideOffset: number) => { - setPlacement(newPlacement); - setSideOffset(newSideOffset); - }, []); + const updatePositioning = useCallback( + (newPlacement: Placement, newSideOffset: number, newCollisionPadding: number) => { + setPlacement(newPlacement); + setSideOffset(newSideOffset); + setCollisionPadding(newCollisionPadding); + }, + [], + ); return { ...coreState, @@ -75,7 +82,7 @@ const PopoverRoot: ConnectedComponent { - updatePositioning(side, sideOffset); - }, [side, sideOffset, updatePositioning]); + updatePositioning(side, sideOffset, collisionPadding); + }, [side, sideOffset, collisionPadding, updatePositioning]); + + useEffect(() => { + if (collisionBoundary) { + _setCollisionBoundaryElement(collisionBoundary as HTMLElement); + } + }, [collisionBoundary, _setCollisionBoundaryElement]); return { children }; } @@ -149,14 +164,14 @@ const PopoverPositioner: ConnectedComponent { +export interface PopoverPopupRenderProps extends React.ComponentProps<'div'> { children: ReactNode; 'data-side': Placement; 'data-starting-style': string | undefined; @@ -167,8 +182,47 @@ interface PopoverPopupRenderProps extends React.ComponentProps<'div'> { export function usePopoverPopupProps(props: PopoverPopupProps, context: PopoverState): PopoverPopupRenderProps { const { className, style, children, id } = props; - const { _setPopoverElement, _transitionStatus, placement, popupId, sideOffset } = context; - const [side, alignment] = placement.split('-'); + const { + _setPopoverElement, + _transitionStatus, + placement, + popupId, + _popoverStyle, + _triggerElement, + _popoverElement, + _setCollisionBoundaryElement, + _collisionBoundaryElement, + } = context; + + // Set bounding box element for collision detection + useEffect(() => { + if (!_popoverElement) return; + + // Only set bounding box if it's not already set (to allow collisionBoundary from Positioner to take precedence) + if (!_collisionBoundaryElement) { + const mediaContainer = _popoverElement.closest('[data-media-container]') as HTMLElement | null; + _setCollisionBoundaryElement(mediaContainer); + } + }, [_popoverElement, _collisionBoundaryElement, _setCollisionBoundaryElement]); + + // Track data attributes from trigger element, updating when element or attributes change + const [dataAttrs, setDataAttrs] = useState | undefined>(() => + getDataAttributes(_triggerElement), + ); + + // Update data attributes when trigger element changes + useEffect(() => { + setDataAttrs(getDataAttributes(_triggerElement)); + }, [_triggerElement]); + + // Update data attributes when attributes mutate + useMutationObserver( + _triggerElement, + () => { + setDataAttrs(getDataAttributes(_triggerElement)); + }, + { attributes: true }, + ); return { ref: _setPopoverElement, @@ -176,12 +230,10 @@ export function usePopoverPopupProps(props: PopoverPopupProps, context: PopoverS className, popover: 'manual' as const, style: { - ...(popupId ? { positionAnchor: `--${popupId}` as any } : {}), - top: `calc(anchor(${side}) - ${sideOffset}px)`, - translate: '0 -100%', - justifySelf: alignment === 'start' ? 'anchor-start' : alignment === 'end' ? 'anchor-end' : 'anchor-center', + ..._popoverStyle, ...style, } as React.CSSProperties, + ...dataAttrs, 'data-side': placement, 'data-starting-style': _transitionStatus === 'initial' ? '' : undefined, 'data-open': _transitionStatus === 'initial' || _transitionStatus === 'open' ? '' : undefined, @@ -191,6 +243,15 @@ export function usePopoverPopupProps(props: PopoverPopupProps, context: PopoverS }; } +function getDataAttributes(element?: HTMLElement | null): Record | undefined { + if (!element) return undefined; + return Object.fromEntries( + Array.from(element.attributes) + .filter(attr => attr.name.startsWith('data-')) + .map(attr => [attr.name, attr.value]), + ); +} + export function renderPopoverPopup(props: PopoverPopupRenderProps): JSX.Element { return
; } diff --git a/packages/react/src/components/Tooltip.tsx b/packages/react/src/components/Tooltip.tsx index 2a8758d7..da4d5e5a 100644 --- a/packages/react/src/components/Tooltip.tsx +++ b/packages/react/src/components/Tooltip.tsx @@ -1,365 +1,166 @@ -import type { ReactNode, RefObject } from 'react'; +import type { TooltipState as CoreTooltipState } from '@videojs/core'; +import type { ReactNode } from 'react'; +import type { Prettify } from '../types'; +import type { ConnectedComponent } from '../utils/component-factory'; +import type { PopoverPopupProps, PopoverPopupRenderProps, PopoverPositionerProps, PopoverRootProps } from './Popover'; -import { contains, getBoundingClientRectWithoutTransform, getInBoundsAdjustments } from '@videojs/utils/dom'; +import { Tooltip as CoreTooltip } from '@videojs/core'; +import { cloneElement, useCallback, useId, useState } from 'react'; +import { toConnectedComponent, toContextComponent, useCore } from '../utils/component-factory'; +import { usePopoverPopupProps, usePopoverPositionerProps, usePopoverTriggerProps } from './Popover'; -import { - Children, - cloneElement, - createContext, - useCallback, - useContext, - useEffect, - useId, - useMemo, - useRef, - useState, -} from 'react'; +type Placement = CoreTooltipState['placement']; -type Placement = 'top' | 'top-start' | 'top-end'; +export type TooltipState = Prettify< + CoreTooltipState & { + popupId: string | undefined; + updatePositioning: (placement: Placement, sideOffset: number, collisionPadding: number) => void; + } +>; -interface UpdatePositioningProps { - side: Placement; - sideOffset: number; - collisionPadding: number; +// ============================================================================ +// ROOT COMPONENT +// ============================================================================ + +interface TooltipRootProps extends PopoverRootProps { + trackCursorAxis?: 'x'; } -type TransitionStatus = 'initial' | 'open' | 'close' | 'unmounted'; +export function useTooltipRootState(props: TooltipRootProps): TooltipState { + const { delay = 0, closeDelay = 0, trackCursorAxis } = props; + const [placement, setPlacement] = useState('top'); + const [sideOffset, setSideOffset] = useState(0); + const [collisionPadding, setCollisionPadding] = useState(0); + const uniqueId = useId(); + const popupId = uniqueId.replace(/^:([^:]+):$/, '«$1»'); -interface TooltipContextType { - popupRef: RefObject; - triggerRef: RefObject; - open: boolean; - transitionStatus: TransitionStatus; - updatePositioning: (props: UpdatePositioningProps) => void; - trackCursorAxis?: 'x' | 'y' | 'both' | undefined; - placement: Placement; - sideOffset: number; - collisionPadding: number; - popupId: string | undefined; + const coreState = useCore(CoreTooltip, { + delay, + closeDelay, + placement, + sideOffset, + collisionPadding, + trackCursorAxis, + }); + + const updatePositioning = useCallback( + (newPlacement: Placement, newSideOffset: number, newCollisionPadding: number) => { + setPlacement(newPlacement); + setSideOffset(newSideOffset); + setCollisionPadding(newCollisionPadding); + }, + [], + ); + + return { + ...coreState, + popupId, + updatePositioning, + }; } -interface TooltipRootProps { - delay?: number; - closeDelay?: number; - children: ReactNode; - trackCursorAxis?: 'x' | 'y' | 'both'; +export function useTooltipRootProps(props: TooltipRootProps, _state: TooltipState): { children: ReactNode } { + return { + children: props.children, + }; } +export function renderTooltipRoot(props: { children: ReactNode }): JSX.Element { + return <>{props.children}; +} + +const TooltipRoot: ConnectedComponent = toConnectedComponent( + useTooltipRootState, + useTooltipRootProps, + renderTooltipRoot, + 'Tooltip.Root', +); + +// ============================================================================ +// TRIGGER COMPONENT +// ============================================================================ + interface TooltipTriggerProps { children: ReactNode; } -interface TooltipPositionerProps { - side?: Placement; - sideOffset?: number; - collisionPadding?: number; - children: ReactNode; +export function useTooltipTriggerProps( + props: TooltipTriggerProps, + context: TooltipState, +): { child: JSX.Element; triggerProps: Record } { + return usePopoverTriggerProps(props, context); } -interface TooltipPopupProps { - id?: string; - className?: string; - style?: React.CSSProperties; - children: ReactNode; -} - -const TooltipContext = createContext(null); - -function useTooltipContext(): TooltipContextType { - const context = useContext(TooltipContext); - if (!context) { - throw new Error('Tooltip components must be used within TooltipRoot'); - } - return context; -} - -function TooltipRoot({ delay = 0, closeDelay = 0, trackCursorAxis, children }: TooltipRootProps): JSX.Element { - const [open, setOpen] = useState(false); - const [placement, setPlacement] = useState('top'); - const [sideOffset, setSideOffset] = useState(0); - const [collisionPadding, setCollisionPadding] = useState(0); - const [transitionStatus, setTransitionStatus] = useState('initial'); - const popupRef = useRef(null); - const triggerRef = useRef(null); - const hoverTimeoutRef = useRef | null>(null); - const pointerPositionRef = useRef({ x: 0, y: 0 }); - const uniqueId = useId(); - const popupId = uniqueId.replace(/^:([^:]+):$/, '«$1»'); - - const updatePositioning = useCallback(({ side, sideOffset, collisionPadding }: UpdatePositioningProps) => { - setPlacement(side); - setSideOffset(sideOffset); - setCollisionPadding(collisionPadding); - }, []); - - const clearHoverTimeout = useCallback(() => { - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - hoverTimeoutRef.current = null; - } - }, []); - - const checkCollision = useCallback(() => { - if (!popupRef.current || !triggerRef.current || !open) return; - - const mediaContainer = popupRef.current.closest('[data-media-container]') as HTMLElement | null; - if (!mediaContainer) return; - - const popupRect = getBoundingClientRectWithoutTransform(popupRef.current); - const boundsRect = getBoundingClientRectWithoutTransform(mediaContainer); - const { x } = getInBoundsAdjustments(popupRect, boundsRect, collisionPadding); - - if (x !== 0) { - if (trackCursorAxis) { - const currentLeft = Number.parseFloat(popupRef.current.style.left || '0'); - popupRef.current.style.setProperty('left', `${currentLeft + x}px`); - } else { - popupRef.current.style.setProperty('translate', `${x}px -100%`); - } - } else { - if (trackCursorAxis) { - popupRef.current.style.setProperty('translate', '-50% -100%'); - } else { - popupRef.current.style.setProperty('translate', '0 -100%'); - } - } - }, [open, collisionPadding, trackCursorAxis]); - - const updatePosition = useCallback(() => { - if (open && trackCursorAxis && popupRef.current) { - popupRef.current.style.setProperty('left', `${pointerPositionRef.current.x}px`); - checkCollision(); - } - }, [open, trackCursorAxis, checkCollision]); - - const setOpenState = useCallback( - (newOpen: boolean) => { - if (open === newOpen) return; - - setOpen(newOpen); - - if (newOpen) { - setTransitionStatus('initial'); - if (popupRef.current) { - popupRef.current.showPopover(); - } - requestAnimationFrame(() => { - setTransitionStatus('open'); - checkCollision(); - }); - } else { - setTransitionStatus('close'); - } - }, - [open, checkCollision], - ); - - useEffect(() => { - if (!popupRef.current || open) return; - - const transitions = popupRef.current.getAnimations().filter(anim => anim instanceof CSSTransition); - if (transitions.length > 0) { - Promise.all(transitions.map(t => t.finished)) - .then(() => popupRef.current?.hidePopover()) - .catch(() => popupRef.current?.hidePopover()); - } else { - popupRef.current.hidePopover(); - } - }, [open, transitionStatus]); - - useEffect(() => { - const trigger = triggerRef.current; - const popup = popupRef.current; - if (!trigger || !popup) return; - - const abortController = new AbortController(); - const { signal } = abortController; - - const handlePointerEnter = () => { - clearHoverTimeout(); - - hoverTimeoutRef.current = setTimeout(() => { - setOpenState(true); - }, delay); - }; - - const handlePointerLeave = () => { - clearHoverTimeout(); - - hoverTimeoutRef.current = setTimeout(() => { - setOpenState(false); - }, closeDelay); - }; - - const handlePointerMove = (event: PointerEvent) => { - if (trackCursorAxis) { - pointerPositionRef.current = { x: event.clientX, y: event.clientY }; - if (open) { - updatePosition(); - } - } - }; - - const handleFocusIn = () => { - setOpenState(true); - }; - - const handleFocusOut = (event: FocusEvent) => { - const relatedTarget = event.relatedTarget as HTMLElement; - if (relatedTarget && popup && contains(popup, relatedTarget)) return; - setOpenState(false); - }; - - if (globalThis.matchMedia?.('(hover: hover)')?.matches) { - // Event listeners are automatically removed when AbortController is aborted - trigger.addEventListener('pointerenter', handlePointerEnter, { signal }); - trigger.addEventListener('pointerleave', handlePointerLeave, { signal }); - - if (trackCursorAxis) { - trigger.addEventListener('pointermove', handlePointerMove, { signal }); - } - } - - // Event listeners are automatically removed when AbortController is aborted - trigger.addEventListener('focusin', handleFocusIn, { signal }); - trigger.addEventListener('focusout', handleFocusOut, { signal }); - - return () => { - abortController.abort(); - clearHoverTimeout(); - }; - }, [delay, closeDelay, trackCursorAxis, open, setOpenState, clearHoverTimeout, updatePosition]); - - useEffect(() => { - if (!popupRef.current || !triggerRef.current) return; - - const popup = popupRef.current; - const [side, alignment] = placement.split('-'); - popup.style.setProperty('top', `calc(anchor(${side}) - ${sideOffset}px)`); - - if (trackCursorAxis) { - popup.style.setProperty('translate', `-50% -100%`); - } else { - popup.style.setProperty('translate', `0 -100%`); - popup.style.setProperty('justify-self', alignment === 'start' - ? 'anchor-start' - : alignment === 'end' - ? 'anchor-end' - : 'anchor-center'); - } - }, [placement, sideOffset, trackCursorAxis]); - - useEffect(() => { - if (!popupRef.current) return; - - const resizeObserver = new ResizeObserver(checkCollision); - resizeObserver.observe(popupRef.current); - - return () => { - resizeObserver.disconnect(); - }; - }, [checkCollision]); - - const value: TooltipContextType = useMemo( - () => ({ - popupRef, - triggerRef, - open, - transitionStatus, - updatePositioning, - trackCursorAxis, - placement, - sideOffset, - collisionPadding, - popupId, - }), - [open, transitionStatus, updatePositioning, trackCursorAxis, placement, sideOffset, collisionPadding, popupId], - ); - - return {children}; -} - -function TooltipTrigger({ children }: TooltipTriggerProps): JSX.Element { - const { triggerRef, open, popupId } = useTooltipContext(); - - const child = Children.only(children) as JSX.Element; - const existingStyle = (child.props as { style?: React.CSSProperties })?.style || {}; - +export function renderTooltipTrigger(props: { child: JSX.Element; triggerProps: Record }): JSX.Element { // eslint-disable-next-line react/no-clone-element - return cloneElement(child, { - ref: triggerRef, - 'data-popup-open': open ? '' : undefined, - commandfor: popupId ?? undefined, - style: { - ...existingStyle, - ...(popupId ? { anchorName: `--${popupId}` as any } : {}), - }, - }); + return cloneElement(props.child, props.triggerProps); } -function TooltipPositioner({ - side = 'top', - sideOffset = 0, - collisionPadding = 0, - children, -}: TooltipPositionerProps): JSX.Element | null { - const { updatePositioning } = useTooltipContext(); +const TooltipTrigger: ConnectedComponent = toContextComponent( + useTooltipTriggerProps, + renderTooltipTrigger, + 'Tooltip.Trigger', +); - useEffect(() => { - updatePositioning({ side, sideOffset, collisionPadding }); - }, [side, sideOffset, collisionPadding, updatePositioning]); +// ============================================================================ +// POSITIONER COMPONENT +// ============================================================================ - return <>{children}; +interface TooltipPositionerProps extends PopoverPositionerProps {} + +export function useTooltipPositionerProps( + props: TooltipPositionerProps, + context: TooltipState, +): { children: ReactNode } { + return usePopoverPositionerProps(props, context); } -function TooltipPopup({ className = '', style, children }: TooltipPopupProps): JSX.Element | null { - const { popupRef, triggerRef, transitionStatus, placement, popupId } = useTooltipContext(); - const triggerElement = triggerRef.current; - - // Copy data attributes from trigger element - const dataAttributes = useMemo(() => { - if (!triggerElement?.attributes) return {}; - return Object.fromEntries( - Array.from(triggerElement.attributes) - .filter(attr => attr.name.startsWith('data-')) - .map(attr => [attr.name, attr.value]), - ); - }, [triggerElement]); - - return ( -
} - id={popupId ?? undefined} - className={className} - role="tooltip" - popover="manual" - style={{ - ...(popupId ? { positionAnchor: `--${popupId}` as any } : {}), - ...style, - }} - {...dataAttributes} - data-side={placement} - data-starting-style={transitionStatus === 'initial' ? '' : undefined} - data-open={transitionStatus === 'initial' || transitionStatus === 'open' ? '' : undefined} - data-ending-style={transitionStatus === 'close' || transitionStatus === 'unmounted' ? '' : undefined} - data-closed={transitionStatus === 'close' || transitionStatus === 'unmounted' ? '' : undefined} - > - {children} -
- ); +export function renderTooltipPositioner(props: { children: ReactNode }): JSX.Element { + return <>{props.children}; } -// eslint-disable-next-line react-refresh/only-export-components -export const Tooltip: { +const TooltipPositioner: ConnectedComponent + = toContextComponent(useTooltipPositionerProps, renderTooltipPositioner, 'Tooltip.Positioner'); + +// ============================================================================ +// POPUP COMPONENT +// ============================================================================ + +interface TooltipPopupProps extends PopoverPopupProps {} + +interface TooltipPopupRenderProps extends PopoverPopupRenderProps {} + +export function useTooltipPopupProps(props: TooltipPopupProps, context: TooltipState): TooltipPopupRenderProps { + return usePopoverPopupProps(props, context); +} + +export function renderTooltipPopup(props: TooltipPopupRenderProps): JSX.Element { + return
; +} + +const TooltipPopup: ConnectedComponent = toContextComponent( + useTooltipPopupProps, + renderTooltipPopup, + 'Tooltip.Popup', +); + +// ============================================================================ +// EXPORTS +// ============================================================================ + +export const Tooltip = Object.assign( + {}, + { + Root: TooltipRoot, + Trigger: TooltipTrigger, + Positioner: TooltipPositioner, + Popup: TooltipPopup, + }, +) as { Root: typeof TooltipRoot; Trigger: typeof TooltipTrigger; Positioner: typeof TooltipPositioner; Popup: typeof TooltipPopup; -} = { - Root: TooltipRoot, - Trigger: TooltipTrigger, - Positioner: TooltipPositioner, - Popup: TooltipPopup, }; export default Tooltip; diff --git a/packages/react/src/utils/use-mutation-observer.ts b/packages/react/src/utils/use-mutation-observer.ts new file mode 100644 index 00000000..214ca991 --- /dev/null +++ b/packages/react/src/utils/use-mutation-observer.ts @@ -0,0 +1,43 @@ +import type { RefObject } from 'react'; +import { useEffect, useRef } from 'react'; + +/** + * Custom React hook for observing DOM mutations using MutationObserver + * + * @param target - Element to observe (ref or element) + * @param callback - Function called when mutations occur + * @param options - MutationObserver options + */ +export function useMutationObserver( + target: RefObject | T | null | undefined, + callback: MutationCallback, + options: MutationObserverInit = { + attributes: true, + childList: true, + subtree: true, + }, +): void { + const callbackRef = useRef(callback); + + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + useEffect(() => { + const element = target && 'current' in target ? target.current : target; + + if (!element || !(element instanceof Element)) { + return; + } + + const observer = new MutationObserver((mutations, obs) => { + callbackRef.current(mutations, obs); + }); + + observer.observe(element, options); + + return () => { + observer.disconnect(); + }; + }, [target, options]); +} diff --git a/packages/utils/src/dom/attributes.ts b/packages/utils/src/dom/attributes.ts index 08cfc2e3..d544f6f0 100644 --- a/packages/utils/src/dom/attributes.ts +++ b/packages/utils/src/dom/attributes.ts @@ -1,3 +1,5 @@ +import { toKebabCase } from '@videojs/utils'; + /** * Converts a `NamedNodeMap` to a plain object. */ @@ -22,9 +24,9 @@ export function setAttributes(element: HTMLElement, attributes: Record [child, ...getNodeChildren(nodes, child.id, onlyOpenChildren)]); } -export function getBoundingClientRectWithoutTransform(element: HTMLElement): DOMRect { +export function getUntransformedBoundingRect(element: HTMLElement): DOMRect { let el = element; let left = 0; let top = 0; @@ -134,6 +134,38 @@ export function getBoundingClientRectWithoutTransform(element: HTMLElement): DOM } as DOMRect; } +export function addTranslateToBoundingRect(rect: DOMRect, element: HTMLElement): DOMRect { + // Get translate from transform + const style = getWindow(element).getComputedStyle(element); + const translate = style.translate; + if (translate && translate !== 'none') { + const values = translate.split(' '); + + // Parse translateX (can be px, %, etc) + const translateX = parseTranslateValue(values[0] ?? '0', element.offsetWidth); + const translateY = parseTranslateValue(values[1] ?? '0', element.offsetHeight); + + return { + ...rect, + left: rect.left + translateX, + top: rect.top + translateY, + }; + } + + return rect; +} + +function parseTranslateValue(value: string, referenceSize: number): number { + if (value.endsWith('%')) { + return (Number.parseFloat(value) / 100) * referenceSize; + } + if (value.endsWith('px')) { + return Number.parseFloat(value); + } + // Handle other units like em, rem, etc if needed + return Number.parseFloat(value) || 0; +} + export function getInBoundsAdjustments( popupRect: DOMRect, containerRect: DOMRect, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index aff03929..c80d0f4b 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,5 +1,6 @@ export * from './shared/console'; export * from './shared/crypto'; +export * from './shared/math'; export * from './shared/memoize'; export * from './shared/state'; export * from './shared/string'; diff --git a/packages/utils/src/shared/math.ts b/packages/utils/src/shared/math.ts new file mode 100644 index 00000000..cd81719c --- /dev/null +++ b/packages/utils/src/shared/math.ts @@ -0,0 +1,27 @@ +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); +} diff --git a/packages/utils/src/shared/string.ts b/packages/utils/src/shared/string.ts index 319df12d..de759e36 100644 --- a/packages/utils/src/shared/string.ts +++ b/packages/utils/src/shared/string.ts @@ -9,3 +9,15 @@ export function toCamelCase(str: string): string { .toLowerCase() .replace(/[-_]([a-z])/g, (_$0, $1) => $1.toUpperCase()); } + +/** + * Converts a string to kebab case. + * + * @param str - The string to convert. + * @returns The kebab case string. + */ +export function toKebabCase(str: string): string { + return str + .replace(/([A-Z])/g, '-$1') + .toLowerCase(); +}