UNPKG

@ngneat/helipopper

Version:

A Powerful Tooltip and Popover for Angular Applications

906 lines (897 loc) 50.2 kB
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