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:
@@ -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