Skip to content

Commit

Permalink
Merge pull request #160 from arturovt/feat/add-loading
Browse files Browse the repository at this point in the history
feat: allow lazy-loading `tippy.js`
  • Loading branch information
NetanelBasal authored Nov 13, 2024
2 parents f898312 + e507e64 commit 74a3ed6
Show file tree
Hide file tree
Showing 9 changed files with 155 additions and 92 deletions.
1 change: 0 additions & 1 deletion .prettierignore

This file was deleted.

107 changes: 52 additions & 55 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,12 @@ If you're using v1 and don't want to migrate, you can find it [here](https://git

### Installation

```
npm install @ngneat/helipopper
```sh
$ npm i @ngneat/helipopper
# Or if you're using yarn
$ yarn add @ngneat/helipopper
# Or if you're using pnpm
$ pnpm i @ngneat/helipopper
```

Configure it as shown below:
Expand All @@ -50,32 +54,41 @@ import { provideTippyConfig, tooltipVariation, popperVariation } from '@ngneat/h
bootstrapApplication(AppComponent, {
providers: [
provideTippyConfig({
loader: () => import('tippy.js'),
defaultVariation: 'tooltip',
variations: {
tooltip: tooltipVariation,
popper: popperVariation,
}
})
]
})
},
}),
],
});
```

Please note that the `loader` property is required, as it specifies how Tippy is loaded - either synchronously or asynchronously. When dynamic import is used, the library will load only when the first Tippy directive is rendered. If we want it to load synchronously, we use the following:

```ts
import tippy from 'tippy.js';

provideTippyConfig({
loader: () => tippy,
});
```

Add the styles you want to `styles.scss`:

```scss
@import '~tippy.js/dist/tippy.css';
@import '~tippy.js/themes/light.css';
@import '~tippy.js/animations/scale.css';
@import 'tippy.js/dist/tippy.css';
@import 'tippy.js/themes/light.css';
@import 'tippy.js/animations/scale.css';
```

You have the freedom to [customize](https://atomiks.github.io/tippyjs/v6/themes/) it if you need to.

Import the standalone `TippyDirective` and use it in your templates:

```html
<button tp="Helpful Message">
I have a tooltip
</button>
<button tp="Helpful Message">I have a tooltip</button>
```

The library exposes default variations for `tooltip` and `popper`. You can use them, extend them, or pass your own
Expand All @@ -88,16 +101,14 @@ export const tooltipVariation = {
arrow: false,
animation: 'scale',
trigger: 'mouseenter',
offset: [0, 5]
offset: [0, 5],
};
```

### Use `TemplateRef` as content

```html
<button [tp]="tpl" tpVariation="popper">
Click Me
</button>
<button [tp]="tpl" tpVariation="popper">Click Me</button>

<ng-template #tpl let-hide>
<h6>Popover title</h6>
Expand All @@ -117,9 +128,7 @@ class MyComponent {
```

```html
<button [tp]="MyComponent">
Click Me
</button>
<button [tp]="MyComponent">Click Me</button>
```

### Text Overflow
Expand All @@ -128,9 +137,7 @@ You can pass the `onlyTextOverflow` input to show the tooltip only when the host

```html
<div style="max-width: 100px;" class="overflow-hidden flex">
<p class="ellipsis" [tp]="text" tpPlacement="right" [tpOnlyTextOverflow]="true">
{{ text }}
</p>
<p class="ellipsis" [tp]="text" tpPlacement="right" [tpOnlyTextOverflow]="true">{{ text }}</p>
</div>
```

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

```html
<div style="max-width: 100px;" class="overflow-hidden flex">
<p style="width: 100px" class="ellipsis" [tp]="dynamicText" tpPlacement="right" [tpOnlyTextOverflow]="true" tpStaticWidthHost>
<p
style="width: 100px"
class="ellipsis"
[tp]="dynamicText"
tpPlacement="right"
[tpOnlyTextOverflow]="true"
tpStaticWidthHost
>
{{ dynamicText }}
</p>
</div>
Expand All @@ -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
<p tp tpUseTextContent>
{{ text }}
</p>
<p tp tpUseTextContent>{{ text }}</p>
```


### 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
<div *ngFor="let item of items"
[tp]="item.label"
[tpIsLazy]="true">{{ item.label }}
</div>
<div *ngFor="let item of items" [tp]="item.label" [tpIsLazy]="true">{{ item.label }}</div>
```

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: [
Expand All @@ -190,10 +195,10 @@ bootstrapApplication(AppComponent, {
tooltip: tooltipVariation,
popper: popperVariation,
contextMenu: withContextMenuVariation(popperVariation),
}
})
]
})
},
}),
],
});
```

Now you can use it in your template:
Expand All @@ -207,21 +212,14 @@ Now you can use it in your template:
</ng-template>

<ul>
<li *ngFor="let item of list"
[tp]="contextMenu"
[tpData]="item"
tpVariation="contextMenu">
{{ item.label }}
</li>
<li *ngFor="let item of list" [tp]="contextMenu" [tpData]="item" tpVariation="contextMenu">{{ item.label }}</li>
</ul>
```

