feat(core): add tooltip (#734)

This commit is contained in:
rahim
2026-03-04 21:18:54 -08:00
committed by GitHub
parent ff12296355
commit b69a2f9994
11 changed files with 729 additions and 0 deletions
+3
View File
@@ -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';
@@ -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);
});
});
});
@@ -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<TooltipProps> = {
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;
}
@@ -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];
@@ -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<TooltipState>;
+2
View File
@@ -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';
@@ -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);
});
});
@@ -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<TooltipOptions>) {
const onOpenChange = vi.fn<(open: boolean, details: TooltipChangeDetails) => void>();
const transition = overrides?.transition ?? createTransition();
const tooltip = createTooltip({
transition,
onOpenChange,
...overrides,
});
return { tooltip, onOpenChange, transition };
}
@@ -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);
});
});
});
@@ -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,
};
}
+113
View File
@@ -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<PopoverTriggerProps, 'onClick'> {}
export interface TooltipPopupProps extends PopoverPopupProps {}
export interface TooltipApi extends Omit<PopoverApi, 'triggerProps' | 'popupProps' | 'open' | 'close'> {
triggerProps: TooltipTriggerProps;
popupProps: TooltipPopupProps;
open: () => void;
close: () => void;
}
/** Map popover reasons to tooltip reasons, filtering out click/outside-click. */
const REASON_MAP: Partial<Record<string, TooltipOpenChangeReason>> = {
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'),
};
}