From b69a2f9994eaf14f4f2bf64643d8fc18e901b365 Mon Sep 17 00:00:00 2001 From: rahim Date: Wed, 4 Mar 2026 21:18:54 -0800 Subject: [PATCH] feat(core): add tooltip (#734) --- packages/core/src/core/index.ts | 3 + .../ui/tooltip/tests/tooltip-core.test.ts | 119 +++++++++++ .../core/src/core/ui/tooltip/tooltip-core.ts | 97 +++++++++ .../src/core/ui/tooltip/tooltip-css-vars.ts | 16 ++ .../src/core/ui/tooltip/tooltip-data-attrs.ts | 15 ++ packages/core/src/dom/index.ts | 2 + .../ui/tooltip/tests/tooltip-group.test.ts | 91 ++++++++ .../dom/ui/tooltip/tests/tooltip-helpers.ts | 14 ++ .../src/dom/ui/tooltip/tests/tooltip.test.ts | 198 ++++++++++++++++++ .../core/src/dom/ui/tooltip/tooltip-group.ts | 61 ++++++ packages/core/src/dom/ui/tooltip/tooltip.ts | 113 ++++++++++ 11 files changed, 729 insertions(+) create mode 100644 packages/core/src/core/ui/tooltip/tests/tooltip-core.test.ts create mode 100644 packages/core/src/core/ui/tooltip/tooltip-core.ts create mode 100644 packages/core/src/core/ui/tooltip/tooltip-css-vars.ts create mode 100644 packages/core/src/core/ui/tooltip/tooltip-data-attrs.ts create mode 100644 packages/core/src/dom/ui/tooltip/tests/tooltip-group.test.ts create mode 100644 packages/core/src/dom/ui/tooltip/tests/tooltip-helpers.ts create mode 100644 packages/core/src/dom/ui/tooltip/tests/tooltip.test.ts create mode 100644 packages/core/src/dom/ui/tooltip/tooltip-group.ts create mode 100644 packages/core/src/dom/ui/tooltip/tooltip.ts diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index d2ab5c72..246701b1 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -33,6 +33,9 @@ export * from './ui/time/time-data-attrs'; export * from './ui/time-slider/time-slider-core'; export * from './ui/time-slider/time-slider-css-vars'; 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/transition'; export * from './ui/types'; export * from './ui/volume-slider/volume-slider-core'; diff --git a/packages/core/src/core/ui/tooltip/tests/tooltip-core.test.ts b/packages/core/src/core/ui/tooltip/tests/tooltip-core.test.ts new file mode 100644 index 00000000..a04ad362 --- /dev/null +++ b/packages/core/src/core/ui/tooltip/tests/tooltip-core.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { TooltipCore, type TooltipInput } from '../tooltip-core'; + +const CLOSED: TooltipInput = { active: false, status: 'idle' }; +const OPEN: TooltipInput = { active: true, status: 'idle' }; + +describe('TooltipCore', () => { + it('uses default props', () => { + const core = new TooltipCore(); + core.setInput(CLOSED); + const state = core.getState(); + + expect(state.side).toBe('top'); + expect(state.align).toBe('center'); + }); + + it('merges input state', () => { + const core = new TooltipCore(); + + core.setInput(CLOSED); + expect(core.getState().open).toBe(false); + + core.setInput(OPEN); + expect(core.getState().open).toBe(true); + }); + + it('applies custom props', () => { + const core = new TooltipCore({ side: 'bottom', align: 'start' }); + core.setInput(OPEN); + const state = core.getState(); + + expect(state.side).toBe('bottom'); + expect(state.align).toBe('start'); + }); + + it('updates props via setProps', () => { + const core = new TooltipCore(); + + core.setProps({ side: 'left' }); + core.setInput(OPEN); + const state = core.getState(); + + expect(state.side).toBe('left'); + expect(state.align).toBe('center'); + }); + + describe('getTriggerAttrs', () => { + it('returns undefined aria-describedby when closed', () => { + const core = new TooltipCore(); + core.setInput(CLOSED); + const attrs = core.getTriggerAttrs(core.getState(), 'tooltip-1'); + + expect(attrs['aria-describedby']).toBeUndefined(); + }); + + it('returns aria-describedby with popupId when open', () => { + const core = new TooltipCore(); + core.setInput(OPEN); + const attrs = core.getTriggerAttrs(core.getState(), 'tooltip-1'); + + expect(attrs['aria-describedby']).toBe('tooltip-1'); + }); + + it('returns undefined aria-describedby when no popupId', () => { + const core = new TooltipCore(); + core.setInput(OPEN); + const attrs = core.getTriggerAttrs(core.getState()); + + expect(attrs['aria-describedby']).toBeUndefined(); + }); + }); + + describe('getPopupAttrs', () => { + it('returns tooltip role', () => { + const core = new TooltipCore(); + core.setInput(OPEN); + const attrs = core.getPopupAttrs(core.getState()); + + expect(attrs.role).toBe('tooltip'); + }); + + it('returns popover manual attribute', () => { + const core = new TooltipCore(); + core.setInput(OPEN); + const attrs = core.getPopupAttrs(core.getState()); + + expect(attrs.popover).toBe('manual'); + }); + }); + + describe('transition flags', () => { + it('sets transitionStarting when status is starting', () => { + const core = new TooltipCore(); + core.setInput({ active: true, status: 'starting' }); + const state = core.getState(); + + expect(state.transitionStarting).toBe(true); + expect(state.transitionEnding).toBe(false); + }); + + it('sets transitionEnding when status is ending', () => { + const core = new TooltipCore(); + core.setInput({ active: true, status: 'ending' }); + const state = core.getState(); + + expect(state.transitionStarting).toBe(false); + expect(state.transitionEnding).toBe(true); + }); + + it('both false when status is idle', () => { + const core = new TooltipCore(); + core.setInput(OPEN); + const state = core.getState(); + + expect(state.transitionStarting).toBe(false); + expect(state.transitionEnding).toBe(false); + }); + }); +}); diff --git a/packages/core/src/core/ui/tooltip/tooltip-core.ts b/packages/core/src/core/ui/tooltip/tooltip-core.ts new file mode 100644 index 00000000..f564cabd --- /dev/null +++ b/packages/core/src/core/ui/tooltip/tooltip-core.ts @@ -0,0 +1,97 @@ +import { defaults } from '@videojs/utils/object'; +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { PopoverAlign, PopoverSide } from '../popover/popover-core'; +import type { TransitionFlags, TransitionState, TransitionStatus } from '../transition'; +import { getTransitionFlags } from '../transition'; + +export interface TooltipProps { + /** Which side of the trigger the tooltip appears on. */ + side?: PopoverSide | undefined; + /** Alignment of the tooltip along the trigger's edge. */ + align?: PopoverAlign | undefined; + /** Controlled open state. */ + open?: boolean | undefined; + /** Initial open state for uncontrolled usage. */ + defaultOpen?: boolean | undefined; + /** Delay in ms before opening on hover. */ + delay?: number | undefined; + /** Delay in ms before closing after pointer leaves. */ + closeDelay?: number | undefined; + /** When true, hovering the popup does not keep it open. */ + disableHoverablePopup?: boolean | undefined; + /** When true, the tooltip is disabled and will not open. */ + disabled?: boolean | undefined; +} + +export interface TooltipInput extends TransitionState {} + +export interface TooltipState extends TransitionFlags { + /** Whether the tooltip is currently visible. */ + open: boolean; + /** Current phase of the transition lifecycle. */ + status: TransitionStatus; + /** Which side of the trigger the tooltip is positioned on. */ + side: PopoverSide; + /** How the tooltip is aligned relative to the specified side. */ + align: PopoverAlign; +} + +export class TooltipCore { + static readonly defaultProps: NonNullableObject = { + side: 'top', + align: 'center', + open: false, + defaultOpen: false, + delay: 600, + closeDelay: 0, + disableHoverablePopup: true, + disabled: false, + }; + + #props = { ...TooltipCore.defaultProps }; + + constructor(props?: TooltipProps) { + if (props) this.setProps(props); + } + + setProps(props: TooltipProps): void { + this.#props = defaults(props, TooltipCore.defaultProps); + } + + #input: TooltipInput | null = null; + + setInput(input: TooltipInput): void { + this.#input = input; + } + + getState(): TooltipState { + const input = this.#input!; + return { + open: input.active, + status: input.status, + side: this.#props.side, + align: this.#props.align, + ...getTransitionFlags(input.status), + }; + } + + getTriggerAttrs(state: TooltipState, popupId?: string) { + return { + 'aria-describedby': state.open ? popupId : undefined, + }; + } + + getPopupAttrs(_state: TooltipState) { + return { + popover: 'manual' as const, + role: 'tooltip', + }; + } +} + +export namespace TooltipCore { + export type Props = TooltipProps; + export type State = TooltipState; + export type Input = TooltipInput; +} diff --git a/packages/core/src/core/ui/tooltip/tooltip-css-vars.ts b/packages/core/src/core/ui/tooltip/tooltip-css-vars.ts new file mode 100644 index 00000000..8e03bc1e --- /dev/null +++ b/packages/core/src/core/ui/tooltip/tooltip-css-vars.ts @@ -0,0 +1,16 @@ +export const TooltipCSSVars = { + /** Distance between the popup and the trigger along the side axis. */ + sideOffset: '--media-tooltip-side-offset', + /** Distance between the popup and the trigger along the alignment axis. */ + alignOffset: '--media-tooltip-align-offset', + /** The anchor element's width. */ + anchorWidth: '--media-tooltip-anchor-width', + /** The anchor element's height. */ + anchorHeight: '--media-tooltip-anchor-height', + /** Available width between the trigger and the boundary edge. */ + availableWidth: '--media-tooltip-available-width', + /** Available height between the trigger and the boundary edge. */ + availableHeight: '--media-tooltip-available-height', +} as const; + +export type TooltipCSSVarKey = (typeof TooltipCSSVars)[keyof typeof TooltipCSSVars]; diff --git a/packages/core/src/core/ui/tooltip/tooltip-data-attrs.ts b/packages/core/src/core/ui/tooltip/tooltip-data-attrs.ts new file mode 100644 index 00000000..fb7b3352 --- /dev/null +++ b/packages/core/src/core/ui/tooltip/tooltip-data-attrs.ts @@ -0,0 +1,15 @@ +import type { StateAttrMap } from '../types'; +import type { TooltipState } from './tooltip-core'; + +export const TooltipDataAttrs = { + /** Present when the tooltip is open. */ + open: 'data-open', + /** Indicates which side the tooltip is positioned relative to the trigger. */ + side: 'data-side', + /** Indicates how the tooltip 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; diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts index a88230e4..cdfc5eb5 100644 --- a/packages/core/src/dom/index.ts +++ b/packages/core/src/dom/index.ts @@ -9,5 +9,7 @@ export * from './ui/popover/popover-positioning'; 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/tooltip/tests/tooltip-group.test.ts b/packages/core/src/dom/ui/tooltip/tests/tooltip-group.test.ts new file mode 100644 index 00000000..e8660316 --- /dev/null +++ b/packages/core/src/dom/ui/tooltip/tests/tooltip-group.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTooltipGroup } from '../tooltip-group'; + +describe('createTooltipGroup', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('exposes default delay and closeDelay', () => { + const group = createTooltipGroup(); + + expect(group.delay).toBe(600); + expect(group.closeDelay).toBe(0); + }); + + it('accepts custom delay and closeDelay', () => { + const group = createTooltipGroup({ delay: 300, closeDelay: 100 }); + + expect(group.delay).toBe(300); + expect(group.closeDelay).toBe(100); + }); + + it('should not skip delay initially', () => { + const group = createTooltipGroup(); + + expect(group.shouldSkipDelay()).toBe(false); + }); + + it('should skip delay after a tooltip closes', () => { + const group = createTooltipGroup({ timeout: 400 }); + + group.notifyOpen(); + group.notifyClose(); + + expect(group.shouldSkipDelay()).toBe(true); + }); + + it('should not skip delay after timeout expires', () => { + const group = createTooltipGroup({ timeout: 400 }); + + group.notifyOpen(); + group.notifyClose(); + + vi.advanceTimersByTime(400); + + expect(group.shouldSkipDelay()).toBe(false); + }); + + it('clears pending timeout on notifyOpen', () => { + const group = createTooltipGroup({ timeout: 400 }); + + group.notifyOpen(); + group.notifyClose(); + // A new tooltip opens before timeout expires + group.notifyOpen(); + + expect(group.shouldSkipDelay()).toBe(false); + }); + + it('should not skip delay when a tooltip is currently open', () => { + const group = createTooltipGroup(); + + group.notifyOpen(); + + expect(group.shouldSkipDelay()).toBe(false); + }); + + it('cleans up on destroy', () => { + const group = createTooltipGroup({ 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(); + + expect(group.shouldSkipDelay()).toBe(false); + }); +}); diff --git a/packages/core/src/dom/ui/tooltip/tests/tooltip-helpers.ts b/packages/core/src/dom/ui/tooltip/tests/tooltip-helpers.ts new file mode 100644 index 00000000..584ee7d4 --- /dev/null +++ b/packages/core/src/dom/ui/tooltip/tests/tooltip-helpers.ts @@ -0,0 +1,14 @@ +import { vi } from 'vitest'; +import { createTransition } from '../../transition'; +import { createTooltip, type TooltipChangeDetails, type TooltipOptions } from '../tooltip'; + +export function createTestTooltip(overrides?: Partial) { + const onOpenChange = vi.fn<(open: boolean, details: TooltipChangeDetails) => void>(); + const transition = overrides?.transition ?? createTransition(); + const tooltip = createTooltip({ + transition, + onOpenChange, + ...overrides, + }); + return { tooltip, onOpenChange, transition }; +} diff --git a/packages/core/src/dom/ui/tooltip/tests/tooltip.test.ts b/packages/core/src/dom/ui/tooltip/tests/tooltip.test.ts new file mode 100644 index 00000000..21aa7bad --- /dev/null +++ b/packages/core/src/dom/ui/tooltip/tests/tooltip.test.ts @@ -0,0 +1,198 @@ +import { flush } from '@videojs/store'; +import { describe, expect, it, vi } from 'vitest'; +import { createTooltipGroup } from '../tooltip-group'; +import { createTestTooltip } from './tooltip-helpers'; + +describe('createTooltip', () => { + it('starts closed', () => { + const { tooltip } = createTestTooltip(); + expect(tooltip.input.current).toEqual({ active: false, status: 'idle' }); + }); + + describe('open/close', () => { + it('updates input state and calls onOpenChange when opening', () => { + const { tooltip, onOpenChange } = createTestTooltip(); + + tooltip.open(); + + expect(tooltip.input.current.active).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(true, { reason: 'hover' }); + }); + + it('transitions to starting status when opening', () => { + const { tooltip } = createTestTooltip(); + + tooltip.open(); + + expect(tooltip.input.current).toEqual({ active: true, status: 'starting' }); + }); + + it('calls onOpenChange when closing', () => { + const { tooltip, onOpenChange } = createTestTooltip(); + + tooltip.open(); + onOpenChange.mockClear(); + + tooltip.close(); + + expect(tooltip.input.current.active).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(false, { reason: 'hover' }); + }); + + it('does not call onOpenChange if already open', () => { + const { tooltip, onOpenChange } = createTestTooltip(); + + tooltip.open(); + onOpenChange.mockClear(); + + tooltip.open(); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('does not call onOpenChange if already closed', () => { + const { tooltip, onOpenChange } = createTestTooltip(); + + tooltip.close(); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + }); + + describe('onOpenChangeComplete', () => { + it('fires after open animation completes', () => { + const onOpenChangeComplete = vi.fn(); + const { tooltip } = createTestTooltip({ onOpenChangeComplete }); + + tooltip.open(); + + // Not called synchronously — fires after transition resolves + expect(onOpenChangeComplete).not.toHaveBeenCalled(); + }); + }); + + describe('triggerProps', () => { + it('does not expose onClick', () => { + const { tooltip } = createTestTooltip(); + + expect(tooltip.triggerProps).not.toHaveProperty('onClick'); + }); + + it('exposes hover and focus handlers', () => { + const { tooltip } = createTestTooltip(); + + expect(tooltip.triggerProps.onPointerEnter).toBeTypeOf('function'); + expect(tooltip.triggerProps.onPointerLeave).toBeTypeOf('function'); + expect(tooltip.triggerProps.onFocusIn).toBeTypeOf('function'); + expect(tooltip.triggerProps.onFocusOut).toBeTypeOf('function'); + }); + + it('does not open when disabled', () => { + const { tooltip, onOpenChange } = createTestTooltip({ + disabled: () => true, + }); + + tooltip.triggerProps.onPointerEnter({ + clientX: 0, + clientY: 0, + pointerId: 1, + pointerType: 'mouse', + buttons: 0, + preventDefault: vi.fn(), + }); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('does not open via focus when disabled', () => { + const { tooltip, onOpenChange } = createTestTooltip({ + disabled: () => true, + }); + + tooltip.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn() }); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + }); + + describe('popupProps', () => { + it('onPointerEnter is no-op when disableHoverablePopup is true', () => { + const { tooltip } = createTestTooltip({ + disableHoverablePopup: () => true, + }); + + tooltip.open(); + + // Should not throw — just a no-op + tooltip.popupProps.onPointerEnter({ + clientX: 0, + clientY: 0, + pointerId: 1, + pointerType: 'mouse', + buttons: 0, + preventDefault: vi.fn(), + }); + }); + }); + + describe('group integration', () => { + it('notifies group on open/close', () => { + const group = createTooltipGroup(); + const notifyOpen = vi.spyOn(group, 'notifyOpen'); + const notifyClose = vi.spyOn(group, 'notifyClose'); + + const { tooltip } = createTestTooltip({ group }); + + tooltip.open(); + expect(notifyOpen).toHaveBeenCalled(); + + tooltip.close(); + expect(notifyClose).toHaveBeenCalled(); + }); + }); + + describe('element setters', () => { + it('sets trigger element', () => { + const { tooltip } = createTestTooltip(); + const el = document.createElement('button'); + + tooltip.setTriggerElement(el); + tooltip.setTriggerElement(null); + }); + + it('sets and clears popup element', () => { + const { tooltip } = createTestTooltip(); + const el = document.createElement('div'); + + tooltip.setPopupElement(el); + tooltip.setPopupElement(null); + }); + }); + + describe('destroy', () => { + it('prevents further open/close calls', () => { + const { tooltip, onOpenChange } = createTestTooltip(); + + tooltip.destroy(); + tooltip.open(); + + expect(onOpenChange).not.toHaveBeenCalled(); + expect(tooltip.input.current.active).toBe(false); + }); + }); + + describe('subscriber notification', () => { + it('notifies subscribers when opened', () => { + const { tooltip } = createTestTooltip(); + const callback = vi.fn(); + + tooltip.input.subscribe(callback); + + tooltip.open(); + flush(); + + expect(callback).toHaveBeenCalled(); + expect(tooltip.input.current.active).toBe(true); + }); + }); +}); diff --git a/packages/core/src/dom/ui/tooltip/tooltip-group.ts b/packages/core/src/dom/ui/tooltip/tooltip-group.ts new file mode 100644 index 00000000..5d49025f --- /dev/null +++ b/packages/core/src/dom/ui/tooltip/tooltip-group.ts @@ -0,0 +1,61 @@ +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 new file mode 100644 index 00000000..be661348 --- /dev/null +++ b/packages/core/src/dom/ui/tooltip/tooltip.ts @@ -0,0 +1,113 @@ +import { + createPopover, + type PopoverApi, + type PopoverChangeDetails, + type PopoverOptions, + type PopoverPopupProps, + type PopoverTriggerProps, +} from '../popover/popover'; +import type { TransitionApi } from '../transition'; +import type { TooltipGroupApi } from './tooltip-group'; + +export type TooltipOpenChangeReason = 'hover' | 'focus' | 'escape' | 'blur'; + +export interface TooltipChangeDetails { + reason: TooltipOpenChangeReason; + event?: Event; +} + +export interface TooltipOptions { + transition: TransitionApi; + onOpenChange: (open: boolean, details: TooltipChangeDetails) => void; + onOpenChangeComplete?: (open: boolean) => void; + delay?: () => number; + closeDelay?: () => number; + disableHoverablePopup?: () => boolean; + disabled?: () => boolean; + group?: TooltipGroupApi; +} + +export interface TooltipTriggerProps extends Omit {} + +export interface TooltipPopupProps extends PopoverPopupProps {} + +export interface TooltipApi extends Omit { + triggerProps: TooltipTriggerProps; + popupProps: TooltipPopupProps; + open: () => void; + close: () => void; +} + +/** Map popover reasons to tooltip reasons, filtering out click/outside-click. */ +const REASON_MAP: Partial> = { + hover: 'hover', + focus: 'focus', + escape: 'escape', + blur: 'blur', +}; + +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; + + if (open) group?.notifyOpen(); + else group?.notifyClose(); + + const tooltipDetails: TooltipChangeDetails = details.event ? { reason, event: details.event } : { reason }; + options.onOpenChange(open, tooltipDetails); + }, + closeOnEscape: () => true, + closeOnOutsideClick: () => false, + openOnHover: () => true, + delay: () => { + if (group?.shouldSkipDelay()) return 0; + return options.delay?.() ?? group?.delay ?? 600; + }, + closeDelay: () => options.closeDelay?.() ?? group?.closeDelay ?? 0, + }; + + if (options.onOpenChangeComplete) { + popoverOpts.onOpenChangeComplete = options.onOpenChangeComplete; + } + + const popover = createPopover(popoverOpts); + + // Spread popover trigger props, omit onClick, guard disabled on open handlers. + const { onClick: _, ...baseTriggerProps } = popover.triggerProps; + const triggerProps: TooltipTriggerProps = { + ...baseTriggerProps, + onPointerEnter(event) { + if (options.disabled?.()) return; + baseTriggerProps.onPointerEnter(event); + }, + onFocusIn(event) { + if (options.disabled?.()) return; + baseTriggerProps.onFocusIn(event); + }, + }; + + // Spread popover popup props, guard disableHoverablePopup on pointer enter. + const popupProps: TooltipPopupProps = { + ...popover.popupProps, + onPointerEnter(event) { + if (options.disableHoverablePopup?.()) return; + popover.popupProps.onPointerEnter(event); + }, + }; + + return { + ...popover, + triggerProps, + popupProps, + get triggerElement() { + return popover.triggerElement; + }, + open: () => popover.open('hover'), + close: () => popover.close('hover'), + }; +}