mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add popover component (#615)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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<PopoverProps> = {
|
||||
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;
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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<PopoverState>;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -19,3 +19,7 @@ export interface UIPointerEvent extends UIEvent {
|
||||
pointerType: string;
|
||||
buttons: number;
|
||||
}
|
||||
|
||||
export interface UIFocusEvent extends UIEvent {
|
||||
relatedTarget: EventTarget | null;
|
||||
}
|
||||
|
||||
@@ -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<PopoverSide, PopoverSide> = {
|
||||
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<Record<PopoverCSSVarKey, string>> {
|
||||
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<Record<PopoverCSSVarKey, string>> {
|
||||
const vars: Partial<Record<PopoverCSSVarKey, string>> = {};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<PopoverInteraction>;
|
||||
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<typeof setTimeout> | 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { vi } from 'vitest';
|
||||
import { createTransitionHandler } from '../../transition';
|
||||
import { createPopover, type PopoverChangeDetails } from '../popover';
|
||||
|
||||
export function createTestPopover(overrides?: Partial<Parameters<typeof createPopover>[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 };
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<TransitionState>;
|
||||
open(): Promise<void>;
|
||||
close(el: HTMLElement | null): Promise<void>;
|
||||
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<TransitionState>({ active: false, status: 'idle' });
|
||||
|
||||
let destroyed = false;
|
||||
let rafId1 = 0;
|
||||
let rafId2 = 0;
|
||||
|
||||
function open(): Promise<void> {
|
||||
cancelAnimationFrame(rafId1);
|
||||
cancelAnimationFrame(rafId2);
|
||||
rafId1 = 0;
|
||||
rafId2 = 0;
|
||||
|
||||
state.patch({ active: true, status: 'starting' });
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
rafId1 = requestAnimationFrame(() => {
|
||||
rafId1 = 0;
|
||||
if (destroyed || !state.current.active) return resolve();
|
||||
state.patch({ status: 'idle' });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function close(el: HTMLElement | null): Promise<void> {
|
||||
cancelAnimationFrame(rafId1);
|
||||
cancelAnimationFrame(rafId2);
|
||||
rafId1 = 0;
|
||||
rafId2 = 0;
|
||||
|
||||
state.patch({ status: 'ending' });
|
||||
|
||||
return new Promise<void>((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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { kebabCase } from '../string/casing';
|
||||
|
||||
export function applyStyles(element: HTMLElement, styles: Record<string, string | undefined>): 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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()}`);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user