Browse Source

Merge branch 'master' into custom-data

master
Roderick Hsiao 4 years ago
committed by GitHub
parent
commit
3fd5357e91
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 145
      index.d.ts
  2. 165
      package-lock.json
  3. 13
      package.json
  4. 2
      rollup.config.js
  5. 16
      site/src/components/Header.module.css
  6. 20
      site/src/pages/docs/api.js
  7. 12
      site/src/pages/docs/styles.js
  8. 21
      src/emoji.ts
  9. 606
      src/index.ts
  10. 44
      src/lazyLoad.ts
  11. 7
      src/preview.ts
  12. 8
      src/search.ts
  13. 9
      src/types.ts
  14. 9
      tsconfig.json

145
index.d.ts

@ -1,145 +0,0 @@
export as namespace EmojiButton;
export = EmojiButton;
declare namespace EmojiButton {
export class EmojiButton {
constructor(options?: EmojiButton.Options);
on(event: Event, callback: (selection: EmojiSelection) => void): void;
off(event: Event, callback: (selection: EmojiSelection) => void): void;
hidePicker(): void;
destroyPicker(): void;
showPicker(referenceEl: HTMLElement): void;
togglePicker(referenceEl: HTMLElement): void;
isPickerVisible(): boolean;
setTheme(theme: EmojiTheme): void;
}
export interface Options {
position?: Placement | FixedPosition;
autoHide?: boolean;
autoFocusSearch?: boolean;
showAnimation?: boolean;
showPreview?: boolean;
showSearch?: boolean;
showRecents?: boolean;
showVariants?: boolean;
showCategoryButtons?: boolean;
recentsCount?: number;
emojiVersion?: EmojiVersion;
i18n?: I18NStrings;
zIndex?: number;
theme?: EmojiTheme;
categories?: Category[];
style?: EmojiStyle;
emojisPerRow?: number;
rows?: number;
emojiSize?: string;
initialCategory?: Category | 'recents';
custom?: CustomEmoji[];
plugins?: Plugin[];
icons?: Icons;
rootElement?: HTMLElement;
styleProperties?: {
[key: string]: string;
};
}
export interface FixedPosition {
top?: string;
bottom?: string;
left?: string;
right?: string;
}
export interface Plugin {
render(picker: EmojiButton): HTMLElement;
destroy?(): void;
}
export interface EmojiSelection {
name: string;
custom?: boolean;
emoji?: string;
url?: string;
}
export interface CustomEmoji {
name: string;
emoji: string;
}
export type EmojiStyle = 'native' | 'twemoji';
export type EmojiTheme = 'dark' | 'light' | 'auto';
export type Event = 'emoji' | 'hidden';
export type Placement =
| 'auto'
| 'auto-start'
| 'auto-end'
| 'top'
| 'top-start'
| 'top-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'right'
| 'right-start'
| 'right-end'
| 'left'
| 'left-start'
| 'left-end';
export type EmojiVersion =
| '1.0'
| '2.0'
| '3.0'
| '4.0'
| '5.0'
| '11.0'
| '12.0'
| '12.1';
export type Category =
| 'smileys'
| 'people'
| 'animals'
| 'food'
| 'activities'
| 'travel'
| 'objects'
| 'symbols'
| 'flags';
export type I18NCategory =
| 'recents'
| 'smileys'
| 'people'
| 'animals'
| 'food'
| 'activities'
| 'travel'
| 'objects'
| 'symbols'
| 'flags'
| 'custom';
export interface I18NStrings {
search: string;
categories: {
[key in I18NCategory]: string;
};
notFound: string;
}
export interface Icons {
search?: string;
clearSearch?: string;
categories?: {
[key in I18NCategory]?: string;
};
notFound?: string;
}
}

165
package-lock.json

