Browse Source

NEW: Emoji Picker v0.1.0

pull/1/head v0.1.0
LOTW 4 years ago
commit
1b30e4372e
  1. 4
      .babelrc
  2. 14
      .editorconfig
  3. 27
      .eslintrc.js
  4. 6
      .gitignore
  5. 5
      .prettierrc
  6. 43
      LICENSE.md
  7. 22
      README.md
  8. 496
      css/emoji-picker.css
  9. 1
      dist/index.js
  10. 4457
      emoji-test.txt
  11. 173
      index.d.ts
  12. 27
      nodejs.yml
  13. 67
      package.json
  14. 34
      rollup.config.js
  15. 143
      scripts/processEmojiData.js
  16. 71
      src/categoryButtons.test.ts
  17. 118
      src/categoryButtons.ts
  18. 23
      src/classes.ts
  19. 1
      src/data/emoji.js
  20. 65
      src/emoji.test.ts
  21. 131
      src/emoji.ts
  22. 84
      src/emojiArea.test.ts
  23. 347
      src/emojiArea.ts
  24. 19
      src/emojiContainer.test.ts
  25. 45
      src/emojiContainer.ts
  26. 8
      src/events.ts
  27. 19
      src/i18n.ts
  28. 41
      src/icons.ts
  29. 579
      src/index.ts
  30. 28
      src/preview.test.ts
  31. 66
      src/preview.ts
  32. 33
      src/recent.ts
  33. 71
      src/search.test.ts
  34. 253
      src/search.ts
  35. 132
      src/types.ts
  36. 17
      src/util.test.ts
  37. 25
      src/util.ts
  38. 29
      src/variantPopup.test.ts
  39. 108
      src/variantPopup.ts
  40. 67
      tsconfig.json
  41. 6449
      yarn.lock

4
.babelrc

@ -0,0 +1,4 @@
{
"presets": ["@babel/preset-env", "@babel/preset-typescript"],
"plugins": ["transform-class-properties"]
}

14
.editorconfig

@ -0,0 +1,14 @@
root = true
[*]
charset = utf-8
# New Line formating
end_of_line = lf
insert_final_newline = true
# Indentation
indent_style = space
indent_size = 4

27
.eslintrc.js

@ -0,0 +1,27 @@
module.exports = {
parser: '@typescript-eslint/parser',
env: {
browser: true,
es6: true,
node: true,
jest: true
},
extends: [
'eslint:recommended',
'plugin:prettier/recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended'
],
globals: {
Atomics: 'readonly',
SharedArrayBuffer: 'readonly'
},
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module'
},
rules: {
'no-console': 'error'
},
plugins: ['@typescript-eslint']
};

6
.gitignore vendored

@ -0,0 +1,6 @@
# Files
.env
.DS_Store
# Folders
node_modules/

5
.prettierrc

@ -0,0 +1,5 @@
{
"arrowParens": "avoid",
"singleQuote": true,
"trailingComma": "none"
}

43
LICENSE.md

@ -0,0 +1,43 @@
# Licenses
* Copyright (c) 2020 LOTW. MIT
* Copyright (c) 2019 Joe Attardi. MIT
* Copyright (c) 2018 Twitter, Inc and other contributors. CC-BY-4.0
## Joe Attardi's emoji-button License
This project is written off the code from Joe Attardi's emoji-button project
* Source: https://github.com/joeattardi/emoji-button
* License: https://github.com/joeattardi/emoji-button/blob/master/LICENSE
## Twitter Emoji for Everyone License
This project uses the emoji files from Twitter Emoji for Everyone project
* Source: https://github.com/twitter/twemoji
* License: https://github.com/twitter/twemoji/blob/master/LICENSE
## Source Code
* Applies to everything else
* License: MIT
Copyright (c) 2020 LOTW.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

22
README.md

