diff --git a/src/lib/picker/category.component.ts b/src/lib/picker/category.component.ts index 890be7a..5dabc66 100644 --- a/src/lib/picker/category.component.ts +++ b/src/lib/picker/category.component.ts @@ -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)" > @@ -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)" > `, @@ -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 = {}; diff --git a/src/lib/picker/ngx-emoji/emoji.component.spec.ts b/src/lib/picker/ngx-emoji/emoji.component.spec.ts index 11a2703..546adde 100644 --- a/src/lib/picker/ngx-emoji/emoji.component.spec.ts +++ b/src/lib/picker/ngx-emoji/emoji.component.spec.ts @@ -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: '', + }) + 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(); + }); }); diff --git a/src/lib/picker/ngx-emoji/emoji.component.ts b/src/lib/picker/ngx-emoji/emoji.component.ts index f19613b..d2139b2 100644 --- a/src/lib/picker/ngx-emoji/emoji.component.ts +++ b/src/lib/picker/ngx-emoji/emoji.component.ts @@ -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" @@ -66,7 +65,6 @@ export interface EmojiEvent { this.button$.pipe( @@ -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: + // ``. + 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 }); diff --git a/src/lib/picker/picker.component.html b/src/lib/picker/picker.component.html index a691584..a81a767 100644 --- a/src/lib/picker/picker.component.html +++ b/src/lib/picker/picker.component.html @@ -17,7 +17,7 @@ *ngIf="enableSearch" [i18n]="i18n" (searchResults)="handleSearch($event)" - (enterKey)="handleEnterKey($event)" + (enterKeyOutsideAngular)="handleEnterKey($event)" [include]="include" [exclude]="exclude" [custom]="custom" @@ -53,7 +53,7 @@ [emojiUseButton]="useButton" (emojiOverOutsideAngular)="handleEmojiOver($event)" (emojiLeaveOutsideAngular)="handleEmojiLeave()" - (emojiClick)="handleEmojiClick($event)" + (emojiClickOutsideAngular)="handleEmojiClick($event)" >
diff --git a/src/lib/picker/picker.component.ts b/src/lib/picker/picker.component.ts index 4edaf69..b013a55 100644 --- a/src/lib/picker/picker.component.ts +++ b/src/lib/picker/picker.component.ts @@ -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()) { @@ -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; } @@ -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) { @@ -455,6 +464,7 @@ export class PickerComponent implements OnInit, OnDestroy { this.cancelAnimationFrame(); this.ref.detectChanges(); } + handleEmojiLeave() { if (!this.showPreview || !this.previewRef) { return; @@ -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; @@ -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( + emitter: EventEmitter, + ngZone: NgZone, + value: T, +): void { + if (emitter.observed) { + ngZone.run(() => emitter.emit(value)); + } +} diff --git a/src/lib/picker/search.component.ts b/src/lib/picker/search.component.ts index f2225f8..d684a4a 100644 --- a/src/lib/picker/search.component.ts +++ b/src/lib/picker/search.component.ts @@ -4,6 +4,8 @@ import { ElementRef, EventEmitter, Input, + NgZone, + OnDestroy, OnInit, Output, ViewChild, @@ -11,6 +13,7 @@ import { import { FormsModule } from '@angular/forms'; import { EmojiSearch } from './emoji-search.service'; +import { Subject, fromEvent, takeUntil } from 'rxjs'; let id = 0; @@ -22,7 +25,6 @@ let id = 0; [id]="inputId" #inputRef type="search" - (keyup.enter)="handleEnterKey($event)" [placeholder]="i18n.search" [autofocus]="autoFocus" [(ngModel)]="query" @@ -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; @@ -69,35 +71,38 @@ export class SearchComponent implements AfterViewInit, OnInit { @Input() icons!: { [key: string]: string }; @Input() emojisToShowFilter?: (x: any) => boolean; @Output() searchResults = new EventEmitter(); - @Output() enterKey = new EventEmitter(); - @ViewChild('inputRef', { static: true }) private inputRef!: ElementRef; + @Output() enterKeyOutsideAngular = new EventEmitter(); + @ViewChild('inputRef', { static: true }) private inputRef!: ElementRef; isSearching = false; icon?: string; query = ''; inputId = `emoji-mart-search-${++id}`; - constructor(private emojiSearch: EmojiSearch) {} + private destroy$ = new Subject(); + + 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; @@ -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(this.inputRef.nativeElement, 'keyup') + .pipe(takeUntil(this.destroy$)) + .subscribe($event => { + if (!this.query || $event.key !== 'Enter') { + return; + } + this.enterKeyOutsideAngular.emit($event); + $event.preventDefault(); + }), + ); + } }