You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
350 lines
9.3 KiB
350 lines
9.3 KiB
import '../css/emoji-button.css';
|
|
|
|
import createFocusTrap, { FocusTrap } from 'focus-trap';
|
|
import { TinyEmitter as Emitter } from 'tiny-emitter';
|
|
import { createPopper, Instance as Popper } from '@popperjs/core';
|
|
import twemoji from 'twemoji';
|
|
|
|
import emojiData from './data/emoji';
|
|
|
|
import {
|
|
EMOJI,
|
|
SHOW_SEARCH_RESULTS,
|
|
HIDE_SEARCH_RESULTS,
|
|
HIDE_VARIANT_POPUP
|
|
} from './events';
|
|
import { EmojiPreview } from './preview';
|
|
import { Search } from './search';
|
|
import { createElement, empty } from './util';
|
|
import { VariantPopup } from './variantPopup';
|
|
|
|
import { i18n } from './i18n';
|
|
|
|
import { EmojiButtonOptions, I18NStrings, EmojiRecord } from './types';
|
|
import { EmojiArea } from './emojiArea';
|
|
|
|
const CLASS_PICKER = 'emoji-picker';
|
|
const CLASS_PICKER_CONTENT = 'emoji-picker__content';
|
|
|
|
// Options for twemoji.parse(emoji, twemojiOptions)
|
|
const twemojiOptions = {
|
|
ext: '.svg',
|
|
folder: 'svg'
|
|
};
|
|
|
|
const DEFAULT_OPTIONS: EmojiButtonOptions = {
|
|
position: 'right-start',
|
|
autoHide: true,
|
|
autoFocusSearch: true,
|
|
showPreview: true,
|
|
showSearch: true,
|
|
showRecents: true,
|
|
showVariants: true,
|
|
showCategoryButtons: true,
|
|
recentsCount: 50,
|
|
emojiVersion: '12.1',
|
|
theme: 'light',
|
|
categories: [
|
|
'smileys',
|
|
'people',
|
|
'animals',
|
|
'food',
|
|
'activities',
|
|
'travel',
|
|
'objects',
|
|
'symbols',
|
|
'flags'
|
|
],
|
|
style: 'native',
|
|
emojisPerRow: 8,
|
|
rows: 6,
|
|
emojiSize: '1.8em'
|
|
};
|
|
|
|
export class EmojiButton {
|
|
pickerVisible: boolean;
|
|
|
|
private events = new Emitter();
|
|
private publicEvents = new Emitter();
|
|
private options: EmojiButtonOptions;
|
|
private i18n: I18NStrings;
|
|
|
|
private pickerEl: HTMLElement;
|
|
private wrapper: HTMLElement;
|
|
private focusTrap: FocusTrap;
|
|
|
|
private hideInProgress: boolean;
|
|
private destroyTimeout: NodeJS.Timeout;
|
|
|
|
private overlay: HTMLElement;
|
|
|
|
private popper: Popper;
|
|
|
|
constructor(options: EmojiButtonOptions = {}) {
|
|
this.pickerVisible = false;
|
|
|
|
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
if (!this.options.rootElement) {
|
|
this.options.rootElement = document.body;
|
|
}
|
|
|
|
this.i18n = {
|
|
...i18n,
|
|
...options.i18n
|
|
};
|
|
|
|
this.onDocumentClick = this.onDocumentClick.bind(this);
|
|
this.onDocumentKeydown = this.onDocumentKeydown.bind(this);
|
|
}
|
|
|
|
on(event: string, callback: (arg: string) => void): void {
|
|
this.publicEvents.on(event, callback);
|
|
}
|
|
|
|
off(event: string, callback: (arg: string) => void): void {
|
|
this.publicEvents.off(event, callback);
|
|
}
|
|
|
|
private buildPicker(): void {
|
|
this.pickerEl = createElement('div', CLASS_PICKER);
|
|
this.pickerEl.classList.add(this.options.theme as string);
|
|
|
|
this.options.emojisPerRow &&
|
|
this.pickerEl.style.setProperty(
|
|
'--emoji-per-row',
|
|
this.options.emojisPerRow.toString()
|
|
);
|
|
this.options.rows &&
|
|
this.pickerEl.style.setProperty(
|
|
'--row-count',
|
|
this.options.rows.toString()
|
|
);
|
|
this.options.emojiSize &&
|
|
this.pickerEl.style.setProperty('--emoji-size', this.options.emojiSize);
|
|
|
|
if (!this.options.showCategoryButtons) {
|
|
this.pickerEl.style.setProperty('--category-button-height', '0');
|
|
}
|
|
|
|
this.focusTrap = createFocusTrap(this.pickerEl as HTMLElement, {
|
|
clickOutsideDeactivates: true,
|
|
initialFocus:
|
|
this.options.showSearch && this.options.autoFocusSearch
|
|
? '.emoji-picker__search'
|
|
: '.emoji-picker__emoji[tabindex="0"]'
|
|
});
|
|
|
|
const pickerContent = createElement('div', CLASS_PICKER_CONTENT);
|
|
|
|
if (this.options.showSearch) {
|
|
const searchContainer = new Search(
|
|
this.events,
|
|
this.i18n,
|
|
this.options,
|
|
emojiData.emoji,
|
|
(this.options.categories || []).map(category =>
|
|
emojiData.categories.indexOf(category)
|
|
)
|
|
).render();
|
|
this.pickerEl.appendChild(searchContainer);
|
|
}
|
|
|
|
this.pickerEl.appendChild(pickerContent);
|
|
|
|
const emojiArea = new EmojiArea(
|
|
this.events,
|
|
this.i18n,
|
|
this.options
|
|
).render();
|
|
pickerContent.appendChild(emojiArea);
|
|
|
|
this.events.on(SHOW_SEARCH_RESULTS, (searchResults: HTMLElement) => {
|
|
empty(pickerContent);
|
|
searchResults.classList.add('search-results');
|
|
pickerContent.appendChild(searchResults);
|
|
});
|
|
|
|
this.events.on(HIDE_SEARCH_RESULTS, () => {
|
|
if (pickerContent.firstChild !== emojiArea) {
|
|
empty(pickerContent);
|
|
pickerContent.appendChild(emojiArea);
|
|
}
|
|
});
|
|
|
|
if (this.options.showPreview) {
|
|
this.pickerEl.appendChild(
|
|
new EmojiPreview(this.events, this.options).render()
|
|
);
|
|
}
|
|
|
|
let variantPopup: HTMLElement | null;
|
|
|
|
this.events.on(
|
|
EMOJI,
|
|
({
|
|
emoji,
|
|
showVariants
|
|
}: {
|
|
emoji: EmojiRecord;
|
|
showVariants: boolean;
|
|
}) => {
|
|
if (
|
|
(emoji as EmojiRecord).variations &&
|
|
showVariants &&
|
|
this.options.showVariants
|
|
) {
|
|
variantPopup = new VariantPopup(
|
|
this.events,
|
|
emoji as EmojiRecord,
|
|
this.options
|
|
).render();
|
|
|
|
if (variantPopup) {
|
|
this.pickerEl.appendChild(variantPopup);
|
|
}
|
|
} else {
|
|
if (variantPopup && variantPopup.parentNode === this.pickerEl) {
|
|
this.pickerEl.removeChild(variantPopup);
|
|
}
|
|
|
|
if (this.options.style === 'twemoji') {
|
|
this.publicEvents.emit(
|
|
'emoji',
|
|
twemoji.parse(emoji.emoji, twemojiOptions)
|
|
);
|
|
} else {
|
|
this.publicEvents.emit('emoji', emoji.emoji);
|
|
}
|
|
if (this.options.autoHide) {
|
|
this.hidePicker();
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
this.events.on(HIDE_VARIANT_POPUP, () => {
|
|
if (variantPopup) {
|
|
variantPopup.classList.add('hiding');
|
|
setTimeout(() => {
|
|
variantPopup && this.pickerEl.removeChild(variantPopup);
|
|
variantPopup = null;
|
|
}, 175);
|
|
}
|
|
});
|
|
|
|
this.wrapper = createElement('div', 'wrapper');
|
|
this.wrapper.appendChild(this.pickerEl);
|
|
|
|
if (this.options.zIndex) {
|
|
this.wrapper.style.zIndex = this.options.zIndex + '';
|
|
}
|
|
|
|
if (this.options.rootElement) {
|
|
this.options.rootElement.appendChild(this.wrapper);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
document.addEventListener('click', this.onDocumentClick);
|
|
document.addEventListener('keydown', this.onDocumentKeydown);
|
|
});
|
|
}
|
|
|
|
private onDocumentClick(event: MouseEvent): void {
|
|
if (!this.pickerEl.contains(event.target as Node)) {
|
|
this.hidePicker();
|
|
}
|
|
}
|
|
|
|
private destroyPicker(): void {
|
|
if (this.options.rootElement) {
|
|
this.options.rootElement.removeChild(this.wrapper);
|
|
|
|
if (this.overlay) {
|
|
document.body.removeChild(this.overlay);
|
|
}
|
|
|
|
this.popper && this.popper.destroy();
|
|
this.hideInProgress = false;
|
|
}
|
|
}
|
|
|
|
hidePicker(): void {
|
|
this.focusTrap.deactivate();
|
|
this.pickerVisible = false;
|
|
this.events.off(EMOJI);
|
|
this.events.off(HIDE_VARIANT_POPUP);
|
|
|
|
this.hideInProgress = true;
|
|
this.pickerEl.classList.add('hiding');
|
|
this.destroyTimeout = setTimeout(this.destroyPicker.bind(this), 170);
|
|
|
|
document.removeEventListener('click', this.onDocumentClick);
|
|
document.removeEventListener('keydown', this.onDocumentKeydown);
|
|
}
|
|
|
|
showPicker(referenceEl: HTMLElement, options: EmojiButtonOptions = {}): void {
|
|
if (this.hideInProgress) {
|
|
clearTimeout(this.destroyTimeout);
|
|
this.destroyPicker();
|
|
}
|
|
|
|
this.pickerVisible = true;
|
|
this.buildPicker();
|
|
|
|
if (window.matchMedia('screen and (max-width: 450px)').matches) {
|
|
const style = window.getComputedStyle(this.pickerEl);
|
|
const htmlEl = document.querySelector('html');
|
|
const viewportHeight = htmlEl && htmlEl.clientHeight;
|
|
const viewportWidth = htmlEl && htmlEl.clientWidth;
|
|
|
|
const height = parseInt(style.height);
|
|
const newTop = viewportHeight ? viewportHeight / 2 - height / 2 : 0;
|
|
|
|
const width = parseInt(style.width);
|
|
const newLeft = viewportWidth ? viewportWidth / 2 - width / 2 : 0;
|
|
|
|
this.wrapper.style.position = 'fixed';
|
|
this.wrapper.style.top = `${newTop}px`;
|
|
this.wrapper.style.left = `${newLeft}px`;
|
|
this.wrapper.style.zIndex = '5000';
|
|
|
|
this.overlay = document.createElement('div');
|
|
this.overlay.style.background = 'rgba(0, 0, 0, 0.75)';
|
|
this.overlay.style.zIndex = '1000';
|
|
this.overlay.style.position = 'fixed';
|
|
this.overlay.style.top = '0';
|
|
this.overlay.style.left = '0';
|
|
this.overlay.style.width = '100%';
|
|
this.overlay.style.height = '100%';
|
|
document.body.appendChild(this.overlay);
|
|
} else {
|
|
this.popper = createPopper(referenceEl, this.wrapper, {
|
|
placement: options.position || this.options.position
|
|
});
|
|
}
|
|
|
|
this.focusTrap.activate();
|
|
}
|
|
|
|
togglePicker(
|
|
referenceEl: HTMLElement,
|
|
options: EmojiButtonOptions = {}
|
|
): void {
|
|
this.pickerVisible
|
|
? this.hidePicker()
|
|
: this.showPicker(referenceEl, options);
|
|
}
|
|
|
|
onDocumentKeydown(event: KeyboardEvent): void {
|
|
if (event.key === 'Escape') {
|
|
this.hidePicker();
|
|
} else if (event.key === 'Tab') {
|
|
this.pickerEl.classList.add('keyboard');
|
|
} else if (event.key.match(/^[\w]$/)) {
|
|
const searchField = this.pickerEl.querySelector(
|
|
'.emoji-picker__search'
|
|
) as HTMLInputElement;
|
|
searchField && searchField.focus();
|
|
}
|
|
}
|
|
}
|
|
|