@ -0,0 +1,22 @@
# Emoji Picker JS
An Emoji Picker and parser (using twemoji) that fits for any use case.
## Demo and Documentation
_Coming Soon.._.
## Browser support
Emoji Picker JS is supported on all modern browsers supporting the latest JavaScript features. Internet Explorer is not supported.
## History
This project is basically a 'rewrite' / 'redo' of _[Joe Attardi's emoji-button project ](https://github.com/joeattardi/emoji-button)_ in which I do things the way I think or I want it to be done.
## License
See [LICENSE.md](LICENSE.md) for full license info.

496
css/emoji-picker.css

@ -0,0 +1,496 @@
@keyframes show {
0% {
opacity: 0;
transform: scale3d(0.8, 0.8, 0.8);
}
50% {
transform: scale3d(1.05, 1.05, 1.05);
}
100% {
transform: scale3d(1, 1, 1);
}
}
@keyframes hide {
0% {
opacity: 1;
transform: scale3d(1, 1, 1);
}
100% {
opacity: 0;
transform: scale3d(0.8, 0.8, 0.8);
}
}
@keyframes grow {
0% {
opacity: 0;
transform: scale3d(0.8, 0.8, 0.8);
}
100% {
opacity: 1;
transform: scale3d(1, 1, 1);
}
}
@keyframes shrink {
0% {
opacity: 1;
transform: scale3d(1, 1, 1);
}
100% {
opacity: 0;
transform: scale3d(0.8, 0.8, 0.8);
}
}
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fade-out {
0% { opacity: 1; }
100% { opacity: 0; }
}
.emoji-picker {
--animation-duration: 0.2s;
--animation-easing: ease-in-out;
--emoji-size: 1.8em;
--emoji-size-multiplier: 1.5;
--emoji-preview-size: 2em;
--emoji-per-row: 8;
--row-count: 6;
--content-height: calc((var(--emoji-size) * var(--emoji-size-multiplier)) * var(--row-count) + var(--category-name-size) + var(--category-button-height) + 0.5em);
--category-name-size: 0.85em;
--category-button-height: 2em;
--category-button-size: 1.1em;
--category-border-bottom-size: 4px;
--focus-indicator-color: #999999;
--search-height: 2em;
--blue-color: #4F81E5;
--border-color: #CCCCCC;
--background-color: #FFFFFF;
--text-color: #000000;
--secondary-text-color: #666666;
--hover-color: #E8F4F9;
--search-focus-border-color: var(--blue-color);
--search-icon-color: #CCCCCC;
--overlay-background-color: rgba(0, 0, 0, 0.8);
--popup-background-color: #FFFFFF;
--category-button-color: #666666;
--category-button-active-color: var(--blue-color);
--dark-border-color: #666666;
--dark-background-color: #333333;
--dark-text-color: #FFFFFF;
--dark-secondary-text-color: #999999;
--dark-hover-color: #666666;
--dark-search-background-color: #666666;
--dark-search-border-color: #999999;
--dark-search-placeholder-color: #999999;
--dark-search-focus-border-color: #DBE5F9;
--dark-popup-background-color: #333333;
--dark-category-button-color: #FFFFFF;
}
.emoji-picker__wrapper {
outline: 0;
border-radius: 5px;
}
.emoji-picker {
font-size: 16px;
border: 1px solid var(--border-color);
border-radius: 5px;
background: var(--background-color);
width: calc(var(--emoji-per-row) * var(--emoji-size) * var(--emoji-size-multiplier) + 1em + 1.5rem);
font-family: Arial, Helvetica, sans-serif;
overflow: hidden;
animation: show var(--animation-duration) var(--animation-easing);
}
.emoji-picker h2 {
font-family: Arial, Helvetica, sans-serif;
}
.emoji-picker__overlay {
background: rgba(0, 0, 0, 0.75);
z-index: 1000;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.emoji-picker.hiding {
animation: hide var(--animation-duration) var(--animation-easing);
}
.emoji-picker.dark {
background: var(--dark-background-color);
color: var(--dark-text-color);
border-color: var(--dark-border-color);
}
.emoji-picker__content {
padding: 0.5em;
height: var(--content-height);
position: relative;
}
.emoji-picker__preview {
height: var(--emoji-preview-size);
padding: 0.5em;
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: row;
align-items: center;
}
.emoji-picker.dark .emoji-picker__preview {
border-top-color: var(--dark-border-color);
}
.emoji-picker__preview-emoji {
font-size: var(--emoji-preview-size);
margin-right: 0.25em;
font-family: "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "EmojiOne Color", "Android Emoji";
}
.emoji-picker__preview-emoji img.emoji {
height: 1em;
width: 1em;
margin: 0 .05em 0 .1em;
vertical-align: -0.1em;
}
.emoji-picker__preview-name {
color: var(--text-color);
font-size: 0.85em;
overflow-wrap: break-word;
word-break: break-all;
}
.emoji-picker.dark .emoji-picker__preview-name {
color: var(--dark-text-color);
}
.emoji-picker__container {
display: grid;
justify-content: center;
grid-template-columns: repeat(var(--emoji-per-row), calc(var(--emoji-size) * var(--emoji-size-multiplier)));
grid-auto-rows: calc(var(--emoji-size) * var(--emoji-size-multiplier));
}
.emoji-picker__container.search-results {
height: var(--content-height);
overflow-y: auto;
}
.emoji-picker__custom-emoji {
width: 1em;
height: 1em;
}
.emoji-picker__emoji {
background: transparent;
border: none;
cursor: pointer;
overflow: hidden;
font-size: var(--emoji-size);
width: 1.5em;
height: 1.5em;
padding: 0;
margin: 0;
outline: none;
font-family: "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "EmojiOne Color", "Android Emoji";
display: inline-flex;
align-items: center;
justify-content: center;
}
.emoji-picker__emoji img.emoji {
height: 1em;
width: 1em;
margin: 0 .05em 0 .1em;
vertical-align: -0.1em;
}
.emoji-picker__emoji:focus, .emoji-picker__emoji:hover {
background: var(--hover-color);
}
.emoji-picker__emoji:focus {
outline: 1px dotted var(--focus-indicator-color);
}
.emoji-picker.dark .emoji-picker__emoji:focus, .emoji-picker.dark .emoji-picker__emoji:hover {
background: var(--dark-hover-color);
}
.emoji-picker__plugin-container {
margin: 0.5em;
display: flex;
flex-direction: row;
}
.emoji-picker__search-container {
margin: 0.5em;
position: relative;
height: var(--search-height);
display: flex;
}
.emoji-picker__search {
box-sizing: border-box;
width: 100%;
border-radius: 3px;
border: 1px solid var(--border-color);
padding-right: 2em;
padding: 0.5em 2.25em 0.5em 0.5em;
font-size: 0.85em;
outline: none;
}
.emoji-picker.dark .emoji-picker__search {
background: var(--dark-search-background-color);
color: var(--dark-text-color);
border-color: var(--dark-search-border-color);
}
.emoji-picker.dark .emoji-picker__search::placeholder {
color: var(--dark-search-placeholder-color);
}
.emoji-picker__search:focus {
border: 1px solid var(--search-focus-border-color);
}
.emoji-picker.dark .emoji-picker__search:focus {
border-color: var(--dark-search-focus-border-color);
}
.emoji-picker__search-icon {
position: absolute;
color: var(--search-icon-color);
width: 1em;
height: 1em;
right: 0.75em;
top: calc(50% - 0.5em);
}
.emoji-picker__search-icon img {
width: 1em;
height: 1em;
}
.emoji-picker__search-not-found {
color: var(--secondary-text-color);
text-align: center;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.emoji-picker__search-not-found h2 {
color: var(--secondary-text-color);
}
.emoji-picker.dark .emoji-picker__search-not-found {
color: var(--dark-secondary-text-color);
}
.emoji-picker.dark .emoji-picker__search-not-found h2 {
color: var(--dark-secondary-text-color);
}
.emoji-picker__search-not-found-icon {
font-size: 3em;
}
.emoji-picker__search-not-found-icon img {
width: 1em;
height: 1em;
}
.emoji-picker__search-not-found h2 {
margin: 0.5em 0;
font-size: 1em;
}
.emoji-picker__variant-overlay {
background: var(--overlay-background-color);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 5px;
display: flex;
flex-direction: column;
justify-content: center;
animation: fade-in var(--animation-duration) var(--animation-easing);
}
.emoji-picker__variant-overlay.hiding {
animation: fade-out var(--animation-duration) var(--animation-easing);
}
.emoji-picker__variant-popup {
background: var(--popup-background-color);
margin: 0.5em;
padding: 0.5em;
text-align: center;
border-radius: 5px;
animation: grow var(--animation-duration) var(--animation-easing);
user-select: none;
}
.emoji-picker__variant-overlay.hiding .emoji-picker__variant-popup {
animation: shrink var(--animation-duration) var(--animation-easing);
}
.emoji-picker.dark .emoji-picker__variant-popup {
background: var(--dark-popup-background-color);
}
.emoji-picker__emojis {
overflow-y: auto;
position: relative;
height: calc((var(--emoji-size) * var(--emoji-size-multiplier)) * var(--row-count) + var(--category-name-size));
}
.emoji-picker__emojis.hiding {
animation: fade-out 0.05s var(--animation-easing);
}
.emoji-picker__emojis h2.emoji-picker__category-name {
font-size: 0.85em;
color: var(--secondary-text-color);
text-transform: uppercase;
margin: 0.25em 0;
text-align: left;
}
.emoji-picker.dark h2.emoji-picker__category-name {
color: var(--dark-secondary-text-color);
}
.emoji-picker__category-buttons {
display: flex;
flex-direction: row;
justify-content: space-around;
height: var(--category-button-height);
margin-bottom: 0.5em;
}
button.emoji-picker__category-button {
flex-grow: 1;
background: transparent;
padding: 0;
border: none;
cursor: pointer;
font-size: var(--category-button-size);
vertical-align: middle;
color: var(--category-button-color);
border-bottom: var(--category-border-bottom-size) solid transparent;
outline: none;
}
button.emoji-picker__category-button img {
width: var(--category-button-size);
height: var(--category-button-size);
}
.emoji-picker.keyboard button.emoji-picker__category-button:focus {
outline: 1px dotted var(--focus-indicator-color);
}
.emoji-picker.dark button.emoji-picker__category-button.active {
color: var(--category-button-active-color);
}
.emoji-picker.dark button.emoji-picker__category-button {
color: var(--dark-category-button-color);
}
button.emoji-picker__category-button.active {
color: var(--category-button-active-color);
border-bottom: var(--category-border-bottom-size) solid var(--category-button-active-color);
}
@media (prefers-color-scheme: dark) {
.emoji-picker.auto {
background: var(--dark-background-color);
color: var(--dark-text-color);
border-color: var(--dark-border-color);
}
.emoji-picker.auto .emoji-picker__preview {
border-top-color: var(--dark-border-color);
}
.emoji-picker.auto .emoji-picker__preview-name {
color: var(--dark-text-color);
}
.emoji-picker.auto button.emoji-picker__category-button {
color: var(--dark-category-button-color);
}
.emoji-picker.auto button.emoji-picker__category-button.active {
color: var(--category-button-active-color);
}
.emoji-picker.auto .emoji-picker__emoji:focus, .emoji-picker.auto .emoji-picker__emoji:hover {
background: var(--dark-hover-color);
}
.emoji-picker.auto .emoji-picker__search {
background: var(--dark-search-background-color);
color: var(--dark-text-color);
border-color: var(--dark-search-border-color);
}
.emoji-picker.auto h2.emoji-picker__category-name {
color: var(--dark-secondary-text-color);
}
.emoji-picker.auto .emoji-picker__search::placeholder {
color: var(--dark-search-placeholder-color);
}
.emoji-picker.auto .emoji-picker__search:focus {
border-color: var(--dark-search-focus-border-color);
}
.emoji-picker.auto .emoji-picker__search-not-found {
color: var(--dark-secondary-text-color);
}
.emoji-picker.auto .emoji-picker__search-not-found h2 {
color: var(--dark-secondary-text-color);
}
.emoji-picker.auto .emoji-picker__variant-popup {
background: var(--dark-popup-background-color);
}
}

1
dist/index.js vendored

File diff suppressed because one or more lines are too long

4457
emoji-test.txt

File diff suppressed because it is too large Load Diff

173
index.d.ts vendored

@ -0,0 +1,173 @@
export as namespace EmojiPicker;
export = EmojiPicker;
declare namespace EmojiPicker {
export class EmojiPicker {
// Constructor Function
constructor(options?: EmojiPicker.Options);
// on event listener
on(event: Event, callback: (selection: EmojiSelection) => void): void;
// off event listener
off(event: Event, callback: (selection: EmojiSelection) => void): void;
// returns an emoji from twemoji based on an emoji input
getEmoji(emoji: String): Object;
// takes in html string and parses it together with the emoji
parse(htmlString: String): String;
// hidePicker function
hidePicker(): void;
// destroyPicker function
destroyPicker(): void;
// showPicker function
showPicker(referenceEl: HTMLElement): void;
// togglePicker function
togglePicker(referenceEl: HTMLElement): void;
// isPickerVisible function
isPickerVisible(): boolean;
// setTheme function
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;
boxShadow?: string | 'none';
theme?: EmojiTheme;
categories?: Category[];
style?: EmojiStyle;
twemojiBaseUrl?: string;
emojisPerRow?: number;
rows?: number;
emojiSize?: string;
initialCategory?: Category | 'recents';
custom?: CustomEmoji[];
plugins?: Plugin[];
icons?: Icons;
rootElement?: HTMLElement;
}
export interface TwemojiOptions {
base?: string,
ext: string,
folder: string
}
export interface FixedPosition {
top?: string;
bottom?: string;
left?: string;
right?: string;
}
export interface Plugin {
render(picker: EmojiPicker): HTMLElement;
destroy?(): void;
}
export interface EmojiSelection {
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;
}
}

27
nodejs.yml

@ -0,0 +1,27 @@
name: Node CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [8.x, 10.x, 12.x]
steps:
- uses: actions/checkout@v1
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: npm install, build, lint, and test
run: |
npm install
npm run build
npm run lint
npm test
env:
CI: true

67
package.json

@ -0,0 +1,67 @@
{
"name": "emoji-picker-js",
"version": "0.1.0",
"description": "An Emoji Picker and Parser",
"keywords": [
"emoji",
"javascript"
],
"main": "dist/index.js",
"module": "dist/index.js",
"types": "index.d.ts",
"scripts": {
"build": "cross-env NODE_ENV=production rollup -c",
"watch": "rollup -cw",
"test": "jest src/**.test.ts",
"test:watch": "jest --watchAll",
"lint": "eslint src/*.ts",
"prettify": "prettier src/*.ts --write"
},
"author": "LOTW <[email protected]>",
"repository": {
"type": "git",
"url": "https://github.com/lotw7277/emoji-picker-js.git"
},
"license": "MIT",
"devDependencies": {
"@babel/core": "^7.10.2",
"@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",
"babel-jest": "^26.0.1",
"babel-plugin-transform-class-properties": "^6.24.1",
"cross-env": "^7.0.2",
"eslint": "^7.2.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-prettier": "^3.1.3",
"jest": "^26.0.1",
"prettier": "^2.0.5",
"rollup": "^2.22.1",
"rollup-plugin-babel": "^4.4.0",
"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",
"ts-jest": "^26.1.0",
"typescript": "^3.9.5"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.28",
"@fortawesome/free-regular-svg-icons": "^5.13.0",
"@fortawesome/free-solid-svg-icons": "^5.13.0",
"@popperjs/core": "^2.4.0",
"focus-trap": "^5.1.0",
"fuzzysort": "^1.1.4",
"tiny-emitter": "^2.1.0",
"tslib": "^2.0.0",
"twemoji": "^13.0.1"
},
"files": [
"dist",
"index.d.ts"
]
}

34
rollup.config.js

@ -0,0 +1,34 @@
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 { terser } from 'rollup-plugin-terser';
const production = process.env.NODE_ENV === 'production';
export default {
input: 'src/index.ts',
output: {
file: 'dist/index.js',
format: 'es',
name: 'EmojiPicker'
},
watch: {
buildDelay: 500
},
plugins: [
replace({
'process.env.NODE_ENV': JSON.stringify(
production ? 'production' : 'development'
)
}),
postcss({
extensions: ['.css']
}),
typescript(),
resolve(),
commonjs(),
production && terser()
]
};

143
scripts/processEmojiData.js

@ -0,0 +1,143 @@
const fs = require('fs');
const readline = require('readline');
const DATA_LINE_REGEX = /((?:[0-9A-F]+ ?)+)\s+;(.+)\s+#.+E([0-9.]+) (.+)/;
const EMOJI_WITH_MODIFIER_REGEX = /([a-z]+): ([a-z -]+)/;
const EMOJI_WITH_SKIN_TONE_AND_MODIFIER_REGEX = /([a-z]+): ([a-z -]+), ([a-z ]+)/;
const categoryKeys = {
'Smileys & Emotion': 'smileys',
'People & Body': 'people',
'Animals & Nature': 'animals',
'Food & Drink': 'food',
'Travel & Places': 'travel',
'Activities': 'activities',
'Objects': 'objects',
'Symbols': 'symbols',
'Flags': 'flags'
};
const BLACKLIST = [
'light skin tone',
'medium-light skin tone',
'medium skin tone',
'medium-dark skin tone',
'dark skin tone',
'red hair',
'white hair',
'curly hair',
'bald'
];
const MODIFIER_SUBSTITUTIONS = {
'bald': 'no hair'
};
const stream = fs.createReadStream('emoji-test.txt');
const interface = readline.createInterface(stream);
let currentGroup;
let currentSubgroup;
let categoryIndex;
const data = {
categories: [],
emoji: []
};
interface.on('line', line => {
if (line.startsWith('# group:')) {
currentGroup = line.slice('# group: '.length);
if (currentGroup !== 'Component') {
data.categories.push(categoryKeys[currentGroup]);
categoryIndex = data.categories.length - 1;
}
} else if (line.startsWith('# subgroup:')) {
currentSubgroup = line.slice('# subgroup: '.length);
} else if (!line.startsWith('#') && currentGroup !== 'Component') {
const matcher = DATA_LINE_REGEX.exec(line);
if (matcher) {
const sequence = matcher[1].trim();
const emoji = getEmoji(sequence);
let name = matcher[4];
let version = matcher[3];
if (version === '0.6' || version === '0.7') {
version = '1.0';
}
if (currentSubgroup === 'person') {
const modifierMatcher = EMOJI_WITH_MODIFIER_REGEX.exec(name);
const skinToneMatcher = EMOJI_WITH_SKIN_TONE_AND_MODIFIER_REGEX.exec(name);
if (skinToneMatcher) {
name = skinToneMatcher[1] + ' with ' + substituteModifier(skinToneMatcher[3]) + ': ' + skinToneMatcher[2];
} else if (modifierMatcher) {
if (!modifierMatcher[2].includes('skin tone')) {
name = modifierMatcher[1] + ' with ' + substituteModifier(modifierMatcher[2]);
}
}
}
if (matcher[2].trim() !== 'unqualified') {
data.emoji.push({ sequence, emoji, category: categoryIndex, name, variations: [], version });
}
}
}
});
interface.on('close', () => {
stream.close();
let toDelete = [];
const emojisWithVariationSelector = data.emoji.filter(emoji => emoji.sequence.includes('FE0F'));
emojisWithVariationSelector.forEach(emoji => {
const baseEmoji = data.emoji.find(e => e.sequence === emoji.sequence.replace(' FE0F', ''));
toDelete.push(baseEmoji);
});
data.emoji = data.emoji.filter(e => !toDelete.includes(e));
toDelete = [];
BLACKLIST.forEach(name => toDelete.push(data.emoji.find(e => e.name === name)));
const emojisWithVariations = data.emoji.filter(emoji => emoji.name.includes(':') && !emoji.name.startsWith('family'));
emojisWithVariations.forEach(emoji => {
const baseName = emoji.name.split(':')[0];
const baseEmoji = data.emoji.find(e => e.name === baseName);
if (baseEmoji) {
baseEmoji.variations.push(emoji.emoji);
toDelete.push(emoji);
}
});
// Cleanup
data.emoji = data.emoji.filter(e => !toDelete.includes(e));
data.emoji.forEach(emoji => {
delete emoji.sequence;
if (!emoji.variations.length) {
delete emoji.variations;
}
});
fs.writeFileSync('src/data/emoji.js', `export default ${JSON.stringify(data)}`);
});
function getEmoji(sequence) {
const chars = sequence.split(' ');
const codePoints = chars.map(char => parseInt(char, 16));
return String.fromCodePoint(...codePoints);
}
function substituteModifier(name) {
const substitutions = Object.keys(MODIFIER_SUBSTITUTIONS);
for (let i = 0; i < substitutions.length; i++) {
const substitution = substitutions[i];
if (name.includes(substitution)) {
return name.replace(substitution, MODIFIER_SUBSTITUTIONS[substitution]);
}
}
return name;
}

71
src/categoryButtons.test.ts

@ -0,0 +1,71 @@
import { TinyEmitter as Emitter } from 'tiny-emitter';
import emojiData from './data/emoji';
import { CategoryButtons } from './categoryButtons';
import { i18n } from './i18n';
const emitter = new Emitter();
describe('CategoryButtons', () => {
test('should render all categories if no categories are specified', () => {
const container = new CategoryButtons({}, emitter, i18n).render();
const buttons = container.querySelectorAll('button');
expect(buttons).toHaveLength(emojiData.categories.length);
buttons.forEach((button, index) => {
expect(button.title).toEqual(
i18n.categories[emojiData.categories[index]]
);
});
});
test('should include the recents category if showRecents is true', () => {
const container = new CategoryButtons(
{ showRecents: true },
emitter,
i18n
).render();
const buttons = container.querySelectorAll('button');
expect(buttons).toHaveLength(emojiData.categories.length + 1);
expect(buttons[0].title).toEqual(i18n.categories.recents);
Array.prototype.slice.call(buttons, 1).forEach((button, index) => {
expect(button.title).toEqual(
i18n.categories[emojiData.categories[index]]
);
});
});
test('should only render specified categories if they are specified', () => {
const container = new CategoryButtons(
{
categories: ['smileys', 'animals']
},
emitter,
i18n
).render();
const buttons = container.querySelectorAll('button');
expect(buttons).toHaveLength(2);
expect(buttons[0].title).toEqual(i18n.categories.smileys);
expect(buttons[1].title).toEqual(i18n.categories.animals);
});
test('should include the recents with filtered categories if showRecents is true', () => {
const container = new CategoryButtons(
{
categories: ['smileys', 'animals'],
showRecents: true
},
emitter,
i18n
).render();
const buttons = container.querySelectorAll('button');
expect(buttons).toHaveLength(3);
expect(buttons[0].title).toEqual(i18n.categories.recents);
expect(buttons[1].title).toEqual(i18n.categories.smileys);
expect(buttons[2].title).toEqual(i18n.categories.animals);
});
});

118
src/categoryButtons.ts

@ -0,0 +1,118 @@
import { TinyEmitter as Emitter } from 'tiny-emitter';
import { CLASS_CATEGORY_BUTTONS, CLASS_CATEGORY_BUTTON } from './classes';
import emojiData from './data/emoji';
import { CATEGORY_CLICKED } from './events';
import * as icons from './icons';
import { createElement } from './util';
import { EmojiPickerOptions, I18NCategory, I18NStrings } from './types';
const categoryIcons: { [key in I18NCategory]: string } = {
recents: icons.history,
smileys: icons.smile,
people: icons.user,
animals: icons.cat,
food: icons.coffee,
activities: icons.futbol,
travel: icons.building,
objects: icons.lightbulb,
symbols: icons.music,
flags: icons.flag,
custom: icons.icons
};
export class CategoryButtons {
constructor(
private options: EmojiPickerOptions,
private events: Emitter,
private i18n: I18NStrings
) { }
activeButton = 0;
buttons: HTMLElement[] = [];
render(): HTMLElement {
const container = createElement('div', CLASS_CATEGORY_BUTTONS);
let categories = this.options.showRecents
? ['recents', ...(this.options.categories || emojiData.categories)]
: this.options.categories || emojiData.categories;
if (this.options.custom) {
categories = [...categories, 'custom'];
}
categories.forEach((category: string) => {
const button = createElement('button', CLASS_CATEGORY_BUTTON);
if (
this.options.icons &&
this.options.icons.categories &&
this.options.icons.categories[category]
) {
button.appendChild(
icons.createIcon(this.options.icons.categories[category])
);
} else {
button.innerHTML = categoryIcons[category];
}
button.tabIndex = -1;
button.title = this.i18n.categories[category];
container.appendChild(button);
this.buttons.push(button);
button.addEventListener('click', () => {
this.events.emit(CATEGORY_CLICKED, category);
});
});
container.addEventListener('keydown', event => {
switch (event.key) {
case 'ArrowRight':
this.events.emit(
CATEGORY_CLICKED,
categories[(this.activeButton + 1) % this.buttons.length]
);
break;
case 'ArrowLeft':
this.events.emit(
CATEGORY_CLICKED,
categories[
this.activeButton === 0
? this.buttons.length - 1
: this.activeButton - 1
]
);
break;
case 'ArrowUp':
case 'ArrowDown':
event.stopPropagation();
event.preventDefault();
}
});
return container;
}
setActiveButton(activeButton: number, focus = true): void {
let activeButtonEl = this.buttons[this.activeButton];
activeButtonEl.classList.remove('active');
activeButtonEl.tabIndex = -1;
this.activeButton = activeButton;
activeButtonEl = this.buttons[this.activeButton];
activeButtonEl.classList.add('active');
activeButtonEl.tabIndex = 0;
if (focus) {
activeButtonEl.focus();
}
}
}

23
src/classes.ts

@ -0,0 +1,23 @@
export const CLASS_CATEGORY_BUTTON = 'emoji-picker__category-button';
export const CLASS_CATEGORY_BUTTONS = 'emoji-picker__category-buttons';
export const CLASS_CATEGORY_NAME = 'emoji-picker__category-name';
export const CLASS_CUSTOM_EMOJI = 'emoji-picker__custom-emoji';
export const CLASS_EMOJI = 'emoji-picker__emoji';
export const CLASS_EMOJI_AREA = 'emoji-picker__emoji-area';
export const CLASS_EMOJI_CONTAINER = 'emoji-picker__container';
export const CLASS_EMOJIS = 'emoji-picker__emojis';
export const CLASS_NOT_FOUND = 'emoji-picker__search-not-found';
export const CLASS_NOT_FOUND_ICON = 'emoji-picker__search-not-found-icon';
export const CLASS_OVERLAY = 'emoji-picker__overlay';
export const CLASS_PICKER = 'emoji-picker';
export const CLASS_PICKER_CONTENT = 'emoji-picker__content';
export const CLASS_PLUGIN_CONTAINER = 'emoji-picker__plugin-container';
export const CLASS_PREVIEW = 'emoji-picker__preview';
export const CLASS_PREVIEW_EMOJI = 'emoji-picker__preview-emoji';
export const CLASS_PREVIEW_NAME = 'emoji-picker__preview-name';
export const CLASS_SEARCH_CONTAINER = 'emoji-picker__search-container';
export const CLASS_SEARCH_FIELD = 'emoji-picker__search';
export const CLASS_SEARCH_ICON = 'emoji-picker__search-icon';
export const CLASS_VARIANT_OVERLAY = 'emoji-picker__variant-overlay';
export const CLASS_VARIANT_POPUP = 'emoji-picker__variant-popup';
export const CLASS_WRAPPER = 'emoji-picker__wrapper';

1
src/data/emoji.js

File diff suppressed because one or more lines are too long

65
src/emoji.test.ts

@ -0,0 +1,65 @@
import { TinyEmitter as Emitter } from 'tiny-emitter';
import { EMOJI, HIDE_PREVIEW, SHOW_PREVIEW } from './events';
import { Emoji } from './emoji';
import { EmojiPickerOptions } from './types';
describe('Emoji', () => {
let events;
const testEmoji = {
emoji: '😄',
name: 'smile',
category: 0,
version: '11.0'
};
const options: EmojiPickerOptions = { showRecents: true, style: 'native' };
beforeEach(() => (events = new Emitter()));
test('should render the emoji', () => {
const emoji = new Emoji(testEmoji, false, false, events, options);
const element = emoji.render();
expect(element.innerHTML).toEqual(testEmoji.emoji);
});
test('should emit the EMOJI event when clicked', done => {
const emoji = new Emoji(testEmoji, false, false, events, options);
const element = emoji.render();
events.on(EMOJI, e => {
expect(e).toEqual({
emoji: testEmoji,
showVariants: false,
button: element
});
done();
});
element.dispatchEvent(new MouseEvent('click'));
});
test('should emit the SHOW_PREVIEW event on mouseover if showPreview is true', done => {
const emoji = new Emoji(testEmoji, false, true, events, options);
const element = emoji.render();
events.on(SHOW_PREVIEW, e => {
expect(e).toEqual(testEmoji);
done();
});
element.dispatchEvent(new MouseEvent('mouseover'));
});
test('should emit the HIDE_PREVIEW event on mouseout if showPreview is true', done => {
const emoji = new Emoji(testEmoji, false, true, events, options);
const element = emoji.render();
events.on(HIDE_PREVIEW, () => {
done();
});
element.dispatchEvent(new MouseEvent('mouseout'));
});
});

131
src/emoji.ts

@ -0,0 +1,131 @@
import { TinyEmitter as Emitter } from 'tiny-emitter';
import twemoji from 'twemoji';
import { EMOJI, HIDE_PREVIEW, SHOW_PREVIEW } from './events';
import { smile } from './icons';
import { save } from './recent';
import { createElement } from './util';
import { CLASS_EMOJI, CLASS_CUSTOM_EMOJI } from './classes';
import { EmojiPickerOptions, EmojiRecord, TwemojiOptions } from './types';
const DEFAULT_TWEMOJI_OPTIONS: TwemojiOptions = {
ext: '.svg',
folder: 'svg'
};
export class Emoji {
private EmojiPicker: HTMLElement;
private emoji: EmojiRecord;
private showVariants: boolean;
private showPreview: boolean;
private events: Emitter;
private options: EmojiPickerOptions;
private twOptions: TwemojiOptions;
private lazy = true;
constructor(
emoji: EmojiRecord,
showVariants: boolean,
showPreview: boolean,
events: Emitter,
options: EmojiPickerOptions,
lazy = true
) {
this.emoji = emoji
this.showVariants = showVariants
this.showPreview = showPreview
this.events = events
this.options = options
this.lazy = lazy
// Check for twemojiBaseUrl, if present add to the default options
options.twemojiBaseUrl ? this.twOptions = { ...DEFAULT_TWEMOJI_OPTIONS, base: options.twemojiBaseUrl } : this.twOptions = { ...DEFAULT_TWEMOJI_OPTIONS }
}
render(): HTMLElement {
this.EmojiPicker = createElement('button', 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, this.twOptions);
}
this.EmojiPicker.innerHTML = content;
// this.options.style === 'native'
// ? this.emoji.emoji
// : this.lazy
// ? smile
// : twemoji.parse(this.emoji.emoji);
this.EmojiPicker.tabIndex = -1;
this.EmojiPicker.dataset.emoji = this.emoji.emoji;
if (this.emoji.custom) {
this.EmojiPicker.dataset.custom = 'true';
}
this.EmojiPicker.title = this.emoji.name;
this.EmojiPicker.addEventListener('focus', () => this.onEmojiHover());
this.EmojiPicker.addEventListener('blur', () => this.onEmojiLeave());
this.EmojiPicker.addEventListener('click', () => this.onEmojiClick());
this.EmojiPicker.addEventListener('mouseover', () => this.onEmojiHover());
this.EmojiPicker.addEventListener('mouseout', () => this.onEmojiLeave());
if (this.options.style === 'twemoji' && this.lazy) {
this.EmojiPicker.style.opacity = '0.25';
}
return this.EmojiPicker;
}
onEmojiClick(): void {
// TODO move this side effect out of Emoji, make the recent module listen for event
if (
(!(this.emoji as EmojiRecord).variations ||
!this.showVariants ||
!this.options.showVariants) &&
this.options.showRecents
) {
save(this.emoji, this.options);
}
this.events.emit(EMOJI, {
emoji: this.emoji,
showVariants: this.showVariants,
button: this.EmojiPicker
});
}
onEmojiHover(): void {
if (this.showPreview) {
this.events.emit(SHOW_PREVIEW, this.emoji);
}
}
onEmojiLeave(): void {
if (this.showPreview) {
this.events.emit(HIDE_PREVIEW);
}
}
}

84
src/emojiArea.test.ts

@ -0,0 +1,84 @@
import { TinyEmitter as Emitter } from 'tiny-emitter';
import emojiData from './data/emoji';
import { EmojiArea } from './emojiArea';
import { i18n } from './i18n';
const emitter = new Emitter();
describe('EmojiArea', () => {
test('renders an emoji list for each category', () => {
const emojiArea = new EmojiArea(emitter, i18n, {
emojiVersion: '11.0'
}).render();
const containers = emojiArea.querySelectorAll('.emoji-picker__container');
expect(containers).toHaveLength(emojiData.categories.length);
const names = emojiArea.querySelectorAll('h2');
expect(names).toHaveLength(emojiData.categories.length);
names.forEach((name, index) => {
expect(name.innerHTML.replace('&amp;', '&')).toEqual(
i18n.categories[emojiData.categories[index]]
);
});
});
test('only renders emoji lists for specified categories', () => {
const emojiArea = new EmojiArea(emitter, i18n, {
emojiVersion: '11.0',
categories: ['smileys', 'animals']
}).render();
const containers = emojiArea.querySelectorAll('.emoji-picker__container');
expect(containers).toHaveLength(2);
const names = emojiArea.querySelectorAll('h2');
expect(names).toHaveLength(2);
expect(names[0].innerHTML.replace('&amp;', '&')).toEqual(
i18n.categories.smileys
);
expect(names[1].innerHTML.replace('&amp;', '&')).toEqual(
i18n.categories.animals
);
});
test('includes the recents category if showRecents is true', () => {
const emojiArea = new EmojiArea(emitter, i18n, {
emojiVersion: '11.0',
categories: ['smileys', 'animals'],
showRecents: true
}).render();
const containers = emojiArea.querySelectorAll('.emoji-picker__container');
expect(containers).toHaveLength(3);
const names = emojiArea.querySelectorAll('h2');
expect(names).toHaveLength(3);
expect(names[0].innerHTML).toEqual(i18n.categories.recents);
expect(names[1].innerHTML.replace('&amp;', '&')).toEqual(
i18n.categories.smileys
);
expect(names[2].innerHTML.replace('&amp;', '&')).toEqual(
i18n.categories.animals
);
});
test('selects the initial category', () => {
const emojiArea = new EmojiArea(emitter, i18n, {
emojiVersion: '11.0',
categories: ['smileys', 'animals'],
showRecents: true,
initialCategory: 'animals',
showCategoryButtons: true
});
const container = emojiArea.render();
emojiArea.reset();
const buttons = container.querySelectorAll(
'.emoji-picker__category-button'
);
expect(buttons[2].classList).toContain('active');
});
});

347
src/emojiArea.ts

@ -0,0 +1,347 @@
import { TinyEmitter as Emitter } from 'tiny-emitter';
import emojiData from './data/emoji';
import { i18n as defaultI18n } from './i18n';
import { CLASS_EMOJI_CONTAINER, CLASS_EMOJI, CLASS_EMOJI_AREA, CLASS_EMOJIS, CLASS_CATEGORY_NAME } from './classes';
import { CategoryButtons } from './categoryButtons';
import { EmojiContainer } from './emojiContainer';
import { CATEGORY_CLICKED } from './events';
import { I18NStrings, EmojiPickerOptions, EmojiRecord, RecentEmoji } from './types';
import { createElement } from './util';
import { load } from './recent';
const emojiCategories: { [key: string]: EmojiRecord[] } = {};
emojiData.emoji.forEach(emoji => {
let categoryList = emojiCategories[emojiData.categories[emoji.category]];
if (!categoryList) {
categoryList = emojiCategories[emojiData.categories[emoji.category]] = [];
}
categoryList.push(emoji);
});
export class EmojiArea {
private headerOffsets: number[];
private currentCategory = 0;
private headers: HTMLElement[] = [];
private categoryButtons: CategoryButtons;
private emojisPerRow: number;
private categories: string[];
private focusedIndex = 0;
container: HTMLElement;
emojis: HTMLElement;
constructor(
private events: Emitter,
private i18n: I18NStrings,
private options: EmojiPickerOptions
) {
this.emojisPerRow = options.emojisPerRow || 8;
this.categories = options.categories || emojiData.categories;
if (options.showRecents) {
this.categories = ['recents', ...this.categories];
}
if (options.custom) {
this.categories = [...this.categories, 'custom'];
}
}
updateRecents(): void {
if (this.options.showRecents) {
emojiCategories.recents = load();
const recentsContainer = this.emojis.querySelector(
`.${CLASS_EMOJI_CONTAINER}`
) as HTMLElement;
if (recentsContainer && recentsContainer.parentNode) {
recentsContainer.parentNode.replaceChild(
new EmojiContainer(
emojiCategories.recents,
true,
this.events,
this.options,
false
).render(),
recentsContainer
);
}
}
}
render(): HTMLElement {
this.container = createElement('div', CLASS_EMOJI_AREA);
if (this.options.showCategoryButtons) {
this.categoryButtons = new CategoryButtons(
this.options,
this.events,
this.i18n
);
this.container.appendChild(this.categoryButtons.render());
}
this.emojis = createElement('div', CLASS_EMOJIS);
if (this.options.showRecents) {
emojiCategories.recents = load();
}
if (this.options.custom) {
emojiCategories.custom = this.options.custom.map(custom => ({
...custom,
custom: true
}));
}
this.categories.forEach(category =>
this.addCategory(category, emojiCategories[category])
);
requestAnimationFrame(() => {
setTimeout(() => {
setTimeout(() =>
this.emojis.addEventListener('scroll', this.highlightCategory)
);
});
});
this.emojis.addEventListener('keydown', this.handleKeyDown);
this.events.on(CATEGORY_CLICKED, this.selectCategory);
this.container.appendChild(this.emojis);
const firstEmoji = this.container.querySelectorAll(
`.${CLASS_EMOJI}`
)[0] as HTMLElement;
firstEmoji.tabIndex = 0;
return this.container;
}
reset(): void {
this.headerOffsets = Array.prototype.map.call(
this.headers,
header => header.offsetTop
) as number[];
this.selectCategory(this.options.initialCategory || 'smileys', false);
this.currentCategory = this.categories.indexOf(
(this.options.initialCategory as string) || 'smileys'
);
if (this.options.showCategoryButtons) {
this.categoryButtons.setActiveButton(this.currentCategory, false);
}
}
private get currentCategoryEl(): HTMLElement {
return this.emojis.querySelectorAll(`.${CLASS_EMOJI_CONTAINER}`)[
this.currentCategory
] as HTMLElement;
}
private get focusedEmoji(): HTMLElement {
return this.currentCategoryEl.querySelectorAll(`.${CLASS_EMOJI}`)[
this.focusedIndex
] as HTMLElement;
}
private get currentEmojiCount(): number {
return this.currentCategoryEl.querySelectorAll(`.${CLASS_EMOJI}`).length;
}
private getEmojiCount(category: number): number {
const container = this.emojis.querySelectorAll(`.${CLASS_EMOJI_CONTAINER}`)[
category
] as HTMLElement;
return container.querySelectorAll(`.${CLASS_EMOJI}`).length;
}
private handleKeyDown = (event: KeyboardEvent): void => {
this.emojis.removeEventListener('scroll', this.highlightCategory);
switch (event.key) {
case 'ArrowRight':
this.focusedEmoji.tabIndex = -1;
if (
this.focusedIndex === this.currentEmojiCount - 1 &&
this.currentCategory < this.categories.length - 1
) {
if (this.options.showCategoryButtons) {
this.categoryButtons.setActiveButton(++this.currentCategory);
}
this.setFocusedEmoji(0);
} else if (this.focusedIndex < this.currentEmojiCount - 1) {
this.setFocusedEmoji(this.focusedIndex + 1);
}
break;
case 'ArrowLeft':
this.focusedEmoji.tabIndex = -1;
if (this.focusedIndex === 0 && this.currentCategory > 0) {
if (this.options.showCategoryButtons) {
this.categoryButtons.setActiveButton(--this.currentCategory);
}
this.setFocusedEmoji(this.currentEmojiCount - 1);
} else {
this.setFocusedEmoji(Math.max(0, this.focusedIndex - 1));
}
break;
case 'ArrowDown':
event.preventDefault();
this.focusedEmoji.tabIndex = -1;
if (
this.focusedIndex + this.emojisPerRow >= this.currentEmojiCount &&
this.currentCategory < this.categories.length - 1
) {
this.currentCategory++;
if (this.options.showCategoryButtons) {
this.categoryButtons.setActiveButton(this.currentCategory);
}
this.setFocusedEmoji(
Math.min(
this.focusedIndex % this.emojisPerRow,
this.currentEmojiCount - 1
)
);
} else if (
this.currentEmojiCount - this.focusedIndex >
this.emojisPerRow
) {
this.setFocusedEmoji(this.focusedIndex + this.emojisPerRow);
}
break;
case 'ArrowUp':
event.preventDefault();
this.focusedEmoji.tabIndex = -1;
if (this.focusedIndex < this.emojisPerRow && this.currentCategory > 0) {
const previousCategoryCount = this.getEmojiCount(
this.currentCategory - 1
);
let previousLastRowCount = previousCategoryCount % this.emojisPerRow;
if (previousLastRowCount === 0) {
previousLastRowCount = this.emojisPerRow;
}
const currentColumn = this.focusedIndex;
const newIndex =
currentColumn > previousLastRowCount - 1
? previousCategoryCount - 1
: previousCategoryCount - previousLastRowCount + currentColumn;
this.currentCategory--;
if (this.options.showCategoryButtons) {
this.categoryButtons.setActiveButton(this.currentCategory);
}
this.setFocusedEmoji(newIndex);
} else {
this.setFocusedEmoji(
this.focusedIndex >= this.emojisPerRow
? this.focusedIndex - this.emojisPerRow
: this.focusedIndex
);
}
break;
}
requestAnimationFrame(() =>
this.emojis.addEventListener('scroll', this.highlightCategory)
);
};
private setFocusedEmoji(index: number, focus = true): void {
this.focusedIndex = index;
if (this.focusedEmoji) {
this.focusedEmoji.tabIndex = 0;
if (focus) {
this.focusedEmoji.focus();
}
}
}
private addCategory = (
category: string,
emojis: Array<EmojiRecord | RecentEmoji>
): void => {
const name = createElement('h2', CLASS_CATEGORY_NAME);
name.innerHTML =
this.i18n.categories[category] || defaultI18n.categories[category];
this.emojis.appendChild(name);
this.headers.push(name);
this.emojis.appendChild(
new EmojiContainer(
emojis,
true,
this.events,
this.options,
category !== 'recents'
).render()
);
};
selectCategory = (category: string, focus = true): void => {
this.emojis.removeEventListener('scroll', this.highlightCategory);
if (this.focusedEmoji) {
this.focusedEmoji.tabIndex = -1;
}
const categoryIndex = this.categories.indexOf(category);
this.currentCategory = categoryIndex;
this.setFocusedEmoji(0, false);
if (this.options.showCategoryButtons) {
this.categoryButtons.setActiveButton(this.currentCategory, focus);
}
const targetPosition = this.headerOffsets[categoryIndex];
this.emojis.scrollTop = targetPosition;
requestAnimationFrame(() =>
this.emojis.addEventListener('scroll', this.highlightCategory)
);
};
highlightCategory = (): void => {
if (
document.activeElement &&
document.activeElement.classList.contains('emoji-picker__emoji')
) {
return;
}
let closestHeaderIndex = this.headerOffsets.findIndex(
offset => offset >= Math.round(this.emojis.scrollTop)
);
if (
this.emojis.scrollTop + this.emojis.offsetHeight ===
this.emojis.scrollHeight
) {
closestHeaderIndex = -1;
}
if (closestHeaderIndex === 0) {
closestHeaderIndex = 1;
} else if (closestHeaderIndex < 0) {
closestHeaderIndex = this.headerOffsets.length;
}
if (this.headerOffsets[closestHeaderIndex] === this.emojis.scrollTop) {
closestHeaderIndex++;
}
this.currentCategory = closestHeaderIndex - 1;
if (this.options.showCategoryButtons) {
this.categoryButtons.setActiveButton(this.currentCategory);
}
};
}

19
src/emojiContainer.test.ts

@ -0,0 +1,19 @@
import { TinyEmitter as Emitter } from 'tiny-emitter';
import { EmojiContainer } from './emojiContainer';
describe('EmojiContainer', () => {
test('should render all the given emojis', () => {
const emojis = [
{ emoji: '⚡', version: '12.1', name: 'zap', category: 0 },
{ emoji: '👍', version: '12.1', name: 'thumbs up', category: 0 }
];
const events = new Emitter();
const container = new EmojiContainer(emojis, false, events, {
emojiVersion: '12.1'
}).render();
expect(container.querySelectorAll('.emoji-picker__emoji').length).toBe(2);
});
});

45
src/emojiContainer.ts

@ -0,0 +1,45 @@
import { TinyEmitter as Emitter } from 'tiny-emitter';
import { Emoji } from './emoji';
import { createElement } from './util';
import { CLASS_EMOJI_CONTAINER } from './classes';
import { EmojiPickerOptions, EmojiRecord, RecentEmoji } from './types';
export class EmojiContainer {
private emojis: Array<EmojiRecord | RecentEmoji>;
constructor(
emojis: Array<EmojiRecord | RecentEmoji>,
private showVariants: boolean,
private events: Emitter,
private options: EmojiPickerOptions,
private lazy = true
) {
this.emojis = emojis.filter(
e =>
!(e as EmojiRecord).version ||
parseFloat((e as EmojiRecord).version as string) <=
parseFloat(options.emojiVersion as string)
);
}
render(): HTMLElement {
const emojiContainer = createElement('div', CLASS_EMOJI_CONTAINER);
this.emojis.forEach(emoji =>
emojiContainer.appendChild(
new Emoji(
emoji,
this.showVariants,
true,
this.events,
this.options,
this.lazy
).render()
)
);
return emojiContainer;
}
}

8
src/events.ts

@ -0,0 +1,8 @@
export const EMOJI = 'emoji';
export const SHOW_SEARCH_RESULTS = 'showSearchResults';
export const HIDE_SEARCH_RESULTS = 'hideSearchResults';
export const SHOW_PREVIEW = 'showPreview';
export const HIDE_PREVIEW = 'hidePreview';
export const HIDE_VARIANT_POPUP = 'hideVariantPopup';
export const CATEGORY_CLICKED = 'categoryClicked';
export const PICKER_HIDDEN = 'hidden';

19
src/i18n.ts

@ -0,0 +1,19 @@
import { I18NStrings } from './types';
export const i18n: I18NStrings = {
search: 'Search Emojis...',
categories: {
recents: 'Recent Emojis',
smileys: 'Smileys & Emotion',
people: 'People & Body',
animals: 'Animals & Nature',
food: 'Food & Drink',
activities: 'Activities',
travel: 'Travel & Places',
objects: 'Objects',
symbols: 'Symbols',
flags: 'Flags',
custom: 'Custom'
},
notFound: 'No emojis found'
};

41
src/icons.ts

@ -0,0 +1,41 @@
import { library, icon } from '@fortawesome/fontawesome-svg-core';
import { faCat, faCoffee, faFutbol, faHistory, faIcons, faMusic, faSearch, faTimes, faUser } from '@fortawesome/free-solid-svg-icons';
import { faBuilding, faFlag, faFrown, faLightbulb, faSmile } from '@fortawesome/free-regular-svg-icons';
library.add(
faBuilding,
faCat,
faCoffee,
faFlag,
faFrown,
faFutbol,
faHistory,
faIcons,
faLightbulb,
faMusic,
faSearch,
faSmile,
faTimes,
faUser
);
export const building = icon({ prefix: 'far', iconName: 'building' }).html[0];
export const cat = icon({ prefix: 'fas', iconName: 'cat' }).html[0];
export const coffee = icon({ prefix: 'fas', iconName: 'coffee' }).html[0];
export const flag = icon({ prefix: 'far', iconName: 'flag' }).html[0];
export const futbol = icon({ prefix: 'fas', iconName: 'futbol' }).html[0];
export const frown = icon({ prefix: 'far', iconName: 'frown' }).html[0];
export const history = icon({ prefix: 'fas', iconName: 'history' }).html[0];
export const icons = icon({ prefix: 'fas', iconName: 'icons' }).html[0];
export const lightbulb = icon({ prefix: 'far', iconName: 'lightbulb' }).html[0];
export const music = icon({ prefix: 'fas', iconName: 'music' }).html[0];
export const search = icon({ prefix: 'fas', iconName: 'search' }).html[0];
export const smile = icon({ prefix: 'far', iconName: 'smile' }).html[0];
export const times = icon({ prefix: 'fas', iconName: 'times' }).html[0];
export const user = icon({ prefix: 'fas', iconName: 'user' }).html[0];
export function createIcon(src: string): HTMLImageElement {
const img = document.createElement('img') as HTMLImageElement;
img.src = src;
return img;
};

579
src/index.ts

@ -0,0 +1,579 @@
import '../css/emoji-picker.css';
import createFocusTrap, { FocusTrap } from 'focus-trap';
import { TinyEmitter as Emitter } from 'tiny-emitter';
import { createPopper, Instance as Popper, Placement } from '@popperjs/core';
import twemoji from 'twemoji';
import emojiData from './data/emoji';
import { EMOJI, SHOW_SEARCH_RESULTS, HIDE_SEARCH_RESULTS, HIDE_VARIANT_POPUP, PICKER_HIDDEN } from './events';
import { EmojiPreview } from './preview';
import { Search } from './search';
import { createElement, empty } from './util';
import { VariantPopup } from './variantPopup';
import { i18n } from './i18n';
import {
CLASS_PICKER, CLASS_PICKER_CONTENT, CLASS_EMOJI, CLASS_SEARCH_FIELD, CLASS_VARIANT_OVERLAY,
CLASS_WRAPPER, CLASS_OVERLAY, CLASS_CUSTOM_EMOJI, CLASS_PLUGIN_CONTAINER
} from './classes';
import { EmojiPickerOptions, TwemojiOptions, I18NStrings, EmojiRecord, EmojiTheme } from './types';
import { EmojiArea } from './emojiArea';
const DEFAULT_TWEMOJI_OPTIONS: TwemojiOptions = {
ext: '.svg',
folder: 'svg'
};
const DEFAULT_OPTIONS: EmojiPickerOptions = {
position: 'auto',
autoHide: true,
autoFocusSearch: true,
showAnimation: 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',
boxShadow: 'none',
emojisPerRow: 8,
rows: 6,
emojiSize: '1.8em',
initialCategory: 'smileys'
};
export class EmojiPicker {
private pickerVisible: boolean;
private hideInProgress: boolean;
private events = new Emitter();
private publicEvents = new Emitter();
private options: EmojiPickerOptions;
private twOptions: TwemojiOptions
private i18n: I18NStrings;
private pickerEl: HTMLElement;
private pickerContent: HTMLElement;
private wrapper: HTMLElement;
private focusTrap: FocusTrap;
private emojiArea: EmojiArea;
private overlay?: HTMLElement;
private popper: Popper;
private observer: IntersectionObserver;
private theme: EmojiTheme;
constructor(options: EmojiPickerOptions = {}) {
this.pickerVisible = false;
this.options = { ...DEFAULT_OPTIONS, ...options };
// Check for twemojiBaseUrl, if present add to the default options
options.twemojiBaseUrl ? this.twOptions = { ...DEFAULT_TWEMOJI_OPTIONS, base: options.twemojiBaseUrl } : this.twOptions = { ...DEFAULT_TWEMOJI_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);
this.hidePicker = this.hidePicker.bind(this);
this.theme = this.options.theme || 'light';
this.initPicker();
}
private initPicker(): void {
this.pickerEl = createElement('div', CLASS_PICKER);
this.updateTheme(this.theme);
if (!this.options.showAnimation) {
this.pickerEl.style.setProperty('--animation-duration', '0s');
}
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"]'
});
this.pickerContent = createElement('div', CLASS_PICKER_CONTENT);
if (this.options.plugins) {
const pluginContainer = createElement('div', CLASS_PLUGIN_CONTAINER);
this.options.plugins.forEach(plugin => {
if (!plugin.render) {
throw new Error(
'Emoji Button plugins must have a "render" function.'
);
}
pluginContainer.appendChild(plugin.render(this));
});
this.pickerEl.appendChild(pluginContainer);
}
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)
),
this.hidePicker
).render();
this.pickerEl.appendChild(searchContainer);
}
this.pickerEl.appendChild(this.pickerContent);
this.emojiArea = new EmojiArea(this.events, this.i18n, this.options);
this.pickerContent.appendChild(this.emojiArea.render());
this.events.on(SHOW_SEARCH_RESULTS, (searchResults: HTMLElement) => {
empty(this.pickerContent);
searchResults.classList.add('search-results');
this.pickerContent.appendChild(searchResults);
});
this.events.on(HIDE_SEARCH_RESULTS, () => {
if (this.pickerContent.firstChild !== this.emojiArea.container) {
empty(this.pickerContent);
this.pickerContent.appendChild(this.emojiArea.container);
}
this.emojiArea.reset();
});
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
) {
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,
custom: true
});
} else if (this.options.style === 'twemoji') {
twemoji.parse(emoji.emoji, {
...this.twOptions,
callback: (icon, options) => {
this.publicEvents.emit(EMOJI, {
url: `${options.base}${options.size}/${icon}${options.ext}`,
emoji: emoji.emoji
});
}
});
} else {
this.publicEvents.emit(EMOJI, {
emoji: emoji.emoji
});
}
if (this.options.autoHide) {
this.hidePicker();
}
}
}
);
this.wrapper = createElement('div', CLASS_WRAPPER);
this.wrapper.appendChild(this.pickerEl);
this.wrapper.style.display = 'none';
if (this.options.zIndex) {
this.wrapper.style.zIndex = this.options.zIndex + '';
}
if (this.options.boxShadow) {
this.wrapper.style.boxShadow = this.options.boxShadow;
}
if (this.options.rootElement) {
this.options.rootElement.appendChild(this.wrapper);
}
this.observeForLazyLoad();
}
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);
}
getEmoji(emoji: String) {
let response: Object = {};
twemoji.parse(emoji, {
...this.twOptions,
callback: (icon, options) => {
response = {
url: `${options.base}${options.size}/${icon}${options.ext}`,
emoji: emoji
}
return response;
}
});
return response;
}
parse(htmlString: String) {
return twemoji.parse(htmlString, {
...this.twOptions
});
}
private showVariantPopup(emoji: EmojiRecord) {
const variantPopup = new VariantPopup(
this.events,
emoji,
this.options
).render();
if (variantPopup) {
this.pickerEl.appendChild(variantPopup);
}
this.events.on(HIDE_VARIANT_POPUP, () => {
if (variantPopup) {
variantPopup.classList.add('hiding');
setTimeout(() => {
variantPopup && this.pickerEl.removeChild(variantPopup);
}, 175);
}
this.events.off(HIDE_VARIANT_POPUP);
});
}
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,
this.twOptions
);
element.dataset.loaded = true;
element.style.opacity = '1';
}
}
});
};
this.observer = new IntersectionObserver(onChange, {
root: this.emojiArea.emojis
});
const emojiElements = this.emojiArea.emojis.querySelectorAll(
`.${CLASS_EMOJI}`
);
emojiElements.forEach(element => {
if (
this.options.style === 'twemoji' ||
(element as HTMLElement).dataset.custom === 'true'
) {
this.observer.observe(element);
}
});
}
private onDocumentClick(event: MouseEvent): void {
if (!this.pickerEl.contains(event.target as Node)) {
this.hidePicker();
}
}
destroyPicker(): void {
this.events.off(EMOJI);
this.events.off(HIDE_VARIANT_POPUP);
if (this.options.rootElement) {
this.options.rootElement.removeChild(this.wrapper);
this.popper && this.popper.destroy();
}
this.observer && this.observer.disconnect();
if (this.options.plugins) {
this.options.plugins.forEach(plugin => {
plugin.destroy && plugin.destroy();
});
}
}
hidePicker(): void {
this.hideInProgress = true;
this.focusTrap.deactivate();
this.pickerVisible = false;
this.popper && this.popper.destroy();
if (this.overlay) {
document.body.removeChild(this.overlay);
this.overlay = undefined;
}
// In some browsers, the delayed hide was triggering the scroll event handler
// and stealing the focus. Remove the scroll listener before doing the delayed hide.
this.emojiArea.emojis.removeEventListener(
'scroll',
this.emojiArea.highlightCategory
);
this.pickerEl.classList.add('hiding');
setTimeout(
() => {
this.wrapper.style.display = 'none';
this.pickerEl.classList.remove('hiding');
if (this.pickerContent.firstChild !== this.emojiArea.container) {
empty(this.pickerContent);
this.pickerContent.appendChild(this.emojiArea.container);
}
const searchField = this.pickerEl.querySelector(
`.${CLASS_SEARCH_FIELD}`
) as HTMLInputElement;
if (searchField) {
searchField.value = '';
}
const variantOverlay = this.pickerEl.querySelector(
`.${CLASS_VARIANT_OVERLAY}`
);
if (variantOverlay) {
this.events.emit(HIDE_VARIANT_POPUP);
}
this.hideInProgress = false;
this.publicEvents.emit(PICKER_HIDDEN);
},
this.options.showAnimation ? 170 : 0
);
setTimeout(() => {
document.removeEventListener('click', this.onDocumentClick);
document.removeEventListener('keydown', this.onDocumentKeydown);
});
}
showPicker(referenceEl: HTMLElement): void {
if (this.hideInProgress) {
setTimeout(() => this.showPicker(referenceEl), 100);
return;
}
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;
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 = 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,
modifiers: [
{
name: 'offset',
options: {
offset: [0, 15]
}
}
]
});
} else if (
this.options.position &&
(this.options.position.top || this.options.position.left)
) {
this.wrapper.style.position = 'fixed';
if (this.options.position.top) {
this.wrapper.style.top = this.options.position.top;
}
if (this.options.position.bottom) {
this.wrapper.style.bottom = this.options.position.bottom;
}
if (this.options.position.left) {
this.wrapper.style.left = this.options.position.left;
}
if (this.options.position.right) {
this.wrapper.style.right = this.options.position.right;
}
}
this.focusTrap.activate();
setTimeout(() => {
document.addEventListener('click', this.onDocumentClick);
document.addEventListener('keydown', this.onDocumentKeydown);
const initialFocusElement = this.pickerEl.querySelector(
this.options.showSearch && this.options.autoFocusSearch
? `.${CLASS_SEARCH_FIELD}`
: `.${CLASS_EMOJI}[tabindex="0"]`
) as HTMLElement;
initialFocusElement.focus();
});
this.emojiArea.reset();
}
togglePicker(referenceEl: HTMLElement): void {
this.pickerVisible ? this.hidePicker() : this.showPicker(referenceEl);
}
isPickerVisible(): boolean {
return this.pickerVisible;
}
private 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(
`.${CLASS_SEARCH_FIELD}`
) as HTMLInputElement;
searchField && searchField.focus();
}
}
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);
}
}

