diff --git a/packages/html/src/define/ui/popover.ts b/packages/html/src/define/ui/popover.ts new file mode 100644 index 00000000..9bdb9cdb --- /dev/null +++ b/packages/html/src/define/ui/popover.ts @@ -0,0 +1,9 @@ +import { PopoverElement } from '../../ui/popover/popover-element'; + +customElements.define(PopoverElement.tagName, PopoverElement); + +declare global { + interface HTMLElementTagNameMap { + [PopoverElement.tagName]: PopoverElement; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index a6d98d8c..55e4cf9c 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -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'; diff --git a/packages/html/src/ui/popover/popover-element.ts b/packages/html/src/ui/popover/popover-element.ts new file mode 100644 index 00000000..041a4041 --- /dev/null +++ b/packages/html/src/ui/popover/popover-element.ts @@ -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; + + 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 | 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(`[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; + } +}