From 44188d4823d687bae2806f38e199e9719ff05083 Mon Sep 17 00:00:00 2001 From: rahim Date: Sat, 28 Feb 2026 00:33:30 -0800 Subject: [PATCH] feat(core): add popover component (#615) --- packages/core/src/core/index.ts | 4 + .../core/src/core/ui/popover/popover-core.ts | 108 ++++++ .../src/core/ui/popover/popover-css-vars.ts | 16 + .../src/core/ui/popover/popover-data-attrs.ts | 15 + .../ui/popover/tests/popover-core.test.ts | 149 ++++++++ packages/core/src/core/ui/transition.ts | 22 ++ packages/core/src/dom/index.ts | 3 + packages/core/src/dom/ui/event.ts | 4 + .../src/dom/ui/popover/popover-positioning.ts | 239 ++++++++++++ packages/core/src/dom/ui/popover/popover.ts | 349 ++++++++++++++++++ .../dom/ui/popover/tests/popover-helpers.ts | 16 + .../popover/tests/popover-positioning.test.ts | 281 ++++++++++++++ .../src/dom/ui/popover/tests/popover.test.ts | 177 +++++++++ .../core/src/dom/ui/tests/transition.test.ts | 127 +++++++ packages/core/src/dom/ui/transition.ts | 104 ++++++ packages/utils/src/dom/index.ts | 8 +- packages/utils/src/dom/style.ts | 11 + packages/utils/src/dom/supports.ts | 8 + packages/utils/src/string/casing.ts | 4 + .../utils/src/string/tests/casing.test.ts | 20 +- 20 files changed, 1663 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/core/ui/popover/popover-core.ts create mode 100644 packages/core/src/core/ui/popover/popover-css-vars.ts create mode 100644 packages/core/src/core/ui/popover/popover-data-attrs.ts create mode 100644 packages/core/src/core/ui/popover/tests/popover-core.test.ts create mode 100644 packages/core/src/core/ui/transition.ts create mode 100644 packages/core/src/dom/ui/popover/popover-positioning.ts create mode 100644 packages/core/src/dom/ui/popover/popover.ts create mode 100644 packages/core/src/dom/ui/popover/tests/popover-helpers.ts create mode 100644 packages/core/src/dom/ui/popover/tests/popover-positioning.test.ts create mode 100644 packages/core/src/dom/ui/popover/tests/popover.test.ts create mode 100644 packages/core/src/dom/ui/tests/transition.test.ts create mode 100644 packages/core/src/dom/ui/transition.ts create mode 100644 packages/utils/src/dom/style.ts diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index e1f792af..0585d11e 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -14,6 +14,9 @@ export * from './ui/play-button/play-button-core'; export * from './ui/play-button/play-button-data-attrs'; export * from './ui/playback-rate-button/playback-rate-button-core'; export * from './ui/playback-rate-button/playback-rate-button-data-attrs'; +export * from './ui/popover/popover-core'; +export * from './ui/popover/popover-css-vars'; +export * from './ui/popover/popover-data-attrs'; export * from './ui/poster/poster-core'; export * from './ui/poster/poster-data-attrs'; export * from './ui/seek-button/seek-button-core'; @@ -30,4 +33,5 @@ export * from './ui/thumbnail/thumbnail-media-fragment'; export * from './ui/thumbnail/types'; export * from './ui/time/time-core'; export * from './ui/time/time-data-attrs'; +export * from './ui/transition'; export * from './ui/types'; diff --git a/packages/core/src/core/ui/popover/popover-core.ts b/packages/core/src/core/ui/popover/popover-core.ts new file mode 100644 index 00000000..9b28d301 --- /dev/null +++ b/packages/core/src/core/ui/popover/popover-core.ts @@ -0,0 +1,108 @@ +import { defaults } from '@videojs/utils/object'; +import type { NonNullableObject } from '@videojs/utils/types'; +import type { TransitionFlags, TransitionState, TransitionStatus } from '../transition'; +import { getTransitionFlags } from '../transition'; + +export type PopoverSide = 'top' | 'bottom' | 'left' | 'right'; + +export type PopoverAlign = 'start' | 'center' | 'end'; + +export interface PopoverProps { + /** Which side of the trigger the popup appears on. */ + side?: PopoverSide | undefined; + /** Alignment of the popup along the trigger's edge. */ + align?: PopoverAlign | undefined; + /** + * - `false` (default): non-modal; background content remains interactive. + * - `true`: modal; sets `aria-modal="true"` on the popup. + * - `'trap-focus'`: reserved for future focus-trapping behavior. + */ + modal?: boolean | 'trap-focus' | undefined; + /** Close the popup when the Escape key is pressed. */ + closeOnEscape?: boolean | undefined; + /** Close the popup when clicking outside the trigger and popup. */ + closeOnOutsideClick?: boolean | undefined; + /** Controlled open state. When set, the consumer is responsible for toggling. */ + open?: boolean | undefined; + /** Initial open state for uncontrolled usage. */ + defaultOpen?: boolean | undefined; + /** Open the popup on pointer hover instead of click. */ + openOnHover?: boolean | undefined; + /** Delay in ms before opening on hover. */ + delay?: number | undefined; + /** Delay in ms before closing after pointer leaves. */ + closeDelay?: number | undefined; +} + +/** + * The raw transition state managed by `createTransitionHandler`. Uses `active` + * (not `open`) to distinguish the generic transition state machine from the + * domain-specific `PopoverState.open`. + */ +export interface PopoverInteraction extends TransitionState {} + +export interface PopoverState extends TransitionFlags { + open: boolean; + status: TransitionStatus; + side: PopoverSide; + align: PopoverAlign; + modal: boolean | 'trap-focus'; +} + +export class PopoverCore { + static readonly defaultProps: NonNullableObject = { + side: 'top', + align: 'center', + modal: false, + closeOnEscape: true, + closeOnOutsideClick: true, + open: false, + defaultOpen: false, + openOnHover: false, + delay: 300, + closeDelay: 0, + }; + + #props = { ...PopoverCore.defaultProps }; + + constructor(props?: PopoverProps) { + if (props) this.setProps(props); + } + + setProps(props: PopoverProps): void { + this.#props = defaults(props, PopoverCore.defaultProps); + } + + getState(interaction: PopoverInteraction): PopoverState { + return { + open: interaction.active, + status: interaction.status, + side: this.#props.side, + align: this.#props.align, + modal: this.#props.modal, + ...getTransitionFlags(interaction.status), + }; + } + + getTriggerAttrs(state: PopoverState, popupId?: string) { + return { + 'aria-expanded': state.open ? 'true' : 'false', + 'aria-haspopup': 'dialog', + 'aria-controls': popupId, + }; + } + + getPopupAttrs(state: PopoverState) { + return { + popover: 'manual' as const, + role: 'dialog', + 'aria-modal': state.modal === true ? 'true' : undefined, + }; + } +} + +export namespace PopoverCore { + export type Props = PopoverProps; + export type State = PopoverState; + export type Interaction = PopoverInteraction; +} diff --git a/packages/core/src/core/ui/popover/popover-css-vars.ts b/packages/core/src/core/ui/popover/popover-css-vars.ts new file mode 100644 index 00000000..8422fa49 --- /dev/null +++ b/packages/core/src/core/ui/popover/popover-css-vars.ts @@ -0,0 +1,16 @@ +export const PopoverCSSVars = { + /** Distance between the popup and the trigger along the side axis. */ + sideOffset: '--media-popover-side-offset', + /** Distance between the popup and the trigger along the alignment axis. */ + alignOffset: '--media-popover-align-offset', + /** The anchor element's width. */ + anchorWidth: '--media-popover-anchor-width', + /** The anchor element's height. */ + anchorHeight: '--media-popover-anchor-height', + /** Available width between the trigger and the boundary edge. */ + availableWidth: '--media-popover-available-width', + /** Available height between the trigger and the boundary edge. */ + availableHeight: '--media-popover-available-height', +} as const; + +export type PopoverCSSVarKey = (typeof PopoverCSSVars)[keyof typeof PopoverCSSVars]; diff --git a/packages/core/src/core/ui/popover/popover-data-attrs.ts b/packages/core/src/core/ui/popover/popover-data-attrs.ts new file mode 100644 index 00000000..f8f9f74b --- /dev/null +++ b/packages/core/src/core/ui/popover/popover-data-attrs.ts @@ -0,0 +1,15 @@ +import type { StateAttrMap } from '../types'; +import type { PopoverState } from './popover-core'; + +export const PopoverDataAttrs = { + /** Present when the popover is open. */ + open: 'data-open', + /** Indicates which side the popover is positioned relative to the trigger. */ + side: 'data-side', + /** Indicates how the popover is aligned relative to the specified side. */ + align: 'data-align', + /** Present when the open transition is in progress. */ + transitionStarting: 'data-starting-style', + /** Present when the close transition is in progress. */ + transitionEnding: 'data-ending-style', +} as const satisfies StateAttrMap; diff --git a/packages/core/src/core/ui/popover/tests/popover-core.test.ts b/packages/core/src/core/ui/popover/tests/popover-core.test.ts new file mode 100644 index 00000000..67e51c18 --- /dev/null +++ b/packages/core/src/core/ui/popover/tests/popover-core.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { PopoverCore, type PopoverInteraction } from '../popover-core'; + +const CLOSED: PopoverInteraction = { active: false, status: 'idle' }; +const OPEN: PopoverInteraction = { active: true, status: 'idle' }; + +describe('PopoverCore', () => { + it('uses default props', () => { + const core = new PopoverCore(); + const state = core.getState(CLOSED); + + expect(state.side).toBe('top'); + expect(state.align).toBe('center'); + expect(state.modal).toBe(false); + }); + + it('merges interaction state', () => { + const core = new PopoverCore(); + + const closed = core.getState(CLOSED); + expect(closed.open).toBe(false); + + const open = core.getState(OPEN); + expect(open.open).toBe(true); + }); + + it('applies custom props', () => { + const core = new PopoverCore({ side: 'bottom', align: 'start' }); + const state = core.getState(OPEN); + + expect(state.side).toBe('bottom'); + expect(state.align).toBe('start'); + }); + + it('updates props via setProps', () => { + const core = new PopoverCore(); + + core.setProps({ side: 'left', modal: true }); + const state = core.getState(OPEN); + + expect(state.side).toBe('left'); + expect(state.modal).toBe(true); + // Other defaults preserved + expect(state.align).toBe('center'); + }); + + describe('getTriggerAttrs', () => { + it('returns aria-expanded false when closed', () => { + const core = new PopoverCore(); + const state = core.getState(CLOSED); + const attrs = core.getTriggerAttrs(state); + + expect(attrs['aria-expanded']).toBe('false'); + expect(attrs['aria-haspopup']).toBe('dialog'); + }); + + it('returns aria-expanded true when open', () => { + const core = new PopoverCore(); + const state = core.getState(OPEN); + const attrs = core.getTriggerAttrs(state); + + expect(attrs['aria-expanded']).toBe('true'); + }); + + it('includes aria-controls when popupId is provided', () => { + const core = new PopoverCore(); + const state = core.getState(OPEN); + const attrs = core.getTriggerAttrs(state, 'popup-123'); + + expect(attrs['aria-controls']).toBe('popup-123'); + }); + + it('returns undefined aria-controls when popupId is not provided', () => { + const core = new PopoverCore(); + const state = core.getState(OPEN); + const attrs = core.getTriggerAttrs(state); + + expect(attrs['aria-controls']).toBeUndefined(); + }); + }); + + describe('getPopupAttrs', () => { + it('returns popover manual attribute', () => { + const core = new PopoverCore(); + const state = core.getState(OPEN); + const attrs = core.getPopupAttrs(state); + + expect(attrs.popover).toBe('manual'); + }); + + it('returns dialog role', () => { + const core = new PopoverCore(); + const state = core.getState(OPEN); + const attrs = core.getPopupAttrs(state); + + expect(attrs.role).toBe('dialog'); + }); + + it('sets aria-modal when modal is true', () => { + const core = new PopoverCore({ modal: true }); + const state = core.getState(OPEN); + const attrs = core.getPopupAttrs(state); + + expect(attrs['aria-modal']).toBe('true'); + }); + + it('omits aria-modal when not modal', () => { + const core = new PopoverCore(); + const state = core.getState(OPEN); + const attrs = core.getPopupAttrs(state); + + expect(attrs['aria-modal']).toBeUndefined(); + }); + + it('omits aria-modal when modal is trap-focus', () => { + const core = new PopoverCore({ modal: 'trap-focus' }); + const state = core.getState(OPEN); + const attrs = core.getPopupAttrs(state); + + expect(attrs['aria-modal']).toBeUndefined(); + }); + }); + + describe('transition flags', () => { + it('sets transitionStarting when status is starting', () => { + const core = new PopoverCore(); + const state = core.getState({ active: true, status: 'starting' }); + + expect(state.transitionStarting).toBe(true); + expect(state.transitionEnding).toBe(false); + }); + + it('sets transitionEnding when status is ending', () => { + const core = new PopoverCore(); + const state = core.getState({ active: true, status: 'ending' }); + + expect(state.transitionStarting).toBe(false); + expect(state.transitionEnding).toBe(true); + }); + + it('both false when status is idle', () => { + const core = new PopoverCore(); + const state = core.getState(OPEN); + + expect(state.transitionStarting).toBe(false); + expect(state.transitionEnding).toBe(false); + }); + }); +}); diff --git a/packages/core/src/core/ui/transition.ts b/packages/core/src/core/ui/transition.ts new file mode 100644 index 00000000..314fa546 --- /dev/null +++ b/packages/core/src/core/ui/transition.ts @@ -0,0 +1,22 @@ +export type TransitionStatus = 'idle' | 'starting' | 'ending'; + +export interface TransitionState { + /** Whether the element is logically active (stays `true` during ending animations). */ + active: boolean; + /** Current phase of the transition lifecycle. */ + status: TransitionStatus; +} + +export interface TransitionFlags { + /** Whether the open transition is in progress. */ + transitionStarting: boolean; + /** Whether the close transition is in progress. */ + transitionEnding: boolean; +} + +export function getTransitionFlags(status: TransitionStatus): TransitionFlags { + return { + transitionStarting: status === 'starting', + transitionEnding: status === 'ending', + }; +} diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts index 4f855946..a88230e4 100644 --- a/packages/core/src/dom/index.ts +++ b/packages/core/src/dom/index.ts @@ -4,7 +4,10 @@ export * from './store/features'; export * from './store/selectors'; export * from './ui/button'; export * from './ui/event'; +export * from './ui/popover/popover'; +export * from './ui/popover/popover-positioning'; export * from './ui/slider'; export * from './ui/slider-css-vars'; export * from './ui/thumbnail'; +export * from './ui/transition'; export * from './utils'; diff --git a/packages/core/src/dom/ui/event.ts b/packages/core/src/dom/ui/event.ts index 9b6a2a5f..3ac95712 100644 --- a/packages/core/src/dom/ui/event.ts +++ b/packages/core/src/dom/ui/event.ts @@ -19,3 +19,7 @@ export interface UIPointerEvent extends UIEvent { pointerType: string; buttons: number; } + +export interface UIFocusEvent extends UIEvent { + relatedTarget: EventTarget | null; +} diff --git a/packages/core/src/dom/ui/popover/popover-positioning.ts b/packages/core/src/dom/ui/popover/popover-positioning.ts new file mode 100644 index 00000000..28369717 --- /dev/null +++ b/packages/core/src/dom/ui/popover/popover-positioning.ts @@ -0,0 +1,239 @@ +import { 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'; + +export interface PositioningOptions { + side: PopoverSide; + align: PopoverAlign; +} + +export interface ManualOffsets { + sideOffset: number; + alignOffset: number; +} + +export interface PopoverPositionStyle { + [key: string]: string | undefined; + positionAnchor?: string; + position?: string; + inset?: string; + margin?: string; + justifySelf?: string; + alignSelf?: string; + marginInlineStart?: string; + marginBlockStart?: string; + top?: string; + bottom?: string; + left?: string; + right?: string; +} + +const OPPOSITE_SIDE: Record = { + top: 'bottom', + bottom: 'top', + left: 'right', + right: 'left', +}; + +/** + * Get positioning styles for the popup element. + * + * When the browser supports CSS Anchor Positioning, returns native CSS properties + * that reference `var(--media-popover-side-offset, 0px)` and + * `var(--media-popover-align-offset, 0px)` — no JS offset values needed. + * + * When rects are provided and anchor positioning is unsupported, falls back to + * manual JS-computed positioning. The caller must resolve offset CSS vars via + * `getComputedStyle` and pass them as `offsets`. + * + * Returns camelCase keys for standard CSS properties and `--*` keys for + * custom properties — compatible with both React's `style` prop and + * `applyStyles()` from `@videojs/utils/dom`. + */ +export function getAnchorPositionStyle( + anchorName: string, + opts: PositioningOptions, + triggerRect?: DOMRect, + popupRect?: DOMRect, + boundaryRect?: DOMRect, + offsets?: ManualOffsets +): PopoverPositionStyle & Partial> { + if (supportsAnchorPositioning()) { + return getAnchorPositionCSS(anchorName, opts); + } + + // JS fallback when CSS Anchor Positioning is not supported. + if (triggerRect && popupRect) { + const resolved: ManualOffsets = offsets ?? { sideOffset: 0, alignOffset: 0 }; + return { + ...getManualPositionStyle(triggerRect, popupRect, opts, resolved), + ...(boundaryRect ? getPopoverCSSVars(triggerRect, boundaryRect, opts.side) : {}), + position: 'fixed', + // Reset UA [popover] defaults (inset: 0; margin: auto) which would + // otherwise conflict with computed positioning. + inset: 'auto', + margin: '0', + }; + } + + return {}; +} + +/** Generate style to set on the trigger for CSS Anchor Positioning. */ +export function getAnchorNameStyle(anchorName: string) { + if (!supportsAnchorPositioning()) return {}; + return { anchorName: `--${anchorName}` }; +} + +const SIDE_OFFSET_VAR = `var(${PopoverCSSVars.sideOffset}, 0px)`; +const ALIGN_OFFSET_VAR = `var(${PopoverCSSVars.alignOffset}, 0px)`; + +function getAnchorPositionCSS(anchorName: string, opts: PositioningOptions): PopoverPositionStyle { + const { side, align } = opts; + const style: PopoverPositionStyle = { + positionAnchor: `--${anchorName}`, + position: 'fixed', + // Reset UA [popover] defaults (inset: 0; margin: auto) and any + // stale properties from a previous side/align configuration. + // applyStyles() only sets properties — it never removes old ones — + // so we emit a complete set of resets every time. + inset: 'auto', + margin: '0', + justifySelf: 'normal', + alignSelf: 'normal', + marginInlineStart: '0', + marginBlockStart: '0', + }; + + // The CSS inset property is the OPPOSITE of the desired side. + // e.g. side='top' → set `bottom: anchor(top)` so the popover's + // bottom edge aligns with the anchor's top edge (placing it above). + const insetProp = OPPOSITE_SIDE[side]; + + // Side positioning — always use calc() with the CSS var so the offset + // is resolved at paint time without any JS round-trip. + if (side === 'top' || side === 'bottom') { + style[insetProp] = `calc(anchor(${side}) + ${SIDE_OFFSET_VAR})`; + + // Alignment along the cross axis + if (align === 'start') { + style.left = `calc(anchor(left) + ${ALIGN_OFFSET_VAR})`; + } else if (align === 'end') { + style.right = `calc(anchor(right) + ${ALIGN_OFFSET_VAR})`; + } else { + style.justifySelf = 'anchor-center'; + style.marginInlineStart = ALIGN_OFFSET_VAR; + } + } else { + style[insetProp] = `calc(anchor(${side}) + ${SIDE_OFFSET_VAR})`; + + if (align === 'start') { + style.top = `calc(anchor(top) + ${ALIGN_OFFSET_VAR})`; + } else if (align === 'end') { + style.bottom = `calc(anchor(bottom) + ${ALIGN_OFFSET_VAR})`; + } else { + style.alignSelf = 'anchor-center'; + style.marginBlockStart = ALIGN_OFFSET_VAR; + } + } + + return style; +} + +/** + * Compute CSS variables for the popup element. + * + * These enable CSS-based sizing constraints relative to the anchor/boundary. + */ +export function getPopoverCSSVars( + triggerRect: DOMRect, + boundaryRect: DOMRect, + side: PopoverSide +): Partial> { + const vars: Partial> = {}; + + vars[PopoverCSSVars.anchorWidth] = `${triggerRect.width}px`; + vars[PopoverCSSVars.anchorHeight] = `${triggerRect.height}px`; + + if (side === 'top' || side === 'bottom') { + vars[PopoverCSSVars.availableHeight] = + side === 'top' ? `${triggerRect.top - boundaryRect.top}px` : `${boundaryRect.bottom - triggerRect.bottom}px`; + vars[PopoverCSSVars.availableWidth] = `${boundaryRect.width}px`; + } else { + vars[PopoverCSSVars.availableWidth] = + side === 'left' ? `${triggerRect.left - boundaryRect.left}px` : `${boundaryRect.right - triggerRect.right}px`; + vars[PopoverCSSVars.availableHeight] = `${boundaryRect.height}px`; + } + + return vars; +} + +/** + * Compute manual positioning when CSS Anchor Positioning is not supported. + * + * Returns inline `top`/`left` styles in **viewport coordinates** for use + * with `position: fixed` (the popup is in the top layer). All rects from + * `getBoundingClientRect()` are already viewport-relative. + * + * Offsets are resolved by the caller from CSS custom properties via + * `getComputedStyle()` and passed as `offsets`. + */ +export function getManualPositionStyle( + triggerRect: DOMRect, + popupRect: DOMRect, + opts: PositioningOptions, + offsets: ManualOffsets = { sideOffset: 0, alignOffset: 0 } +) { + const { side, align } = opts; + const { sideOffset, alignOffset } = offsets; + let top = 0; + let left = 0; + + // Side positioning in viewport coordinates. + // Positive sideOffset always increases distance from the trigger. + if (side === 'top') { + top = triggerRect.top - popupRect.height - sideOffset; + } else if (side === 'bottom') { + top = triggerRect.bottom + sideOffset; + } else if (side === 'left') { + left = triggerRect.left - popupRect.width - sideOffset; + } else { + left = triggerRect.right + sideOffset; + } + + // Alignment along cross axis + if (side === 'top' || side === 'bottom') { + if (align === 'start') { + left = triggerRect.left + alignOffset; + } else if (align === 'end') { + left = triggerRect.right - popupRect.width + alignOffset; + } else { + left = triggerRect.left + (triggerRect.width - popupRect.width) / 2 + alignOffset; + } + } else { + if (align === 'start') { + top = triggerRect.top + alignOffset; + } else if (align === 'end') { + top = triggerRect.bottom - popupRect.height + alignOffset; + } else { + top = triggerRect.top + (triggerRect.height - popupRect.height) / 2 + alignOffset; + } + } + + return { + top: `${top}px`, + left: `${left}px`, + }; +} + +/** + * Read `--media-popover-side-offset` and `--media-popover-align-offset` + * from the popup element's computed style, returning numeric pixel values. + */ +export function resolveOffsets(el: Element): ManualOffsets { + const computed = getComputedStyle(el); + return { + sideOffset: Number.parseFloat(computed.getPropertyValue(PopoverCSSVars.sideOffset)) || 0, + alignOffset: Number.parseFloat(computed.getPropertyValue(PopoverCSSVars.alignOffset)) || 0, + }; +} diff --git a/packages/core/src/dom/ui/popover/popover.ts b/packages/core/src/dom/ui/popover/popover.ts new file mode 100644 index 00000000..54458072 --- /dev/null +++ b/packages/core/src/dom/ui/popover/popover.ts @@ -0,0 +1,349 @@ +import type { State } from '@videojs/store'; +import { listen } from '@videojs/utils/dom'; +import type { PopoverInteraction } from '../../../core/ui/popover/popover-core'; +import type { UIFocusEvent, UIPointerEvent } from '../event'; +import type { TransitionHandler } from '../transition'; + +export type PopoverOpenChangeReason = 'click' | 'hover' | 'focus' | 'escape' | 'outside-click' | 'blur'; + +export interface PopoverChangeDetails { + reason: PopoverOpenChangeReason; + event?: Event; +} + +export interface PopoverOptions { + transition: TransitionHandler; + onOpenChange: (open: boolean, details: PopoverChangeDetails) => void; + /** Fires after open/close animations complete. */ + onOpenChangeComplete?: (open: boolean) => void; + closeOnEscape: () => boolean; + closeOnOutsideClick: () => boolean; + openOnHover?: () => boolean; + delay?: () => number; + closeDelay?: () => number; +} + +export interface PopoverTriggerProps { + onClick: (event: UIEvent) => void; + onPointerEnter: (event: UIPointerEvent) => void; + onPointerLeave: (event: UIPointerEvent) => void; + onFocusIn: (event: UIFocusEvent) => void; + onFocusOut: (event: UIFocusEvent) => void; +} + +export interface PopoverPopupProps { + onPointerEnter: (event: UIPointerEvent) => void; + onPointerLeave: (event: UIPointerEvent) => void; + onFocusOut: (event: UIFocusEvent) => void; +} + +export interface PopoverHandle { + interaction: State; + triggerProps: PopoverTriggerProps; + popupProps: PopoverPopupProps; + readonly triggerElement: HTMLElement | null; + setTriggerElement: (el: HTMLElement | null) => void; + setPopupElement: (el: HTMLElement | null) => void; + open: (reason?: PopoverOpenChangeReason) => void; + close: (reason?: PopoverOpenChangeReason) => void; + destroy: () => void; +} + +export function createPopover(options: PopoverOptions): PopoverHandle { + const { transition, onOpenChange, closeOnEscape, closeOnOutsideClick } = options; + + const state = transition.state; + + let triggerEl: HTMLElement | null = null; + let popupEl: HTMLElement | null = null; + let hoverTimeout: ReturnType | null = null; + + const abort = new AbortController(); + let docAc: AbortController | null = null; + + // --- Hover management --- + + function clearHoverTimeout(): void { + if (hoverTimeout !== null) { + clearTimeout(hoverTimeout); + hoverTimeout = null; + } + } + + function canHover(): boolean { + return globalThis.matchMedia?.('(hover: hover)')?.matches ?? false; + } + + // --- Open/close --- + + /** + * The transition handler manages animation lifecycle via `createState`: + * + * **Open:** `transition.open()` patches `{ active: true, status: 'starting' }`. + * After one RAF it patches `{ status: 'idle' }` and the promise resolves. + * Frameworks render `data-starting-style` / `data-ending-style` via + * `getPopupAttrs(state)` — no imperative DOM mutation needed. + * + * **Close:** `transition.close(el)` patches `{ status: 'ending' }` (keeping + * `active: true` so the element stays mounted). After a double-RAF it waits + * for `getAnimations()` to settle, then patches `{ active: false, status: 'idle' }`. + * + * `onOpenChange` fires immediately (before animations). + * `onOpenChangeComplete` fires after animations finish. + */ + function applyOpen(reason: PopoverOpenChangeReason, event?: Event): void { + if (abort.signal.aborted) return; + + const { active, status } = state.current; + + // If a close animation is in progress, cancel it and re-open. + // If already active and not closing, bail. + if (active && status !== 'ending') return; + + if (status === 'ending') { + transition.cancel(); + } + + transition.open().then(() => { + if (abort.signal.aborted || !state.current.active) return; + options.onOpenChangeComplete?.(true); + }); + + tryShowPopover(popupEl); + + const details: PopoverChangeDetails = event ? { reason, event } : { reason }; + onOpenChange(true, details); + } + + function applyClose(reason: PopoverOpenChangeReason, event?: Event): void { + const { active, status } = state.current; + if (abort.signal.aborted || !active || status === 'ending') return; + + transition.close(popupEl).then(() => { + if (abort.signal.aborted) return; + tryHidePopover(popupEl); + options.onOpenChangeComplete?.(false); + }); + + const details: PopoverChangeDetails = event ? { reason, event } : { reason }; + onOpenChange(false, details); + } + + // --- Imperative API --- + + function open(reason: PopoverOpenChangeReason = 'click'): void { + applyOpen(reason); + } + + function close(reason: PopoverOpenChangeReason = 'click'): void { + applyClose(reason); + } + + // --- Document-level listeners (scoped to open state) --- + + function setupDocumentListeners(): void { + cleanupDocumentListeners(); + + if (typeof document === 'undefined') return; + + docAc = new AbortController(); + const signal = docAc.signal; + + listen(document, 'keydown', handleDocumentKeydown, { signal }); + listen(document, 'pointerdown', handleDocumentPointerdown, { capture: true, signal }); + } + + function cleanupDocumentListeners(): void { + docAc?.abort(); + docAc = null; + } + + function handleDocumentKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape' && closeOnEscape() && state.current.active) { + event.preventDefault(); + applyClose('escape', event); + } + } + + function handleDocumentPointerdown(event: PointerEvent): void { + if (!closeOnOutsideClick() || !state.current.active) return; + + const target = event.target as Node | null; + if (!target) return; + + if (triggerEl?.contains(target) || popupEl?.contains(target)) return; + + applyClose('outside-click', event); + } + + // Subscribe to open state to manage document listeners. + const unsubscribe = state.subscribe(() => { + if (state.current.active) { + setupDocumentListeners(); + } else { + cleanupDocumentListeners(); + } + }); + + // Centralize cleanup on abort so any call to abort.abort() is sufficient. + abort.signal.addEventListener('abort', () => { + unsubscribe(); + clearHoverTimeout(); + transition.destroy(); + cleanupDocumentListeners(); + triggerEl = null; + popupEl = null; + }); + + // --- Trigger props --- + + const triggerProps: PopoverTriggerProps = { + onClick(event) { + // 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') { + applyClose('click', event); + } else { + applyOpen('click', event); + } + }, + + onPointerEnter(_event) { + if (!options.openOnHover?.()) return; + if (!canHover()) return; + + clearHoverTimeout(); + + if (state.current.active) return; + + const delay = options.delay?.() ?? 300; + hoverTimeout = setTimeout(() => applyOpen('hover'), delay); + }, + + onPointerLeave(_event) { + if (!options.openOnHover?.()) return; + if (!canHover()) return; + + clearHoverTimeout(); + + if (!state.current.active) return; + + const closeDelay = options.closeDelay?.() ?? 0; + hoverTimeout = setTimeout(() => applyClose('hover'), closeDelay); + }, + + onFocusIn(_event) { + if (options.openOnHover?.()) { + applyOpen('focus'); + } + }, + + onFocusOut(event) { + const relatedTarget = event.relatedTarget as Node | null; + + // Don't close if focus moved within trigger or popup + if (relatedTarget && (triggerEl?.contains(relatedTarget) || popupEl?.contains(relatedTarget))) { + return; + } + + if (options.openOnHover?.()) { + applyClose('blur'); + } + }, + }; + + // --- Popup props --- + + const popupProps: PopoverPopupProps = { + onPointerEnter(_event) { + if (!options.openOnHover?.()) return; + // Cancel any pending close when pointer enters popup + clearHoverTimeout(); + }, + + onPointerLeave(_event) { + if (!options.openOnHover?.()) return; + + clearHoverTimeout(); + + if (!state.current.active) return; + + const closeDelay = options.closeDelay?.() ?? 0; + hoverTimeout = setTimeout(() => applyClose('hover'), closeDelay); + }, + + onFocusOut(event) { + const relatedTarget = event.relatedTarget as Node | null; + + if (relatedTarget && (triggerEl?.contains(relatedTarget) || popupEl?.contains(relatedTarget))) { + return; + } + + applyClose('blur'); + }, + }; + + // --- Element setters --- + + function setTriggerElement(el: HTMLElement | null): void { + triggerEl = el; + } + + function setPopupElement(el: HTMLElement | null): void { + // Hide the old element before clearing the reference so it + // doesn't remain visually shown via the Popover API. + if (!el && popupEl && state.current.active) { + tryHidePopover(popupEl); + } + + popupEl = el; + + if (el) { + // If the interaction is already open (e.g., React mount after state + // change), show the popover now. In `applyOpen` the element may not + // have been in the DOM yet, so the earlier `tryShowPopover` was a no-op. + if (state.current.active) { + tryShowPopover(el); + } + } + } + + // --- Cleanup --- + + function destroy(): void { + if (abort.signal.aborted) return; + abort.abort(); + } + + return { + interaction: state, + triggerProps, + popupProps, + get triggerElement() { + return triggerEl; + }, + setTriggerElement, + setPopupElement, + open, + close, + destroy, + }; +} + +// --- Popover API helpers --- + +function tryShowPopover(el: HTMLElement | null): void { + try { + el?.showPopover?.(); + } catch { + // Element may not support popover API + } +} + +function tryHidePopover(el: HTMLElement | null): void { + try { + el?.hidePopover?.(); + } catch { + // Element may not support popover API or may already be hidden + } +} diff --git a/packages/core/src/dom/ui/popover/tests/popover-helpers.ts b/packages/core/src/dom/ui/popover/tests/popover-helpers.ts new file mode 100644 index 00000000..4a448482 --- /dev/null +++ b/packages/core/src/dom/ui/popover/tests/popover-helpers.ts @@ -0,0 +1,16 @@ +import { vi } from 'vitest'; +import { createTransitionHandler } from '../../transition'; +import { createPopover, type PopoverChangeDetails } from '../popover'; + +export function createTestPopover(overrides?: Partial[0]>) { + const onOpenChange = vi.fn<(open: boolean, details: PopoverChangeDetails) => void>(); + const transition = overrides?.transition ?? createTransitionHandler(); + const popover = createPopover({ + transition, + onOpenChange, + closeOnEscape: () => true, + closeOnOutsideClick: () => true, + ...overrides, + }); + return { popover, onOpenChange, transition }; +} 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 new file mode 100644 index 00000000..55fa9c04 --- /dev/null +++ b/packages/core/src/dom/ui/popover/tests/popover-positioning.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PopoverCSSVars } from '../../../../core/ui/popover/popover-css-vars'; +import { + getAnchorNameStyle, + getAnchorPositionStyle, + getManualPositionStyle, + getPopoverCSSVars, + type ManualOffsets, +} from '../popover-positioning'; + +// Mock supportsAnchorPositioning for deterministic tests. +vi.mock('@videojs/utils/dom', async (importOriginal) => { + const original = (await importOriginal()) as Record; + return { + ...original, + supportsAnchorPositioning: vi.fn(() => false), + }; +}); + +function makeDOMRect(x: number, y: number, width: number, height: number): DOMRect { + return { + x, + y, + width, + height, + top: y, + left: x, + right: x + width, + bottom: y + height, + toJSON() {}, + }; +} + +describe('getManualPositionStyle', () => { + const trigger = makeDOMRect(100, 200, 120, 40); + const popup = makeDOMRect(0, 0, 200, 80); + + it('positions above trigger for side=top', () => { + const style = getManualPositionStyle(trigger, popup, { side: 'top', align: 'center' }); + + // top = trigger.top - popup.height = 200 - 80 = 120 + expect(style.top).toBe('120px'); + // left = trigger.left + (trigger.width - popup.width)/2 = 100 + (120-200)/2 = 60 + expect(style.left).toBe('60px'); + }); + + it('positions below trigger for side=bottom', () => { + const style = getManualPositionStyle(trigger, popup, { side: 'bottom', align: 'center' }); + + // top = trigger.bottom = 240 + expect(style.top).toBe('240px'); + }); + + it('positions to the left of trigger for side=left', () => { + const style = getManualPositionStyle(trigger, popup, { side: 'left', align: 'center' }); + + // left = trigger.left - popup.width = 100 - 200 = -100 + expect(style.left).toBe('-100px'); + }); + + it('positions to the right of trigger for side=right', () => { + const style = getManualPositionStyle(trigger, popup, { side: 'right', align: 'center' }); + + // left = trigger.right = 220 + expect(style.left).toBe('220px'); + }); + + it('applies sideOffset from resolved CSS vars', () => { + const offsets: ManualOffsets = { sideOffset: 8, alignOffset: 0 }; + const style = getManualPositionStyle(trigger, popup, { side: 'top', align: 'center' }, offsets); + + // top = 200 - 80 - 8 = 112 + expect(style.top).toBe('112px'); + }); + + it('applies sideOffset for bottom side', () => { + const offsets: ManualOffsets = { sideOffset: 8, alignOffset: 0 }; + const style = getManualPositionStyle(trigger, popup, { side: 'bottom', align: 'center' }, offsets); + + // top = 240 + 8 = 248 + expect(style.top).toBe('248px'); + }); + + it('aligns to start for horizontal sides', () => { + const style = getManualPositionStyle(trigger, popup, { side: 'top', align: 'start' }); + + // left = trigger.left = 100 + expect(style.left).toBe('100px'); + }); + + it('aligns to end for horizontal sides', () => { + const style = getManualPositionStyle(trigger, popup, { side: 'top', align: 'end' }); + + // left = trigger.right - popup.width = 220 - 200 = 20 + expect(style.left).toBe('20px'); + }); + + it('applies alignOffset from resolved CSS vars', () => { + const offsets: ManualOffsets = { sideOffset: 0, alignOffset: 10 }; + const style = getManualPositionStyle(trigger, popup, { side: 'top', align: 'start' }, offsets); + + // left = trigger.left + alignOffset = 100 + 10 = 110 + expect(style.left).toBe('110px'); + }); + + it('aligns vertically for left/right sides', () => { + const style = getManualPositionStyle(trigger, popup, { side: 'right', align: 'start' }); + + // top = trigger.top = 200 + expect(style.top).toBe('200px'); + }); +}); + +describe('getPopoverCSSVars', () => { + const boundary = makeDOMRect(0, 0, 800, 600); + const trigger = makeDOMRect(100, 200, 120, 40); + + it('includes anchor dimensions', () => { + const vars = getPopoverCSSVars(trigger, boundary, 'top'); + + expect(vars[PopoverCSSVars.anchorWidth]).toBe('120px'); + expect(vars[PopoverCSSVars.anchorHeight]).toBe('40px'); + }); + + it('computes available height for top side', () => { + const vars = getPopoverCSSVars(trigger, boundary, 'top'); + + // availableHeight = trigger.top - boundary.top = 200 + expect(vars[PopoverCSSVars.availableHeight]).toBe('200px'); + expect(vars[PopoverCSSVars.availableWidth]).toBe('800px'); + }); + + it('computes available height for bottom side', () => { + const vars = getPopoverCSSVars(trigger, boundary, 'bottom'); + + // availableHeight = boundary.bottom - trigger.bottom = 600 - 240 = 360 + expect(vars[PopoverCSSVars.availableHeight]).toBe('360px'); + }); + + it('computes available width for left side', () => { + const vars = getPopoverCSSVars(trigger, boundary, 'left'); + + // availableWidth = trigger.left - boundary.left = 100 + expect(vars[PopoverCSSVars.availableWidth]).toBe('100px'); + expect(vars[PopoverCSSVars.availableHeight]).toBe('600px'); + }); + + it('computes available width for right side', () => { + const vars = getPopoverCSSVars(trigger, boundary, 'right'); + + // availableWidth = boundary.right - trigger.right = 800 - 220 = 580 + expect(vars[PopoverCSSVars.availableWidth]).toBe('580px'); + }); +}); + +describe('getAnchorNameStyle', () => { + it('returns empty object when anchor positioning is not supported', () => { + const style = getAnchorNameStyle('my-anchor'); + expect(style).toEqual({}); + }); +}); + +describe('getAnchorPositionStyle', () => { + it('returns empty object when anchor positioning unsupported and no rects', () => { + const style = getAnchorPositionStyle('my-anchor', { side: 'top', align: 'center' }); + + expect(style).toEqual({}); + }); + + it('returns manual positioning when rects are provided and anchor unsupported', () => { + const boundary = makeDOMRect(0, 0, 800, 600); + const trigger = makeDOMRect(100, 200, 120, 40); + const positioner = makeDOMRect(0, 0, 200, 80); + + const style = getAnchorPositionStyle('my-anchor', { side: 'top', align: 'center' }, trigger, positioner, boundary); + + expect(style.top).toBe('120px'); + expect(style.left).toBe('60px'); + expect(style.position).toBe('fixed'); + // Also includes sizing CSS vars + expect(style[PopoverCSSVars.anchorWidth]).toBe('120px'); + }); +}); + +// Tests the CSS anchor positioning path via getAnchorPositionStyle with +// a fresh module import where supportsAnchorPositioning returns true. +describe('getAnchorPositionStyle (CSS Anchor Positioning)', () => { + const SIDE_VAR = 'var(--media-popover-side-offset, 0px)'; + const ALIGN_VAR = 'var(--media-popover-align-offset, 0px)'; + + async function importWithAnchorSupport() { + vi.resetModules(); + vi.doMock('@videojs/utils/dom', async (importOriginal) => { + const original = (await importOriginal()) as Record; + return { ...original, supportsAnchorPositioning: () => true }; + }); + const mod = await import('../popover-positioning'); + return mod.getAnchorPositionStyle; + } + + it('includes positionAnchor and position: fixed', async () => { + const getStyle = await importWithAnchorSupport(); + const style = getStyle('my-popover', { side: 'top', align: 'center' }); + + expect(style.positionAnchor).toBe('--my-popover'); + expect(style.position).toBe('fixed'); + }); + + it('places popover above trigger for side=top using CSS var offset', async () => { + const getStyle = await importWithAnchorSupport(); + const style = getStyle('a', { side: 'top', align: 'center' }); + + expect(style.bottom).toBe(`calc(anchor(top) + ${SIDE_VAR})`); + expect(style.top).toBeUndefined(); + expect(style.justifySelf).toBe('anchor-center'); + expect(style.marginInlineStart).toBe(ALIGN_VAR); + }); + + it('places popover below trigger for side=bottom', async () => { + const getStyle = await importWithAnchorSupport(); + const style = getStyle('a', { side: 'bottom', align: 'center' }); + + expect(style.top).toBe(`calc(anchor(bottom) + ${SIDE_VAR})`); + expect(style.bottom).toBeUndefined(); + }); + + it('places popover to the left for side=left', async () => { + const getStyle = await importWithAnchorSupport(); + const style = getStyle('a', { side: 'left', align: 'center' }); + + expect(style.right).toBe(`calc(anchor(left) + ${SIDE_VAR})`); + expect(style.left).toBeUndefined(); + }); + + it('places popover to the right for side=right', async () => { + const getStyle = await importWithAnchorSupport(); + const style = getStyle('a', { side: 'right', align: 'center' }); + + expect(style.left).toBe(`calc(anchor(right) + ${SIDE_VAR})`); + expect(style.right).toBeUndefined(); + }); + + it('aligns to start with CSS var offset for top/bottom sides', async () => { + const getStyle = await importWithAnchorSupport(); + const style = getStyle('a', { side: 'top', align: 'start' }); + + expect(style.left).toBe(`calc(anchor(left) + ${ALIGN_VAR})`); + expect(style.right).toBeUndefined(); + }); + + it('aligns to end with CSS var offset for top/bottom sides', async () => { + const getStyle = await importWithAnchorSupport(); + const style = getStyle('a', { side: 'bottom', align: 'end' }); + + expect(style.right).toBe(`calc(anchor(right) + ${ALIGN_VAR})`); + expect(style.left).toBeUndefined(); + }); + + it('uses anchor-center and margin for center alignment', async () => { + const getStyle = await importWithAnchorSupport(); + const style = getStyle('a', { side: 'top', align: 'center' }); + + expect(style.justifySelf).toBe('anchor-center'); + expect(style.marginInlineStart).toBe(ALIGN_VAR); + }); + + it('aligns vertically for left/right sides', async () => { + const getStyle = await importWithAnchorSupport(); + const style = getStyle('a', { side: 'left', align: 'start' }); + + expect(style.top).toBe(`calc(anchor(top) + ${ALIGN_VAR})`); + }); + + it('uses alignSelf for center on left/right sides', async () => { + const getStyle = await importWithAnchorSupport(); + const style = getStyle('a', { side: 'right', align: 'center' }); + + expect(style.alignSelf).toBe('anchor-center'); + expect(style.marginBlockStart).toBe(ALIGN_VAR); + }); +}); diff --git a/packages/core/src/dom/ui/popover/tests/popover.test.ts b/packages/core/src/dom/ui/popover/tests/popover.test.ts new file mode 100644 index 00000000..26de6240 --- /dev/null +++ b/packages/core/src/dom/ui/popover/tests/popover.test.ts @@ -0,0 +1,177 @@ +import { flush } from '@videojs/store'; +import { describe, expect, it, vi } from 'vitest'; +import { createTestPopover } from './popover-helpers'; + +describe('createPopover', () => { + it('starts closed', () => { + const { popover } = createTestPopover(); + expect(popover.interaction.current).toEqual({ active: false, status: 'idle' }); + }); + + describe('open/close', () => { + it('updates interaction state and calls onOpenChange when opening', () => { + const { popover, onOpenChange } = createTestPopover(); + + popover.open(); + + expect(popover.interaction.current.active).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(true, { reason: 'click' }); + }); + + it('transitions to starting status when opening', () => { + const { popover } = createTestPopover(); + + popover.open(); + + expect(popover.interaction.current).toEqual({ active: true, status: 'starting' }); + }); + + it('calls onOpenChange when closing', () => { + const { popover, onOpenChange } = createTestPopover(); + + popover.open(); + onOpenChange.mockClear(); + + popover.close(); + + // active stays true until close animation completes + expect(popover.interaction.current.active).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(false, { reason: 'click' }); + }); + + it('transitions to ending status when closing', () => { + const { popover } = createTestPopover(); + + popover.open(); + popover.close(); + + expect(popover.interaction.current).toEqual({ active: true, status: 'ending' }); + }); + + it('does not call onOpenChange if already open', () => { + const { popover, onOpenChange } = createTestPopover(); + + popover.open(); + onOpenChange.mockClear(); + + popover.open(); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('does not call onOpenChange if already closed', () => { + const { popover, onOpenChange } = createTestPopover(); + + popover.close(); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('supports custom reason', () => { + const { popover, onOpenChange } = createTestPopover(); + + popover.open('hover'); + + expect(onOpenChange).toHaveBeenCalledWith(true, { reason: 'hover' }); + }); + }); + + describe('onOpenChangeComplete', () => { + it('fires after open animation completes', () => { + const onOpenChangeComplete = vi.fn(); + const { popover } = createTestPopover({ onOpenChangeComplete }); + + popover.open(); + + // Not called synchronously + expect(onOpenChangeComplete).not.toHaveBeenCalled(); + }); + }); + + describe('triggerProps', () => { + it('opens on click when closed', () => { + const { popover, onOpenChange } = createTestPopover(); + const event = { preventDefault: vi.fn() } as unknown as UIEvent; + + popover.triggerProps.onClick(event); + + expect(popover.interaction.current.active).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(true, expect.objectContaining({ reason: 'click' })); + }); + + it('closes on click when open', () => { + const { popover, onOpenChange } = createTestPopover(); + + popover.open(); + onOpenChange.mockClear(); + + popover.triggerProps.onClick({ preventDefault: vi.fn() } as unknown as UIEvent); + + // active stays true until close animation completes + expect(popover.interaction.current.active).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(false, expect.objectContaining({ reason: 'click' })); + }); + + it('re-opens on click during close animation', () => { + const { popover, onOpenChange } = createTestPopover(); + + popover.open(); + popover.close(); + onOpenChange.mockClear(); + + // Click during close animation should re-open + popover.triggerProps.onClick({ preventDefault: vi.fn() } as unknown as UIEvent); + + expect(popover.interaction.current.active).toBe(true); + expect(popover.interaction.current.status).not.toBe('ending'); + expect(onOpenChange).toHaveBeenCalledWith(true, expect.objectContaining({ reason: 'click' })); + }); + }); + + describe('element setters', () => { + it('sets trigger element', () => { + const { popover } = createTestPopover(); + const el = document.createElement('button'); + + popover.setTriggerElement(el); + popover.setTriggerElement(null); + // Should not throw + }); + + it('sets and clears popup element', () => { + const { popover } = createTestPopover(); + const el = document.createElement('div'); + + popover.setPopupElement(el); + popover.setPopupElement(null); + // Should not throw + }); + }); + + describe('destroy', () => { + it('prevents further open/close calls', () => { + const { popover, onOpenChange } = createTestPopover(); + + popover.destroy(); + popover.open(); + + expect(onOpenChange).not.toHaveBeenCalled(); + expect(popover.interaction.current.active).toBe(false); + }); + }); + + describe('subscriber notification', () => { + it('notifies subscribers when opened', () => { + const { popover } = createTestPopover(); + const callback = vi.fn(); + + popover.interaction.subscribe(callback); + + popover.open(); + flush(); + + expect(callback).toHaveBeenCalled(); + expect(popover.interaction.current.active).toBe(true); + }); + }); +}); diff --git a/packages/core/src/dom/ui/tests/transition.test.ts b/packages/core/src/dom/ui/tests/transition.test.ts new file mode 100644 index 00000000..a723808b --- /dev/null +++ b/packages/core/src/dom/ui/tests/transition.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createTransitionHandler } from '../transition'; + +describe('createTransitionHandler', () => { + it('starts with idle state', () => { + const handler = createTransitionHandler(); + expect(handler.state.current).toEqual({ active: false, status: 'idle' }); + }); + + describe('open', () => { + it('patches open and starting status synchronously', () => { + const handler = createTransitionHandler(); + + handler.open(); + + expect(handler.state.current).toEqual({ active: true, status: 'starting' }); + }); + + it('transitions to idle after one RAF', async () => { + const handler = createTransitionHandler(); + + const promise = handler.open(); + expect(handler.state.current.status).toBe('starting'); + + await vi.waitFor(() => { + expect(handler.state.current.status).toBe('idle'); + }); + + await promise; + expect(handler.state.current).toEqual({ active: true, status: 'idle' }); + }); + }); + + describe('close', () => { + it('patches ending status synchronously', () => { + const handler = createTransitionHandler(); + const el = document.createElement('div'); + + // Open first + handler.open(); + + handler.close(el); + + expect(handler.state.current).toEqual({ active: true, status: 'ending' }); + }); + + it('keeps open true during close animation', () => { + const handler = createTransitionHandler(); + const el = document.createElement('div'); + + handler.open(); + handler.close(el); + + expect(handler.state.current.active).toBe(true); + expect(handler.state.current.status).toBe('ending'); + }); + + it('handles null element gracefully', async () => { + const handler = createTransitionHandler(); + + handler.open(); + const promise = handler.close(null); + + expect(handler.state.current.status).toBe('ending'); + + await vi.waitFor(() => { + expect(handler.state.current.active).toBe(false); + }); + + await promise; + expect(handler.state.current).toEqual({ active: false, status: 'idle' }); + }); + }); + + describe('cancel', () => { + it('resets status to idle', () => { + const handler = createTransitionHandler(); + + handler.open(); + expect(handler.state.current.status).toBe('starting'); + + handler.cancel(); + expect(handler.state.current.status).toBe('idle'); + }); + + it('preserves open state', () => { + const handler = createTransitionHandler(); + + handler.open(); + handler.cancel(); + + expect(handler.state.current.active).toBe(true); + expect(handler.state.current.status).toBe('idle'); + }); + + it('is a no-op when already idle', () => { + const handler = createTransitionHandler(); + const callback = vi.fn(); + + handler.state.subscribe(callback); + handler.cancel(); + + // No state change, so no notification + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('destroy', () => { + it('prevents further open calls from updating state', () => { + const handler = createTransitionHandler(); + + handler.destroy(); + handler.open(); + + // open() still patches synchronously (state.patch runs before the RAF guard), + // but the RAF callback won't fire the idle transition. + expect(handler.state.current.active).toBe(true); + }); + + it('is idempotent', () => { + const handler = createTransitionHandler(); + + handler.destroy(); + handler.destroy(); // should not throw + }); + }); +}); diff --git a/packages/core/src/dom/ui/transition.ts b/packages/core/src/dom/ui/transition.ts new file mode 100644 index 00000000..6d329ee3 --- /dev/null +++ b/packages/core/src/dom/ui/transition.ts @@ -0,0 +1,104 @@ +import { createState, type State } from '@videojs/store'; +import { noop } from '@videojs/utils/function'; +import type { TransitionState } from '../../core/ui/transition'; + +export interface TransitionHandler { + state: State; + open(): Promise; + close(el: HTMLElement | null): Promise; + cancel(): void; + destroy(): void; +} + +/** + * Manages open/close transition lifecycle via `createState`. + * + * **Open:** patches `{ active: true, status: 'starting' }`, then after one + * RAF patches `{ status: 'idle' }` so the browser paints the initial + * state before transitioning. + * + * **Close:** patches `{ status: 'ending' }` (keeping `active: true` so the + * element stays mounted), then after a double-RAF waits for + * `getAnimations()` to settle before patching `{ active: false, status: 'idle' }`. + */ +export function createTransitionHandler(): TransitionHandler { + const state = createState({ active: false, status: 'idle' }); + + let destroyed = false; + let rafId1 = 0; + let rafId2 = 0; + + function open(): Promise { + cancelAnimationFrame(rafId1); + cancelAnimationFrame(rafId2); + rafId1 = 0; + rafId2 = 0; + + state.patch({ active: true, status: 'starting' }); + + return new Promise((resolve) => { + rafId1 = requestAnimationFrame(() => { + rafId1 = 0; + if (destroyed || !state.current.active) return resolve(); + state.patch({ status: 'idle' }); + resolve(); + }); + }); + } + + function close(el: HTMLElement | null): Promise { + cancelAnimationFrame(rafId1); + cancelAnimationFrame(rafId2); + rafId1 = 0; + rafId2 = 0; + + state.patch({ status: 'ending' }); + + return new Promise((resolve) => { + rafId1 = requestAnimationFrame(() => { + rafId1 = 0; + rafId2 = requestAnimationFrame(() => { + rafId2 = 0; + if (destroyed) return resolve(); + waitForAnimations(el).finally(() => { + if (destroyed || state.current.status !== 'ending') return resolve(); + state.patch({ active: false, status: 'idle' }); + resolve(); + }); + }); + }); + }); + } + + function cancel(): void { + cancelAnimationFrame(rafId1); + cancelAnimationFrame(rafId2); + rafId1 = 0; + rafId2 = 0; + if (state.current.status !== 'idle') { + state.patch({ status: 'idle' }); + } + } + + return { + state, + open, + close, + cancel, + destroy() { + if (destroyed) return; + destroyed = true; + cancel(); + }, + }; +} + +function waitForAnimations(el: HTMLElement | null): Promise { + if (!el) return Promise.resolve(); + + const animations = el.getAnimations?.() ?? []; + + if (animations.length === 0) return Promise.resolve(); + + return Promise.all(animations.map((a) => a.finished)).then(noop, noop); +} diff --git a/packages/utils/src/dom/index.ts b/packages/utils/src/dom/index.ts index 15f915e7..adf39f92 100644 --- a/packages/utils/src/dom/index.ts +++ b/packages/utils/src/dom/index.ts @@ -7,7 +7,13 @@ export { listen } from './listen'; export { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from './predicates'; export { type RafThrottled, rafThrottle } from './raf-throttle'; export { getSlottedElement, querySlot } from './slotted'; -export { supportsAnimationFrame, supportsIdleCallback } from './supports'; +export { applyStyles } from './style'; +export { + supportsAnchorPositioning, + supportsAnimationFrame, + supportsIdleCallback, + supportsPopoverAPI, +} from './supports'; export { findTrackElement } from './text-track'; export { serializeTimeRanges } from './time-ranges'; export type { CustomElement, CustomElementCallbacks } from './types'; diff --git a/packages/utils/src/dom/style.ts b/packages/utils/src/dom/style.ts new file mode 100644 index 00000000..54d4db2c --- /dev/null +++ b/packages/utils/src/dom/style.ts @@ -0,0 +1,11 @@ +import { kebabCase } from '../string/casing'; + +export function applyStyles(element: HTMLElement, styles: Record): void { + for (const [prop, value] of Object.entries(styles)) { + if (typeof value === 'string') { + // CSS custom properties (--*) are already in the correct format. + const key = prop.startsWith('--') ? prop : kebabCase(prop); + element.style.setProperty(key, value); + } + } +} diff --git a/packages/utils/src/dom/supports.ts b/packages/utils/src/dom/supports.ts index b7c0bc1b..46d63d6e 100644 --- a/packages/utils/src/dom/supports.ts +++ b/packages/utils/src/dom/supports.ts @@ -5,3 +5,11 @@ export function supportsIdleCallback(): boolean { export function supportsAnimationFrame(): boolean { return typeof requestAnimationFrame === 'function'; } + +export function supportsPopoverAPI(): boolean { + return typeof HTMLElement !== 'undefined' && 'popover' in HTMLElement.prototype; +} + +export function supportsAnchorPositioning(): boolean { + return typeof CSS !== 'undefined' && CSS.supports('anchor-name: --a'); +} diff --git a/packages/utils/src/string/casing.ts b/packages/utils/src/string/casing.ts index 46cb4612..ac854875 100644 --- a/packages/utils/src/string/casing.ts +++ b/packages/utils/src/string/casing.ts @@ -5,3 +5,7 @@ export function pascalCase(str: string): string { export function camelCase(str: string): string { return pascalCase(str).replace(/^(.)/, (_, c) => c.toLowerCase()); } + +export function kebabCase(str: string): string { + return str.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); +} diff --git a/packages/utils/src/string/tests/casing.test.ts b/packages/utils/src/string/tests/casing.test.ts index 0d20a66d..931e7238 100644 --- a/packages/utils/src/string/tests/casing.test.ts +++ b/packages/utils/src/string/tests/casing.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { camelCase, pascalCase } from '../casing'; +import { camelCase, kebabCase, pascalCase } from '../casing'; describe('casing', () => { describe('pascalCase', () => { @@ -41,4 +41,22 @@ describe('casing', () => { expect(camelCase('hello_world')).toBe('helloWorld'); }); }); + + describe('kebabCase', () => { + it('converts camelCase', () => { + expect(kebabCase('positionAnchor')).toBe('position-anchor'); + }); + + it('converts PascalCase', () => { + expect(kebabCase('PositionAnchor')).toBe('-position-anchor'); + }); + + it('preserves lowercase', () => { + expect(kebabCase('margin')).toBe('margin'); + }); + + it('does not special-case CSS custom properties', () => { + expect(kebabCase('--media-popover-offset')).toBe('--media-popover-offset'); + }); + }); });