@ -1,6 +1,6 @@
{
"name": "@joeattardi/emoji-button",
"version": "4.3.0",
"version": "4.5.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -1956,27 +1956,6 @@
}
}
},
"@rollup/plugin-typescript": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-4.1.2.tgz",
"integrity": "sha512-+7UlGat/99e2JbmGNnIauxwEhYLwrL7adO/tSJxUN57xrrS3Ps+ZzYpLCDGPZJ57j+ZJTZLLN89KXW9JMEB+jg==",
"dev": true,
"requires": {
"@rollup/pluginutils": "^3.0.1",
"resolve": "^1.14.1"
},
"dependencies": {
"resolve": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz",
"integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==",
"dev": true,
"requires": {
"path-parse": "^1.0.6"
}
}
}
},
"@rollup/pluginutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
@ -2162,6 +2141,11 @@
"integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==",
"dev": true
},
"@types/twemoji": {
"version": "12.1.1",
"resolved": "https://registry.npmjs.org/@types/twemoji/-/twemoji-12.1.1.tgz",
"integrity": "sha512-dW1B1WHTfrWmEzXb/tp8xsZqQHAyMB9JwLwbBqkIQVzmNUI02R7lJqxUpKFM114ygNZHKA1r74oPugCAiYHt1A=="
},
"@types/yargs": {
"version": "15.0.5",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.5.tgz",
@ -3162,6 +3146,12 @@
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true
},
"commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
"integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=",
"dev": true
},
"component-emitter": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
@ -4569,6 +4559,17 @@
"to-regex-range": "^5.0.1"
}
},
"find-cache-dir": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.1.tgz",
"integrity": "sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ==",
"dev": true,
"requires": {
"commondir": "^1.0.1",
"make-dir": "^3.0.2",
"pkg-dir": "^4.1.0"
}
},
"find-up": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz",
@ -9723,15 +9724,99 @@
}
},
"rollup-plugin-terser": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-6.1.0.tgz",
"integrity": "sha512-4fB3M9nuoWxrwm39habpd4hvrbrde2W2GG4zEGPQg1YITNkM3Tqur5jSuXlWNzbv/2aMLJ+dZJaySc3GCD8oDw==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz",
"integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.8.3",
"jest-worker": "^26.0.0",
"serialize-javascript": "^3.0.0",
"terser": "^4.7.0"
"@babel/code-frame": "^7.10.4",
"jest-worker": "^26.2.1",
"serialize-javascript": "^4.0.0",
"terser": "^5.0.0"
},
"dependencies": {
"@babel/code-frame": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
"integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==",
"dev": true,
"requires": {
"@babel/highlight": "^7.10.4"
}
},
"@babel/helper-validator-identifier": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz",
"integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==",
"dev": true
},
"@babel/highlight": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz",
"integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.10.4",
"chalk": "^2.0.0",
"js-tokens": "^4.0.0"
}
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true
},
"jest-worker": {
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz",
"integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==",
"dev": true,
"requires": {
"@types/node": "*",
"merge-stream": "^2.0.0",
"supports-color": "^7.0.0"
}
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"requires": {
"has-flag": "^4.0.0"
}
}
}
},
"rollup-plugin-typescript2": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.29.0.tgz",
"integrity": "sha512-YytahBSZCIjn/elFugEGQR5qTsVhxhUwGZIsA9TmrSsC88qroGo65O5HZP/TTArH2dm0vUmYWhKchhwi2wL9bw==",
"dev": true,
"requires": {
"@rollup/pluginutils": "^3.1.0",
"find-cache-dir": "^3.3.1",
"fs-extra": "8.1.0",
"resolve": "1.17.0",
"tslib": "2.0.1"
},
"dependencies": {
"resolve": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz",
"integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==",
"dev": true,
"requires": {
"path-parse": "^1.0.6"
}
},
"tslib": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz",
"integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==",
"dev": true
}
}
},
"rollup-pluginutils": {
@ -9962,9 +10047,9 @@
"dev": true
},
"serialize-javascript": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-3.1.0.tgz",
"integrity": "sha512-JIJT1DGiWmIKhzRsG91aS6Ze4sFUrYbltlkg2onR5OrnNM02Kl/hnY/T4FN2omvyeBbQmMJv+K4cPOpGzOTFBg==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
"integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
"dev": true,
"requires": {
"randombytes": "^2.1.0"
@ -10657,20 +10742,20 @@
}
},
"terser": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-4.7.0.tgz",
"integrity": "sha512-Lfb0RiZcjRDXCC3OSHJpEkxJ9Qeqs6mp2v4jf2MHfy8vGERmVDuvjXdd/EnP5Deme5F2yBRBymKmKHCBg2echw==",
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.3.8.tgz",
"integrity": "sha512-zVotuHoIfnYjtlurOouTazciEfL7V38QMAOhGqpXDEg6yT13cF4+fEP9b0rrCEQTn+tT46uxgFsTZzhygk+CzQ==",
"dev": true,
"requires": {
"commander": "^2.20.0",
"source-map": "~0.6.1",
"source-map-support": "~0.5.12"
"source-map": "~0.7.2",
"source-map-support": "~0.5.19"
},
"dependencies": {
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
"integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==",
"dev": true
}
}

13
package.json