### Manual Trigger

```html
<div tp="Helpful Message" tpTrigger="manual" #tooltip="tippy">
Click Open to see me
</div>
<div tp="Helpful Message" tpTrigger="manual" #tooltip="tippy">Click Open to see me</div>

<button (click)="tooltip.show()">Open</button>
<button (click)="tooltip.hide()">Close</button>
Expand All @@ -232,9 +230,7 @@ Now you can use it in your template:
Use isVisible to trigger show and hide. Set trigger to manual.

```html
<div tp="Helpful Message" tpTrigger="manual" [tpIsVisible]="visibility">
Click Open to see me
</div>
<div tp="Helpful Message" tpTrigger="manual" [tpIsVisible]="visibility">Click Open to see me</div>

<button (click)="visibility = true">Open</button>
<button (click)="visibility = false">Close</button>
Expand Down Expand Up @@ -286,7 +282,8 @@ tpVisible = new EventEmitter<boolean>();
```

### 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
Expand All @@ -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');
}

Expand Down
2 changes: 1 addition & 1 deletion projects/ngneat/helipopper/src/lib/providers.ts
Original file line number Diff line number Diff line change
@@ -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<TippyConfig> = {}) {
export function provideTippyConfig(config: TippyConfig) {
return makeEnvironmentProviders([{ provide: TIPPY_CONFIG, useValue: config }]);
}

Expand Down
23 changes: 15 additions & 8 deletions projects/ngneat/helipopper/src/lib/tippy.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Directive,
ElementRef,
EventEmitter,
inject,
Inject,
Injector,
Input,
Expand All @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -94,6 +96,8 @@ export class TippyDirective implements OnChanges, AfterViewInit, OnDestroy, OnIn
private visibilityObserverCleanup: () => void | undefined;
private contentChanged = new Subject<void>();

private tippyFactory = inject(TippyFactory);

constructor(
@Inject(PLATFORM_ID) protected platformId: string,
@Inject(TIPPY_CONFIG) protected globalConfig: TippyConfig,
Expand Down Expand Up @@ -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() } : {}),
Expand Down Expand Up @@ -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) {
Expand Down
44 changes: 44 additions & 0 deletions projects/ngneat/helipopper/src/lib/tippy.factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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<T>(value: any): value is Promise<T> {
return typeof value?.then === 'function';
}

@Injectable({ providedIn: 'root' })
export class TippyFactory {
private readonly _ngZone = inject(NgZone);

private readonly _config = inject(TIPPY_CONFIG);

private _tippyImpl$: Observable<typeof tippy> | 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<TippyProps>) {
// 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._tippyImpl$.pipe(
map((tippy) => {
return this._ngZone.runOutsideAngular(() => tippy(target, props));
})
);
}
}
16 changes: 12 additions & 4 deletions projects/ngneat/helipopper/src/lib/tippy.service.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Content>(host: Element, content: T, options: Partial<CreateOptions> = {}): ExtendedTippyInstance<T> {
create<T extends Content>(
host: HTMLElement,
content: T,
options: Partial<CreateOptions> = {}
): Observable<ExtendedTippyInstance<T>> {
const variation = options.variation || this.globalConfig.defaultVariation;
const config = {
onShow: (instance) => {
Expand Down Expand Up @@ -73,6 +81,6 @@ export class TippyService {
},
};

return tippy(host, config) as ExtendedTippyInstance<T>;
return this._tippyFactory.create(host, config) as Observable<ExtendedTippyInstance<T>>;
}
}
Loading

0 comments on commit 74a3ed6

Please sign in to comment.