mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(html): add tooltip element (#735)
This commit is contained in:
@@ -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';
|
||||
|
||||
+23
-23
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<TooltipGroupCore>(TOOLTIP_GROUP_CONTEXT_KEY);
|
||||
@@ -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<keyof TooltipCore.Props>;
|
||||
|
||||
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<TooltipInput> | 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<HTMLElement>(`[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;
|
||||
}
|
||||
}
|
||||
@@ -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<keyof TooltipGroupCore.Props>;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user