@ngneat/helipopper
Version:
A Powerful Tooltip and Popover for Angular Applications
906 lines (897 loc) • 50.2 kB
JavaScript
import * as i0 from '@angular/core';
import { ElementRef, inject, NgZone, ɵisPromise as _isPromise, Injectable, InjectionToken, Injector, signal, booleanAttribute, input, output, model, computed, DestroyRef, PLATFORM_ID, ViewContainerRef, afterEveryRender, untracked, effect, Directive } from '@angular/core';
import { isPlatformServer } from '@angular/common';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Observable, defer, of, tap, map as map$1, Subject, firstValueFrom, combineLatest, from, merge } from 'rxjs';
import { auditTime, map, switchMap, takeUntil, filter } from 'rxjs/operators';
import { ViewService, isTemplateRef, isComponent, isString } from '@ngneat/overview';
import { TIPPY_LOADER, TIPPY_CONFIG, TIPPY_LOADER_COMPONENT, TIPPY_LOADER_TIMING } from '@ngneat/helipopper/config';
// Let's retrieve the native `IntersectionObserver` implementation hidden by
// `__zone_symbol__IntersectionObserver`. This would be the unpatched version of
// the observer present in zone.js environments.
// Otherwise, if the user is using zoneless change detection (and zone.js is not included),
// we fall back to the native implementation. Accessing the native implementation
// allows us to remove `runOutsideAngular` calls and reduce indentation,
// making the code a bit more readable.
const IntersectionObserver = globalThis['__zone_symbol__IntersectionObserver'] ||
globalThis.IntersectionObserver;
let supportsIntersectionObserver = false;
let supportsResizeObserver = false;
if (typeof window !== 'undefined') {
supportsIntersectionObserver = 'IntersectionObserver' in window;
supportsResizeObserver = 'ResizeObserver' in window;
}
function inView(host, options = {
root: null,
threshold: 0.3,
}) {
const element = coerceElement(host);
return new Observable((subscriber) => {
if (!supportsIntersectionObserver) {
subscriber.next();
subscriber.complete();
return;
}
const observer = new IntersectionObserver((entries) => {
// Several changes may occur in the same tick, we want to check the latest entry state.
const entry = entries[entries.length - 1];
if (entry.isIntersecting) {
subscriber.next();
subscriber.complete();
}
}, options);
observer.observe(element);
return () => observer.disconnect();
});
}
function isElementOverflow(host) {
// Don't access the `offsetWidth`/`offsetHeight` multiple times since it triggers layout updates.
const hostOffsetWidth = host.offsetWidth;
const hostOffsetHeight = host.offsetHeight;
return (hostOffsetWidth > host.parentElement.offsetWidth ||
hostOffsetWidth < host.scrollWidth ||
hostOffsetHeight < host.scrollHeight);
}
function overflowChanges(host) {
const element = coerceElement(host);
return dimensionsChanges(element).pipe(auditTime(150), map(() => isElementOverflow(element)));
}
function dimensionsChanges(target) {
return new Observable((subscriber) => {
if (!supportsResizeObserver) {
subscriber.next();
subscriber.complete();
return;
}
const observer = new ResizeObserver(() => subscriber.next());
observer.observe(target);
return () => observer.disconnect();
});
}
function onlyTippyProps(allProps) {
const tippyProps = {};
if (!allProps) {
return tippyProps;
}
const ownProps = [
'useTextContent',
'variations',
'useHostWidth',
'defaultVariation',
'beforeRender',
'isLazy',
'variation',
'isEnabled',
'className',
'onlyTextOverflow',
'data',
'content',
'context',
'hideOnEscape',
'customHost',
'injector',
'preserveView',
'vcr',
'popperWidth',
'zIndexGetter',
'staticWidthHost',
'bindings',
'directives',
];
const overriddenMethods = ['onShow', 'onHidden', 'onCreate'];
Object.keys(allProps).forEach((prop) => {
if (!ownProps.includes(prop) && !overriddenMethods.includes(prop)) {
tippyProps[prop] = allProps[prop];
}
});
return tippyProps;
}
function normalizeClassName(className) {
const classes = typeof className === 'string' ? className.split(' ') : className;
return classes.map((klass) => klass?.trim()).filter(Boolean);
}
function coerceCssPixelValue(value) {
if (value == null) {
return '';
}
return typeof value === 'string' ? value : `${value}px`;
}
function coerceElement(element) {
return element instanceof ElementRef ? element.nativeElement : element;
}
let observer;
const elementHiddenHandlers = new WeakMap();
function observeVisibility(host, hiddenHandler) {
observer ??= new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) {
elementHiddenHandlers.get(entry.target)();
}
});
});
elementHiddenHandlers.set(host, hiddenHandler);
observer.observe(host);
return () => {
elementHiddenHandlers.delete(host);
observer.unobserve(host);
};
}
class TippyFactory {
constructor() {
this._ngZone = inject(NgZone);
this._loader = inject(TIPPY_LOADER);
this._tippyImpl$ = null;
this._tippy = null;
}
/**
* This returns an observable because the user should provide a `loader`
* function, which may return a promise if the tippy.js library is to be
* loaded asynchronously.
*/
getTippyImpl() {
this._tippyImpl$ ||= defer(() => {
if (this._tippy)
return of(this._tippy);
// Call the `loader` function lazily — only when a subscriber
// arrives — to avoid importing `tippy.js` on the server.
const maybeTippy = this._ngZone.runOutsideAngular(() => this._loader());
let tippy$;
// We need to use `isPromise` instead of checking whether
// `result instanceof Promise`. In zone.js patched environments, `global.Promise`
// is the `ZoneAwarePromise`. Some APIs, which are likely not patched by zone.js
// for certain reasons, might not work with `instanceof`. For instance, the dynamic
// import `() => import('./chunk.js')` returns a native promise (not a `ZoneAwarePromise`),
// causing this check to be falsy.
if (_isPromise(maybeTippy)) {
// This pulls less RxJS symbols compared to using `from()` to resolve a promise value.
tippy$ = new Observable((subscriber) => {
maybeTippy.then((tippy) => {
subscriber.next(tippy.default);
subscriber.complete();
});
});
}
else {
tippy$ = of(maybeTippy);
}
return tippy$.pipe(tap((tippy) => {
this._tippy = tippy;
}));
});
return this._tippyImpl$;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TippyFactory, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TippyFactory, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TippyFactory, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
const TIPPY_REF = /* @__PURE__ */ new InjectionToken(ngDevMode ? 'TIPPY_REF' : '');
function injectTippyRef() {
const instance = inject(TIPPY_REF, { optional: true });
if (!instance) {
if (ngDevMode) {
throw new Error('tp is not provided in the current context or on one of its ancestors');
}
else {
throw new Error(`[tp]: ${1 /* TippyErrorCode.TippyNotProvided */}`);
}
}
return instance;
}
class TippyService {
constructor() {
this._ngZone = inject(NgZone);
this._injector = inject(Injector);
this._globalConfig = inject(TIPPY_CONFIG, { optional: true });
this._viewService = inject(ViewService);
this._tippyFactory = inject(TippyFactory);
this.enabled = signal(true, ...(ngDevMode ? [{ debugName: "enabled" }] : /* istanbul ignore next */ []));
}
enableAll() {
this.enabled.set(true);
}
disableAll() {
this.enabled.set(false);
}
create(host, content, options = {}) {
const variation = options.variation || this._globalConfig?.defaultVariation || '';
const config = {
onShow: (instance) => {
host.setAttribute('data-tippy-open', '');
if (!instance.$viewOptions) {
instance.$viewOptions = {
injector: Injector.create({
providers: [
{
provide: TIPPY_REF,
useValue: instance,
},
],
parent: options.injector || this._injector,
}),
};
if (isTemplateRef(content)) {
instance.$viewOptions.context = {
$implicit: instance.hide.bind(instance),
...options.context,
};
}
else if (isComponent(content)) {
instance.context = options.context;
instance.data = options.data;
}
}
instance.view ||= this._viewService.createView(content, {
...options,
...instance.$viewOptions,
});
instance.setContent(instance.view.getElement());
options?.onShow?.(instance);
},
onHidden: (instance) => {
host.removeAttribute('data-tippy-open');
if (!options.preserveView) {
instance.view?.destroy();
instance.view = null;
}
options?.onHidden?.(instance);
},
...onlyTippyProps(this._globalConfig),
...this._globalConfig?.variations?.[variation],
...onlyTippyProps(options),
onCreate: (instance) => {
instance.popper.classList.add(`tippy-variation-${variation}`);
if (options.className) {
for (const klass of normalizeClassName(options.className)) {
instance.popper.classList.add(klass);
}
}
this._globalConfig?.onCreate?.(instance);
options.onCreate?.(instance);
},
};
return this._tippyFactory.getTippyImpl().pipe(map$1((tippy) => {
return this._ngZone.runOutsideAngular(() => {
return tippy(host, config);
});
}));
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TippyService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TippyService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TippyService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
/**
* Transforms a value (typically a string) to a boolean.
* Intended to be used as a transform function of an input.
*
* @see https://material.angular.io/cdk/coercion/overview
*/
const coerceBooleanAttribute = booleanAttribute;
// An arrow function defined inside a method closes over the method's lexical scope,
// causing V8 to allocate a Context object that indirectly references the directive
// instance — keeping it alive after destroy.
//
// A bound function (JSBoundFunction) has no [[context]] field in V8's heap layout;
// it only stores { bound_target_function, bound_this, bound_arguments }.
// Binding to `null` produces a context-free callable, breaking the retention chain.
const appendTo = function appendTo() {
return document.fullscreenElement || document.body;
}.bind(null);
// These are the default values used by `tippy.js`.
// We are providing them as default input values.
// The `tippy.js` repository has been archived and is unlikely to
// change in the future, so it is safe to use these values as defaults.
const defaultAppendTo = (() => document.body);
const defaultDelay = 0;
const defaultDuration = [300, 250];
const defaultInteractiveBorder = 2;
const defaultMaxWidth = 350;
const defaultOffset = [0, 10];
const defaultPlacement = 'top';
const defaultTrigger = 'mouseenter focus';
const defaultTriggerTarget = null;
const defaultZIndex = 9999;
const defaultAnimation = 'fade';
class TippyDirective {
// It should be a getter because computations are cached until
// any of the producers change.
get hostWidth() {
return this.host().getBoundingClientRect().width;
}
constructor() {
this.appendTo = input(defaultAppendTo, { ...(ngDevMode ? { debugName: "appendTo" } : /* istanbul ignore next */ {}), alias: 'tpAppendTo' });
this.content = input('', { ...(ngDevMode ? { debugName: "content" } : /* istanbul ignore next */ {}), alias: 'tp' });
this.delay = input(defaultDelay, { ...(ngDevMode ? { debugName: "delay" } : /* istanbul ignore next */ {}), alias: 'tpDelay' });
this.duration = input(defaultDuration, { ...(ngDevMode ? { debugName: "duration" } : /* istanbul ignore next */ {}), alias: 'tpDuration' });
this.hideOnClick = input(true, { ...(ngDevMode ? { debugName: "hideOnClick" } : /* istanbul ignore next */ {}), alias: 'tpHideOnClick' });
this.interactive = input(false, { ...(ngDevMode ? { debugName: "interactive" } : /* istanbul ignore next */ {}), alias: 'tpInteractive' });
this.interactiveBorder = input(defaultInteractiveBorder, { ...(ngDevMode ? { debugName: "interactiveBorder" } : /* istanbul ignore next */ {}), alias: 'tpInteractiveBorder' });
this.maxWidth = input(defaultMaxWidth, { ...(ngDevMode ? { debugName: "maxWidth" } : /* istanbul ignore next */ {}), alias: 'tpMaxWidth' });
// Note that some of the input signal types are declared explicitly because the compiler
// also uses types from `@popperjs/core` and requires a type annotation.
this.offset = input(defaultOffset, { ...(ngDevMode ? { debugName: "offset" } : /* istanbul ignore next */ {}), alias: 'tpOffset' });
this.placement = input(defaultPlacement, { ...(ngDevMode ? { debugName: "placement" } : /* istanbul ignore next */ {}), alias: 'tpPlacement' });
this.popperOptions = input({}, { ...(ngDevMode ? { debugName: "popperOptions" } : /* istanbul ignore next */ {}), alias: 'tpPopperOptions' });
this.showOnCreate = input(false, { ...(ngDevMode ? { debugName: "showOnCreate" } : /* istanbul ignore next */ {}), alias: 'tpShowOnCreate' });
this.trigger = input(defaultTrigger, { ...(ngDevMode ? { debugName: "trigger" } : /* istanbul ignore next */ {}), alias: 'tpTrigger' });
this.triggerTarget = input(defaultTriggerTarget, { ...(ngDevMode ? { debugName: "triggerTarget" } : /* istanbul ignore next */ {}), alias: 'tpTriggerTarget' });
this.zIndex = input(defaultZIndex, { ...(ngDevMode ? { debugName: "zIndex" } : /* istanbul ignore next */ {}), alias: 'tpZIndex' });
this.animation = input(defaultAnimation, { ...(ngDevMode ? { debugName: "animation" } : /* istanbul ignore next */ {}), alias: 'tpAnimation' });
this.useTextContent = input(false, { ...(ngDevMode ? { debugName: "useTextContent" } : /* istanbul ignore next */ {}), transform: coerceBooleanAttribute,
alias: 'tpUseTextContent' });
this.isLazy = input(false, { ...(ngDevMode ? { debugName: "isLazy" } : /* istanbul ignore next */ {}), transform: coerceBooleanAttribute,
alias: 'tpIsLazy' });
this.variation = input(undefined, { ...(ngDevMode ? { debugName: "variation" } : /* istanbul ignore next */ {}), alias: 'tpVariation' });
this.isEnabled = input(true, { ...(ngDevMode ? { debugName: "isEnabled" } : /* istanbul ignore next */ {}), alias: 'tpIsEnabled' });
this.className = input('', { ...(ngDevMode ? { debugName: "className" } : /* istanbul ignore next */ {}), alias: 'tpClassName' });
this.onlyTextOverflow = input(false, { ...(ngDevMode ? { debugName: "onlyTextOverflow" } : /* istanbul ignore next */ {}), transform: coerceBooleanAttribute,
alias: 'tpOnlyTextOverflow' });
this.staticWidthHost = input(false, { ...(ngDevMode ? { debugName: "staticWidthHost" } : /* istanbul ignore next */ {}), transform: coerceBooleanAttribute,
alias: 'tpStaticWidthHost' });
this.data = input(undefined, { ...(ngDevMode ? { debugName: "data" } : /* istanbul ignore next */ {}), alias: 'tpData' });
/** Angular `inputBinding`/`outputBinding`/`twoWayBinding` descriptors forwarded to `createComponent`. */
this.bindings = input(undefined, { ...(ngDevMode ? { debugName: "bindings" } : /* istanbul ignore next */ {}), alias: 'tpBindings' });
/** Host directives (with optional bindings) forwarded to `createComponent`. */
this.directives = input(undefined, { ...(ngDevMode ? { debugName: "directives" } : /* istanbul ignore next */ {}), alias: 'tpDirectives' });
this.useHostWidth = input(false, { ...(ngDevMode ? { debugName: "useHostWidth" } : /* istanbul ignore next */ {}), transform: coerceBooleanAttribute,
alias: 'tpUseHostWidth' });
this.hideOnEscape = input(false, { ...(ngDevMode ? { debugName: "hideOnEscape" } : /* istanbul ignore next */ {}), transform: coerceBooleanAttribute,
alias: 'tpHideOnEscape' });
this.tippyProps = input(undefined, { ...(ngDevMode ? { debugName: "tippyProps" } : /* istanbul ignore next */ {}), alias: 'tpTippyProps' });
this.popperWidth = input(undefined, { ...(ngDevMode ? { debugName: "popperWidth" } : /* istanbul ignore next */ {}), alias: 'tpPopperWidth' });
this.customHost = input(undefined, { ...(ngDevMode ? { debugName: "customHost" } : /* istanbul ignore next */ {}), alias: 'tpHost' });
this.tpOnShow = output({ alias: 'tpOnShow' });
this.tpOnHide = output({ alias: 'tpOnHide' });
this.isVisible = model(false, { ...(ngDevMode ? { debugName: "isVisible" } : /* istanbul ignore next */ {}), alias: 'tpIsVisible' });
this.visible = output({ alias: 'tpVisible' });
this.viewRef = null;
this.variationDefined = false;
this.viewOptions$ = null;
/**
* We had use `visible` event emitter previously as a `takeUntil` subscriber in multiple places
* within the directive.
* This is for internal use only; thus we don't have to deal with the `visible` event emitter
* and trigger change detections only when the `visible` event is being listened outside
* in the template (`<button [tippy]="..." (visible)="..."></button>`).
*/
this.visibleInternal = new Subject();
this.visibilityObserverCleanup = null;
this.contentChanged = new Subject();
this.host = computed(() => this.customHost() || this.hostRef.nativeElement, ...(ngDevMode ? [{ debugName: "host" }] : /* istanbul ignore next */ []));
this.destroyRef = inject(DestroyRef);
this.tippyService = inject(TippyService);
this.isServer =
// Drop `isPlatformServer` once `ngServeMode` is available during compilation.
(typeof ngServerMode !== 'undefined' && ngServerMode) ||
isPlatformServer(inject(PLATFORM_ID));
this.tippyFactory = inject(TippyFactory);
this.destroyed = false;
this.created = false;
this.globalConfig = inject(TIPPY_CONFIG);
this.injector = inject(Injector);
this.viewService = inject(ViewService);
this.vcr = inject(ViewContainerRef);
this.ngZone = inject(NgZone);
this.hostRef = inject(ElementRef);
this.loaderViewRef = null;
this.globalLoaderComponent = inject(TIPPY_LOADER_COMPONENT, { optional: true });
this.loaderTiming = inject(TIPPY_LOADER_TIMING);
if (this.isServer)
return;
this.setupListeners();
// `afterEveryRender` fires synchronously within Angular's CD cycle.
// This lets us update the enabled/disabled state of the instance BEFORE a
// synthetic mouseenter event is dispatched, which fixes test timing when
// Angular-triggered changes (content/style bindings) cause the overflow
// state to change. ResizeObserver (`overflowChanges`) remains as a fallback
// for non-Angular-triggered resize events (browser window resize, etc.).
afterEveryRender({
read: () => {
if (!this.onlyTextOverflow())
return;
untracked(() => this.checkOverflow(isElementOverflow(this.host())));
},
});
this.destroyRef.onDestroy(() => {
this.destroyed = true;
this.instance?.destroy();
this.destroyView();
this.visibilityObserverCleanup?.();
});
}
ngOnChanges(changes) {
if (this.isServer)
return;
// `isVisible` and `tippyProps` are not merged into `this.props`; they are
// handled separately (model signal and direct-spread effect respectively).
const changedProps = Object.keys(changes)
.filter((key) => key !== 'isVisible' && key !== 'tippyProps')
.reduce((accumulator, key) => ({ ...accumulator, [key]: changes[key].currentValue }), {});
// Variation defaults are applied only on the first call or when the variation
// input itself changes. Re-applying them on every ngOnChanges call would
// overwrite explicitly-bound inputs (e.g. tpTrigger) with variation defaults
// whenever any other input (e.g. tpData, tpIsEnabled) changes.
if (!this.variationDefined || 'variation' in changes) {
this.variationDefined = true;
const variation = this.variation() || this.globalConfig.defaultVariation || '';
this.setProps({
...this.globalConfig.variations?.[variation],
...this.props,
...changedProps,
});
}
else {
this.updateProps(changedProps);
}
}
ngAfterViewInit() {
if (this.isServer)
return;
const onlyTextOverflow = this.onlyTextOverflow();
if (this.isLazy()) {
const hostInView$ = inView(this.host());
if (onlyTextOverflow) {
hostInView$
.pipe(switchMap(() => this.isOverflowing$()), takeUntilDestroyed(this.destroyRef))
.subscribe((isElementOverflow) => {
this.checkOverflow(isElementOverflow);
});
}
else {
hostInView$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.createInstance();
});
}
}
else if (onlyTextOverflow) {
this.isOverflowing$()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((isElementOverflow) => {
this.checkOverflow(isElementOverflow);
});
}
else {
this.createInstance();
}
}
destroyView() {
this.viewOptions$ = null;
this.viewRef?.destroy();
this.viewRef = null;
this.loaderViewRef?.destroy();
this.loaderViewRef = null;
}
/**
* This method is useful when you append to an element that you might remove from the DOM.
* In such cases we want to hide the tooltip and let it go through the destroy lifecycle.
* For example, if you have a grid row with an element that you toggle using the display CSS property on hover.
*/
observeHostVisibility() {
if (this.isServer)
return;
// We don't want to observe the host visibility if we are appending to the body.
if (this.props.appendTo && this.props.appendTo !== document.body) {
this.visibilityObserverCleanup?.();
return this.visibleInternal
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((isVisible) => {
if (isVisible) {
this.visibilityObserverCleanup = observeVisibility(this.instance.reference, () => {
this.hide();
// Because we have animation on the popper it doesn't close immediately doesn't trigger the `tpVisible` event.
// Tippy is relying on the transitionend event to trigger the `onHidden` callback.
// https://github.com/atomiks/tippyjs/blob/master/src/dom-utils.ts#L117
// This event never fires because the popper is removed from the DOM before the transition ends.
if (this.props.animation) {
this.onHidden();
}
});
}
else {
this.visibilityObserverCleanup?.();
}
});
}
}
show() {
this.instance?.show();
}
hide() {
this.instance?.hide();
}
enable() {
this.instance?.enable();
}
disable() {
this.instance?.disable();
}
updateProps(props) {
this.setProps({ ...this.props, ...props });
}
setProps(props) {
this.props = props;
this.instance?.setProps({ ...onlyTippyProps(props), ...(this.tippyProps() ?? {}) });
}
setStatus(isEnabled) {
isEnabled ? this.instance?.enable() : this.instance?.disable();
}
hasContent() {
return !!(this.content() || this.useTextContent());
}
async createInstance() {
if (this.created || !this.hasContent()) {
return;
}
this.created = true;
const tippy = await this.ngZone.runOutsideAngular(() => {
return firstValueFrom(this.tippyFactory.getTippyImpl(), {
defaultValue: undefined,
});
});
if (tippy === undefined || this.destroyRef.destroyed) {
return;
}
this.instance = this.ngZone.runOutsideAngular(() => {
return tippy(this.host(), {
appendTo,
allowHTML: true,
...(this.globalConfig.zIndexGetter
? { zIndex: this.globalConfig.zIndexGetter() }
: {}),
...onlyTippyProps(this.globalConfig),
...onlyTippyProps(this.props),
...(this.tippyProps() ?? {}),
// Arrow functions or inline callbacks close over the method's lexical scope,
// causing V8 to allocate a Context object that indirectly retains the directive
// instance inside tippy.js after destroy. A JSBoundFunction has no [[Context]]
// slot — only { bound_target_function, bound_this, bound_arguments } — so no
// closure context is created. onHide is bound to null because it needs no `this`
// at all, fully breaking the retention chain (same reasoning as `appendTo` above).
onMount: this.onMount.bind(this),
onCreate: this.onCreate.bind(this),
onShow: this.onShow.bind(this),
onHide: function (instance) {
instance.reference.removeAttribute('data-tippy-open');
}.bind(null),
onHidden: this.onHidden.bind(this),
});
});
this.setStatus(this.isEnabled());
this.setProps(this.props);
const variationName = this.variation() || this.globalConfig.defaultVariation || '';
const variationConfig = this.globalConfig.variations?.[variationName];
// Check the isContextMenu marker set by withContextMenuVariation so any
// variation name works. The 'contextMenu' name fallback preserves
// compatibility with manually-constructed variations that predate this flag.
(variationConfig?.isContextMenu || variationName === 'contextMenu') &&
this.handleContextMenu();
}
// `resolvedContent` is provided when the caller already awaited a lazy factory.
// Passing it here avoids re-reading `this.content()`, which would still carry
// the raw factory function type and require another cast.
resolveContent(instance, resolvedContent) {
const content = (resolvedContent ?? this.content());
if (!this.viewOptions$ && !isString(content)) {
const injector = Injector.create({
providers: [
{
provide: TIPPY_REF,
useValue: this.instance,
},
],
parent: this.injector,
});
const data = this.data();
if (isComponent(content)) {
this.instance.data = data;
this.viewOptions$ = {
injector,
bindings: this.bindings(),
directives: this.directives(),
};
}
else if (isTemplateRef(content)) {
this.viewOptions$ = {
injector,
context: {
data,
$implicit: this.hide.bind(this),
},
};
}
}
let newContent = this.ngZone.run(() => {
this.viewRef = this.viewService.createView(content, {
vcr: this.vcr,
...this.viewOptions$,
});
// We need to call `detectChanges` for OnPush components to update their content.
if (isComponent(content)) {
// `ɵcmp` is a component defition set for any component.
// Checking the `onPush` property of the component definition is a
// smarter way to determine whether we need to call `detectChanges()`,
// as users may be unaware of setting the binding.
const isOnPush = content.ɵcmp?.onPush;
if (isOnPush) {
this.viewRef.detectChanges();
}
}
return this.viewRef.getElement();
});
if (this.useTextContent()) {
newContent = instance.reference.textContent;
}
if (isString(newContent) && this.globalConfig.beforeRender) {
newContent = this.globalConfig.beforeRender(newContent);
}
return newContent;
}
handleContextMenu() {
const host = this.host();
const onContextMenu = (event) => {
event.preventDefault();
this.instance.setProps({
getReferenceClientRect: () => ({
width: 0,
height: 0,
top: event.clientY,
bottom: event.clientY,
left: event.clientX,
right: event.clientX,
}),
});
this.instance.show();
};
host.addEventListener('contextmenu', onContextMenu);
this.destroyRef.onDestroy(() => host.removeEventListener('contextmenu', onContextMenu));
}
handleEscapeButton() {
const onKeydown = (event) => {
if (event.code === 'Escape') {
this.hide();
}
};
document.body.addEventListener('keydown', onKeydown);
// Remove listener when `visibleInternal` becomes false.
const visibleSubscription = this.visibleInternal.subscribe((v) => {
if (!v) {
document.body.removeEventListener('keydown', onKeydown);
visibleSubscription.unsubscribe();
}
});
this.destroyRef.onDestroy(() => {
document.body.removeEventListener('keydown', onKeydown);
visibleSubscription.unsubscribe();
});
}
checkOverflow(isElementOverflow) {
if (isElementOverflow) {
if (!this.instance) {
this.createInstance();
}
else {
this.instance.enable();
}
}
else {
this.instance?.disable();
}
}
listenToHostResize() {
dimensionsChanges(this.host())
.pipe(takeUntil(this.visibleInternal), takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.setInstanceWidth(this.instance, this.hostWidth);
});
}
clearInstanceWidth(instance) {
instance.popper.style.width = '';
instance.popper.style.maxWidth = '';
instance.popper.firstElementChild.style.maxWidth = '';
}
setInstanceWidth(instance, width) {
const inPixels = coerceCssPixelValue(width);
instance.popper.style.width = inPixels;
instance.popper.style.maxWidth = inPixels;
instance.popper.firstElementChild.style.maxWidth = inPixels;
}
onMount(instance) {
const isVisible = true;
this.isVisible.set(isVisible);
this.visibleInternal.next(isVisible);
this.ngZone.run(() => this.visible.emit(isVisible));
this.useHostWidth() && this.listenToHostResize();
this.globalConfig.onMount?.(instance);
}
onCreate(instance) {
instance.popper.classList.add(`tippy-variation-${this.variation() || this.globalConfig.defaultVariation}`);
if (this.className()) {
for (const klass of normalizeClassName(this.className())) {
instance.popper.classList.add(klass);
}
}
this.globalConfig.onCreate?.(instance);
if (this.isVisible() === true) {
instance.show();
}
}
onHidden(instance = this.instance) {
this.destroyView();
const isVisible = false;
// `model()` uses `OutputEmitterRef` internally to emit events when the
// signal changes. If the directive is destroyed, it will throw an error:
// "Unexpected emit for destroyed `OutputRef`".
if (!this.destroyed) {
this.isVisible.set(isVisible);
this.ngZone.run(() => this.visible.emit(isVisible));
this.tpOnHide.emit();
}
this.visibleInternal.next(isVisible);
this.globalConfig.onHidden?.(instance);
}
onShow(instance) {
// In onlyTextOverflow mode the tooltip must not appear when the host is
// not overflowing. Returning false from onShow prevents tippy from
// showing regardless of the instance's enabled/disabled state. This
// acts as a last-resort guard for cases where checkOverflow() hasn't
// been called yet (e.g. Angular's scheduler hasn't flushed CD between
// the content/width change and the trigger event).
if (this.onlyTextOverflow() && !isElementOverflow(this.host())) {
return false;
}
// The outer `onShow` must be synchronous so that `return false` above
// is seen as a boolean by tippy.js (an `async` function always returns
// a Promise, which is truthy and would never suppress the show).
this.handleOnShow(instance);
}
async handleOnShow(instance) {
instance.reference.setAttribute('data-tippy-open', '');
const maybeContent = this.content();
const isLazyFactory = !isComponentClass(maybeContent) && typeof maybeContent === 'function';
let resolvedContent;
if (isLazyFactory) {
const loaderComponent = this.globalLoaderComponent;
if (loaderComponent) {
const loaderElement = this.ngZone.run(() => {
this.loaderViewRef = this.viewService.createView(loaderComponent, {
vcr: this.vcr,
});
return this.loaderViewRef.getElement();
});
instance.setContent(loaderElement);
}
const cancelled = Symbol();
// combineLatest ensures we swap the loader only when both the component
// is ready AND the timing observable has emitted — guaranteeing the spinner
// is visible for at least the configured duration regardless of import speed.
// takeUntil + takeUntilDestroyed cancel if the tooltip hides or the
// directive is destroyed mid-flight.
const result = await firstValueFrom(combineLatest([
from(maybeContent()),
this.loaderTiming,
]).pipe(map(([component]) => component), takeUntil(this.visibleInternal.pipe(filter((v) => !v))), takeUntilDestroyed(this.destroyRef)), { defaultValue: cancelled });
this.loaderViewRef?.destroy();
this.loaderViewRef = null;
if (result === cancelled)
return;
resolvedContent = result;
}
// For non-lazy content this call is fully synchronous — skipping the
// await avoids a microtask tick that would otherwise cause a visible flicker.
const content = this.resolveContent(instance, resolvedContent);
if (isString(content)) {
instance.setProps({ allowHTML: false });
if (content?.trim()) {
this.enable();
}
else {
this.disable();
}
}
instance.setContent(content);
this.hideOnEscape() && this.handleEscapeButton();
this.clearInstanceWidth(instance);
if (this.useHostWidth()) {
this.setInstanceWidth(instance, this.hostWidth);
}
else if (this.popperWidth()) {
this.setInstanceWidth(instance, this.popperWidth());
}
this.globalConfig.onShow?.(instance);
this.tpOnShow.emit();
}
isOverflowing$() {
const host = this.host();
const notifiers$ = [overflowChanges(host)];
// We need to handle cases where the host has a static width but the content might change
if (this.staticWidthHost()) {
notifiers$.push(this.contentChanged.pipe(
// We need to wait for the content to be rendered before we can check if it's overflowing.
switchMap(() => {
return new Observable((subscriber) => {
const id = window.requestAnimationFrame(() => {
subscriber.next(isElementOverflow(host));
subscriber.complete();
});
return () => cancelAnimationFrame(id);
});
})));
}
return merge(...notifiers$);
}
setupListeners() {
effect(() => {
// Capture signal read to track its changes.
this.content();
untracked(() => this.contentChanged.next());
});
effect(() => this.setStatus(this.tippyService.enabled() && this.isEnabled()));
effect(() => {
const maxWidth = this.useHostWidth() ? this.hostWidth : defaultMaxWidth;
untracked(() => this.setProps({ ...this.props, maxWidth }));
});
effect(() => {
const isVisible = this.isVisible();
isVisible ? this.show() : this.hide();
});
effect(() => {
const hasContent = this.hasContent();
if (hasContent && !this.instance && !this.isLazy() && !this.onlyTextOverflow()) {
this.createInstance();
}
else if (!hasContent && this.instance) {
this.instance.destroy();
this.instance = null;
this.destroyView();
this.created = false;
}
});
effect(() => {
const tippyProps = this.tippyProps();
untracked(() => this.instance?.setProps(tippyProps ?? {}));
});
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TippyDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.8", type: TippyDirective, isStandalone: true, selector: "[tp]", inputs: { appendTo: { classPropertyName: "appendTo", publicName: "tpAppendTo", isSignal: true, isRequired: false, transformFunction: null }, content: { classPropertyName: "content", publicName: "tp", isSignal: true, isRequired: false, transformFunction: null }, delay: { classPropertyName: "delay", publicName: "tpDelay", isSignal: true, isRequired: false, transformFunction: null }, duration: { classPropertyName: "duration", publicName: "tpDuration", isSignal: true, isRequired: false, transformFunction: null }, hideOnClick: { classPropertyName: "hideOnClick", publicName: "tpHideOnClick", isSignal: true, isRequired: false, transformFunction: null }, interactive: { classPropertyName: "interactive", publicName: "tpInteractive", isSignal: true, isRequired: false, transformFunction: null }, interactiveBorder: { classPropertyName: "interactiveBorder", publicName: "tpInteractiveBorder", isSignal: true, isRequired: false, transformFunction: null }, maxWidth: { classPropertyName: "maxWidth", publicName: "tpMaxWidth", isSignal: true, isRequired: false, transformFunction: null }, offset: { classPropertyName: "offset", publicName: "tpOffset", isSignal: true, isRequired: false, transformFunction: null }, placement: { classPropertyName: "placement", publicName: "tpPlacement", isSignal: true, isRequired: false, transformFunction: null }, popperOptions: { classPropertyName: "popperOptions", publicName: "tpPopperOptions", isSignal: true, isRequired: false, transformFunction: null }, showOnCreate: { classPropertyName: "showOnCreate", publicName: "tpShowOnCreate", isSignal: true, isRequired: false, transformFunction: null }, trigger: { classPropertyName: "trigger", publicName: "tpTrigger", isSignal: true, isRequired: false, transformFunction: null }, triggerTarget: { classPropertyName: "triggerTarget", publicName: "tpTriggerTarget", isSignal: true, isRequired: false, transformFunction: null }, zIndex: { classPropertyName: "zIndex", publicName: "tpZIndex", isSignal: true, isRequired: false, transformFunction: null }, animation: { classPropertyName: "animation", publicName: "tpAnimation", isSignal: true, isRequired: false, transformFunction: null }, useTextContent: { classPropertyName: "useTextContent", publicName: "tpUseTextContent", isSignal: true, isRequired: false, transformFunction: null }, isLazy: { classPropertyName: "isLazy", publicName: "tpIsLazy", isSignal: true, isRequired: false, transformFunction: null }, variation: { classPropertyName: "variation", publicName: "tpVariation", isSignal: true, isRequired: false, transformFunction: null }, isEnabled: { classPropertyName: "isEnabled", publicName: "tpIsEnabled", isSignal: true, isRequired: false, transformFunction: null }, className: { classPropertyName: "className", publicName: "tpClassName", isSignal: true, isRequired: false, transformFunction: null }, onlyTextOverflow: { classPropertyName: "onlyTextOverflow", publicName: "tpOnlyTextOverflow", isSignal: true, isRequired: false, transformFunction: null }, staticWidthHost: { classPropertyName: "staticWidthHost", publicName: "tpStaticWidthHost", isSignal: true, isRequired: false, transformFunction: null }, data: { classPropertyName: "data", publicName: "tpData", isSignal: true, isRequired: false, transformFunction: null }, bindings: { classPropertyName: "bindings", publicName: "tpBindings", isSignal: true, isRequired: false, transformFunction: null }, directives: { classPropertyName: "directives", publicName: "tpDirectives", isSignal: true, isRequired: false, transformFunction: null }, useHostWidth: { classPropertyName: "useHostWidth", publicName: "tpUseHostWidth", isSignal: true, isRequired: false, transformFunction: null }, hideOnEscape: { classPropertyName: "hideOnEscape", publicName: "tpHideOnEscape", isSignal: true, isRequired: false, transformFunction: null }, tippyProps: { classPropertyName: "tippyProps", publicName: "tpTippyProps", isSignal: true, isRequired: false, transformFunction: null }, popperWidth: { classPropertyName: "popperWidth", publicName: "tpPopperWidth", isSignal: true, isRequired: false, transformFunction: null }, customHost: { classPropertyName: "customHost", publicName: "tpHost", isSignal: true, isRequired: false, transformFunction: null }, isVisible: { classPropertyName: "isVisible", publicName: "tpIsVisible", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { tpOnShow: "tpOnShow", tpOnHide: "tpOnHide", isVisible: "tpIsVisibleChange", visible: "tpVisible" }, exportAs: ["tippy"], usesOnChanges: true, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TippyDirective, decorators: [{
type: Directive,
args: [{
// eslint-disable-next-line @angular-eslint/directive-selector
selector: '[tp]',
exportAs: 'tippy',
}]
}], ctorParameters: () => [], propDecorators: { appendTo: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpAppendTo", required: false }] }], content: [{ type: i0.Input, args: [{ isSignal: true, alias: "tp", required: false }] }], delay: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpDelay", required: false }] }], duration: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpDuration", required: false }] }], hideOnClick: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpHideOnClick", required: false }] }], interactive: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpInteractive", required: false }] }], interactiveBorder: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpInteractiveBorder", required: false }] }], maxWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpMaxWidth", required: false }] }], offset: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpOffset", required: false }] }], placement: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpPlacement", required: false }] }], popperOptions: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpPopperOptions", required: false }] }], showOnCreate: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpShowOnCreate", required: false }] }], trigger: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpTrigger", required: false }] }], triggerTarget: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpTriggerTarget", required: false }] }], zIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpZIndex", required: false }] }], animation: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpAnimation", required: false }] }], useTextContent: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpUseTextContent", required: false }] }], isLazy: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpIsLazy", required: false }] }], variation: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpVariation", required: false }] }], isEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpIsEnabled", required: false }] }], className: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpClassName", required: false }] }], onlyTextOverflow: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpOnlyTextOverflow", required: false }] }], staticWidthHost: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpStaticWidthHost", required: false }] }], data: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpData", required: false }] }], bindings: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpBindings", required: false }] }], directives: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpDirectives", required: false }] }], useHostWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpUseHostWidth", required: false }] }], hideOnEscape: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpHideOnEscape", required: false }] }], tippyProps: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpTippyProps", required: false }] }], popperWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpPopperWidth", required: false }] }], customHost: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpHost", required: false }] }], tpOnShow: [{ type: i0.Output, args: ["tpOnShow"] }], tpOnHide: [{ type: i0.Output, args: ["tpOnHide"] }], isVisible: [{ type: i0.Input, args: [{ isSignal: true, alias: "tpIsVisible", required: false }] }, { type: i0.Output, args: ["tpIsVisibleChange"] }], visible: [{ type: i0.Output, args: ["tpVisible"] }] } });
function isComponentClass(content) {
return (typeof content === 'function' &&
/^class\s/.test(Function.prototype.toString.call(content)));
}
/**
* Generated bundle index. Do not edit.
*/
export