diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index 246701b1..4f1ec84e 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -36,6 +36,7 @@ export * from './ui/time-slider/time-slider-data-attrs'; export * from './ui/tooltip/tooltip-core'; export * from './ui/tooltip/tooltip-css-vars'; export * from './ui/tooltip/tooltip-data-attrs'; +export * from './ui/tooltip/tooltip-group-core'; export * from './ui/transition'; export * from './ui/types'; export * from './ui/volume-slider/volume-slider-core'; diff --git a/packages/core/src/dom/ui/tooltip/tests/tooltip-group.test.ts b/packages/core/src/core/ui/tooltip/tests/tooltip-group-core.test.ts similarity index 58% rename from packages/core/src/dom/ui/tooltip/tests/tooltip-group.test.ts rename to packages/core/src/core/ui/tooltip/tests/tooltip-group-core.test.ts index e8660316..d6284022 100644 --- a/packages/core/src/dom/ui/tooltip/tests/tooltip-group.test.ts +++ b/packages/core/src/core/ui/tooltip/tests/tooltip-group-core.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createTooltipGroup } from '../tooltip-group'; +import { TooltipGroupCore } from '../tooltip-group-core'; -describe('createTooltipGroup', () => { +describe('TooltipGroupCore', () => { beforeEach(() => { vi.useFakeTimers(); }); @@ -11,27 +11,35 @@ describe('createTooltipGroup', () => { }); it('exposes default delay and closeDelay', () => { - const group = createTooltipGroup(); + const group = new TooltipGroupCore(); expect(group.delay).toBe(600); expect(group.closeDelay).toBe(0); }); - it('accepts custom delay and closeDelay', () => { - const group = createTooltipGroup({ delay: 300, closeDelay: 100 }); + it('accepts custom props', () => { + const group = new TooltipGroupCore({ delay: 300, closeDelay: 100 }); expect(group.delay).toBe(300); expect(group.closeDelay).toBe(100); }); + it('updates props via setProps', () => { + const group = new TooltipGroupCore(); + + group.setProps({ delay: 200, timeout: 500 }); + + expect(group.delay).toBe(200); + }); + it('should not skip delay initially', () => { - const group = createTooltipGroup(); + const group = new TooltipGroupCore(); expect(group.shouldSkipDelay()).toBe(false); }); it('should skip delay after a tooltip closes', () => { - const group = createTooltipGroup({ timeout: 400 }); + const group = new TooltipGroupCore({ timeout: 400 }); group.notifyOpen(); group.notifyClose(); @@ -40,7 +48,7 @@ describe('createTooltipGroup', () => { }); it('should not skip delay after timeout expires', () => { - const group = createTooltipGroup({ timeout: 400 }); + const group = new TooltipGroupCore({ timeout: 400 }); group.notifyOpen(); group.notifyClose(); @@ -50,8 +58,8 @@ describe('createTooltipGroup', () => { expect(group.shouldSkipDelay()).toBe(false); }); - it('clears pending timeout on notifyOpen', () => { - const group = createTooltipGroup({ timeout: 400 }); + it('clears skip-delay when a new tooltip opens', () => { + const group = new TooltipGroupCore({ timeout: 400 }); group.notifyOpen(); group.notifyClose(); @@ -62,29 +70,21 @@ describe('createTooltipGroup', () => { }); it('should not skip delay when a tooltip is currently open', () => { - const group = createTooltipGroup(); + const group = new TooltipGroupCore(); group.notifyOpen(); expect(group.shouldSkipDelay()).toBe(false); }); - it('cleans up on destroy', () => { - const group = createTooltipGroup({ timeout: 400 }); + it('respects updated timeout via setProps', () => { + const group = new TooltipGroupCore({ timeout: 400 }); group.notifyOpen(); group.notifyClose(); - group.destroy(); - expect(group.shouldSkipDelay()).toBe(false); - }); - - it('ignores calls after destroy', () => { - const group = createTooltipGroup(); - - group.destroy(); - group.notifyOpen(); - group.notifyClose(); + // Shrink timeout so we're already past it + group.setProps({ timeout: 0 }); expect(group.shouldSkipDelay()).toBe(false); }); diff --git a/packages/core/src/core/ui/tooltip/tooltip-group-core.ts b/packages/core/src/core/ui/tooltip/tooltip-group-core.ts new file mode 100644 index 00000000..89cccb6f --- /dev/null +++ b/packages/core/src/core/ui/tooltip/tooltip-group-core.ts @@ -0,0 +1,57 @@ +import { defaults } from '@videojs/utils/object'; +import type { NonNullableObject } from '@videojs/utils/types'; + +export interface TooltipGroupProps { + /** Default open delay in ms for tooltips in this group. */ + delay?: number | undefined; + /** Default close delay in ms for tooltips in this group. */ + closeDelay?: number | undefined; + /** Duration in ms after a tooltip closes during which the next tooltip opens instantly. */ + timeout?: number | undefined; +} + +export class TooltipGroupCore { + static readonly defaultProps: NonNullableObject = { + delay: 600, + closeDelay: 0, + timeout: 400, + }; + + #props = { ...TooltipGroupCore.defaultProps }; + #lastCloseTime = 0; + #isOpen = false; + + constructor(props?: TooltipGroupProps) { + if (props) this.setProps(props); + } + + setProps(props: TooltipGroupProps): void { + this.#props = defaults(props, TooltipGroupCore.defaultProps); + } + + get delay(): number { + return this.#props.delay; + } + + get closeDelay(): number { + return this.#props.closeDelay; + } + + shouldSkipDelay(): boolean { + if (this.#isOpen) return false; + return Date.now() - this.#lastCloseTime < this.#props.timeout; + } + + notifyOpen(): void { + this.#isOpen = true; + } + + notifyClose(): void { + this.#isOpen = false; + this.#lastCloseTime = Date.now(); + } +} + +export namespace TooltipGroupCore { + export type Props = TooltipGroupProps; +} diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts index cdfc5eb5..d76bfdf9 100644 --- a/packages/core/src/dom/index.ts +++ b/packages/core/src/dom/index.ts @@ -10,6 +10,5 @@ export * from './ui/slider'; export * from './ui/slider-css-vars'; export * from './ui/thumbnail'; export * from './ui/tooltip/tooltip'; -export * from './ui/tooltip/tooltip-group'; export * from './ui/transition'; export * from './utils'; diff --git a/packages/core/src/dom/ui/popover/popover-positioning.ts b/packages/core/src/dom/ui/popover/popover-positioning.ts index 28369717..92c804cb 100644 --- a/packages/core/src/dom/ui/popover/popover-positioning.ts +++ b/packages/core/src/dom/ui/popover/popover-positioning.ts @@ -12,6 +12,16 @@ export interface ManualOffsets { alignOffset: number; } +/** CSS custom property names for anchor-based positioning. */ +export interface PositioningCSSVars { + sideOffset: string; + alignOffset: string; + anchorWidth: string; + anchorHeight: string; + availableWidth: string; + availableHeight: string; +} + export interface PopoverPositionStyle { [key: string]: string | undefined; positionAnchor?: string; @@ -39,8 +49,8 @@ const OPPOSITE_SIDE: Record = { * 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. + * that reference the provided CSS var names for side/align offsets — 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 @@ -56,10 +66,11 @@ export function getAnchorPositionStyle( triggerRect?: DOMRect, popupRect?: DOMRect, boundaryRect?: DOMRect, - offsets?: ManualOffsets -): PopoverPositionStyle & Partial> { + offsets?: ManualOffsets, + cssVars: PositioningCSSVars = PopoverCSSVars +): PopoverPositionStyle & Record { if (supportsAnchorPositioning()) { - return getAnchorPositionCSS(anchorName, opts); + return getAnchorPositionCSS(anchorName, opts, cssVars); } // JS fallback when CSS Anchor Positioning is not supported. @@ -67,7 +78,7 @@ export function getAnchorPositionStyle( const resolved: ManualOffsets = offsets ?? { sideOffset: 0, alignOffset: 0 }; return { ...getManualPositionStyle(triggerRect, popupRect, opts, resolved), - ...(boundaryRect ? getPopoverCSSVars(triggerRect, boundaryRect, opts.side) : {}), + ...(boundaryRect ? getPositioningCSSVars(triggerRect, boundaryRect, opts.side, cssVars) : {}), position: 'fixed', // Reset UA [popover] defaults (inset: 0; margin: auto) which would // otherwise conflict with computed positioning. @@ -85,10 +96,13 @@ export function getAnchorNameStyle(anchorName: string) { 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 { +function getAnchorPositionCSS( + anchorName: string, + opts: PositioningOptions, + cssVars: PositioningCSSVars = PopoverCSSVars +): PopoverPositionStyle { + const SIDE_OFFSET_VAR = `var(${cssVars.sideOffset}, 0px)`; + const ALIGN_OFFSET_VAR = `var(${cssVars.alignOffset}, 0px)`; const { side, align } = opts; const style: PopoverPositionStyle = { positionAnchor: `--${anchorName}`, @@ -141,31 +155,42 @@ function getAnchorPositionCSS(anchorName: string, opts: PositioningOptions): Pop } /** - * Compute CSS variables for the popup element. + * Compute CSS variables for sizing constraints relative to the anchor/boundary. * - * These enable CSS-based sizing constraints relative to the anchor/boundary. + * Accepts a `cssVars` map so the same logic works for both popover + * (`--media-popover-*`) and tooltip (`--media-tooltip-*`) namespaces. */ +export function getPositioningCSSVars( + triggerRect: DOMRect, + boundaryRect: DOMRect, + side: PopoverSide, + cssVars: PositioningCSSVars = PopoverCSSVars +): Record { + const vars: Record = {}; + + vars[cssVars.anchorWidth] = `${triggerRect.width}px`; + vars[cssVars.anchorHeight] = `${triggerRect.height}px`; + + if (side === 'top' || side === 'bottom') { + vars[cssVars.availableHeight] = + side === 'top' ? `${triggerRect.top - boundaryRect.top}px` : `${boundaryRect.bottom - triggerRect.bottom}px`; + vars[cssVars.availableWidth] = `${boundaryRect.width}px`; + } else { + vars[cssVars.availableWidth] = + side === 'left' ? `${triggerRect.left - boundaryRect.left}px` : `${boundaryRect.right - triggerRect.right}px`; + vars[cssVars.availableHeight] = `${boundaryRect.height}px`; + } + + return vars; +} + +/** @deprecated Use `getPositioningCSSVars` instead. */ export function getPopoverCSSVars( triggerRect: DOMRect, boundaryRect: DOMRect, side: PopoverSide ): Partial> { - const vars: Partial> = {}; - - vars[PopoverCSSVars.anchorWidth] = `${triggerRect.width}px`; - vars[PopoverCSSVars.anchorHeight] = `${triggerRect.height}px`; - - if (side === 'top' || side === 'bottom') { - vars[PopoverCSSVars.availableHeight] = - side === 'top' ? `${triggerRect.top - boundaryRect.top}px` : `${boundaryRect.bottom - triggerRect.bottom}px`; - vars[PopoverCSSVars.availableWidth] = `${boundaryRect.width}px`; - } else { - vars[PopoverCSSVars.availableWidth] = - side === 'left' ? `${triggerRect.left - boundaryRect.left}px` : `${boundaryRect.right - triggerRect.right}px`; - vars[PopoverCSSVars.availableHeight] = `${boundaryRect.height}px`; - } - - return vars; + return getPositioningCSSVars(triggerRect, boundaryRect, side, PopoverCSSVars); } /** @@ -227,13 +252,13 @@ export function getManualPositionStyle( } /** - * Read `--media-popover-side-offset` and `--media-popover-align-offset` - * from the popup element's computed style, returning numeric pixel values. + * Read side-offset and align-offset CSS custom properties from the + * popup element's computed style, returning numeric pixel values. */ -export function resolveOffsets(el: Element): ManualOffsets { +export function resolveOffsets(el: Element, cssVars: PositioningCSSVars = PopoverCSSVars): ManualOffsets { const computed = getComputedStyle(el); return { - sideOffset: Number.parseFloat(computed.getPropertyValue(PopoverCSSVars.sideOffset)) || 0, - alignOffset: Number.parseFloat(computed.getPropertyValue(PopoverCSSVars.alignOffset)) || 0, + sideOffset: Number.parseFloat(computed.getPropertyValue(cssVars.sideOffset)) || 0, + alignOffset: Number.parseFloat(computed.getPropertyValue(cssVars.alignOffset)) || 0, }; } diff --git a/packages/core/src/dom/ui/tooltip/tests/tooltip.test.ts b/packages/core/src/dom/ui/tooltip/tests/tooltip.test.ts index 21aa7bad..e3d06ccf 100644 --- a/packages/core/src/dom/ui/tooltip/tests/tooltip.test.ts +++ b/packages/core/src/dom/ui/tooltip/tests/tooltip.test.ts @@ -1,6 +1,6 @@ import { flush } from '@videojs/store'; import { describe, expect, it, vi } from 'vitest'; -import { createTooltipGroup } from '../tooltip-group'; +import { TooltipGroupCore } from '../../../../core/ui/tooltip/tooltip-group-core'; import { createTestTooltip } from './tooltip-helpers'; describe('createTooltip', () => { @@ -137,11 +137,11 @@ describe('createTooltip', () => { describe('group integration', () => { it('notifies group on open/close', () => { - const group = createTooltipGroup(); + const group = new TooltipGroupCore(); const notifyOpen = vi.spyOn(group, 'notifyOpen'); const notifyClose = vi.spyOn(group, 'notifyClose'); - const { tooltip } = createTestTooltip({ group }); + const { tooltip } = createTestTooltip({ group: () => group }); tooltip.open(); expect(notifyOpen).toHaveBeenCalled(); diff --git a/packages/core/src/dom/ui/tooltip/tooltip-group.ts b/packages/core/src/dom/ui/tooltip/tooltip-group.ts deleted file mode 100644 index 5d49025f..00000000 --- a/packages/core/src/dom/ui/tooltip/tooltip-group.ts +++ /dev/null @@ -1,61 +0,0 @@ -export interface TooltipGroupOptions { - /** Default open delay in ms for tooltips in this group. */ - delay?: number; - /** Default close delay in ms for tooltips in this group. */ - closeDelay?: number; - /** Duration in ms after a tooltip closes during which the next tooltip opens instantly. */ - timeout?: number; -} - -export interface TooltipGroupApi { - readonly delay: number; - readonly closeDelay: number; - shouldSkipDelay: () => boolean; - notifyOpen: () => void; - notifyClose: () => void; - destroy: () => void; -} - -export function createTooltipGroup(options?: TooltipGroupOptions): TooltipGroupApi { - const delay = options?.delay ?? 600; - const closeDelay = options?.closeDelay ?? 0; - const timeout = options?.timeout ?? 400; - - let lastCloseTime = 0; - let isOpen = false; - let destroyed = false; - - function shouldSkipDelay(): boolean { - if (destroyed || isOpen) return false; - return Date.now() - lastCloseTime < timeout; - } - - function notifyOpen(): void { - if (destroyed) return; - isOpen = true; - } - - function notifyClose(): void { - if (destroyed) return; - isOpen = false; - lastCloseTime = Date.now(); - } - - function destroy(): void { - if (destroyed) return; - destroyed = true; - } - - return { - get delay() { - return delay; - }, - get closeDelay() { - return closeDelay; - }, - shouldSkipDelay, - notifyOpen, - notifyClose, - destroy, - }; -} diff --git a/packages/core/src/dom/ui/tooltip/tooltip.ts b/packages/core/src/dom/ui/tooltip/tooltip.ts index be661348..7468671c 100644 --- a/packages/core/src/dom/ui/tooltip/tooltip.ts +++ b/packages/core/src/dom/ui/tooltip/tooltip.ts @@ -1,3 +1,4 @@ +import type { TooltipGroupCore } from '../../../core/ui/tooltip/tooltip-group-core'; import { createPopover, type PopoverApi, @@ -7,7 +8,6 @@ import { type PopoverTriggerProps, } from '../popover/popover'; import type { TransitionApi } from '../transition'; -import type { TooltipGroupApi } from './tooltip-group'; export type TooltipOpenChangeReason = 'hover' | 'focus' | 'escape' | 'blur'; @@ -24,7 +24,7 @@ export interface TooltipOptions { closeDelay?: () => number; disableHoverablePopup?: () => boolean; disabled?: () => boolean; - group?: TooltipGroupApi; + group?: () => TooltipGroupCore | undefined; } export interface TooltipTriggerProps extends Omit {} @@ -47,14 +47,13 @@ const REASON_MAP: Partial> = { }; export function createTooltip(options: TooltipOptions): TooltipApi { - const { group } = options; - const popoverOpts: PopoverOptions = { transition: options.transition, onOpenChange(open: boolean, details: PopoverChangeDetails) { const reason = REASON_MAP[details.reason]; if (!reason) return; + const group = options.group?.(); if (open) group?.notifyOpen(); else group?.notifyClose(); @@ -65,10 +64,14 @@ export function createTooltip(options: TooltipOptions): TooltipApi { closeOnOutsideClick: () => false, openOnHover: () => true, delay: () => { + const group = options.group?.(); if (group?.shouldSkipDelay()) return 0; return options.delay?.() ?? group?.delay ?? 600; }, - closeDelay: () => options.closeDelay?.() ?? group?.closeDelay ?? 0, + closeDelay: () => { + const group = options.group?.(); + return options.closeDelay?.() ?? group?.closeDelay ?? 0; + }, }; if (options.onOpenChangeComplete) { diff --git a/packages/html/src/define/ui/tooltip-group.ts b/packages/html/src/define/ui/tooltip-group.ts new file mode 100644 index 00000000..ce08e828 --- /dev/null +++ b/packages/html/src/define/ui/tooltip-group.ts @@ -0,0 +1,10 @@ +import { TooltipGroupElement } from '../../ui/tooltip/tooltip-group-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(TooltipGroupElement); + +declare global { + interface HTMLElementTagNameMap { + [TooltipGroupElement.tagName]: TooltipGroupElement; + } +} diff --git a/packages/html/src/define/ui/tooltip.ts b/packages/html/src/define/ui/tooltip.ts new file mode 100644 index 00000000..b79f24de --- /dev/null +++ b/packages/html/src/define/ui/tooltip.ts @@ -0,0 +1,10 @@ +import { TooltipElement } from '../../ui/tooltip/tooltip-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(TooltipElement); + +declare global { + interface HTMLElementTagNameMap { + [TooltipElement.tagName]: TooltipElement; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 7e1e3dc5..5bcc58a5 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -43,4 +43,7 @@ export { TimeElement } from './ui/time/time-element'; export { TimeGroupElement } from './ui/time/time-group-element'; export { TimeSeparatorElement } from './ui/time/time-separator-element'; export { TimeSliderElement } from './ui/time-slider/time-slider-element'; +export { tooltipGroupContext } from './ui/tooltip/context'; +export { TooltipElement } from './ui/tooltip/tooltip-element'; +export { TooltipGroupElement } from './ui/tooltip/tooltip-group-element'; export { VolumeSliderElement } from './ui/volume-slider/volume-slider-element'; diff --git a/packages/html/src/ui/tooltip/context.ts b/packages/html/src/ui/tooltip/context.ts new file mode 100644 index 00000000..ebb28934 --- /dev/null +++ b/packages/html/src/ui/tooltip/context.ts @@ -0,0 +1,6 @@ +import type { TooltipGroupCore } from '@videojs/core'; +import { createContext } from '@videojs/element/context'; + +const TOOLTIP_GROUP_CONTEXT_KEY = Symbol('@videojs/tooltip-group'); + +export const tooltipGroupContext = createContext(TOOLTIP_GROUP_CONTEXT_KEY); diff --git a/packages/html/src/ui/tooltip/tooltip-element.ts b/packages/html/src/ui/tooltip/tooltip-element.ts new file mode 100644 index 00000000..e097bbb1 --- /dev/null +++ b/packages/html/src/ui/tooltip/tooltip-element.ts @@ -0,0 +1,204 @@ +import { TooltipCore, TooltipCSSVars, TooltipDataAttrs, type TooltipInput } from '@videojs/core'; +import { + applyElementProps, + applyStateDataAttrs, + createTooltip, + createTransition, + getAnchorNameStyle, + getAnchorPositionStyle, + resolveOffsets, + type TooltipApi, + type TooltipChangeDetails, +} from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; +import { SnapshotController } from '@videojs/store/html'; +import { applyStyles, supportsAnchorPositioning } from '@videojs/utils/dom'; + +import { MediaElement } from '../media-element'; +import { tooltipGroupContext } from './context'; + +export class TooltipElement extends MediaElement { + static readonly tagName = 'media-tooltip'; + + static override properties = { + open: { type: Boolean }, + defaultOpen: { type: Boolean, attribute: 'default-open' }, + side: { type: String }, + align: { type: String }, + delay: { type: Number }, + closeDelay: { type: Number, attribute: 'close-delay' }, + disableHoverablePopup: { type: Boolean, attribute: 'disable-hoverable-popup' }, + disabled: { type: Boolean }, + } satisfies PropertyDeclarationMap; + + open = TooltipCore.defaultProps.open; + defaultOpen = TooltipCore.defaultProps.defaultOpen; + side = TooltipCore.defaultProps.side; + align = TooltipCore.defaultProps.align; + delay = TooltipCore.defaultProps.delay; + closeDelay = TooltipCore.defaultProps.closeDelay; + disableHoverablePopup = TooltipCore.defaultProps.disableHoverablePopup; + disabled = TooltipCore.defaultProps.disabled; + + readonly #core = new TooltipCore(); + readonly #groupConsumer = new ContextConsumer(this, { context: tooltipGroupContext }); + #tooltip: TooltipApi | null = null; + #snapshot: SnapshotController | null = null; + + // Cleanup controllers + #disconnect: AbortController | null = null; + #triggerAbort: AbortController | null = null; + #currentTrigger: HTMLElement | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + this.#disconnect = new AbortController(); + + this.#tooltip = createTooltip({ + transition: createTransition(), + onOpenChange: (nextOpen: boolean, details: TooltipChangeDetails) => { + this.open = nextOpen; + this.dispatchEvent(new CustomEvent('open-change', { detail: { open: nextOpen, ...details } })); + }, + delay: () => this.delay, + closeDelay: () => this.closeDelay, + disableHoverablePopup: () => this.disableHoverablePopup, + disabled: () => this.disabled, + // Lazy getter — group may arrive after connect via context. + group: () => this.#groupConsumer.value, + }); + + // Register self as the popup element — the element IS the popup. + this.#tooltip.setPopupElement(this); + + // Apply popup event handlers (pointerenter/leave, focusout) to self. + applyElementProps(this, this.#tooltip.popupProps, { signal: this.#disconnect.signal }); + + // Subscribe to interaction state for reactive updates. + if (this.#snapshot) { + this.#snapshot.track(this.#tooltip.input); + } else { + this.#snapshot = new SnapshotController(this, this.#tooltip.input); + } + } + + 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.#tooltip?.open(); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#cleanupTrigger(); + this.#tooltip?.destroy(); + this.#tooltip = 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.#tooltip && changed.has('open')) { + const { active: interactionOpen } = this.#tooltip.input.current; + if (this.open !== interactionOpen) { + if (this.open) { + this.#tooltip.open(); + } else { + this.#tooltip.close(); + } + } + } + } + + protected override update(_changed: PropertyValues): void { + super.update(_changed); + if (!this.#tooltip) return; + + // Discover trigger via commandfor linkage. + const triggerEl = this.#findTrigger(); + this.#syncTrigger(triggerEl); + + // Derive state from core + input. + const input = this.#tooltip.input.current; + this.#core.setInput(input); + const state = this.#core.getState(); + + // Apply popup ARIA and data attributes to self. + applyElementProps(this, this.#core.getPopupAttrs(state)); + applyStateDataAttrs(this, state, TooltipDataAttrs); + + // 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, undefined, undefined, undefined, undefined, TooltipCSSVars) + ); + } 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, TooltipCSSVars); + applyStyles( + this, + getAnchorPositionStyle(this.id, posOpts, triggerRect, selfRect, boundaryRect, offsets, TooltipCSSVars) + ); + } + } + + // --- 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.#tooltip?.setTriggerElement(triggerEl); + + if (triggerEl && this.#tooltip) { + this.#triggerAbort = new AbortController(); + applyElementProps(triggerEl, this.#tooltip.triggerProps, { signal: this.#triggerAbort.signal }); + } + } + + #cleanupTrigger(): void { + if (this.#currentTrigger) { + // Remove ARIA attributes and anchor-name style from the old trigger. + applyElementProps(this.#currentTrigger, { + 'aria-describedby': undefined, + }); + this.#currentTrigger.style.removeProperty('anchor-name'); + } + + this.#triggerAbort?.abort(); + this.#triggerAbort = null; + this.#currentTrigger = null; + } +} diff --git a/packages/html/src/ui/tooltip/tooltip-group-element.ts b/packages/html/src/ui/tooltip/tooltip-group-element.ts new file mode 100644 index 00000000..7ebcf042 --- /dev/null +++ b/packages/html/src/ui/tooltip/tooltip-group-element.ts @@ -0,0 +1,29 @@ +import { TooltipGroupCore } from '@videojs/core'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { ContextProvider } from '@videojs/element/context'; + +import { MediaElement } from '../media-element'; +import { tooltipGroupContext } from './context'; + +export class TooltipGroupElement extends MediaElement { + static readonly tagName = 'media-tooltip-group'; + + static override properties = { + delay: { type: Number }, + closeDelay: { type: Number, attribute: 'close-delay' }, + timeout: { type: Number }, + } satisfies PropertyDeclarationMap; + + delay = TooltipGroupCore.defaultProps.delay; + closeDelay = TooltipGroupCore.defaultProps.closeDelay; + timeout = TooltipGroupCore.defaultProps.timeout; + + readonly #core = new TooltipGroupCore(); + readonly #provider = new ContextProvider(this, { context: tooltipGroupContext }); + + protected override update(_changed: PropertyValues): void { + super.update(_changed); + this.#core.setProps(this); + this.#provider.setValue(this.#core); + } +}