From 3913322b43f7b23f4ebd51df93bf2dd306e7e169 Mon Sep 17 00:00:00 2001 From: arturovt Date: Wed, 13 Nov 2024 17:07:56 +0200 Subject: [PATCH 1/2] feat: allow lazy-loading `tippy.js` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In this commit, we implement lazy loading for `tippy.js` to avoid loading it until it's actually necessary. Tooltips are commonly used across the app, but they’re not needed until the directive is rendered. However, the `tippy.js` package is always included in the common bundle. For example, if we have a ` + ``` The library exposes default variations for `tooltip` and `popper`. You can use them, extend them, or pass your own @@ -88,16 +101,14 @@ export const tooltipVariation = { arrow: false, animation: 'scale', trigger: 'mouseenter', - offset: [0, 5] + offset: [0, 5], }; ``` ### Use `TemplateRef` as content ```html - +
Popover title
@@ -117,9 +128,7 @@ class MyComponent { ``` ```html - + ``` ### Text Overflow @@ -128,9 +137,7 @@ You can pass the `onlyTextOverflow` input to show the tooltip only when the host ```html
-

- {{ text }} -

+

{{ text }}

``` @@ -140,7 +147,14 @@ You might have cases where the host has a static width and the content is dynami ```html
-

+

{{ dynamicText }}

@@ -153,34 +167,25 @@ Note: when using `tpStaticWidthHost` you can't use `tpUseTextContent`, you need You can instruct tippy to use the element textContent as the tooltip content: ```html -

- {{ text }} -

+

{{ text }}

``` - ### Lazy You can pass the `tpIsLazy` input when you want to defer the creation of tippy only when the element is in the view: ```html -
{{ item.label }} -
+
{{ item.label }}
``` Note that it's using [`IntersectionObserver`](https://caniuse.com/intersectionobserver) api. ### Context Menu + First, define the `contextMenu` variation: + ```ts -import { - popperVariation, - tooltipVariation, - provideTippyConfig, - withContextMenuVariation -} from '@ngneat/helipopper'; +import { popperVariation, tooltipVariation, provideTippyConfig, withContextMenuVariation } from '@ngneat/helipopper'; bootstrapApplication(AppComponent, { providers: [ @@ -190,10 +195,10 @@ bootstrapApplication(AppComponent, { tooltip: tooltipVariation, popper: popperVariation, contextMenu: withContextMenuVariation(popperVariation), - } - }) - ] -}) + }, + }), + ], +}); ``` Now you can use it in your template: @@ -207,21 +212,14 @@ Now you can use it in your template:
``` ### Manual Trigger ```html -
- Click Open to see me -
+
Click Open to see me
@@ -232,9 +230,7 @@ Now you can use it in your template: Use isVisible to trigger show and hide. Set trigger to manual. ```html -
- Click Open to see me -
+
Click Open to see me
@@ -286,7 +282,8 @@ tpVisible = new EventEmitter(); ``` ### Global Config -- You can pass any `tippy` option at global config level. + +- You can pass any `tippy` option at global config level. - `beforeRender` - Hook that'll be called before rendering the tooltip content ( applies only for string ) ### Create `tippy` Programmatically @@ -300,7 +297,7 @@ class Component { private tippyService = inject(TippyService); show() { - if(!this.tippy) { + if (!this.tippy) { this.tippy = this.tippyService.create(this.inputName, 'this field is required'); } diff --git a/projects/ngneat/helipopper/src/lib/providers.ts b/projects/ngneat/helipopper/src/lib/providers.ts index 27468b6..e19858b 100644 --- a/projects/ngneat/helipopper/src/lib/providers.ts +++ b/projects/ngneat/helipopper/src/lib/providers.ts @@ -1,7 +1,7 @@ import { inject, makeEnvironmentProviders } from '@angular/core'; import { TIPPY_CONFIG, TIPPY_REF, TippyConfig, TippyInstance } from './tippy.types'; -export function provideTippyConfig(config: Partial = {}) { +export function provideTippyConfig(config: TippyConfig) { return makeEnvironmentProviders([{ provide: TIPPY_CONFIG, useValue: config }]); } diff --git a/projects/ngneat/helipopper/src/lib/tippy.directive.ts b/projects/ngneat/helipopper/src/lib/tippy.directive.ts index 3bd4298..a22dd8f 100644 --- a/projects/ngneat/helipopper/src/lib/tippy.directive.ts +++ b/projects/ngneat/helipopper/src/lib/tippy.directive.ts @@ -4,6 +4,7 @@ import { Directive, ElementRef, EventEmitter, + inject, Inject, Injector, Input, @@ -16,7 +17,7 @@ import { ViewContainerRef, } from '@angular/core'; import { isPlatformServer } from '@angular/common'; -import tippy, { Instance } from 'tippy.js'; +import type { Instance } from 'tippy.js'; import { fromEvent, merge, Observable, Subject } from 'rxjs'; import { filter, map, switchMap, takeUntil } from 'rxjs/operators'; import { Content, isComponent, isString, isTemplateRef, ViewOptions, ViewRef, ViewService } from '@ngneat/overview'; @@ -32,6 +33,7 @@ import { overflowChanges, } from './utils'; import { NgChanges, TIPPY_CONFIG, TIPPY_REF, TippyConfig, TippyInstance, TippyProps } from './tippy.types'; +import { TippyFactory } from './tippy.factory'; @Directive({ // eslint-disable-next-line @angular-eslint/directive-selector @@ -94,6 +96,8 @@ export class TippyDirective implements OnChanges, AfterViewInit, OnDestroy, OnIn private visibilityObserverCleanup: () => void | undefined; private contentChanged = new Subject(); + private tippyFactory = inject(TippyFactory); + constructor( @Inject(PLATFORM_ID) protected platformId: string, @Inject(TIPPY_CONFIG) protected globalConfig: TippyConfig, @@ -276,8 +280,8 @@ export class TippyDirective implements OnChanges, AfterViewInit, OnDestroy, OnIn return; } - this.zone.runOutsideAngular(() => { - this.instance = tippy(this.host, { + this.tippyFactory + .create(this.host, { allowHTML: true, appendTo: document.body, ...(this.globalConfig.zIndexGetter ? { zIndex: this.globalConfig.zIndexGetter() } : {}), @@ -334,13 +338,16 @@ export class TippyDirective implements OnChanges, AfterViewInit, OnDestroy, OnIn onHidden: (instance) => { this.onHidden(instance); }, - }); + }) + .pipe(takeUntil(this.destroyed)) + .subscribe((instance) => { + this.instance = instance; - this.setStatus(); - this.setProps(this.props); + this.setStatus(); + this.setProps(this.props); - this.variation === 'contextMenu' && this.handleContextMenu(); - }); + this.variation === 'contextMenu' && this.handleContextMenu(); + }); } protected resolveContent(instance: TippyInstance) { diff --git a/projects/ngneat/helipopper/src/lib/tippy.factory.ts b/projects/ngneat/helipopper/src/lib/tippy.factory.ts new file mode 100644 index 0000000..7493d9f --- /dev/null +++ b/projects/ngneat/helipopper/src/lib/tippy.factory.ts @@ -0,0 +1,36 @@ +import type tippy from 'tippy.js'; +import { inject, Injectable, NgZone } from '@angular/core'; +import { defer, from, map, type Observable, of, shareReplay } from 'rxjs'; + +import { TIPPY_CONFIG, type TippyProps } from './tippy.types'; + +// We need to use `isPromise` instead of checking whether +// `value instanceof Promise`. In zone.js patched environments, `global.Promise` +// is the `ZoneAwarePromise`. +// `import(...)` returns a native promise (not a `ZoneAwarePromise`), causing +// `instanceof` check to be falsy. +function isPromise(value: any): value is Promise { + return typeof value?.then === 'function'; +} + +@Injectable({ providedIn: 'root' }) +export class TippyFactory { + private readonly _ngZone = inject(NgZone); + + private readonly _config = inject(TIPPY_CONFIG); + + private _tippy$: Observable | null = null; + + create(target: HTMLElement, props?: Partial) { + this._tippy$ = defer(() => { + const maybeTippy = this._ngZone.runOutsideAngular(() => this._config.loader()); + return isPromise(maybeTippy) ? from(maybeTippy).pipe(map((tippy) => tippy.default)) : of(maybeTippy); + }).pipe(shareReplay()); + + return this._tippy$.pipe( + map((tippy) => { + return this._ngZone.runOutsideAngular(() => tippy(target, props)); + }) + ); + } +} diff --git a/projects/ngneat/helipopper/src/lib/tippy.service.ts b/projects/ngneat/helipopper/src/lib/tippy.service.ts index 5bb6dc4..8815c5f 100644 --- a/projects/ngneat/helipopper/src/lib/tippy.service.ts +++ b/projects/ngneat/helipopper/src/lib/tippy.service.ts @@ -1,19 +1,27 @@ -import { Inject, Injectable, Injector } from '@angular/core'; -import tippy from 'tippy.js'; +import { inject, Inject, Injectable, Injector } from '@angular/core'; import { isComponent, isTemplateRef, ViewService } from '@ngneat/overview'; import { Content } from '@ngneat/overview'; +import type { Observable } from 'rxjs'; + import { CreateOptions, ExtendedTippyInstance, TIPPY_CONFIG, TIPPY_REF, TippyConfig } from './tippy.types'; import { normalizeClassName, onlyTippyProps } from './utils'; +import { TippyFactory } from './tippy.factory'; @Injectable({ providedIn: 'root' }) export class TippyService { + private readonly _tippyFactory = inject(TippyFactory); + constructor( @Inject(TIPPY_CONFIG) private globalConfig: TippyConfig, private view: ViewService, private injector: Injector ) {} - create(host: Element, content: T, options: Partial = {}): ExtendedTippyInstance { + create( + host: HTMLElement, + content: T, + options: Partial = {} + ): Observable> { const variation = options.variation || this.globalConfig.defaultVariation; const config = { onShow: (instance) => { @@ -73,6 +81,6 @@ export class TippyService { }, }; - return tippy(host, config) as ExtendedTippyInstance; + return this._tippyFactory.create(host, config) as Observable>; } } diff --git a/projects/ngneat/helipopper/src/lib/tippy.types.ts b/projects/ngneat/helipopper/src/lib/tippy.types.ts index e3744e9..627bd4a 100644 --- a/projects/ngneat/helipopper/src/lib/tippy.types.ts +++ b/projects/ngneat/helipopper/src/lib/tippy.types.ts @@ -1,4 +1,5 @@ -import { Instance, Props } from 'tippy.js'; +import type tippy from 'tippy.js'; +import type { Instance, Props } from 'tippy.js'; import { ElementRef, InjectionToken } from '@angular/core'; import { ResolveViewRef, ViewOptions } from '@ngneat/overview'; @@ -24,12 +25,6 @@ type MarkFunctionPropertyNames = { type ExcludeFunctions = Pick>; -export const TIPPY_CONFIG = new InjectionToken>('Tippy config', { - providedIn: 'root', - factory() { - return {}; - } -}); export const TIPPY_REF = new InjectionToken('TIPPY_REF'); export interface TippyInstance extends Instance { @@ -38,9 +33,9 @@ export interface TippyInstance extends Instance { export type TippyProps = Props; -export interface TippyConfig extends TippyProps { +export interface ExtendedTippyProps extends TippyProps { variations: Record>; - defaultVariation: keyof TippyConfig['variations']; + defaultVariation: keyof ExtendedTippyProps['variations']; beforeRender?: (text: string) => string; zIndexGetter?(): number; } @@ -52,3 +47,9 @@ export interface ExtendedTippyInstance extends TippyInstance { $viewOptions: ViewOptions; context?: ViewOptions['context']; } + +export interface TippyConfig extends Partial { + loader: () => typeof tippy | Promise<{ default: typeof tippy }>; +} + +export const TIPPY_CONFIG = new InjectionToken('Tippy config'); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index e6c8844..328bb9f 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -10,7 +10,7 @@ import { provideTippyConfig, TippyDirective, tooltipVariation, - withContextMenuVariation + withContextMenuVariation, } from '@ngneat/helipopper'; import { PlaygroundComponent } from './playground/playground.component'; import { IsVisibleComponent } from './is-visible/isVisible.component'; @@ -26,6 +26,7 @@ function getZIndex() { imports: [BrowserModule, AppRoutingModule, ReactiveFormsModule, TippyDirective], providers: [ provideTippyConfig({ + loader: () => import('tippy.js'), defaultVariation: 'tooltip', zIndexGetter: getZIndex, variations: { @@ -35,16 +36,16 @@ function getZIndex() { ...popperVariation, appendTo: 'parent', arrow: false, - offset: [0, 0] + offset: [0, 0], }, contextMenu: withContextMenuVariation(popperVariation), popperBorder: { ...popperVariation, - theme: 'light-border' - } - } - }) + theme: 'light-border', + }, + }, + }), ], - bootstrap: [AppComponent] + bootstrap: [AppComponent], }) export class AppModule {} diff --git a/src/app/playground/playground.component.ts b/src/app/playground/playground.component.ts index eb1583f..c49f4ab 100644 --- a/src/app/playground/playground.component.ts +++ b/src/app/playground/playground.component.ts @@ -88,7 +88,9 @@ export class PlaygroundComponent { useService(host: HTMLButtonElement) { if (!this.instance2) { - this.instance2 = this.service.create(host, 'Created'); + this.service.create(host, 'Created').subscribe((instance) => { + this.instance2 = instance; + }); } } @@ -96,12 +98,16 @@ export class PlaygroundComponent { useServiceComponent(host2: HTMLButtonElement) { if (!this.instance) { - this.instance = this.service.create(host2, ExampleComponent, { - variation: 'popper', - data: { - name: 'ngneat', - }, - }); + this.service + .create(host2, ExampleComponent, { + variation: 'popper', + data: { + name: 'ngneat', + }, + }) + .subscribe((instance) => { + this.instance = instance; + }); } } From e507e6418f0a7cdfd01f91c4669fccf019854be0 Mon Sep 17 00:00:00 2001 From: arturovt Date: Wed, 13 Nov 2024 20:09:13 +0200 Subject: [PATCH 2/2] refactor: address feedback --- .../ngneat/helipopper/src/lib/tippy.factory.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/projects/ngneat/helipopper/src/lib/tippy.factory.ts b/projects/ngneat/helipopper/src/lib/tippy.factory.ts index 7493d9f..af18d79 100644 --- a/projects/ngneat/helipopper/src/lib/tippy.factory.ts +++ b/projects/ngneat/helipopper/src/lib/tippy.factory.ts @@ -19,15 +19,23 @@ export class TippyFactory { private readonly _config = inject(TIPPY_CONFIG); - private _tippy$: Observable | null = null; + private _tippyImpl$: Observable | null = 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. + */ create(target: HTMLElement, props?: Partial) { - this._tippy$ = defer(() => { + // We use `shareReplay` to ensure that subsequent emissions are + // synchronous and to avoid triggering the `defer` callback repeatedly + // when new subscribers arrive. + this._tippyImpl$ ||= defer(() => { const maybeTippy = this._ngZone.runOutsideAngular(() => this._config.loader()); return isPromise(maybeTippy) ? from(maybeTippy).pipe(map((tippy) => tippy.default)) : of(maybeTippy); }).pipe(shareReplay()); - return this._tippy$.pipe( + return this._tippyImpl$.pipe( map((tippy) => { return this._ngZone.runOutsideAngular(() => tippy(target, props)); })