Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/html/src/define/ui/popover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { PopoverElement } from '../../ui/popover/popover-element';

customElements.define(PopoverElement.tagName, PopoverElement);

declare global {
interface HTMLElementTagNameMap {
[PopoverElement.tagName]: PopoverElement;
}
}
1 change: 1 addition & 0 deletions packages/html/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export { MuteButtonElement } from './ui/mute-button/mute-button-element';
export { PiPButtonElement } from './ui/pip-button/pip-button-element';
export { PlayButtonElement } from './ui/play-button/play-button-element';
export { PlaybackRateButtonElement } from './ui/playback-rate-button/playback-rate-button-element';
export { PopoverElement } from './ui/popover/popover-element';
export { PosterElement } from './ui/poster/poster-element';
export { SeekButtonElement } from './ui/seek-button/seek-button-element';
export { ThumbnailElement } from './ui/thumbnail/thumbnail-element';
Expand Down
201 changes: 201 additions & 0 deletions packages/html/src/ui/popover/popover-element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { PopoverCore, PopoverDataAttrs, type PopoverInteraction, type PopoverProps } from '@videojs/core';
import {
applyElementProps,
applyStateDataAttrs,
createPopover,
createTransitionHandler,
getAnchorNameStyle,
getAnchorPositionStyle,
type PopoverChangeDetails,
type PopoverHandle,
resolveOffsets,
} from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { SnapshotController } from '@videojs/store/html';
import { applyStyles, supportsAnchorPositioning } from '@videojs/utils/dom';

import { MediaElement } from '../media-element';

export class PopoverElement extends MediaElement {
static readonly tagName = 'media-popover';

static override properties = {
open: { type: Boolean },
defaultOpen: { type: Boolean, attribute: 'default-open' },
side: { type: String },
align: { type: String },
modal: { type: Boolean },
closeOnEscape: { type: Boolean, attribute: 'close-on-escape' },
closeOnOutsideClick: { type: Boolean, attribute: 'close-on-outside-click' },
openOnHover: { type: Boolean, attribute: 'open-on-hover' },
delay: { type: Number },
closeDelay: { type: Number, attribute: 'close-delay' },
} satisfies PropertyDeclarationMap<keyof PopoverCore.Props>;

open = PopoverCore.defaultProps.open;
defaultOpen = PopoverCore.defaultProps.defaultOpen;
side = PopoverCore.defaultProps.side;
align = PopoverCore.defaultProps.align;
modal: PopoverProps['modal'] = PopoverCore.defaultProps.modal;
closeOnEscape = PopoverCore.defaultProps.closeOnEscape;
closeOnOutsideClick = PopoverCore.defaultProps.closeOnOutsideClick;
openOnHover = PopoverCore.defaultProps.openOnHover;
delay = PopoverCore.defaultProps.delay;
closeDelay = PopoverCore.defaultProps.closeDelay;

readonly #core = new PopoverCore();
#popover: PopoverHandle | null = null;
#snapshot: SnapshotController<PopoverInteraction> | null = null;

// Cleanup controllers
#disconnect: AbortController | null = null;
#triggerAc: AbortController | null = null;
#currentTrigger: HTMLElement | null = null;

override connectedCallback(): void {
super.connectedCallback();
this.#disconnect = new AbortController();

this.#popover = createPopover({
transition: createTransitionHandler(),
onOpenChange: (nextOpen: boolean, details: PopoverChangeDetails) => {
this.open = nextOpen;
this.dispatchEvent(new CustomEvent('open-change', { detail: { open: nextOpen, ...details } }));
},
closeOnEscape: () => this.closeOnEscape,
closeOnOutsideClick: () => this.closeOnOutsideClick,
openOnHover: () => this.openOnHover,
delay: () => this.delay,
closeDelay: () => this.closeDelay,
});

// Register self as the popup element — the element IS the popup.
this.#popover.setPopupElement(this);

