mirror of
https://github.com/Qortal/emoji-picker-js.git
synced 2025-01-30 14:52:15 +00:00
NEW: Emoji Picker v0.1.0
This commit is contained in:
commit
1b30e4372e
4
.babelrc
Normal file
4
.babelrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env", "@babel/preset-typescript"],
|
||||
"plugins": ["transform-class-properties"]
|
||||
}
|
14
.editorconfig
Normal file
14
.editorconfig
Normal file
@ -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
Normal file
27
.eslintrc.js
Normal file
@ -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
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
# Files
|
||||
.env
|
||||
.DS_Store
|
||||
|
||||
# Folders
|
||||
node_modules/
|
5
.prettierrc
Normal file
5
.prettierrc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"arrowParens": "avoid",
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none"
|
||||
}
|
43
LICENSE.md
Normal file
43
LICENSE.md
Normal file
@ -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
Normal file
22
README.md
Normal file
@ -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
Normal file
496
css/emoji-picker.css
Normal file
@ -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
Normal file
1
dist/index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4457
emoji-test.txt
Normal file
4457
emoji-test.txt
Normal file
File diff suppressed because it is too large
Load Diff
173
index.d.ts
vendored
Normal file
173
index.d.ts
vendored
Normal file
@ -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
Normal file
27
nodejs.yml
Normal file
@ -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
Normal file
67
package.json
Normal file
@ -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 <lotw7270@protonmail.com>",
|
||||
"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
Normal file
34
rollup.config.js
Normal file
@ -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
Normal file
143
scripts/processEmojiData.js
Normal file
@ -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
Normal file
71
src/categoryButtons.test.ts
Normal file
@ -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
Normal file
118
src/categoryButtons.ts
Normal file
@ -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
Normal file
23
src/classes.ts
Normal file
@ -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
Normal file
1
src/data/emoji.js
Normal file
File diff suppressed because one or more lines are too long
65
src/emoji.test.ts
Normal file
65
src/emoji.test.ts
Normal file
@ -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
Normal file
131
src/emoji.ts
Normal file
@ -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
Normal file
84
src/emojiArea.test.ts
Normal file
@ -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('&', '&')).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('&', '&')).toEqual(
|
||||
i18n.categories.smileys
|
||||
);
|
||||
expect(names[1].innerHTML.replace('&', '&')).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('&', '&')).toEqual(
|
||||
i18n.categories.smileys
|
||||
);
|
||||
expect(names[2].innerHTML.replace('&', '&')).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
Normal file
347
src/emojiArea.ts
Normal file
@ -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
Normal file
19
src/emojiContainer.test.ts
Normal file
@ -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
Normal file
45
src/emojiContainer.ts
Normal file
@ -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
Normal file
8
src/events.ts
Normal file
@ -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
Normal file
19
src/i18n.ts
Normal file
@ -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
Normal file
41
src/icons.ts
Normal file
@ -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
Normal file
579
src/index.ts
Normal file
@ -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
Normal file
28
src/preview.test.ts
Normal file
@ -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
Normal file
66
src/preview.ts
Normal file
@ -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
Normal file
33
src/recent.ts
Normal file
@ -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
Normal file
71
src/search.test.ts
Normal file
@ -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
Normal file
253
src/search.ts
Normal file
@ -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
Normal file
132
src/types.ts
Normal file
@ -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
Normal file
17
src/util.test.ts
Normal file
@ -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
Normal file
25
src/util.ts
Normal file
@ -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
Normal file
29
src/variantPopup.test.ts
Normal file
@ -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
Normal file
108
src/variantPopup.ts
Normal file
@ -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
Normal file
67
tsconfig.json
Normal file
@ -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"]
|
||||
}
|
Loading…
Reference in New Issue
Block a user