28
src/preview.test.ts

@ -0,0 +1,28 @@
import { TinyEmitter as Emitter } from 'tiny-emitter';
import { SHOW_PREVIEW, HIDE_PREVIEW } from './events';
import { EmojiPreview } from './preview';
describe('EmojiPreview', () => {
test('should show an emoji preview on the SHOW_PREVIEW event and remove it on the HIDE_PREVIEW event', () => {
const events = new Emitter();
const preview = new EmojiPreview(events, { style: 'native' }).render();
events.emit(SHOW_PREVIEW, { emoji: '⚡', name: 'zap' });
const previewEmoji = preview.querySelector(
'.emoji-picker__preview-emoji'
) as HTMLElement;
expect(previewEmoji.innerHTML).toBe('⚡');
const previewName = preview.querySelector(
'.emoji-picker__preview-name'
) as HTMLElement;
expect(previewName.innerHTML).toBe('zap');
events.emit(HIDE_PREVIEW);
expect(previewEmoji.innerHTML).toBe('');
expect(previewName.innerHTML).toBe('');
});
});

66
src/preview.ts

@ -0,0 +1,66 @@
import { TinyEmitter as Emitter } from 'tiny-emitter';
import twemoji from 'twemoji';
import { SHOW_PREVIEW, HIDE_PREVIEW } from './events';
import { createElement } from './util';
import { EmojiRecord, EmojiPickerOptions, TwemojiOptions } from './types';
import { CLASS_PREVIEW, CLASS_PREVIEW_EMOJI, CLASS_PREVIEW_NAME, CLASS_CUSTOM_EMOJI } from './classes';
const DEFAULT_TWEMOJI_OPTIONS: TwemojiOptions = {
ext: '.svg',
folder: 'svg'
};
export class EmojiPreview {
private emoji: HTMLElement;
private name: HTMLElement;
private options: EmojiPickerOptions;
private twOptions: TwemojiOptions;
constructor(private events: Emitter, options: EmojiPickerOptions) {
this.options = options
// Check for twemojiBaseUrl, if present add to the default options
options.twemojiBaseUrl ? this.twOptions = { ...DEFAULT_TWEMOJI_OPTIONS, base: options.twemojiBaseUrl } : this.twOptions = { ...DEFAULT_TWEMOJI_OPTIONS }
}
render(): HTMLElement {
const preview = createElement('div', CLASS_PREVIEW);
this.emoji = createElement('div', CLASS_PREVIEW_EMOJI);
preview.appendChild(this.emoji);
this.name = createElement('div', CLASS_PREVIEW_NAME);
preview.appendChild(this.name);
this.events.on(SHOW_PREVIEW, (emoji: EmojiRecord) =>
this.showPreview(emoji)
);
this.events.on(HIDE_PREVIEW, () => this.hidePreview());
return preview;
}
showPreview(emoji: EmojiRecord): void {
let content = emoji.emoji;
if (emoji.custom) {
content = `<img class="${CLASS_CUSTOM_EMOJI}" src="${emoji.emoji}">`;
} else if (this.options.style === 'twemoji') {
content = twemoji.parse(emoji.emoji, this.twOptions);
}
this.emoji.innerHTML = content;
this.name.innerHTML = emoji.name;
}
hidePreview(): void {
this.emoji.innerHTML = '';
this.name.innerHTML = '';
}
}

