mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
fix: correct popup fallback positioning offsets (#981)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { supportsAnchorPositioning } from '@videojs/utils/dom';
|
||||
import { resolveCSSLength, supportsAnchorPositioning } from '@videojs/utils/dom';
|
||||
import type { PopoverAlign, PopoverSide } from '../../../core/ui/popover/popover-core';
|
||||
import { type PopoverCSSVarKey, PopoverCSSVars } from '../../../core/ui/popover/popover-css-vars';
|
||||
|
||||
@@ -258,7 +258,32 @@ export function getManualPositionStyle(
|
||||
export function resolveOffsets(el: Element, cssVars: PositioningCSSVars = PopoverCSSVars): ManualOffsets {
|
||||
const computed = getComputedStyle(el);
|
||||
return {
|
||||
sideOffset: Number.parseFloat(computed.getPropertyValue(cssVars.sideOffset)) || 0,
|
||||
alignOffset: Number.parseFloat(computed.getPropertyValue(cssVars.alignOffset)) || 0,
|
||||
sideOffset: resolveCSSLength(el, computed.getPropertyValue(cssVars.sideOffset)),
|
||||
alignOffset: resolveCSSLength(el, computed.getPropertyValue(cssVars.alignOffset)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the popup's layout box for positioning.
|
||||
*
|
||||
* `getBoundingClientRect()` includes active transforms, which causes the
|
||||
* fallback position to drift while opening/closing animations scale the popup.
|
||||
* Using `offsetWidth`/`offsetHeight` preserves the untransformed size.
|
||||
*/
|
||||
export function getPopupPositionRect(el: HTMLElement): DOMRect {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const width = el.offsetWidth || rect.width;
|
||||
const height = el.offsetHeight || rect.height;
|
||||
const adjustedRect = {
|
||||
...rect,
|
||||
width,
|
||||
height,
|
||||
right: rect.left + width,
|
||||
bottom: rect.top + height,
|
||||
};
|
||||
|
||||
return {
|
||||
...adjustedRect,
|
||||
toJSON: () => adjustedRect,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,6 +87,16 @@ export function createPopover(options: PopoverOptions): PopoverApi {
|
||||
return globalThis.matchMedia?.('(hover: hover)')?.matches ?? false;
|
||||
}
|
||||
|
||||
function canOpenOnFocus(): boolean {
|
||||
if (!canHover()) return false;
|
||||
return globalThis.matchMedia?.('(pointer: fine)')?.matches ?? false;
|
||||
}
|
||||
|
||||
function canToggleOnClick(): boolean {
|
||||
if (!options.openOnHover?.()) return true;
|
||||
return canHover();
|
||||
}
|
||||
|
||||
// --- Open/close ---
|
||||
|
||||
/**
|
||||
@@ -168,6 +178,8 @@ export function createPopover(options: PopoverOptions): PopoverApi {
|
||||
|
||||
const triggerProps: PopoverTriggerProps = {
|
||||
onClick(event) {
|
||||
if (!canToggleOnClick()) return;
|
||||
|
||||
// During a close animation (open=true, status=ending), treat
|
||||
// the click as a re-open rather than a second close attempt.
|
||||
if (state.current.active && state.current.status !== 'ending') {
|
||||
@@ -203,6 +215,7 @@ export function createPopover(options: PopoverOptions): PopoverApi {
|
||||
|
||||
onFocusIn(_event) {
|
||||
if (options.openOnHover?.()) {
|
||||
if (!canOpenOnFocus()) return;
|
||||
applyOpen('focus');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
getAnchorPositionStyle,
|
||||
getManualPositionStyle,
|
||||
getPopoverCSSVars,
|
||||
getPopupPositionRect,
|
||||
type ManualOffsets,
|
||||
resolveOffsets,
|
||||
} from '../popover-positioning';
|
||||
|
||||
// Mock supportsAnchorPositioning for deterministic tests.
|
||||
@@ -182,6 +184,67 @@ describe('getAnchorPositionStyle', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOffsets', () => {
|
||||
it('resolves non-pixel CSS lengths to pixels', () => {
|
||||
const el = document.createElement('div');
|
||||
const getComputedStyleSpy = vi.spyOn(globalThis, 'getComputedStyle').mockImplementation(
|
||||
(target: Element) =>
|
||||
({
|
||||
fontSize: target === document.documentElement ? '16px' : '14px',
|
||||
getPropertyValue(name: string) {
|
||||
if (name === PopoverCSSVars.sideOffset) return '0.5rem';
|
||||
if (name === PopoverCSSVars.alignOffset) return '1em';
|
||||
return '';
|
||||
},
|
||||
}) as CSSStyleDeclaration
|
||||
);
|
||||
|
||||
expect(resolveOffsets(el)).toEqual({ sideOffset: 8, alignOffset: 14 });
|
||||
|
||||
getComputedStyleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPopupPositionRect', () => {
|
||||
it('uses untransformed layout size when transforms change the client rect', () => {
|
||||
const el = document.createElement('div');
|
||||
|
||||
Object.defineProperty(el, 'offsetWidth', { configurable: true, value: 200 });
|
||||
Object.defineProperty(el, 'offsetHeight', { configurable: true, value: 80 });
|
||||
vi.spyOn(el, 'getBoundingClientRect').mockImplementation(() => makeDOMRect(20, 40, 100, 40));
|
||||
|
||||
const rect = getPopupPositionRect(el);
|
||||
|
||||
expect(rect.left).toBe(20);
|
||||
expect(rect.top).toBe(40);
|
||||
expect(rect.width).toBe(200);
|
||||
expect(rect.height).toBe(80);
|
||||
expect(rect.right).toBe(220);
|
||||
expect(rect.bottom).toBe(120);
|
||||
});
|
||||
|
||||
it('serializes adjusted rect values from toJSON', () => {
|
||||
const el = document.createElement('div');
|
||||
|
||||
Object.defineProperty(el, 'offsetWidth', { configurable: true, value: 200 });
|
||||
Object.defineProperty(el, 'offsetHeight', { configurable: true, value: 80 });
|
||||
vi.spyOn(el, 'getBoundingClientRect').mockImplementation(() => makeDOMRect(20, 40, 100, 40));
|
||||
|
||||
const rect = getPopupPositionRect(el);
|
||||
|
||||
expect(rect.toJSON()).toEqual(
|
||||
expect.objectContaining({
|
||||
left: 20,
|
||||
top: 40,
|
||||
width: 200,
|
||||
height: 80,
|
||||
right: 220,
|
||||
bottom: 120,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Tests the CSS anchor positioning path via getAnchorPositionStyle with
|
||||
// a fresh module import where supportsAnchorPositioning returns true.
|
||||
describe('getAnchorPositionStyle (CSS Anchor Positioning)', () => {
|
||||
|
||||
@@ -126,6 +126,75 @@ describe('createPopover', () => {
|
||||
expect(popover.input.current.status).not.toBe('ending');
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true, expect.objectContaining({ reason: 'click' }));
|
||||
});
|
||||
|
||||
it('does not open on click on touch devices when openOnHover is enabled', () => {
|
||||
const matchMedia = vi.fn((query: string) => ({
|
||||
matches: query === '(hover: hover)' ? false : false,
|
||||
}));
|
||||
vi.stubGlobal('matchMedia', matchMedia);
|
||||
|
||||
const { popover, onOpenChange } = createTestPopover({
|
||||
openOnHover: () => true,
|
||||
});
|
||||
|
||||
popover.triggerProps.onClick({ preventDefault: vi.fn() } as unknown as UIEvent);
|
||||
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(popover.input.current.active).toBe(false);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not open via focus on touch devices when openOnHover is enabled', () => {
|
||||
const matchMedia = vi.fn((query: string) => ({
|
||||
matches: query === '(hover: hover)' ? false : false,
|
||||
}));
|
||||
vi.stubGlobal('matchMedia', matchMedia);
|
||||
|
||||
const { popover, onOpenChange } = createTestPopover({
|
||||
openOnHover: () => true,
|
||||
});
|
||||
|
||||
popover.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn() });
|
||||
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not open via focus when pointer is not fine', () => {
|
||||
const matchMedia = vi.fn((query: string) => ({
|
||||
matches: query === '(hover: hover)',
|
||||
}));
|
||||
vi.stubGlobal('matchMedia', matchMedia);
|
||||
|
||||
const { popover, onOpenChange } = createTestPopover({
|
||||
openOnHover: () => true,
|
||||
});
|
||||
|
||||
popover.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn() });
|
||||
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('opens via focus when hover and fine pointer are supported', () => {
|
||||
const matchMedia = vi.fn((query: string) => ({
|
||||
matches: query === '(hover: hover)' || query === '(pointer: fine)',
|
||||
}));
|
||||
vi.stubGlobal('matchMedia', matchMedia);
|
||||
|
||||
const { popover, onOpenChange } = createTestPopover({
|
||||
openOnHover: () => true,
|
||||
});
|
||||
|
||||
popover.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn() });
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true, { reason: 'focus' });
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe('element setters', () => {
|
||||
|
||||
@@ -27,12 +27,15 @@ const createConfig = (mode: BuildMode): UserConfig => ({
|
||||
define: {
|
||||
__DEV__: mode === 'dev' ? 'true' : 'false',
|
||||
},
|
||||
dts: {
|
||||
build: true,
|
||||
// Unified tsconfig covering both core and dom sources.
|
||||
// Needs DOM libs to preserve MediaApiMixin return types.
|
||||
tsconfig: 'tsconfig.dts.json',
|
||||
},
|
||||
dts:
|
||||
mode === 'dev'
|
||||
? {
|
||||
build: true,
|
||||
// Unified tsconfig covering both core and dom sources.
|
||||
// Needs DOM libs to preserve MediaApiMixin return types.
|
||||
tsconfig: 'tsconfig.dts.json',
|
||||
}
|
||||
: false,
|
||||
});
|
||||
|
||||
export default defineConfig(buildModes.map((mode) => createConfig(mode)));
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createTransition,
|
||||
getAnchorNameStyle,
|
||||
getAnchorPositionStyle,
|
||||
getPopupPositionRect,
|
||||
type PopoverApi,
|
||||
type PopoverChangeDetails,
|
||||
resolveOffsets,
|
||||
@@ -51,6 +52,10 @@ export class PopoverElement extends MediaElement {
|
||||
#disconnect: AbortController | null = null;
|
||||
#triggerAbort: AbortController | null = null;
|
||||
#currentTrigger: HTMLElement | null = null;
|
||||
#positionAbort: AbortController | null = null;
|
||||
#positionFrame = 0;
|
||||
#resizeObserver: ResizeObserver | null = null;
|
||||
#positionTrigger: HTMLElement | null = null;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
@@ -99,11 +104,13 @@ export class PopoverElement extends MediaElement {
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.#cleanupPositioning();
|
||||
this.#disconnect?.abort();
|
||||
this.#disconnect = null;
|
||||
}
|
||||
|
||||
override destroyCallback(): void {
|
||||
this.#cleanupPositioning();
|
||||
this.#cleanupTrigger();
|
||||
this.#popover?.destroy();
|
||||
super.destroyCallback();
|
||||
@@ -158,7 +165,10 @@ export class PopoverElement extends MediaElement {
|
||||
}
|
||||
|
||||
// Skip positioning when closed — no rects to measure.
|
||||
if (!state.open) return;
|
||||
if (!state.open) {
|
||||
this.#cleanupPositioning();
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply positioning styles to self.
|
||||
const posOpts = { side: state.side, align: state.align };
|
||||
@@ -169,11 +179,13 @@ export class PopoverElement extends MediaElement {
|
||||
} else {
|
||||
// JS fallback: measure rects and resolve CSS var offsets.
|
||||
const triggerRect = this.#currentTrigger?.getBoundingClientRect();
|
||||
const selfRect = this.getBoundingClientRect();
|
||||
const selfRect = getPopupPositionRect(this);
|
||||
const boundaryRect = document.documentElement.getBoundingClientRect();
|
||||
const offsets = resolveOffsets(this);
|
||||
applyStyles(this, getAnchorPositionStyle(this.id, posOpts, triggerRect, selfRect, boundaryRect, offsets));
|
||||
}
|
||||
|
||||
this.#syncPositioning();
|
||||
}
|
||||
|
||||
// --- Trigger discovery ---
|
||||
@@ -187,6 +199,7 @@ export class PopoverElement extends MediaElement {
|
||||
#syncTrigger(triggerEl: HTMLElement | null): void {
|
||||
if (triggerEl === this.#currentTrigger) return;
|
||||
|
||||
this.#cleanupPositioning();
|
||||
this.#cleanupTrigger();
|
||||
this.#currentTrigger = triggerEl;
|
||||
this.#popover?.setTriggerElement(triggerEl);
|
||||
@@ -212,4 +225,49 @@ export class PopoverElement extends MediaElement {
|
||||
this.#triggerAbort = null;
|
||||
this.#currentTrigger = null;
|
||||
}
|
||||
|
||||
#syncPositioning(): void {
|
||||
if (supportsAnchorPositioning()) return;
|
||||
|
||||
const triggerEl = this.#currentTrigger;
|
||||
|
||||
if (!triggerEl) return;
|
||||
if (this.#positionAbort && this.#positionTrigger === triggerEl) return;
|
||||
|
||||
this.#cleanupPositioning();
|
||||
this.#positionAbort = new AbortController();
|
||||
this.#positionTrigger = triggerEl;
|
||||
const { signal } = this.#positionAbort;
|
||||
|
||||
const reposition = () => {
|
||||
cancelAnimationFrame(this.#positionFrame);
|
||||
this.#positionFrame = requestAnimationFrame(() => {
|
||||
if (signal.aborted) return;
|
||||
this.requestUpdate();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', reposition, { capture: true, passive: true, signal });
|
||||
window.addEventListener('resize', reposition, { signal });
|
||||
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
this.#resizeObserver = new ResizeObserver(() => {
|
||||
reposition();
|
||||
});
|
||||
this.#resizeObserver.observe(triggerEl);
|
||||
this.#resizeObserver.observe(this);
|
||||
}
|
||||
|
||||
reposition();
|
||||
}
|
||||
|
||||
#cleanupPositioning(): void {
|
||||
this.#positionAbort?.abort();
|
||||
this.#positionAbort = null;
|
||||
this.#positionTrigger = null;
|
||||
cancelAnimationFrame(this.#positionFrame);
|
||||
this.#positionFrame = 0;
|
||||
this.#resizeObserver?.disconnect();
|
||||
this.#resizeObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createTransition,
|
||||
getAnchorNameStyle,
|
||||
getAnchorPositionStyle,
|
||||
getPopupPositionRect,
|
||||
resolveOffsets,
|
||||
type TooltipApi,
|
||||
type TooltipChangeDetails,
|
||||
@@ -50,6 +51,10 @@ export class TooltipElement extends MediaElement {
|
||||
#disconnect: AbortController | null = null;
|
||||
#triggerAbort: AbortController | null = null;
|
||||
#currentTrigger: HTMLElement | null = null;
|
||||
#positionAbort: AbortController | null = null;
|
||||
#positionFrame = 0;
|
||||
#resizeObserver: ResizeObserver | null = null;
|
||||
#positionTrigger: HTMLElement | null = null;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
@@ -95,6 +100,7 @@ export class TooltipElement extends MediaElement {
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.#cleanupPositioning();
|
||||
this.#cleanupTrigger();
|
||||
this.#tooltip?.destroy();
|
||||
this.#tooltip = null;
|
||||
@@ -151,7 +157,10 @@ export class TooltipElement extends MediaElement {
|
||||
}
|
||||
|
||||
// Skip positioning when closed — no rects to measure.
|
||||
if (!state.open) return;
|
||||
if (!state.open) {
|
||||
this.#cleanupPositioning();
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply positioning styles to self.
|
||||
const posOpts = { side: state.side, align: state.align };
|
||||
@@ -165,7 +174,7 @@ export class TooltipElement extends MediaElement {
|
||||
} else {
|
||||
// JS fallback: measure rects and resolve CSS var offsets.
|
||||
const triggerRect = this.#currentTrigger?.getBoundingClientRect();
|
||||
const selfRect = this.getBoundingClientRect();
|
||||
const selfRect = getPopupPositionRect(this);
|
||||
const boundaryRect = document.documentElement.getBoundingClientRect();
|
||||
const offsets = resolveOffsets(this, TooltipCSSVars);
|
||||
applyStyles(
|
||||
@@ -173,6 +182,8 @@ export class TooltipElement extends MediaElement {
|
||||
getAnchorPositionStyle(this.id, posOpts, triggerRect, selfRect, boundaryRect, offsets, TooltipCSSVars)
|
||||
);
|
||||
}
|
||||
|
||||
this.#syncPositioning();
|
||||
}
|
||||
|
||||
// --- Trigger discovery ---
|
||||
@@ -186,6 +197,7 @@ export class TooltipElement extends MediaElement {
|
||||
#syncTrigger(triggerEl: HTMLElement | null): void {
|
||||
if (triggerEl === this.#currentTrigger) return;
|
||||
|
||||
this.#cleanupPositioning();
|
||||
this.#cleanupTrigger();
|
||||
this.#currentTrigger = triggerEl;
|
||||
this.#tooltip?.setTriggerElement(triggerEl);
|
||||
@@ -209,4 +221,49 @@ export class TooltipElement extends MediaElement {
|
||||
this.#triggerAbort = null;
|
||||
this.#currentTrigger = null;
|
||||
}
|
||||
|
||||
#syncPositioning(): void {
|
||||
if (supportsAnchorPositioning()) return;
|
||||
|
||||
const triggerEl = this.#currentTrigger;
|
||||
|
||||
if (!triggerEl) return;
|
||||
if (this.#positionAbort && this.#positionTrigger === triggerEl) return;
|
||||
|
||||
this.#cleanupPositioning();
|
||||
this.#positionAbort = new AbortController();
|
||||
this.#positionTrigger = triggerEl;
|
||||
const { signal } = this.#positionAbort;
|
||||
|
||||
const reposition = () => {
|
||||
cancelAnimationFrame(this.#positionFrame);
|
||||
this.#positionFrame = requestAnimationFrame(() => {
|
||||
if (signal.aborted) return;
|
||||
this.requestUpdate();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', reposition, { capture: true, passive: true, signal });
|
||||
window.addEventListener('resize', reposition, { signal });
|
||||
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
this.#resizeObserver = new ResizeObserver(() => {
|
||||
reposition();
|
||||
});
|
||||
this.#resizeObserver.observe(triggerEl);
|
||||
this.#resizeObserver.observe(this);
|
||||
}
|
||||
|
||||
reposition();
|
||||
}
|
||||
|
||||
#cleanupPositioning(): void {
|
||||
this.#positionAbort?.abort();
|
||||
this.#positionAbort = null;
|
||||
this.#positionTrigger = null;
|
||||
cancelAnimationFrame(this.#positionFrame);
|
||||
this.#positionFrame = 0;
|
||||
this.#resizeObserver?.disconnect();
|
||||
this.#resizeObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { PopoverState } from '@videojs/core';
|
||||
import { getAnchorPositionStyle, resolveOffsets } from '@videojs/core/dom';
|
||||
import { getAnchorPositionStyle, getPopupPositionRect, resolveOffsets } from '@videojs/core/dom';
|
||||
import { supportsAnchorPositioning } from '@videojs/utils/dom';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -64,7 +64,7 @@ export const PopoverPopup = forwardRef<HTMLDivElement, PopoverPopupProps>(functi
|
||||
if (!triggerEl || !popupEl) return;
|
||||
|
||||
const triggerRect = triggerEl.getBoundingClientRect();
|
||||
const popupRect = popupEl.getBoundingClientRect();
|
||||
const popupRect = getPopupPositionRect(popupEl);
|
||||
const boundaryRect = document.documentElement.getBoundingClientRect();
|
||||
const offsets = resolveOffsets(popupEl);
|
||||
|
||||
@@ -74,19 +74,40 @@ export const PopoverPopup = forwardRef<HTMLDivElement, PopoverPopupProps>(functi
|
||||
}
|
||||
|
||||
measure();
|
||||
const triggerEl = popover.triggerElement;
|
||||
const popupEl = internalRef.current;
|
||||
|
||||
// Recompute on scroll/resize so the popover tracks its trigger.
|
||||
let rafId = 0;
|
||||
function reposition(): void {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(measure);
|
||||
}
|
||||
|
||||
// Re-measure after the popover has entered the top layer and whenever
|
||||
// its own size or the trigger size changes.
|
||||
reposition();
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === 'function'
|
||||
? new ResizeObserver(() => {
|
||||
reposition();
|
||||
})
|
||||
: null;
|
||||
|
||||
if (triggerEl && resizeObserver) {
|
||||
resizeObserver.observe(triggerEl);
|
||||
}
|
||||
|
||||
if (popupEl && resizeObserver) {
|
||||
resizeObserver.observe(popupEl);
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', reposition, { capture: true, passive: true });
|
||||
window.addEventListener('resize', reposition);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener('scroll', reposition, true);
|
||||
window.removeEventListener('resize', reposition);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { TooltipState } from '@videojs/core';
|
||||
import { TooltipCSSVars } from '@videojs/core';
|
||||
import { getAnchorPositionStyle, resolveOffsets } from '@videojs/core/dom';
|
||||
import { getAnchorPositionStyle, getPopupPositionRect, resolveOffsets } from '@videojs/core/dom';
|
||||
import { supportsAnchorPositioning } from '@videojs/utils/dom';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -73,7 +73,7 @@ export const TooltipPopup = forwardRef<HTMLDivElement, TooltipPopupProps>(functi
|
||||
if (!triggerEl || !popupEl) return;
|
||||
|
||||
const triggerRect = triggerEl.getBoundingClientRect();
|
||||
const popupRect = popupEl.getBoundingClientRect();
|
||||
const popupRect = getPopupPositionRect(popupEl);
|
||||
const boundaryRect = document.documentElement.getBoundingClientRect();
|
||||
const offsets = resolveOffsets(popupEl, TooltipCSSVars);
|
||||
|
||||
@@ -91,19 +91,40 @@ export const TooltipPopup = forwardRef<HTMLDivElement, TooltipPopupProps>(functi
|
||||
}
|
||||
|
||||
measure();
|
||||
const triggerEl = tooltip.triggerElement;
|
||||
const popupEl = internalRef.current;
|
||||
|
||||
// Recompute on scroll/resize so the tooltip tracks its trigger.
|
||||
let rafId = 0;
|
||||
function reposition(): void {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(measure);
|
||||
}
|
||||
|
||||
// Re-measure after the tooltip has entered the top layer and whenever
|
||||
// its own size or the trigger size changes.
|
||||
reposition();
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === 'function'
|
||||
? new ResizeObserver(() => {
|
||||
reposition();
|
||||
})
|
||||
: null;
|
||||
|
||||
if (triggerEl && resizeObserver) {
|
||||
resizeObserver.observe(triggerEl);
|
||||
}
|
||||
|
||||
if (popupEl && resizeObserver) {
|
||||
resizeObserver.observe(popupEl);
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', reposition, { capture: true, passive: true });
|
||||
window.addEventListener('resize', reposition);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener('scroll', reposition, true);
|
||||
window.removeEventListener('resize', reposition);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ export { tryHidePopover, tryShowPopover } from './popover';
|
||||
export { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from './predicates';
|
||||
export { type RafThrottled, rafThrottle } from './raf-throttle';
|
||||
export { getSlottedElement, querySlot } from './slotted';
|
||||
export { applyStyles } from './style';
|
||||
export { applyStyles, resolveCSSLength } from './style';
|
||||
export { supportsAnchorPositioning, supportsAnimationFrame, supportsIdleCallback } from './supports';
|
||||
export { findTrackElement, getTextTrackList } from './text-track';
|
||||
export { serializeTimeRanges } from './time-ranges';
|
||||
|
||||
@@ -9,3 +9,50 @@ export function applyStyles(element: HTMLElement, styles: Record<string, string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCSSLength(el: Element, value: string): number {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) return 0;
|
||||
|
||||
const parsed = Number.parseFloat(trimmed);
|
||||
|
||||
if (Number.isNaN(parsed)) return 0;
|
||||
if (/^-?\d*\.?\d+$/.test(trimmed) || trimmed.endsWith('px')) return parsed;
|
||||
|
||||
const doc = el.ownerDocument;
|
||||
const root = doc?.documentElement;
|
||||
|
||||
if (trimmed.endsWith('rem')) {
|
||||
const rootFontSize = root ? Number.parseFloat(getComputedStyle(root).fontSize) || 16 : 16;
|
||||
return parsed * rootFontSize;
|
||||
}
|
||||
|
||||
if (trimmed.endsWith('em')) {
|
||||
const fontSize = el instanceof HTMLElement ? Number.parseFloat(getComputedStyle(el).fontSize) || 16 : 16;
|
||||
return parsed * fontSize;
|
||||
}
|
||||
|
||||
if (!doc) return parsed;
|
||||
|
||||
const measurementEl = doc.createElement('div');
|
||||
measurementEl.style.position = 'absolute';
|
||||
measurementEl.style.visibility = 'hidden';
|
||||
measurementEl.style.pointerEvents = 'none';
|
||||
measurementEl.style.inlineSize = trimmed;
|
||||
measurementEl.style.blockSize = '0';
|
||||
measurementEl.style.padding = '0';
|
||||
measurementEl.style.border = '0';
|
||||
measurementEl.style.inset = '0';
|
||||
|
||||
const parent = doc.body ?? doc.documentElement;
|
||||
|
||||
if (!parent) return parsed;
|
||||
|
||||
parent.appendChild(measurementEl);
|
||||
|
||||
const pixels = measurementEl.getBoundingClientRect().width;
|
||||
measurementEl.remove();
|
||||
|
||||
return Number.isFinite(pixels) ? pixels : parsed;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resolveCSSLength } from '../style';
|
||||
|
||||
describe('resolveCSSLength', () => {
|
||||
it('Returns px values directly', () => {
|
||||
const el = document.createElement('div');
|
||||
|
||||
expect(resolveCSSLength(el, '8px')).toBe(8);
|
||||
});
|
||||
|
||||
it('Resolves rem values using the root font size', () => {
|
||||
const el = document.createElement('div');
|
||||
const getComputedStyleSpy = vi.spyOn(globalThis, 'getComputedStyle').mockImplementation(
|
||||
(target: Element) =>
|
||||
({
|
||||
fontSize: target === document.documentElement ? '16px' : '14px',
|
||||
}) as CSSStyleDeclaration
|
||||
);
|
||||
|
||||
expect(resolveCSSLength(el, '0.5rem')).toBe(8);
|
||||
|
||||
getComputedStyleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('Resolves em values using the element font size', () => {
|
||||
const el = document.createElement('div');
|
||||
const getComputedStyleSpy = vi.spyOn(globalThis, 'getComputedStyle').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
fontSize: '14px',
|
||||
}) as CSSStyleDeclaration
|
||||
);
|
||||
|
||||
expect(resolveCSSLength(el, '1em')).toBe(14);
|
||||
|
||||
getComputedStyleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('Falls back to measurement for other CSS lengths', () => {
|
||||
const el = document.createElement('div');
|
||||
const createElement = document.createElement.bind(document);
|
||||
const appendChildSpy = vi.spyOn(document.body, 'appendChild');
|
||||
const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
|
||||
const node = createElement(tagName);
|
||||
|
||||
if (tagName === 'div') {
|
||||
vi.spyOn(node, 'getBoundingClientRect').mockImplementation(() => {
|
||||
const width = node.style.inlineSize === '10vw' ? 24 : 0;
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: width,
|
||||
bottom: 0,
|
||||
toJSON() {},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return node;
|
||||
});
|
||||
|
||||
expect(resolveCSSLength(el, '10vw')).toBe(24);
|
||||
expect(appendChildSpy).toHaveBeenCalled();
|
||||
|
||||
createElementSpy.mockRestore();
|
||||
appendChildSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('Preserves measured zero pixel values', () => {
|
||||
const el = document.createElement('div');
|
||||
const createElement = document.createElement.bind(document);
|
||||
const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
|
||||
const node = createElement(tagName);
|
||||
|
||||
if (tagName === 'div') {
|
||||
vi.spyOn(node, 'getBoundingClientRect').mockImplementation(() => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
toJSON() {},
|
||||
}));
|
||||
}
|
||||
|
||||
return node;
|
||||
});
|
||||
|
||||
expect(resolveCSSLength(el, 'calc(1px - 1px)')).toBe(0);
|
||||
|
||||
createElementSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user