feat(core): add popover component (#615)

This commit is contained in:
rahim
2026-02-28 00:33:30 -08:00
committed by GitHub
parent c92a9f914f
commit 44188d4823
20 changed files with 1663 additions and 2 deletions
+4
View File
@@ -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);
});
});
});
+22
View File
@@ -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',
};
}
+3
View File
@@ -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';
+4
View File
@@ -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,
};
}
+349
View File
@@ -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
});
});
});
+104
View File
@@ -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 -1
View File
@@ -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';
+11
View File
@@ -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);
}
}
}
+8
View File
@@ -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');
}
+4
View File
@@ -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()}`);
}
+19 -1
View File
@@ -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');
});
});
});