Skip to content

Commit

Permalink
feat: Do not trigger change detection internally on click (#427)
Browse files Browse the repository at this point in the history
  • Loading branch information
arturovt authored May 30, 2023
1 parent 16c40d5 commit 1291377
Show file tree
Hide file tree
Showing 6 changed files with 113 additions and 31 deletions.
8 changes: 5 additions & 3 deletions src/lib/picker/category.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import { EmojiFrequentlyService } from './emoji-frequently.service';
[useButton]="emojiUseButton"
(emojiOverOutsideAngular)="emojiOverOutsideAngular.emit($event)"
(emojiLeaveOutsideAngular)="emojiLeaveOutsideAngular.emit($event)"
(emojiClick)="emojiClick.emit($event)"
(emojiClickOutsideAngular)="emojiClickOutsideAngular.emit($event)"
></ngx-emoji>
</div>
</div>
Expand Down Expand Up @@ -97,7 +97,7 @@ import { EmojiFrequentlyService } from './emoji-frequently.service';
[useButton]="emojiUseButton"
(emojiOverOutsideAngular)="emojiOverOutsideAngular.emit($event)"
(emojiLeaveOutsideAngular)="emojiLeaveOutsideAngular.emit($event)"
(emojiClick)="emojiClick.emit($event)"
(emojiClickOutsideAngular)="emojiClickOutsideAngular.emit($event)"
></ngx-emoji>
</ng-template>
`,
Expand Down Expand Up @@ -130,12 +130,14 @@ export class CategoryComponent implements OnChanges, OnInit, AfterViewInit {
@Input() emojiBackgroundImageFn?: Emoji['backgroundImageFn'];
@Input() emojiImageUrlFn?: Emoji['imageUrlFn'];
@Input() emojiUseButton?: boolean;
@Output() emojiClick: Emoji['emojiClick'] = new EventEmitter();

/**
* Note: the suffix is added explicitly so we know the event is dispatched outside of the Angular zone.
*/
@Output() emojiOverOutsideAngular: Emoji['emojiOver'] = new EventEmitter();
@Output() emojiLeaveOutsideAngular: Emoji['emojiLeave'] = new EventEmitter();
@Output() emojiClickOutsideAngular: Emoji['emojiClick'] = new EventEmitter();

@ViewChild('container', { static: true }) container!: ElementRef;
@ViewChild('label', { static: true }) label!: ElementRef;
containerStyles: any = {};
Expand Down
27 changes: 27 additions & 0 deletions src/lib/picker/ngx-emoji/emoji.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,31 @@ describe('EmojiComponent', () => {
expect(appRef.tick).toHaveBeenCalledTimes(1);
expect(fixture.componentInstance.onEmojiLeave).toHaveBeenCalled();
});

it('should trigger change detection whenever `emojiClick` has observers', () => {
@Component({
template: '<ngx-emoji (emojiClick)="onEmojiClick()"></ngx-emoji>',
})
class TestComponent {
onEmojiClick() {}
}

TestBed.configureTestingModule({
imports: [EmojiModule],
declarations: [TestComponent],
});

const fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();

const appRef = TestBed.inject(ApplicationRef);
spyOn(appRef, 'tick');
spyOn(fixture.componentInstance, 'onEmojiClick');

const emoji = fixture.nativeElement.querySelector('span.emoji-mart-emoji');
emoji.dispatchEvent(new MouseEvent('click'));

expect(appRef.tick).toHaveBeenCalledTimes(1);
expect(fixture.componentInstance.onEmojiClick).toHaveBeenCalled();
});
});
21 changes: 13 additions & 8 deletions src/lib/picker/ngx-emoji/emoji.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ export interface EmojiEvent {
*ngIf="useButton; else spanTpl"
#button
type="button"
(click)="handleClick($event)"
[attr.title]="title"
[attr.aria-label]="label"
class="emoji-mart-emoji"
Expand All @@ -66,7 +65,6 @@ export interface EmojiEvent {
<ng-template #spanTpl>
<span
#button
(click)="handleClick($event)"
[attr.title]="title"
[attr.aria-label]="label"
class="emoji-mart-emoji"
Expand Down Expand Up @@ -100,7 +98,6 @@ export class EmojiComponent implements OnChanges, Emoji, OnDestroy {
@Input() sheetRows?: number;
@Input() sheetColumns?: number;
@Input() useButton?: boolean;
@Output() emojiClick: Emoji['emojiClick'] = new EventEmitter();
/**
* Note: `emojiOver` and `emojiOverOutsideAngular` are dispatched on the same event (`mouseenter`), but
* for different purposes. The `emojiOverOutsideAngular` event is listened only in `emoji-category`
Expand All @@ -112,6 +109,8 @@ export class EmojiComponent implements OnChanges, Emoji, OnDestroy {
/** See comments above, this serves the same purpose. */
@Output() emojiLeave: Emoji['emojiLeave'] = new EventEmitter();
@Output() emojiLeaveOutsideAngular: Emoji['emojiLeave'] = new EventEmitter();
@Output() emojiClick: Emoji['emojiClick'] = new EventEmitter();
@Output() emojiClickOutsideAngular: Emoji['emojiClick'] = new EventEmitter();

style: any;
title?: string = undefined;
Expand Down Expand Up @@ -237,11 +236,6 @@ export class EmojiComponent implements OnChanges, Emoji, OnDestroy {
return this.emojiService.getSanitizedData(this.emoji, this.skin, this.set) as EmojiData;
}

handleClick($event: Event) {
const emoji = this.getSanitizedData();
this.emojiClick.emit({ emoji, $event });
}

private setupMouseListeners(): void {
const eventListener$ = (eventName: string) =>
this.button$.pipe(
Expand All @@ -250,6 +244,17 @@ export class EmojiComponent implements OnChanges, Emoji, OnDestroy {
takeUntil(this.destroy$),
);

eventListener$('click').subscribe($event => {
const emoji = this.getSanitizedData();
this.emojiClickOutsideAngular.emit({ emoji, $event });
// Note: this is done for backwards compatibility. We run change detection if developers
// are listening to `emojiClick` in their code. For instance:
// `<ngx-emoji (emojiClick)="..."></ngx-emoji>`.
if (this.emojiClick.observed) {
this.ngZone.run(() => this.emojiClick.emit({ emoji, $event }));
}
});

eventListener$('mouseenter').subscribe($event => {
const emoji = this.getSanitizedData();
this.emojiOverOutsideAngular.emit({ emoji, $event });
Expand Down
4 changes: 2 additions & 2 deletions src/lib/picker/picker.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
*ngIf="enableSearch"
[i18n]="i18n"
(searchResults)="handleSearch($event)"
(enterKey)="handleEnterKey($event)"
(enterKeyOutsideAngular)="handleEnterKey($event)"
[include]="include"
[exclude]="exclude"
[custom]="custom"
Expand Down Expand Up @@ -53,7 +53,7 @@
[emojiUseButton]="useButton"
(emojiOverOutsideAngular)="handleEmojiOver($event)"
(emojiLeaveOutsideAngular)="handleEmojiLeave()"
(emojiClick)="handleEmojiClick($event)"
(emojiClickOutsideAngular)="handleEmojiClick($event)"
></emoji-category>
</section>
<div class="emoji-mart-bar" *ngIf="showPreview">
Expand Down
40 changes: 34 additions & 6 deletions src/lib/picker/picker.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ export class PickerComponent implements OnInit, OnDestroy {
this.ref.detectChanges();
}
}

handleSearch($emojis: any[] | null) {
this.SEARCH_CATEGORY.emojis = $emojis;
for (const component of this.categoryRefs.toArray()) {
Expand All @@ -417,12 +418,18 @@ export class PickerComponent implements OnInit, OnDestroy {
this.handleScroll();
}

handleEnterKey($event: Event, emoji?: EmojiData) {
handleEnterKey($event: Event, emoji?: EmojiData): void {
// Note: the `handleEnterKey` is invoked when the search component dispatches the
// `enterKeyOutsideAngular` event or when any emoji is clicked thus `emojiClickOutsideAngular`
// event is dispatched. Both events are dispatched outside of the Angular zone to prevent
// no-op ticks, basically when users outside of the picker component are not listening
// to any of these events.

if (!emoji) {
if (this.SEARCH_CATEGORY.emojis !== null && this.SEARCH_CATEGORY.emojis.length) {
emoji = this.SEARCH_CATEGORY.emojis[0];
if (emoji) {
this.emojiSelect.emit({ $event, emoji });
dispatchInAngularContextIfObserved(this.emojiSelect, this.ngZone, { $event, emoji });
} else {
return;
}
Expand All @@ -435,8 +442,10 @@ export class PickerComponent implements OnInit, OnDestroy {

const component = this.categoryRefs.toArray()[1];
if (component && this.enableFrequentEmojiSort) {
component.updateRecentEmojis();
component.ref.markForCheck();
this.ngZone.run(() => {
component.updateRecentEmojis();
component.ref.markForCheck();
});
}
}
handleEmojiOver($event: EmojiEvent) {
Expand All @@ -455,6 +464,7 @@ export class PickerComponent implements OnInit, OnDestroy {
this.cancelAnimationFrame();
this.ref.detectChanges();
}

handleEmojiLeave() {
if (!this.showPreview || !this.previewRef) {
return;
Expand All @@ -468,16 +478,21 @@ export class PickerComponent implements OnInit, OnDestroy {
this.ref.detectChanges();
});
}

handleEmojiClick($event: EmojiEvent) {
this.emojiClick.emit($event);
this.emojiSelect.emit($event);
// Note: we're getting back into the Angular zone because click events on emojis are handled
// outside of the Angular zone.
dispatchInAngularContextIfObserved(this.emojiClick, this.ngZone, $event);
dispatchInAngularContextIfObserved(this.emojiSelect, this.ngZone, $event);
this.handleEnterKey($event.$event, $event.emoji);
}

handleSkinChange(skin: Emoji['skin']) {
this.skin = skin;
localStorage.setItem(`${this.NAMESPACE}.skin`, String(skin));
this.skinChange.emit(skin);
}

getWidth(): string {
if (this.style && this.style.width) {
return this.style.width;
Expand All @@ -492,3 +507,16 @@ export class PickerComponent implements OnInit, OnDestroy {
}
}
}

/**
* This is only a helper function because the same code is being re-used many times.
*/
function dispatchInAngularContextIfObserved<T>(
emitter: EventEmitter<T>,
ngZone: NgZone,
value: T,
): void {
if (emitter.observed) {
ngZone.run(() => emitter.emit(value));
}
}
44 changes: 32 additions & 12 deletions src/lib/picker/search.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import {
ElementRef,
EventEmitter,
Input,
NgZone,
OnDestroy,
OnInit,
Output,
ViewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';

import { EmojiSearch } from './emoji-search.service';
import { Subject, fromEvent, takeUntil } from 'rxjs';

let id = 0;

Expand All @@ -22,7 +25,6 @@ let id = 0;
[id]="inputId"
#inputRef
type="search"
(keyup.enter)="handleEnterKey($event)"
[placeholder]="i18n.search"
[autofocus]="autoFocus"
[(ngModel)]="query"
Expand Down Expand Up @@ -59,7 +61,7 @@ let id = 0;
standalone: true,
imports: [FormsModule],
})
export class SearchComponent implements AfterViewInit, OnInit {
export class SearchComponent implements AfterViewInit, OnInit, OnDestroy {
@Input() maxResults = 75;
@Input() autoFocus = false;
@Input() i18n: any;
Expand All @@ -69,35 +71,38 @@ export class SearchComponent implements AfterViewInit, OnInit {
@Input() icons!: { [key: string]: string };
@Input() emojisToShowFilter?: (x: any) => boolean;
@Output() searchResults = new EventEmitter<any[]>();
@Output() enterKey = new EventEmitter<any>();
@ViewChild('inputRef', { static: true }) private inputRef!: ElementRef;
@Output() enterKeyOutsideAngular = new EventEmitter<KeyboardEvent>();
@ViewChild('inputRef', { static: true }) private inputRef!: ElementRef<HTMLInputElement>;
isSearching = false;
icon?: string;
query = '';
inputId = `emoji-mart-search-${++id}`;

constructor(private emojiSearch: EmojiSearch) {}
private destroy$ = new Subject<void>();

constructor(private ngZone: NgZone, private emojiSearch: EmojiSearch) {}

ngOnInit() {
this.icon = this.icons.search;
this.setupKeyupListener();
}

ngAfterViewInit() {
if (this.autoFocus) {
this.inputRef.nativeElement.focus();
}
}

ngOnDestroy(): void {
this.destroy$.next();
}

clear() {
this.query = '';
this.handleSearch('');
this.inputRef.nativeElement.focus();
}
handleEnterKey($event: Event) {
if (!this.query) {
return;
}
this.enterKey.emit($event);
$event.preventDefault();
}

handleSearch(value: string) {
if (value === '') {
this.icon = this.icons.search;
Expand All @@ -116,7 +121,22 @@ export class SearchComponent implements AfterViewInit, OnInit {
) as any[];
this.searchResults.emit(emojis);
}

handleChange() {
this.handleSearch(this.query);
}

private setupKeyupListener(): void {
this.ngZone.runOutsideAngular(() =>
fromEvent<KeyboardEvent>(this.inputRef.nativeElement, 'keyup')
.pipe(takeUntil(this.destroy$))
.subscribe($event => {
if (!this.query || $event.key !== 'Enter') {
return;
}
this.enterKeyOutsideAngular.emit($event);
$event.preventDefault();
}),
);
}
}

0 comments on commit 1291377

Please sign in to comment.