Skip to content

Commit

Permalink
fix: 🐛 handle content changes with overflow
Browse files Browse the repository at this point in the history
  • Loading branch information
shaharkazaz committed Sep 4, 2023
1 parent c4107b6 commit c13950d
Show file tree
Hide file tree
Showing 5 changed files with 151 additions and 51 deletions.
74 changes: 53 additions & 21 deletions cypress/e2e/helipopper.cy.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
Cypress.on('scrolled', element => {
Cypress.on('scrolled', (element) => {
// When we do `cy.get()` the Cypress finds the element
// and scrolls down, thus this element becomes at the very top
// of the page.
// `tippy.js` will not show tooltips if they appeared in an invisible
// part of the window.
element.get(0).scrollIntoView({
block: 'center',
inline: 'center'
inline: 'center',
});
});

Expand All @@ -31,11 +31,7 @@ describe('@ngneat/helipopper', () => {

describe('Custom template', () => {
it('should create tooltip with a custom template', () => {
cy.get('#custom-template button')
.click({ force: true })
.get('.positions')
.contains('top')
.should('exist');
cy.get('#custom-template button').click({ force: true }).get('.positions').contains('top').should('exist');
});
});

Expand All @@ -47,9 +43,7 @@ describe('@ngneat/helipopper', () => {
.contains('Hello, ngneat')
.should('exist');

cy.get('#custom-component input')
.clear()
.type('world!');
cy.get('#custom-component input').clear().type('world!');

cy.get('#custom-component button')
.click({ force: true })
Expand All @@ -69,17 +63,11 @@ describe('@ngneat/helipopper', () => {
});

it('should not create a tooltip when using a null value', () => {
cy.get('#tippy-value-null button')
.click({ force: true })
.get(popperSelector)
.should('not.exist');
cy.get('#tippy-value-null button').click({ force: true }).get(popperSelector).should('not.exist');
});

it('should not create a tooltip when using an undefined value', () => {
cy.get('#tippy-value-undefined button')
.click({ force: true })
.get(popperSelector)
.should('not.exist');
cy.get('#tippy-value-undefined button').click({ force: true }).get(popperSelector).should('not.exist');
});
});

Expand Down Expand Up @@ -110,9 +98,53 @@ describe('@ngneat/helipopper', () => {

describe('showOnCreate', () => {
it('should show tooltip if created', () => {
cy.get('.tippy-content')
.contains('Shown immediately when created')
.should('exist');
cy.get('.tippy-content').contains('Shown immediately when created').should('exist');
});
});

describe('onlyTextOverflow', () => {
it('should show tooltip if text is overflowed', () => {
cy.get('[data-cy="overflow-case-1"]').trigger('mouseenter');

cy.get('.tippy-content').contains('Only shown when text is overflowed 1').should('be.visible');

cy.get('[data-cy="content-toggler"]').first().click();

cy.get('[data-cy="overflow-case-1"]').trigger('mouseenter');

cy.get('.tippy-content').contains('Only shown when text is overflowed 1').should('not.exist');
});

it('should show tooltip when decreasing the tp host width', () => {
cy.get('[data-cy="content-toggler"]').eq(1).click();

cy.get('[data-cy="overflow-case-2"]').trigger('mouseenter');

cy.get('.tippy-content').contains('Only shown when text is overflowed 2').should('not.exist');

cy.get('[data-cy="width-toggler"]').click();

cy.get('[data-cy="overflow-case-2"]').trigger('mouseenter');

cy.get('.tippy-content').contains('Only shown when text is overflowed 2').should('be.visible');
});

it('should show tooltip when changing the content with static width', () => {
cy.get('[data-cy="overflow-case-3"]').trigger('mouseenter');

cy.get('.tippy-content').contains('Only shown when text is overflowed 3').should('not.exist');

cy.get('[data-cy="content-toggler"]').last().click();

cy.get('[data-cy="overflow-case-3"]').trigger('mouseenter');

cy.get('.tippy-content').contains('Only shown when text is overflowed 3').should('be.visible');

cy.get('[data-cy="content-toggler"]').last().click();

cy.get('[data-cy="overflow-case-3"]').trigger('mouseenter');

cy.get('.tippy-content').contains('Only shown when text is overflowed 3').should('not.exist');
});
});
});
27 changes: 23 additions & 4 deletions projects/ngneat/helipopper/src/lib/tippy.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ import {
import { isPlatformServer } from '@angular/common';
import tippy, { Instance } from 'tippy.js';
import { fromEvent, merge, Subject } from 'rxjs';
import { filter, switchMap, takeUntil } from 'rxjs/operators';
import { filter, map, switchMap, takeUntil } from 'rxjs/operators';
import { Content, isComponent, isString, isTemplateRef, ViewOptions, ViewRef, ViewService } from '@ngneat/overview';

import {
coerceCssPixelValue,
contentChange$,
dimensionsChanges,
inView,
isElementOverflow,
normalizeClassName,
observeVisibility,
onlyTippyProps,
Expand All @@ -39,8 +41,12 @@ import { NgChanges, TIPPY_CONFIG, TIPPY_REF, TippyConfig, TippyInstance, TippyPr
})
export class TippyDirective implements OnChanges, AfterViewInit, OnDestroy, OnInit {
static ngAcceptInputType_useTextContent: boolean | '';
content: Content | undefined | null;

@Input('tp') content: Content | undefined | null;
@Input('tp') set tp(content: Content | undefined | null) {
this.content = content;
this.contentChanged.next();
}

@Input('tpAppendTo') set appendTo(appendTo: TippyProps['appendTo']) {
this.updateProps({ appendTo });
Expand Down Expand Up @@ -93,6 +99,7 @@ export class TippyDirective implements OnChanges, AfterViewInit, OnDestroy, OnIn
*/
protected visibleInternal = new Subject<boolean>();
private visibilityObserverCleanup: () => void | undefined;
private contentChanged = new Subject<void>();

constructor(
@Inject(PLATFORM_ID) protected platformId: string,
Expand Down Expand Up @@ -158,7 +165,7 @@ export class TippyDirective implements OnChanges, AfterViewInit, OnDestroy, OnIn
if (this.onlyTextOverflow) {
inView(this.host)
.pipe(
switchMap(() => overflowChanges(this.host)),
switchMap(() => this.isOverflowing$()),
takeUntil(this.destroyed)
)
.subscribe((isElementOverflow) => {
Expand All @@ -172,7 +179,7 @@ export class TippyDirective implements OnChanges, AfterViewInit, OnDestroy, OnIn
});
}
} else if (this.onlyTextOverflow) {
overflowChanges(this.host)
this.isOverflowing$()
.pipe(takeUntil(this.destroyed))
.subscribe((isElementOverflow) => {
this.checkOverflow(isElementOverflow);
Expand Down Expand Up @@ -460,6 +467,18 @@ export class TippyDirective implements OnChanges, AfterViewInit, OnDestroy, OnIn
}
this.globalConfig.onHidden?.(instance);
}

private isOverflowing$() {
return merge(
overflowChanges(this.host),
// We need to handle cases where the host has a static width but the content might change
this.contentChanged.asObservable().pipe(
// We need to wait for the content to be rendered before we can check if it's overflowing.
switchMap(() => contentChange$(this.host)),
map(() => isElementOverflow(this.host))
)
);
}
}

function isChanged<T extends object>(key: keyof T, changes: T) {
Expand Down
17 changes: 15 additions & 2 deletions projects/ngneat/helipopper/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,22 @@ export function inView(
});
}

function isElementOverflow(host: HTMLElement): boolean {
// Don't access the `offsetWidth` multipe times since it triggers layout updates.
export function contentChange$(host: HTMLElement) {
return new Observable((subscriber) => {
const observer = new MutationObserver(() => {
subscriber.next(true);
subscriber.complete();
});
observer.observe(host, { characterData: true, subtree: true });

return () => observer.disconnect();
});
}

export function isElementOverflow(host: HTMLElement): boolean {
// Don't access the `offsetWidth` multiple times since it triggers layout updates.
const hostOffsetWidth = host.offsetWidth;

return hostOffsetWidth > host.parentElement.offsetWidth || hostOffsetWidth < host.scrollWidth;
}

Expand Down
43 changes: 35 additions & 8 deletions src/app/playground/playground.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,9 @@ <h6>Disabled</h6>
<button [tpIsEnabled]="isEnabled" tp="Tooltip" class="btn btn-outline-dark">Element</button>
</div>

<button (click)="toggle()" class="btn btn-outline-primary btn-sm">{{ isEnabled ? 'Disable' : 'Enable' }}</button>
<button (click)="toggleEnabled()" class="btn btn-outline-primary btn-sm">
{{ isEnabled ? 'Disable' : 'Enable' }}
</button>
</div>

<hr />
Expand All @@ -168,24 +170,49 @@ <h6>Disabled</h6>
<h6>Text Overflow</h6>

<p>Start with overflow and change to not overflow</p>
<div style="max-width: 100px;" class="overflow-hidden flex">
<p class="ellipsis" [tp]="text" tpPlacement="right" [tpOnlyTextOverflow]="true">
<div style="max-width: 100px" class="overflow-hidden flex">
<p class="ellipsis" [tp]="text" tpPlacement="right" [tpOnlyTextOverflow]="true" data-cy="overflow-case-1">
{{ text }}
</p>
</div>

<button (click)="changeContent()" class="btn btn-outline-info btn-sm mt-2">Change content</button>
<button data-cy="content-toggler" (click)="text = toggleText(text, 1)" class="btn btn-outline-info btn-sm mt-2">
Change content
</button>

<hr />

<p>Start with not overflow and change to overflow</p>
<p>Start with not overflow and change to overflow by decreasing the host width</p>
<div [style.maxWidth.px]="maxWidth" class="overflow-hidden flex">
<p class="ellipsis" [tp]="text" tpPlacement="right" [tpOnlyTextOverflow]="true">
{{ text }}
<p class="ellipsis" [tp]="text2" tpPlacement="right" [tpOnlyTextOverflow]="true" data-cy="overflow-case-2">
{{ text2 }}
</p>
</div>

<button (click)="maxWidth = 100" class="btn btn-outline-info btn-sm mt-2">Change width</button>
<button data-cy="width-toggler" (click)="toggleMaxWidth()" class="btn btn-outline-info btn-sm mt-2">
Change max width
</button>
<button data-cy="content-toggler" (click)="text2 = toggleText(text2, 2)" class="btn btn-outline-info btn-sm mt-2">
Change content
</button>

<p>Start with not overflow and change to overflow by changing the content</p>
<div style="max-width: 100px" class="overflow-hidden flex">
<p
style="width: 100px"
class="ellipsis"
[tp]="text3"
tpPlacement="right"
[tpOnlyTextOverflow]="true"
data-cy="overflow-case-3"
>
{{ text3 }}
</p>
</div>

<button data-cy="content-toggler" (click)="text3 = toggleText(text3, 3)" class="btn btn-outline-info btn-sm mt-2">
Change content
</button>
</div>

<hr />
Expand Down
41 changes: 25 additions & 16 deletions src/app/playground/playground.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ import { TippyDirective, TippyInstance, TippyService } from '@ngneat/helipopper'
selector: 'app-is-visible',
templateUrl: './playground.component.html',
styleUrls: ['./playground.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlaygroundComponent {
tooltipPositions = ['auto', 'top', 'right', 'bottom', 'left'];
tooltipAlignments = [
{ label: 'start', value: '-start' },
{ label: 'center', value: '' },
{ label: 'end', value: '-end' }
{ label: 'end', value: '-end' },
];

tooltipTypes = ['popper', 'tooltip', 'popperBorder'];
Expand All @@ -23,10 +23,12 @@ export class PlaygroundComponent {
type: this.fb.control('tooltip'),
alignment: this.fb.control(''),
position: this.fb.control('top'),
hideOnEsc: this.fb.control(false)
hideOnEsc: this.fb.control(false),
});

noContextText: string | undefined;
maxWidth = 300;
show = true;

get tooltipPosition() {
const { position, alignment } = this.tooltipSettings.value;
Expand All @@ -44,30 +46,37 @@ export class PlaygroundComponent {

items = Array.from({ length: 500 }, (_, i) => ({
id: i,
label: `Value ${i + 1}`
label: `Value ${i + 1}`,
}));

list = Array.from({ length: 5 }, (_, i) => ({
id: i,
label: `Value ${i + 1}`
label: `Value ${i + 1}`,
}));

isEnabled = true;
text = `Long Long All Text`;
text = `Only shown when text is overflowed 1`;
text2 = `Short`;
text3 = `Short`;
comp = ExampleComponent;

changeContent() {
this.text = this.text === `Long Long All Text` ? `Short` : `Long Long All Text`;
}
@ViewChild('inputName', { static: true }) inputName: ElementRef;
@ViewChild('inputNameComp', { static: true }) inputNameComp: ElementRef;

constructor(private fb: UntypedFormBuilder, private service: TippyService) {}

@ViewChild('inputName', { static: true }) inputName: ElementRef;
@ViewChild('inputNameComp', { static: true }) inputNameComp: ElementRef;
maxWidth = 300;
show = true;
toggleText(text: string, index: number) {
const resolved =
text === `Only shown when text is overflowed ${index}` ? `Short` : `Only shown when text is overflowed`;

return `${resolved} ${index}`;
}

toggleMaxWidth() {
this.maxWidth = this.maxWidth === 300 ? 100 : 300;
}

toggle() {
toggleEnabled() {
this.isEnabled = !this.isEnabled;
}

Expand All @@ -90,8 +99,8 @@ export class PlaygroundComponent {
this.instance = this.service.create(host2, ExampleComponent, {
variation: 'popper',
data: {
name: 'ngneat'
}
name: 'ngneat',
},
});
}
}
Expand Down

0 comments on commit c13950d

Please sign in to comment.