33
src/recent.ts

@ -0,0 +1,33 @@
import { EmojiRecord, EmojiPickerOptions, RecentEmoji } from './types';
const LOCAL_STORAGE_KEY = 'emojiPicker.recent';
export function load(): Array<RecentEmoji> {
const recentJson = localStorage.getItem(LOCAL_STORAGE_KEY);
const recents = recentJson ? JSON.parse(recentJson) : [];
return recents.filter(recent => !!recent.emoji);
}
export function save(
emoji: EmojiRecord | RecentEmoji,
options: EmojiPickerOptions
): void {
const recents = load();
const recent = {
emoji: emoji.emoji,
name: emoji.name,
key: (emoji as RecentEmoji).key || emoji.name,
custom: emoji.custom
};
localStorage.setItem(
LOCAL_STORAGE_KEY,
JSON.stringify(
[
recent,
...recents.filter((r: RecentEmoji) => !!r.emoji && r.key !== recent.key)
].slice(0, options.recentsCount)
)
);
}

71
src/search.test.ts

@ -0,0 +1,71 @@
import { TinyEmitter as Emitter } from 'tiny-emitter';
import { SHOW_SEARCH_RESULTS } from './events';
import { Search } from './search';
import { i18n } from './i18n';
import { EmojiPickerOptions, EmojiRecord } from './types';
const hidePicker = () => {
return;
}
describe('Search', () => {
const emojis: EmojiRecord[] = [
{ category: 0, emoji: '⚡', name: 'zap', version: '12.1' },
{ category: 1, emoji: '😀', name: 'grinning', version: '12.1' }
];
const options: EmojiPickerOptions = { emojiVersion: '12.1', style: 'native' };
let events;
let search;
let searchField;
beforeEach(() => {
events = new Emitter();
search = new Search(events, i18n, options, emojis, [0], hidePicker).render();
searchField = search.querySelector('.emoji-picker__search');
});
test('should render search results', done => {
events.on(SHOW_SEARCH_RESULTS, searchResultsContainer => {
const searchResults = searchResultsContainer.querySelectorAll(
'.emoji-picker__emoji'
);
expect(searchResults.length).toBe(1);
expect(searchResults[0].innerHTML).toEqual(emojis[0].emoji);
done();
});
searchField.value = 'zap';
searchField.dispatchEvent(new KeyboardEvent('keyup'));
});
test('should not show search results for the unselected categories', done => {
search = new Search(events, i18n, options, emojis, [0], hidePicker).render();
events.on(SHOW_SEARCH_RESULTS, searchResultsContainer => {
const searchResults = searchResultsContainer.querySelectorAll(
'.emoji-picker__emoji'
);
expect(searchResults.length).toBe(0);
done();
});
searchField.value = 'grinning';
searchField.dispatchEvent(new KeyboardEvent('keyup'));
});
test('should render a not found message when there are no results', done => {
events.on(SHOW_SEARCH_RESULTS, searchResultsContainer => {
expect(
searchResultsContainer.classList.contains(
'emoji-picker__search-not-found'
)
).toBe(true);
done();
});
searchField.value = 'blah';
searchField.dispatchEvent(new KeyboardEvent('keyup'));
});
});