@ -1,6 +1,6 @@
{
"name": "@joeattardi/emoji-button",
"version": "4.3.0",
"version": "4.5.0",
"description": "Vanilla JavaScript emoji picker",
"keywords": [
"emoji",
@ -8,10 +8,11 @@
],
"main": "dist/index.js",
"module": "dist/index.js",
"types": "index.d.ts",
"types": "dist/index.d.ts",
"scripts": {
"build": "cross-env NODE_ENV=production rollup -c",
"build:watch": "rollup -cw",
"start": "npm run build:watch",
"prepublishOnly": "cross-env NODE_ENV=production rollup -c",
"test": "jest src/**.test.ts",
"test:watch": "jest --watchAll",
@ -31,7 +32,6 @@
"@babel/preset-env": "^7.10.2",
"@babel/preset-typescript": "^7.10.1",
"@rollup/plugin-replace": "^2.3.3",
"@rollup/plugin-typescript": "^4.1.2",
"@types/jest": "^25.2.3",
"@typescript-eslint/eslint-plugin": "^3.1.0",
"@typescript-eslint/parser": "^3.1.0",
@ -48,7 +48,8 @@
"rollup-plugin-commonjs": "^10.1.0",
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-postcss": "^3.1.2",
"rollup-plugin-terser": "^6.1.0",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.29.0",
"ts-jest": "^26.1.0",
"typescript": "^3.9.5"
},
@ -57,6 +58,7 @@
"@fortawesome/free-regular-svg-icons": "^5.13.0",
"@fortawesome/free-solid-svg-icons": "^5.13.0",
"@popperjs/core": "^2.4.0",
"@types/twemoji": "^12.1.1",
"focus-trap": "^5.1.0",
"fuzzysort": "^1.1.4",
"tiny-emitter": "^2.1.0",
@ -64,7 +66,6 @@
"twemoji": "^13.0.0"
},
"files": [
"dist",
"index.d.ts"
"dist"
]
}

2
rollup.config.js

@ -2,7 +2,7 @@ import commonjs from 'rollup-plugin-commonjs';
import postcss from 'rollup-plugin-postcss';
import resolve from 'rollup-plugin-node-resolve';
import replace from '@rollup/plugin-replace';
import typescript from '@rollup/plugin-typescript';
import typescript from 'rollup-plugin-typescript2';
import { terser } from 'rollup-plugin-terser';
const production = process.env.NODE_ENV === 'production';

16
site/src/components/Header.module.css

@ -53,3 +53,19 @@
.buttons a:hover {
filter: brightness(1.2);
}
@media (max-width: 400px) {
.buttons {
display: flex;
flex-direction: column;
}
.buttons a {
margin: 0.5rem;
text-align: center;
}
.header h2 {
font-size: 1.5rem;
}
}

20
site/src/pages/docs/api.js

