From 82ede77322204500d7ca0adc5cb24d4f068af462 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Tue, 17 Mar 2026 13:14:35 +1100 Subject: [PATCH] fix: correct popup fallback positioning offsets (#981) --- .../src/dom/ui/popover/popover-positioning.ts | 31 +++++- packages/core/src/dom/ui/popover/popover.ts | 13 +++ .../popover/tests/popover-positioning.test.ts | 63 +++++++++++ .../src/dom/ui/popover/tests/popover.test.ts | 69 ++++++++++++ packages/core/tsdown.config.ts | 15 +-- .../html/src/ui/popover/popover-element.ts | 62 ++++++++++- .../html/src/ui/tooltip/tooltip-element.ts | 61 ++++++++++- .../react/src/ui/popover/popover-popup.tsx | 27 ++++- .../react/src/ui/tooltip/tooltip-popup.tsx | 27 ++++- packages/utils/src/dom/index.ts | 2 +- packages/utils/src/dom/style.ts | 47 ++++++++ packages/utils/src/dom/tests/style.test.ts | 101 ++++++++++++++++++ 12 files changed, 498 insertions(+), 20 deletions(-) create mode 100644 packages/utils/src/dom/tests/style.test.ts diff --git a/packages/core/src/dom/ui/popover/popover-positioning.ts b/packages/core/src/dom/ui/popover/popover-positioning.ts index 92c804cb..2ac95bdc 100644 --- a/packages/core/src/dom/ui/popover/popover-positioning.ts +++ b/packages/core/src/dom/ui/popover/popover-positioning.ts @@ -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, }; } diff --git a/packages/core/src/dom/ui/popover/popover.ts b/packages/core/src/dom/ui/popover/popover.ts index 0dc6fe64..e96aad15 100644 --- a/packages/core/src/dom/ui/popover/popover.ts +++ b/packages/core/src/dom/ui/popover/popover.ts @@ -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'); } }, diff --git a/packages/core/src/dom/ui/popover/tests/popover-positioning.test.ts b/packages/core/src/dom/ui/popover/tests/popover-positioning.test.ts index 55fa9c04..9dad41ea 100644 --- a/packages/core/src/dom/ui/popover/tests/popover-positioning.test.ts +++ b/packages/core/src/dom/ui/popover/tests/popover-positioning.test.ts @@ -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)', () => { diff --git a/packages/core/src/dom/ui/popover/tests/popover.test.ts b/packages/core/src/dom/ui/popover/tests/popover.test.ts index d3f9e52a..c42ae530 100644 --- a/packages/core/src/dom/ui/popover/tests/popover.test.ts +++ b/packages/core/src/dom/ui/popover/tests/popover.test.ts @@ -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', () => { diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 5465fbd0..886703cc 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -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))); diff --git a/packages/html/src/ui/popover/popover-element.ts b/packages/html/src/ui/popover/popover-element.ts index 1ffb7191..ad6a1d0b 100644 --- a/packages/html/src/ui/popover/popover-element.ts +++ b/packages/html/src/ui/popover/popover-element.ts @@ -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; + } } diff --git a/packages/html/src/ui/tooltip/tooltip-element.ts b/packages/html/src/ui/tooltip/tooltip-element.ts index 211cc609..a6661f49 100644 --- a/packages/html/src/ui/tooltip/tooltip-element.ts +++ b/packages/html/src/ui/tooltip/tooltip-element.ts @@ -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; + } } diff --git a/packages/react/src/ui/popover/popover-popup.tsx b/packages/react/src/ui/popover/popover-popup.tsx index cd614b26..f02f7164 100644 --- a/packages/react/src/ui/popover/popover-popup.tsx +++ b/packages/react/src/ui/popover/popover-popup.tsx @@ -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(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(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); }; diff --git a/packages/react/src/ui/tooltip/tooltip-popup.tsx b/packages/react/src/ui/tooltip/tooltip-popup.tsx index e25970f2..200142e5 100644 --- a/packages/react/src/ui/tooltip/tooltip-popup.tsx +++ b/packages/react/src/ui/tooltip/tooltip-popup.tsx @@ -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(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(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); }; diff --git a/packages/utils/src/dom/index.ts b/packages/utils/src/dom/index.ts index 51c9836d..fc8d6000 100644 --- a/packages/utils/src/dom/index.ts +++ b/packages/utils/src/dom/index.ts @@ -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'; diff --git a/packages/utils/src/dom/style.ts b/packages/utils/src/dom/style.ts index 54d4db2c..143e10c9 100644 --- a/packages/utils/src/dom/style.ts +++ b/packages/utils/src/dom/style.ts @@ -9,3 +9,50 @@ export function applyStyles(element: HTMLElement, styles: Record { + 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(); + }); +});