253
src/search.ts

@ -0,0 +1,253 @@
import { TinyEmitter as Emitter } from 'tiny-emitter';
import * as icons from './icons';
import { EmojiContainer } from './emojiContainer';
import { HIDE_PREVIEW, HIDE_VARIANT_POPUP, SHOW_SEARCH_RESULTS, HIDE_SEARCH_RESULTS } from './events';
import { createElement, empty } from './util';
import { I18NStrings, EmojiPickerOptions, EmojiRecord } from './types';
import { CLASS_SEARCH_CONTAINER, CLASS_SEARCH_FIELD, CLASS_SEARCH_ICON, CLASS_NOT_FOUND, CLASS_NOT_FOUND_ICON, CLASS_EMOJI } from './classes';
import fuzzysort from 'fuzzysort';
class NotFoundMessage {
constructor(private message: string, private iconUrl?: string) { }
render(): HTMLElement {
const container = createElement('div', CLASS_NOT_FOUND);
const iconContainer = createElement('div', CLASS_NOT_FOUND_ICON);
if (this.iconUrl) {
iconContainer.appendChild(icons.createIcon(this.iconUrl));
} else {
iconContainer.innerHTML = icons.frown;
}
container.appendChild(iconContainer);
const messageContainer = createElement('h2');
messageContainer.innerHTML = this.message;
container.appendChild(messageContainer);
return container;
}
}
export class Search {
private emojiData: EmojiRecord[];
private emojisPerRow: number;
private focusedEmojiIndex = 0;
private searchContainer: HTMLElement;
private searchField: HTMLInputElement;
private searchIcon: HTMLElement;
private resultsContainer: HTMLElement | null;
constructor(
private events: Emitter,
private i18n: I18NStrings,
private options: EmojiPickerOptions,
emojiData: EmojiRecord[],
categories: number[],
private hidePicker: Function
) {
this.emojisPerRow = this.options.emojisPerRow || 8;
this.emojiData = emojiData.filter(
e =>
e.version &&
parseFloat(e.version) <= parseFloat(options.emojiVersion as string) &&
e.category !== undefined &&
categories.indexOf(e.category) >= 0
);
if (this.options.custom) {
const customEmojis = this.options.custom.map(custom => ({
...custom,
custom: true
}));
this.emojiData = [...this.emojiData, ...customEmojis];
}
this.events.on(HIDE_VARIANT_POPUP, () => {
setTimeout(() => this.setFocusedEmoji(this.focusedEmojiIndex));
});
}
render(): HTMLElement {
this.searchContainer = createElement('div', CLASS_SEARCH_CONTAINER);
this.searchField = createElement(
'input',
CLASS_SEARCH_FIELD
) as HTMLInputElement;
this.searchField.placeholder = this.i18n.search;
this.searchContainer.appendChild(this.searchField);
this.searchIcon = createElement('span', CLASS_SEARCH_ICON);
if (this.options.icons && this.options.icons.search) {
this.searchIcon.appendChild(icons.createIcon(this.options.icons.search));
} else {
this.searchIcon.innerHTML = icons.search;
}
this.searchIcon.addEventListener('click', (event: MouseEvent) =>
this.onClearSearch(event)
);
this.searchContainer.appendChild(this.searchIcon);
this.searchField.addEventListener('keydown', (event: KeyboardEvent) => {
this.onKeyDown(event);
event.stopPropagation();
return false;
});
this.searchField.addEventListener('keyup', (event: KeyboardEvent) => this.onKeyUp(event));
return this.searchContainer;
}
onClearSearch(event: Event): void {
event.stopPropagation();
if (this.searchField.value) {
this.searchField.value = '';
this.resultsContainer = null;
if (this.options.icons && this.options.icons.search) {
empty(this.searchIcon);
this.searchIcon.appendChild(
icons.createIcon(this.options.icons.search)
);
} else {
this.searchIcon.innerHTML = icons.search;
}
this.searchIcon.style.cursor = 'default';
this.events.emit(HIDE_SEARCH_RESULTS);
setTimeout(() => this.searchField.focus());
}
}
setFocusedEmoji(index: number): void {
if (this.resultsContainer) {
const emojis = this.resultsContainer.querySelectorAll(`.${CLASS_EMOJI}`);
const currentFocusedEmoji = emojis[this.focusedEmojiIndex] as HTMLElement;
currentFocusedEmoji.tabIndex = -1;
this.focusedEmojiIndex = index;
const newFocusedEmoji = emojis[this.focusedEmojiIndex] as HTMLElement;
newFocusedEmoji.tabIndex = 0;
newFocusedEmoji.focus();
}
}
handleResultsKeydown(event: KeyboardEvent): void {
if (this.resultsContainer) {
const emojis = this.resultsContainer.querySelectorAll(`.${CLASS_EMOJI}`);
if (event.key === 'ArrowRight') {
this.setFocusedEmoji(
Math.min(this.focusedEmojiIndex + 1, emojis.length - 1)
);
} else if (event.key === 'ArrowLeft') {
this.setFocusedEmoji(Math.max(0, this.focusedEmojiIndex - 1));
} else if (event.key === 'ArrowDown') {
event.preventDefault();
if (this.focusedEmojiIndex < emojis.length - this.emojisPerRow) {
this.setFocusedEmoji(this.focusedEmojiIndex + this.emojisPerRow);
}
} else if (event.key === 'ArrowUp') {
event.preventDefault();
if (this.focusedEmojiIndex >= this.emojisPerRow) {
this.setFocusedEmoji(this.focusedEmojiIndex - this.emojisPerRow);
}
} else if (event.key === 'Escape') {
this.onClearSearch(event);
}
}
}
onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Escape' && this.searchField.value) {
this.onClearSearch(event);
} else if (event.key === 'Escape' && !this.searchField.value) {
this.hidePicker();
}
}
onKeyUp(event: KeyboardEvent): void {
if (event.key === 'Tab' || event.key === 'Shift') {
return;
} else if (!this.searchField.value) {
if (this.options.icons && this.options.icons.search) {
empty(this.searchIcon);
this.searchIcon.appendChild(
icons.createIcon(this.options.icons.search)
);
} else {
this.searchIcon.innerHTML = icons.search;
}
this.searchIcon.style.cursor = 'default';
this.events.emit(HIDE_SEARCH_RESULTS);
} else {
if (this.options.icons && this.options.icons.clearSearch) {
empty(this.searchIcon);
this.searchIcon.appendChild(
icons.createIcon(this.options.icons.clearSearch)
);
} else {
this.searchIcon.innerHTML = icons.times;
}
this.searchIcon.style.cursor = 'pointer';
const searchResults = fuzzysort
.go(this.searchField.value, this.emojiData, {
allowTypo: true,
limit: 100,
key: 'name'
})
.map(result => result.obj);
this.events.emit(HIDE_PREVIEW);
if (searchResults.length) {
this.resultsContainer = new EmojiContainer(
searchResults,
true,
this.events,
this.options,
false
).render();
if (this.resultsContainer) {
(this.resultsContainer.querySelector(
`.${CLASS_EMOJI}`
) as HTMLElement).tabIndex = 0;
this.focusedEmojiIndex = 0;
this.resultsContainer.addEventListener('keydown', event =>
this.handleResultsKeydown(event)
);
this.events.emit(SHOW_SEARCH_RESULTS, this.resultsContainer);
}
} else {
this.events.emit(
SHOW_SEARCH_RESULTS,
new NotFoundMessage(
this.i18n.notFound,
this.options.icons && this.options.icons.notFound
).render()
);
}
}
}
}

