feat(html): add tooltip element (#735)

This commit is contained in:
rahim
2026-03-04 22:49:04 -08:00
committed by GitHub
parent b69a2f9994
commit e9fbaece87
14 changed files with 412 additions and 126 deletions
+1
View File
@@ -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';
@@ -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);
});
@@ -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<TooltipGroupProps> = {
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;
}
-1
View File
@@ -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';
@@ -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<PopoverSide, PopoverSide> = {
* 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<Record<PopoverCSSVarKey, string>> {
offsets?: ManualOffsets,
cssVars: PositioningCSSVars = PopoverCSSVars
): PopoverPositionStyle & Record<string, string | undefined> {
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<string, string> {
const vars: Record<string, string> = {};
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<Record<PopoverCSSVarKey, string>> {
const vars: Partial<Record<PopoverCSSVarKey, string>> = {};
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,
};
}
@@ -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();
@@ -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,
};
}
+8 -5
View File
@@ -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<PopoverTriggerProps, 'onClick'> {}
@@ -47,14 +47,13 @@ const REASON_MAP: Partial<Record<string, TooltipOpenChangeReason>> = {
};
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) {