// Apply popup event handlers (pointerenter/leave, focusout) to self.
applyElementProps(this, this.#popover.popupProps, this.#disconnect.signal);

// Subscribe to interaction state for reactive updates.
// Reuse the controller across connect/disconnect cycles to avoid
// leaking stale controllers in the host's controller set.
if (this.#snapshot) {
this.#snapshot.track(this.#popover.interaction);
} else {
this.#snapshot = new SnapshotController(this, this.#popover.interaction);
}
}

protected override firstUpdated(changed: PropertyValues): void {
super.firstUpdated(changed);

// Uncontrolled mode: open if `defaultOpen` is set. Controlled `open`
// is already synced by `willUpdate` on the first render cycle.
if (this.defaultOpen && !this.open) {
this.#popover?.open();
}
}

override disconnectedCallback(): void {
super.disconnectedCallback();
this.#cleanupTrigger();
this.#popover?.destroy();
this.#popover = null;
this.#disconnect?.abort();
this.#disconnect = null;
}

protected override willUpdate(changed: PropertyValues): void {
super.willUpdate(changed);
this.#core.setProps(this);

// Sync controlled open state
if (this.#popover && changed.has('open')) {
const { active: interactionOpen } = this.#popover.interaction.current;
if (this.open !== interactionOpen) {
if (this.open) {
this.#popover.open();
} else {
this.#popover.close();
}
}
}
}

protected override update(_changed: PropertyValues): void {
super.update(_changed);
if (!this.#popover) return;

// Discover trigger via commandfor linkage.
const triggerEl = this.#findTrigger();
this.#syncTrigger(triggerEl);

// Derive state from core + interaction.
const interaction = this.#popover.interaction.current;
const state = this.#core.getState(interaction);

// Apply popup ARIA and data attributes to self.
applyElementProps(this, this.#core.getPopupAttrs(state));
applyStateDataAttrs(this, state, PopoverDataAttrs);

// Apply trigger ARIA and anchor-name to the discovered trigger.
if (this.#currentTrigger) {
applyElementProps(this.#currentTrigger, this.#core.getTriggerAttrs(state, this.id));
applyStyles(this.#currentTrigger, getAnchorNameStyle(this.id));
}

// Skip positioning when closed — no rects to measure.
if (!state.open) return;

// Apply positioning styles to self.
const posOpts = { side: state.side, align: state.align };

if (supportsAnchorPositioning()) {
// Native CSS Anchor Positioning — no JS rect measurements needed.
applyStyles(this, getAnchorPositionStyle(this.id, posOpts));
} else {
// JS fallback: measure rects and resolve CSS var offsets.
const triggerRect = this.#currentTrigger?.getBoundingClientRect();
const selfRect = this.getBoundingClientRect();
const boundaryRect = document.documentElement.getBoundingClientRect();
const offsets = resolveOffsets(this);
applyStyles(this, getAnchorPositionStyle(this.id, posOpts, triggerRect, selfRect, boundaryRect, offsets));
}
}

// --- Trigger discovery ---

#findTrigger(): HTMLElement | null {
if (!this.id) return null;
const root = this.getRootNode() as Document | ShadowRoot;
return root.querySelector<HTMLElement>(`[commandfor="${this.id}"]`);
}

#syncTrigger(triggerEl: HTMLElement | null): void {
if (triggerEl === this.#currentTrigger) return;

this.#cleanupTrigger();
this.#currentTrigger = triggerEl;
this.#popover?.setTriggerElement(triggerEl);

if (triggerEl && this.#popover) {
this.#triggerAc = new AbortController();
applyElementProps(triggerEl, this.#popover.triggerProps, this.#triggerAc.signal);
}
}

#cleanupTrigger(): void {
if (this.#currentTrigger) {
// Remove ARIA attributes and anchor-name style from the old trigger.
applyElementProps(this.#currentTrigger, {
'aria-expanded': undefined,
'aria-haspopup': undefined,
'aria-controls': undefined,
});
this.#currentTrigger.style.removeProperty('anchor-name');
}

this.#triggerAc?.abort();
this.#triggerAc = null;
this.#currentTrigger = null;
}
}