132
src/types.ts

@ -0,0 +1,132 @@
import { Placement } from '@popperjs/core';
import { EmojiPicker } from './index';
export interface EmojiRecord {
name: string;
emoji: string;
custom?: boolean;
category?: number;
version?: string;
variations?: string[];
key?: string;
}
export interface EmojiData {
categories: string[];
emojiData: EmojiRecord[];
}
export interface RecentEmoji {
key: string;
name: string;
emoji: string;
custom?: boolean;
}
export interface EmojiEventData {
emoji: EmojiRecord;
showVariants: boolean;
button: HTMLElement;
}
export interface Plugin {
render(picker: EmojiPicker): HTMLElement;
destroy?(): void;
}
export interface EmojiPickerOptions {
position?: Placement | FixedPosition;
autoHide?: boolean;
autoFocusSearch?: boolean;
showAnimation?: boolean;
showPreview?: boolean;
showSearch?: boolean;
showRecents?: boolean;
showVariants?: boolean;
showCategoryButtons?: boolean;
recentsCount?: number;
rootElement?: HTMLElement;
emojiVersion?: EmojiVersion;
i18n?: I18NStrings;
zIndex?: number;
boxShadow?: string | 'none';
theme?: EmojiTheme;
categories?: Category[];
style?: EmojiStyle;
twemojiBaseUrl?: string;
emojisPerRow?: number;
rows?: number;
emojiSize?: string;
initialCategory?: Category | 'recents';
custom?: EmojiRecord[];
plugins?: Plugin[];
icons?: Icons;
}
export interface TwemojiOptions {
base?: string,
ext: string,
folder: string
}
export interface FixedPosition {
top?: string;
bottom?: string;
left?: string;
right?: string;
}
export type EmojiStyle = 'native' | 'twemoji';
export type EmojiTheme = 'dark' | 'light' | 'auto';
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;
}

