mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add tooltip (#734)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user