@ -440,6 +440,24 @@ export default function ApiDocs() {
</td>
</tr>
<tr>
<th scope="row">
<code>twemojiOptions</code>
</th>
<td>object</td>
<td>
<code>{"{ ext: 'svg', folder: 'svg' }"}</code>
</td>
<td>
The options to pass to Twemoji when using the Twemoji style. For
a list of valid options, see the{' '}
<a href="https://github.com/twitter/twemoji">
Twemoji documentation
</a>
.
</td>
</tr>
<tr>
<th scope="row">
<code>zIndex</code>
@ -511,7 +529,7 @@ export default function ApiDocs() {
Method: <code>setTheme(theme)</code>
</h3>
<p>
Sets the theme of the picker. See{' '}
Sets the theme of the picker. See{' '}
<Link to="/docs/themes">Themes</Link> for more details.
</p>

12
site/src/pages/docs/styles.js

@ -39,6 +39,18 @@ export default function StylesExample() {
<code>EmojiButton</code> constructor.
</p>
<p>
When using Twemoji, you can also optionally pass a{' '}
<code>twemojiOptions</code>
object to the <code>EmojiButton</code> options. This will customize how
Twemoji parses and generates an image URL. For valid Twemoji options,
see{' '}
<a href="https://github.com/twitter/twemoji">
the Twemoji documentation
</a>
.
</p>
<p>
When using the Twemoji style, the argument to the <code>emoji</code>{' '}
event has a <code>url</code> property, which is the URL of the Twemoji

21
src/emoji.ts

@ -27,32 +27,17 @@ export class Emoji {
let content = this.emoji.emoji;
/*
const img = createElement(
'img',
CLASS_CUSTOM_EMOJI
) as HTMLImageElement;
img.src = element.dataset.emoji;
element.innerText = '';
element.appendChild(img);
element.dataset.loaded = true;
element.style.opacity = 1;
*/
if (this.emoji.custom) {
content = this.lazy
? smile
: `<img class="${CLASS_CUSTOM_EMOJI}" src="${this.emoji.emoji}">`;
} else if (this.options.style === 'twemoji') {
content = this.lazy ? smile : twemoji.parse(this.emoji.emoji);
content = this.lazy
? smile
: twemoji.parse(this.emoji.emoji, this.options.twemojiOptions);
}
this.emojiButton.innerHTML = content;
// this.options.style === 'native'
// ? this.emoji.emoji
// : this.lazy
// ? smile
// : twemoji.parse(this.emoji.emoji);
this.emojiButton.tabIndex = -1;
this.emojiButton.dataset.emoji = this.emoji.emoji;

606
src/index.ts

@ -14,6 +14,7 @@ import {
HIDE_VARIANT_POPUP,
PICKER_HIDDEN
} from './events';
import { lazyLoadEmoji } from './lazyLoad';
import { EmojiPreview } from './preview';
import { Search } from './search';
import { createElement, empty } from './util';
@ -26,10 +27,8 @@ import {
CLASS_PICKER_CONTENT,
CLASS_EMOJI,
CLASS_SEARCH_FIELD,
CLASS_VARIANT_OVERLAY,
CLASS_WRAPPER,
CLASS_OVERLAY,
CLASS_CUSTOM_EMOJI,
CLASS_PLUGIN_CONTAINER
} from './classes';
@ -37,14 +36,15 @@ import {
EmojiButtonOptions,
I18NStrings,
EmojiRecord,
EmojiTheme
EmojiSelection,
EmojiTheme,
FixedPosition
} from './types';
import { EmojiArea } from './emojiArea';
const twemojiOptions = {
ext: '.svg',
folder: 'svg'
};
const MOBILE_BREAKPOINT = 450;
const STYLE_TWEMOJI = 'twemoji';
const DEFAULT_OPTIONS: EmojiButtonOptions = {
position: 'auto',
@ -72,6 +72,10 @@ const DEFAULT_OPTIONS: EmojiButtonOptions = {
'flags'
],
style: 'native',
twemojiOptions: {
ext: '.svg',
folder: 'svg'
},
emojisPerRow: 8,
rows: 6,
emojiSize: '1.8em',
@ -94,6 +98,7 @@ export class EmojiButton {
private focusTrap: FocusTrap;
private emojiArea: EmojiArea;
private search: Search;
private overlay?: HTMLElement;
@ -124,18 +129,30 @@ export class EmojiButton {
this.buildPicker();
}
/**
* Adds an event listener to the picker.
*
* @param event The name of the event to listen for
* @param callback The function to call when the event is fired
*/
on(event: string, callback: (arg: string) => void): void {
this.publicEvents.on(event, callback);
}
/**
* Removes an event listener from the picker.
*
* @param event The name of the event
* @param callback The callback to remove
*/
off(event: string, callback: (arg: string) => void): void {
this.publicEvents.off(event, callback);
}
private buildPicker(): void {
this.pickerEl = createElement('div', CLASS_PICKER);
this.updateTheme(this.theme);
/**
* Sets any CSS variable values that need to be set.
*/
private setStyleProperties(): void {
if (!this.options.showAnimation) {
this.pickerEl.style.setProperty('--animation-duration', '0s');
}
@ -145,11 +162,13 @@ export class EmojiButton {
'--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);
@ -167,17 +186,147 @@ export class EmojiButton {
}
});
}
}
this.focusTrap = createFocusTrap(this.pickerEl as HTMLElement, {
clickOutsideDeactivates: true,
initialFocus:
this.options.showSearch && this.options.autoFocusSearch
? '.emoji-picker__search'
: '.emoji-picker__emoji[tabindex="0"]'
/**
* Shows the search results in the main emoji area.
*
* @param searchResults The element containing the search results.
*/
private showSearchResults(searchResults: HTMLElement): void {
empty(this.pickerContent);
searchResults.classList.add('search-results');
this.pickerContent.appendChild(searchResults);
}
/**
* Hides the search results and resets the picker.
*/
private hideSearchResults(): void {
if (this.pickerContent.firstChild !== this.emojiArea.container) {
empty(this.pickerContent);
this.pickerContent.appendChild(this.emojiArea.container);
}
this.emojiArea.reset();
}
/**
* Emits a selected emoji event.
* @param param0 The selected emoji and show variants flag
*/
private async emitEmoji({
emoji,
showVariants
}: {
emoji: EmojiRecord;
showVariants: boolean;
}): Promise<void> {
if (
(emoji as EmojiRecord).variations &&
showVariants &&
this.options.showVariants
) {
this.showVariantPopup(emoji as EmojiRecord);
} else {
setTimeout(() => this.emojiArea.updateRecents());
let eventData: EmojiSelection;
if (emoji.custom) {
eventData = this.emitCustomEmoji(emoji);
} else if (this.options.style === STYLE_TWEMOJI) {
eventData = await this.emitTwemoji(emoji);
} else {
eventData = this.emitNativeEmoji(emoji);
}
this.publicEvents.emit(EMOJI, eventData);
if (this.options.autoHide) {
this.hidePicker();
}
}
}
/**
* Emits a native emoji record.
* @param emoji The selected emoji
*/
private emitNativeEmoji(emoji: EmojiRecord): EmojiSelection {
return {
emoji: emoji.emoji,
name: emoji.name
};
}
/**
* Emits a custom emoji record.
* @param emoji The selected emoji
*/
private emitCustomEmoji(emoji: EmojiRecord): EmojiSelection {
return {
url: emoji.emoji,
name: emoji.name,
custom: true
};
}
/**
* Emits a Twemoji emoji record.
* @param emoji The selected emoji
*/
private emitTwemoji(emoji: EmojiRecord): Promise<EmojiSelection> {
return new Promise(resolve => {
twemoji.parse(emoji.emoji, {
...this.options.twemojiOptions,
callback: (icon, { base, size, ext }: any) => {
const imageUrl = `${base}${size}/${icon}${ext}`;
resolve({
url: imageUrl,
emoji: emoji.emoji,
name: emoji.name
});
return imageUrl;
}
});
});
}
this.pickerContent = createElement('div', CLASS_PICKER_CONTENT);
/**
* Builds the search UI.
*/
private buildSearch(): void {
if (this.options.showSearch) {
this.search = new Search(
this.events,
this.i18n,
this.options,
this.options.emojiData.emoji,
(this.options.categories || []).map(category =>
this.options.emojiData.categories.indexOf(category)
)
);
this.pickerEl.appendChild(this.search.render());
}
}
/**
* Builds the emoji preview area.
*/
private buildPreview(): void {
if (this.options.showPreview) {
this.pickerEl.appendChild(
new EmojiPreview(this.events, this.options).render()
);
}
}
/**
* Initializes any plugins that were specified.
*/
private initPlugins(): void {
if (this.options.plugins) {
const pluginContainer = createElement('div', CLASS_PLUGIN_CONTAINER);
@ -192,99 +341,46 @@ export class EmojiButton {
this.pickerEl.appendChild(pluginContainer);
}
}
if (this.options.showSearch) {
const searchContainer = new Search(
this.events,
this.i18n,
this.options,
this.options.emojiData.emoji,
(this.options.categories || []).map(category =>
this.options.emojiData.categories.indexOf(category)
)
).render();
this.pickerEl.appendChild(searchContainer);
}
/**
* Initializes the emoji picker's focus trap.
*/
private initFocusTrap(): void {
this.focusTrap = createFocusTrap(this.pickerEl as HTMLElement, {
clickOutsideDeactivates: true,
initialFocus:
this.options.showSearch && this.options.autoFocusSearch
? '.emoji-picker__search'
: '.emoji-picker__emoji[tabindex="0"]'
});
}
this.pickerEl.appendChild(this.pickerContent);
/**
* Builds the emoji picker.
*/
private buildPicker(): void {
this.pickerEl = createElement('div', CLASS_PICKER);
this.pickerEl.classList.add(this.theme);
this.emojiArea = new EmojiArea(this.events, this.i18n, this.options);
this.pickerContent.appendChild(this.emojiArea.render());
this.setStyleProperties();
this.initFocusTrap();
this.events.on(SHOW_SEARCH_RESULTS, (searchResults: HTMLElement) => {
empty(this.pickerContent);
searchResults.classList.add('search-results');
this.pickerContent.appendChild(searchResults);
});
this.pickerContent = createElement('div', CLASS_PICKER_CONTENT);
this.events.on(HIDE_SEARCH_RESULTS, () => {
if (this.pickerContent.firstChild !== this.emojiArea.container) {
empty(this.pickerContent);
this.pickerContent.appendChild(this.emojiArea.container);
}
this.initPlugins();
this.buildSearch();
this.emojiArea.reset();
});
this.pickerEl.appendChild(this.pickerContent);
if (this.options.showPreview) {
this.pickerEl.appendChild(
new EmojiPreview(this.events, this.options).render()
);
}
this.emojiArea = new EmojiArea(this.events, this.i18n, this.options);
this.pickerContent.appendChild(this.emojiArea.render());
let variantPopup: HTMLElement | null;
this.events.on(
EMOJI,
({
emoji,
showVariants
}: {
emoji: EmojiRecord;
showVariants: boolean;
}) => {
if (
(emoji as EmojiRecord).variations &&
showVariants &&
this.options.showVariants
) {
this.showVariantPopup(emoji as EmojiRecord);
} else {
if (variantPopup && variantPopup.parentNode === this.pickerEl) {
this.events.emit(HIDE_VARIANT_POPUP);
}
setTimeout(() => this.emojiArea.updateRecents());
if (emoji.custom) {
this.publicEvents.emit(EMOJI, {
url: emoji.emoji,
name: emoji.name,
custom: true
});
} else if (this.options.style === 'twemoji') {
twemoji.parse(emoji.emoji, {
...twemojiOptions,
callback: (icon, options) => {
this.publicEvents.emit(EMOJI, {
url: `${options.base}${options.size}/${icon}${options.ext}`,
emoji: emoji.emoji,
name: emoji.name
});
}
});
} else {
this.publicEvents.emit(EMOJI, {
emoji: emoji.emoji,
name: emoji.name
});
}
if (this.options.autoHide) {
this.hidePicker();
}
}
}
);
this.events.on(SHOW_SEARCH_RESULTS, this.showSearchResults.bind(this));
this.events.on(HIDE_SEARCH_RESULTS, this.hideSearchResults.bind(this));
this.events.on(EMOJI, this.emitEmoji.bind(this));
this.buildPreview();
this.wrapper = createElement('div', CLASS_WRAPPER);
this.wrapper.appendChild(this.pickerEl);
@ -301,7 +397,12 @@ export class EmojiButton {
this.observeForLazyLoad();
}
private showVariantPopup(emoji: EmojiRecord) {
/**
* Shows the variant popup for an emoji.
*
* @param emoji The emoji whose variants are to be shown.
*/
private showVariantPopup(emoji: EmojiRecord): void {
const variantPopup = new VariantPopup(
this.events,
emoji,
@ -324,62 +425,73 @@ export class EmojiButton {
});
}
private observeForLazyLoad() {
const onChange = changes => {
const visibleElements = Array.prototype.filter
.call(changes, change => {
return change.intersectionRatio > 0;
})
.map(entry => entry.target);
visibleElements.forEach(element => {
if (!element.dataset.loaded) {
if (element.dataset.custom) {
const img = createElement(
'img',
CLASS_CUSTOM_EMOJI
) as HTMLImageElement;
img.src = element.dataset.emoji;
element.innerText = '';
element.appendChild(img);
element.dataset.loaded = true;
element.style.opacity = 1;
} else if (this.options.style === 'twemoji') {
element.innerHTML = twemoji.parse(
element.dataset.emoji,
twemojiOptions
);
element.dataset.loaded = true;
element.style.opacity = '1';
}
/**
* Initializes the IntersectionObserver for lazy loading emoji images
* as they are scrolled into view.
*/
private observeForLazyLoad(): void {
this.observer = new IntersectionObserver(
this.handleIntersectionChange.bind(this),
{
root: this.emojiArea.emojis
}
);
this.emojiArea.emojis
.querySelectorAll(`.${CLASS_EMOJI}`)
.forEach((element: Element) => {
if (this.shouldLazyLoad(element as HTMLElement)) {
this.observer.observe(element);
}
});
};
}
this.observer = new IntersectionObserver(onChange, {
root: this.emojiArea.emojis
});
/**
* IntersectionObserver callback that triggers lazy loading of emojis
* that need it.
*
* @param entries The entries observed by the IntersectionObserver.
*/
private handleIntersectionChange(entries: IntersectionObserverEntry[]): void {
Array.prototype.filter
.call(
entries,
(entry: IntersectionObserverEntry) => entry.intersectionRatio > 0
)
.map((entry: IntersectionObserverEntry) => entry.target)
.forEach((element: Element) => {
lazyLoadEmoji(element as HTMLElement, this.options);
});
}
const emojiElements = this.emojiArea.emojis.querySelectorAll(
`.${CLASS_EMOJI}`
/**
* Determines whether or not an emoji should be lazily loaded.
*
* @param element The element containing the emoji.
* @return true if the emoji should be lazily loaded, false if not.
*/
private shouldLazyLoad(element: HTMLElement): boolean {
return (
this.options.style === STYLE_TWEMOJI || element.dataset.custom === 'true'
);
emojiElements.forEach(element => {
if (
this.options.style === 'twemoji' ||
(element as HTMLElement).dataset.custom === 'true'
) {
this.observer.observe(element);
}
});
}
/**
* Handles a click on the document, so that the picker is hidden
* if the mouse is clicked outside of it.
*
* @param event The MouseEvent that was dispatched.
*/
private onDocumentClick(event: MouseEvent): void {
if (!this.pickerEl.contains(event.target as Node)) {
this.hidePicker();
}
}
/**
* Destroys the picker. Once this is called, the picker can no longer
* be shown.
*/
destroyPicker(): void {
this.events.off(EMOJI);
this.events.off(HIDE_VARIANT_POPUP);
@ -399,6 +511,9 @@ export class EmojiButton {
}
}
/**
* Hides, but does not destroy, the picker.
*/
hidePicker(): void {
this.hideInProgress = true;
this.focusTrap.deactivate();
@ -417,6 +532,9 @@ export class EmojiButton {
);
this.pickerEl.classList.add('hiding');
// Let the transition finish before actually hiding the picker so that
// the user sees the hide animation.
setTimeout(
() => {
this.wrapper.style.display = 'none';
@ -427,19 +545,11 @@ export class EmojiButton {
this.pickerContent.appendChild(this.emojiArea.container);
}
const searchField = this.pickerEl.querySelector(
`.${CLASS_SEARCH_FIELD}`
) as HTMLInputElement;
if (searchField) {
searchField.value = '';
if (this.search) {
this.search.clear();
}
const variantOverlay = this.pickerEl.querySelector(
`.${CLASS_VARIANT_OVERLAY}`
);
if (variantOverlay) {
this.events.emit(HIDE_VARIANT_POPUP);
}
this.events.emit(HIDE_VARIANT_POPUP);
this.hideInProgress = false;
this.popper && this.popper.destroy();
@ -455,6 +565,11 @@ export class EmojiButton {
});
}
/**
* Shows the picker.
*
* @param referenceEl The element to position relative to if relative positioning is used.
*/
showPicker(referenceEl: HTMLElement): void {
if (this.hideInProgress) {
setTimeout(() => this.showPicker(referenceEl), 100);
@ -464,99 +579,152 @@ export class EmojiButton {
this.pickerVisible = true;
this.wrapper.style.display = 'block';
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;
this.determineDisplay(referenceEl);
const height = parseInt(style.height);
const newTop = viewportHeight ? viewportHeight / 2 - height / 2 : 0;
this.focusTrap.activate();
const width = parseInt(style.width);
const newLeft = viewportWidth ? viewportWidth / 2 - width / 2 : 0;
setTimeout(() => {
this.addEventListeners();
this.setInitialFocus();
});
this.wrapper.style.position = 'fixed';
this.wrapper.style.top = `${newTop}px`;
this.wrapper.style.left = `${newLeft}px`;
this.wrapper.style.zIndex = '5000';
this.emojiArea.reset();
}
this.overlay = createElement('div', CLASS_OVERLAY);
document.body.appendChild(this.overlay);
} else if (typeof this.options.position === 'string') {
this.popper = createPopper(referenceEl, this.wrapper, {
placement: this.options.position as Placement
});
} else if (
this.options.position &&
(this.options.position.top || this.options.position.left)
/**
* Determines which display and position are used for the picker, based on
* the viewport size and specified options.
*
* @param referenceEl The element to position relative to if relative positioning is used.
*/
determineDisplay(referenceEl: HTMLElement): void {
if (
window.matchMedia(`screen and (max-width: ${MOBILE_BREAKPOINT}px)`)
.matches
) {
this.wrapper.style.position = 'fixed';
this.showMobileView();
} else if (typeof this.options.position === 'string') {
this.setRelativePosition(referenceEl);
} else {
this.setFixedPosition();
}
}
if (this.options.position.top) {
this.wrapper.style.top = this.options.position.top;
}
/**
* Sets the initial focus to the appropriate element, depending on the specified
* options.
*/
setInitialFocus(): void {
// If the search field is visible and should be auto-focused, set the focus on
// the search field. Otherwise, the initial focus will be on the first focusable emoji.
const initialFocusElement = this.pickerEl.querySelector(
this.options.showSearch && this.options.autoFocusSearch
? `.${CLASS_SEARCH_FIELD}`
: `.${CLASS_EMOJI}[tabindex="0"]`
) as HTMLElement;
initialFocusElement.focus();
}
if (this.options.position.bottom) {
this.wrapper.style.bottom = this.options.position.bottom;
}
/**
* Adds the event listeners that will close the picker without selecting an emoji.
*/
private addEventListeners(): void {
document.addEventListener('click', this.onDocumentClick);
document.addEventListener('keydown', this.onDocumentKeydown);
}
if (this.options.position.left) {
this.wrapper.style.left = this.options.position.left;
}
/**
* Sets relative positioning with Popper.js.
*
* @param referenceEl The element to position relative to.
*/
private setRelativePosition(referenceEl: HTMLElement): void {
this.popper = createPopper(referenceEl, this.wrapper, {
placement: this.options.position as Placement
});
}
if (this.options.position.right) {
this.wrapper.style.right = this.options.position.right;
}
/**
* Sets fixed positioning.
*/
private setFixedPosition(): void {
if (this.options?.position) {
this.wrapper.style.position = 'fixed';
const fixedPosition = this.options.position as FixedPosition;
Object.keys(fixedPosition).forEach(key => {
this.wrapper.style[key] = fixedPosition[key];
});
}
}
this.focusTrap.activate();
/**
* Shows the picker in a mobile view.
*/
private showMobileView(): void {
const style = window.getComputedStyle(this.pickerEl);
const htmlEl = document.querySelector('html');
const viewportHeight = htmlEl && htmlEl.clientHeight;
const viewportWidth = htmlEl && htmlEl.clientWidth;
setTimeout(() => {
document.addEventListener('click', this.onDocumentClick);
document.addEventListener('keydown', this.onDocumentKeydown);
const height = parseInt(style.height);
const newTop = viewportHeight ? viewportHeight / 2 - height / 2 : 0;
const initialFocusElement = this.pickerEl.querySelector(
this.options.showSearch && this.options.autoFocusSearch
? `.${CLASS_SEARCH_FIELD}`
: `.${CLASS_EMOJI}[tabindex="0"]`
) as HTMLElement;
initialFocusElement.focus();
});
const width = parseInt(style.width);
const newLeft = viewportWidth ? viewportWidth / 2 - width / 2 : 0;
this.emojiArea.reset();
this.wrapper.style.position = 'fixed';
this.wrapper.style.top = `${newTop}px`;
this.wrapper.style.left = `${newLeft}px`;
this.wrapper.style.zIndex = '5000';
this.overlay = createElement('div', CLASS_OVERLAY);
document.body.appendChild(this.overlay);
}
/**
* Toggles the picker's visibility.
*
* @param referenceEl The element to position relative to if relative positioning is used.
*/
togglePicker(referenceEl: HTMLElement): void {
this.pickerVisible ? this.hidePicker() : this.showPicker(referenceEl);
}
/**
* Determines whether or not the picker is currently visible.
* @return true if the picker is visible, false if not.
*/
isPickerVisible(): boolean {
return this.pickerVisible;
}
/**
* Handles a keydown event on the document.
* @param event The keyboard event that was dispatched.
*/
private onDocumentKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
// Escape closes the picker.
this.hidePicker();
} else if (event.key === 'Tab') {
// The `keyboard` class adds some extra styling to indicate keyboard focus.
this.pickerEl.classList.add('keyboard');
} else if (event.key.match(/^[\w]$/)) {
const searchField = this.pickerEl.querySelector(
`.${CLASS_SEARCH_FIELD}`
) as HTMLInputElement;
searchField && searchField.focus();
} else if (event.key.match(/^[\w]$/) && this.search) {
// If a search term is entered, move the focus to the search field.
this.search.focus();
}
}
/**
* Sets the theme to use for the picker.
*/
setTheme(theme: EmojiTheme): void {
if (theme === this.theme) return;
this.pickerEl.classList.remove(this.theme);
this.theme = theme;
this.updateTheme(this.theme);
}
private updateTheme(theme: EmojiTheme): void {
this.pickerEl.classList.add(theme);
if (theme !== this.theme) {
this.pickerEl.classList.remove(this.theme);
this.theme = theme;
this.pickerEl.classList.add(theme);
}
}
}

44
src/lazyLoad.ts

@ -0,0 +1,44 @@
import twemoji from 'twemoji';
import { CLASS_CUSTOM_EMOJI } from './classes';
import { EmojiButtonOptions } from './types';
import { createElement } from './util';
export function lazyLoadEmoji(
element: HTMLElement,
options: EmojiButtonOptions
): void {
if (!element.dataset.loaded) {
if (element.dataset.custom) {
lazyLoadCustomEmoji(element);
} else if (options.style === 'twemoji') {
lazyLoadTwemoji(element, options);
}
element.dataset.loaded = 'true';
element.style.opacity = '1';
}
}
function lazyLoadCustomEmoji(element: HTMLElement): void {
const img = createElement('img', CLASS_CUSTOM_EMOJI) as HTMLImageElement;
if (element.dataset.emoji) {
img.src = element.dataset.emoji;
element.innerText = '';
element.appendChild(img);
}
}
function lazyLoadTwemoji(
element: HTMLElement,
options: EmojiButtonOptions
): void {
if (element.dataset.emoji) {
element.innerHTML = twemoji.parse(
element.dataset.emoji,
options.twemojiOptions
);
}
}

7
src/preview.ts

@ -13,11 +13,6 @@ import {
CLASS_CUSTOM_EMOJI
} from './classes';
const twemojiOptions = {
ext: '.svg',
folder: 'svg'
};
export class EmojiPreview {
private emoji: HTMLElement;
private name: HTMLElement;
@ -47,7 +42,7 @@ export class EmojiPreview {
if (emoji.custom) {
content = `<img class="${CLASS_CUSTOM_EMOJI}" src="${emoji.emoji}">`;
} else if (this.options.style === 'twemoji') {
content = twemoji.parse(emoji.emoji, twemojiOptions);
content = twemoji.parse(emoji.emoji, this.options.twemojiOptions);
}
this.emoji.innerHTML = content;

8
src/search.ts

@ -119,6 +119,14 @@ export class Search {
return this.searchContainer;
}
clear(): void {
this.searchField.value = '';
}
focus(): void {
this.searchField.focus();
}
onClearSearch(event: Event): void {
event.stopPropagation();

9
src/types.ts

@ -1,4 +1,5 @@
import { Placement } from '@popperjs/core';
import { ParseObject } from 'twemoji';
import { EmojiButton } from './index';
export interface EmojiRecord {
@ -16,6 +17,13 @@ export interface EmojiData {
emojiData: EmojiRecord[];
}
export interface EmojiSelection {
name: string;
custom?: boolean;
emoji?: string;
url?: string;
}
export interface RecentEmoji {
key: string;
name: string;
@ -53,6 +61,7 @@ export interface EmojiButtonOptions {
theme?: EmojiTheme;
categories?: Category[];
style?: EmojiStyle;
twemojiOptions?: Partial<ParseObject>;
emojisPerRow?: number;
rows?: number;
emojiSize?: string;

9
tsconfig.json

@ -2,13 +2,13 @@
"compilerOptions": {
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "es2020", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
"target": "es2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "es2015", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
"declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
@ -63,5 +63,6 @@
/* Advanced Options */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
},
"include": ["src/*.ts"]
"include": ["src/*.ts"],
"exclude": ["src/*.test.ts"]
}

Loading…
Cancel
Save