17
src/util.test.ts

@ -0,0 +1,17 @@
import * as util from './util';
describe('Utils', () => {
describe('formatEmojiName', () => {
test('should format a dash-separated name', () => {
expect(util.formatEmojiName('foo-bar-baz')).toEqual('Foo bar baz');
});
test('should format an underscore-separated name', () => {
expect(util.formatEmojiName('foo_bar_baz')).toEqual('Foo bar baz');
});
test('should format a name separated by dashes and underscores', () => {
expect(util.formatEmojiName('foo_bar-baz')).toEqual('Foo bar baz');
});
});
});

25
src/util.ts

@ -0,0 +1,25 @@
export function createElement(
tagName: string,
className?: string
): HTMLElement {
const element = document.createElement(tagName);
if (className) {
element.className = className;
}
return element;
}
export function empty(element: HTMLElement): void {
while (element.firstChild) {
element.removeChild(element.firstChild);
}
}
export function formatEmojiName(name: string): string {
const words = name.split(/[-_]/);
words[0] = words[0][0].toUpperCase() + words[0].slice(1);
return words.join(' ');
}

29
src/variantPopup.test.ts

@ -0,0 +1,29 @@
import { TinyEmitter as Emitter } from 'tiny-emitter';
import { VariantPopup } from './variantPopup';
describe('VariantPopup', () => {
const emoji = {
name: 'thumbs up',
category: 0,
emoji: '👍',
variations: ['👍🏻', '👍🏿'],
version: '11.0'
};
let events;
let container;
beforeEach(() => {
events = new Emitter();
container = new VariantPopup(events, emoji, { style: 'native' }).render();
});
test('should render the emoji variants', () => {
const EmojiPickers = container.querySelectorAll('.emoji-picker__emoji');
expect(EmojiPickers[0].innerHTML).toEqual(emoji.emoji);
expect(EmojiPickers[1].innerHTML).toEqual(emoji.variations[0]);
expect(EmojiPickers[2].innerHTML).toEqual(emoji.variations[1]);
});
});

108
src/variantPopup.ts

@ -0,0 +1,108 @@
import { TinyEmitter as Emitter } from 'tiny-emitter';
import { Emoji } from './emoji';
import { createElement } from './util';
import { HIDE_VARIANT_POPUP } from './events';
import { EmojiRecord, EmojiPickerOptions } from './types';
import { CLASS_VARIANT_OVERLAY, CLASS_VARIANT_POPUP, CLASS_EMOJI } from './classes';
export class VariantPopup {
private popup: HTMLElement;
private focusedEmojiIndex = 0;
constructor(
private events: Emitter,
private emoji: EmojiRecord,
private options: EmojiPickerOptions
) { }
getEmoji(index: number): Element {
return this.popup.querySelectorAll(`.${CLASS_EMOJI}`)[index];
}
setFocusedEmoji(newIndex: number): void {
const currentFocusedEmoji = this.getEmoji(
this.focusedEmojiIndex
) as HTMLElement;
currentFocusedEmoji.tabIndex = -1;
this.focusedEmojiIndex = newIndex;
const newFocusedEmoji = this.getEmoji(
this.focusedEmojiIndex
) as HTMLElement;
newFocusedEmoji.tabIndex = 0;
newFocusedEmoji.focus();
}
render(): HTMLElement {
this.popup = createElement('div', CLASS_VARIANT_POPUP);
const overlay = createElement('div', CLASS_VARIANT_OVERLAY);
overlay.addEventListener('click', (event: MouseEvent) => {
event.stopPropagation();
if (!this.popup.contains(event.target as Node)) {
this.events.emit(HIDE_VARIANT_POPUP);
}
});
this.popup.appendChild(
new Emoji(
this.emoji,
false,
false,
this.events,
this.options,
false
).render()
);
(this.emoji.variations || []).forEach((variation, index) =>
this.popup.appendChild(
new Emoji(
{
name: this.emoji.name,
emoji: variation,
key: this.emoji.name + index
},
false,
false,
this.events,
this.options,
false
).render()
)
);
const firstEmoji = this.popup.querySelector(
`.${CLASS_EMOJI}`
) as HTMLElement;
this.focusedEmojiIndex = 0;
firstEmoji.tabIndex = 0;
setTimeout(() => firstEmoji.focus());
this.popup.addEventListener('keydown', event => {
if (event.key === 'ArrowRight') {
this.setFocusedEmoji(
Math.min(
this.focusedEmojiIndex + 1,
this.popup.querySelectorAll(`.${CLASS_EMOJI}`).length - 1
)
);
} else if (event.key === 'ArrowLeft') {
this.setFocusedEmoji(Math.max(this.focusedEmojiIndex - 1, 0));
} else if (event.key === 'Escape') {
event.stopPropagation();
this.events.emit(HIDE_VARIANT_POPUP);
}
});
overlay.appendChild(this.popup);
return overlay;
}
}

67
tsconfig.json

@ -0,0 +1,67 @@
{
"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'. */
// "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. */
// "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. */
// "outDir": "./", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
"strictPropertyInitialization": false, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
},
"include": ["src/*.ts"]
}

6449
yarn.lock

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save