emoji-picker-element
Version:
Lightweight emoji picker distributed as a web component
1,364 lines (1,185 loc) β’ 69.4 kB
JavaScript
import Database from './database.js';
// via https://unpkg.com/browse/emojibase-data@6.0.0/meta/groups.json
const allGroups = [
[-1, 'β¨', 'custom'],
[0, 'π', 'smileys-emotion'],
[1, 'π', 'people-body'],
[3, 'π±', 'animals-nature'],
[4, 'π', 'food-drink'],
[5, 'π οΈ', 'travel-places'],
[6, 'β½', 'activities'],
[7, 'π', 'objects'],
[8, 'βοΈ', 'symbols'],
[9, 'π', 'flags']
].map(([id, emoji, name]) => ({ id, emoji, name }));
const groups = allGroups.slice(1);
const MIN_SEARCH_TEXT_LENGTH = 2;
const NUM_SKIN_TONES = 6;
/* istanbul ignore next */
const rIC = typeof requestIdleCallback === 'function' ? requestIdleCallback : setTimeout;
// check for ZWJ (zero width joiner) character
function hasZwj (emoji) {
return emoji.unicode.includes('\u200d')
}
// Find one good representative emoji from each version to test by checking its color.
// Ideally it should have color in the center. For some inspiration, see:
// https://about.gitlab.com/blog/2018/05/30/journey-in-native-unicode-emoji/
//
// Note that for certain versions (12.1, 13.1), there is no point in testing them explicitly, because
// all the emoji from this version are compound-emoji from previous versions. So they would pass a color
// test, even in browsers that display them as double emoji. (E.g. "face in clouds" might render as
// "face without mouth" plus "fog".) These emoji can only be filtered using the width test,
// which happens in checkZwjSupport.js.
const versionsAndTestEmoji = {
'π«©': 16, // face with bags under eyes
'π«¨': 15.1, // shaking head, technically from v15 but see note above
'π« ': 14,
'π₯²': 13.1, // smiling face with tear, technically from v13 but see note above
'π₯»': 12.1, // sari, technically from v12 but see note above
'π₯°': 11,
'π€©': 5,
'π±ββοΈ': 4,
'π€£': 3,
'ποΈβπ¨οΈ': 2,
'π': 1,
'ποΈ': 0.7,
'π': 0.6
};
const TIMEOUT_BEFORE_LOADING_MESSAGE = 1000; // 1 second
const DEFAULT_SKIN_TONE_EMOJI = 'ποΈ';
const DEFAULT_NUM_COLUMNS = 8;
// Based on https://fivethirtyeight.com/features/the-100-most-used-emojis/ and
// https://blog.emojipedia.org/facebook-reveals-most-and-least-used-emojis/ with
// a bit of my own curation. (E.g. avoid the "OK" gesture because of connotations:
// https://emojipedia.org/ok-hand/)
const MOST_COMMONLY_USED_EMOJI = [
'π',
'π',
'β€οΈ',
'ποΈ',
'π',
'π',
'π',
'βΊοΈ',
'π',
'π©',
'π',
'π',
'π',
'π'
];
// It's important to list Twemoji Mozilla before everything else, because Mozilla bundles their
// own font on some platforms (notably Windows and Linux as of this writing). Typically, Mozilla
// updates faster than the underlying OS, and we don't want to render older emoji in one font and
// newer emoji in another font:
// https://github.com/nolanlawson/emoji-picker-element/pull/268#issuecomment-1073347283
const FONT_FAMILY = '"Twemoji Mozilla","Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol",' +
'"Noto Color Emoji","EmojiOne Color","Android Emoji",sans-serif';
/* istanbul ignore next */
const DEFAULT_CATEGORY_SORTING = (a, b) => a < b ? -1 : a > b ? 1 : 0;
// Test if an emoji is supported by rendering it to canvas and checking that the color is not black
// See https://about.gitlab.com/blog/2018/05/30/journey-in-native-unicode-emoji/
// and https://www.npmjs.com/package/if-emoji for inspiration
// This implementation is largely borrowed from if-emoji, adding the font-family
const getTextFeature = (text, color) => {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const ctx = canvas.getContext('2d', {
// Improves the performance of `getImageData()`
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getContextAttributes#willreadfrequently
willReadFrequently: true
});
ctx.textBaseline = 'top';
ctx.font = `100px ${FONT_FAMILY}`;
ctx.fillStyle = color;
ctx.scale(0.01, 0.01);
ctx.fillText(text, 0, 0);
return ctx.getImageData(0, 0, 1, 1).data
};
const compareFeatures = (feature1, feature2) => {
const feature1Str = [...feature1].join(',');
const feature2Str = [...feature2].join(',');
// This is RGBA, so for 0,0,0, we are checking that the first RGB is not all zeroes.
// Most of the time when unsupported this is 0,0,0,0, but on Chrome on Mac it is
// 0,0,0,61 - there is a transparency here.
return feature1Str === feature2Str && !feature1Str.startsWith('0,0,0,')
};
function testColorEmojiSupported (text) {
// Render white and black and then compare them to each other and ensure they're the same
// color, and neither one is black. This shows that the emoji was rendered in color.
const feature1 = getTextFeature(text, '#000');
const feature2 = getTextFeature(text, '#fff');
return feature1 && feature2 && compareFeatures(feature1, feature2)
}
// rather than check every emoji ever, which would be expensive, just check some representatives from the
// different emoji releases to determine what the font supports
function determineEmojiSupportLevel () {
const entries = Object.entries(versionsAndTestEmoji);
try {
// start with latest emoji and work backwards
for (const [emoji, version] of entries) {
if (testColorEmojiSupported(emoji)) {
return version
}
}
} catch (e) { // canvas error
} finally {
}
// In case of an error, be generous and just assume all emoji are supported (e.g. for canvas errors
// due to anti-fingerprinting add-ons). Better to show some gray boxes than nothing at all.
return entries[0][1] // first one in the list is the most recent version
}
// Check which emojis we know for sure aren't supported, based on Unicode version level
let promise;
const detectEmojiSupportLevel = () => {
if (!promise) {
// Delay so it can run while the IDB database is being created by the browser (on another thread).
// This helps especially with first load β we want to start pre-populating the database on the main thread,
// and then wait for IDB to commit everything, and while waiting we run this check.
promise = new Promise(resolve => (
rIC(() => (
resolve(determineEmojiSupportLevel()) // delay so ideally this can run while IDB is first populating
))
));
}
return promise
};
// determine which emojis containing ZWJ (zero width joiner) characters
// are supported (rendered as one glyph) rather than unsupported (rendered as two or more glyphs)
const supportedZwjEmojis = new Map();
const VARIATION_SELECTOR = '\ufe0f';
const SKINTONE_MODIFIER = '\ud83c';
const ZWJ = '\u200d';
const LIGHT_SKIN_TONE = 0x1F3FB;
const LIGHT_SKIN_TONE_MODIFIER = 0xdffb;
// TODO: this is a naive implementation, we can improve it later
// It's only used for the skintone picker, so as long as people don't customize with
// really exotic emoji then it should work fine
function applySkinTone (str, skinTone) {
if (skinTone === 0) {
return str
}
const zwjIndex = str.indexOf(ZWJ);
if (zwjIndex !== -1) {
return str.substring(0, zwjIndex) +
String.fromCodePoint(LIGHT_SKIN_TONE + skinTone - 1) +
str.substring(zwjIndex)
}
if (str.endsWith(VARIATION_SELECTOR)) {
str = str.substring(0, str.length - 1);
}
return str + SKINTONE_MODIFIER + String.fromCodePoint(LIGHT_SKIN_TONE_MODIFIER + skinTone - 1)
}
function halt (event) {
event.preventDefault();
event.stopPropagation();
}
// Implementation left/right or up/down navigation, circling back when you
// reach the start/end of the list
function incrementOrDecrement (decrement, val, arr) {
val += (decrement ? -1 : 1);
if (val < 0) {
val = arr.length - 1;
} else if (val >= arr.length) {
val = 0;
}
return val
}
// like lodash's uniqBy but much smaller
function uniqBy (arr, func) {
const set = new Set();
const res = [];
for (const item of arr) {
const key = func(item);
if (!set.has(key)) {
set.add(key);
res.push(item);
}
}
return res
}
// We don't need all the data on every emoji, and there are specific things we need
// for the UI, so build a "view model" from the emoji object we got from the database
function summarizeEmojisForUI (emojis, emojiSupportLevel) {
const toSimpleSkinsMap = skins => {
const res = {};
for (const skin of skins) {
// ignore arrays like [1, 2] with multiple skin tones
// also ignore variants that are in an unsupported emoji version
// (these do exist - variants from a different version than their base emoji)
if (typeof skin.tone === 'number' && skin.version <= emojiSupportLevel) {
res[skin.tone] = skin.unicode;
}
}
return res
};
return emojis.map(({ unicode, skins, shortcodes, url, name, category, annotation }) => ({
unicode,
name,
shortcodes,
url,
category,
annotation,
id: unicode || name,
skins: skins && toSimpleSkinsMap(skins)
}))
}
// import rAF from one place so that the bundle size is a bit smaller
const rAF = requestAnimationFrame;
// "Svelte action"-like utility to detect layout changes via ResizeObserver.
// If ResizeObserver is unsupported, we just use rAF once and don't bother to update.
let resizeObserverSupported = typeof ResizeObserver === 'function';
function resizeObserverAction (node, abortSignal, onUpdate) {
let resizeObserver;
if (resizeObserverSupported) {
resizeObserver = new ResizeObserver(onUpdate);
resizeObserver.observe(node);
} else { // just run once, don't bother trying to track it
rAF(onUpdate);
}
// cleanup function (called on destroy)
abortSignal.addEventListener('abort', () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
});
}
// get the width of the text inside of a DOM node, via https://stackoverflow.com/a/59525891/680742
function calculateTextWidth (node) {
// skip running this in jest/vitest because we don't need to check for emoji support in that environment
/* istanbul ignore else */
{
const range = document.createRange();
range.selectNode(node.firstChild);
return range.getBoundingClientRect().width
}
}
let baselineEmojiWidth;
/**
* Check if the given emojis containing ZWJ characters are supported by the current browser (don't render
* as double characters) and return true if all are supported.
* @param zwjEmojisToCheck
* @param baselineEmoji
* @param emojiToDomNode
*/
function checkZwjSupport (zwjEmojisToCheck, baselineEmoji, emojiToDomNode) {
let allSupported = true;
for (const emoji of zwjEmojisToCheck) {
const domNode = emojiToDomNode(emoji);
// sanity check to make sure the node is defined properly
/* istanbul ignore if */
if (!domNode) {
// This is a race condition that can occur when the component is unmounted/remounted
// It doesn't really matter what we do here since the old context is not going to render anymore.
// Just bail out of emoji support detection and return `allSupported=true` since the rendering context is gone
continue
}
const emojiWidth = calculateTextWidth(domNode);
if (typeof baselineEmojiWidth === 'undefined') { // calculate the baseline emoji width only once
baselineEmojiWidth = calculateTextWidth(baselineEmoji);
}
// On Windows, some supported emoji are ~50% bigger than the baseline emoji, but what we really want to guard
// against are the ones that are 2x the size, because those are truly broken (person with red hair = person with
// floating red wig, black cat = cat with black square, polar bear = bear with snowflake, etc.)
// So here we set the threshold at 1.8 times the size of the baseline emoji.
const supported = emojiWidth / 1.8 < baselineEmojiWidth;
supportedZwjEmojis.set(emoji.unicode, supported);
if (!supported) {
allSupported = false;
}
}
return allSupported
}
// like lodash's uniq
function uniq (arr) {
return uniqBy(arr, _ => _)
}
// Note we put this in its own function outside Picker.js to avoid Svelte doing an invalidation on the "setter" here.
// At best the invalidation is useless, at worst it can cause infinite loops:
// https://github.com/nolanlawson/emoji-picker-element/pull/180
// https://github.com/sveltejs/svelte/issues/6521
// Also note tabpanelElement can be null if the element is disconnected immediately after connected
function resetScrollTopIfPossible (element) {
/* istanbul ignore else */
if (element) { // Makes me nervous not to have this `if` guard
element.scrollTop = 0;
}
}
function getFromMap (cache, key, func) {
let cached = cache.get(key);
if (!cached) {
cached = func();
cache.set(key, cached);
}
return cached
}
function toString (value) {
return '' + value
}
function parseTemplate (htmlString) {
const template = document.createElement('template');
template.innerHTML = htmlString;
return template
}
const parseCache = new WeakMap();
const domInstancesCache = new WeakMap();
// This needs to be a symbol because it needs to be different from any possible output of a key function
const unkeyedSymbol = Symbol('un-keyed');
// Not supported in Safari <=13
const hasReplaceChildren = 'replaceChildren' in Element.prototype;
function replaceChildren (parentNode, newChildren) {
/* istanbul ignore else */
if (hasReplaceChildren) {
parentNode.replaceChildren(...newChildren);
} else { // minimal polyfill for Element.prototype.replaceChildren
parentNode.innerHTML = '';
parentNode.append(...newChildren);
}
}
function doChildrenNeedRerender (parentNode, newChildren) {
let oldChild = parentNode.firstChild;
let oldChildrenCount = 0;
// iterate using firstChild/nextSibling because browsers use a linked list under the hood
while (oldChild) {
const newChild = newChildren[oldChildrenCount];
// check if the old child and new child are the same
if (newChild !== oldChild) {
return true
}
oldChild = oldChild.nextSibling;
oldChildrenCount++;
}
// if new children length is different from old, we must re-render
return oldChildrenCount !== newChildren.length
}
function patchChildren (newChildren, instanceBinding) {
const { targetNode } = instanceBinding;
let { targetParentNode } = instanceBinding;
let needsRerender = false;
if (targetParentNode) { // already rendered once
needsRerender = doChildrenNeedRerender(targetParentNode, newChildren);
} else { // first render of list
needsRerender = true;
instanceBinding.targetNode = undefined; // placeholder node not needed anymore, free memory
instanceBinding.targetParentNode = targetParentNode = targetNode.parentNode;
}
// avoid re-rendering list if the dom nodes are exactly the same before and after
if (needsRerender) {
replaceChildren(targetParentNode, newChildren);
}
}
function patch (expressions, instanceBindings) {
for (const instanceBinding of instanceBindings) {
const {
targetNode,
currentExpression,
binding: {
expressionIndex,
attributeName,
attributeValuePre,
attributeValuePost
}
} = instanceBinding;
const expression = expressions[expressionIndex];
if (currentExpression === expression) {
// no need to update, same as before
continue
}
instanceBinding.currentExpression = expression;
if (attributeName) { // attribute replacement
if (expression === null) {
// null is treated as a special case by the framework - we don't render an attribute at all in this case
targetNode.removeAttribute(attributeName);
} else {
// attribute value is not null; set a new attribute
const newValue = attributeValuePre + toString(expression) + attributeValuePost;
targetNode.setAttribute(attributeName, newValue);
}
} else { // text node / child element / children replacement
let newNode;
if (Array.isArray(expression)) { // array of DOM elements produced by tag template literals
patchChildren(expression, instanceBinding);
} else if (expression instanceof Element) { // html tag template returning a DOM element
newNode = expression;
targetNode.replaceWith(newNode);
} else { // primitive - string, number, etc
// nodeValue is faster than textContent supposedly https://www.youtube.com/watch?v=LY6y3HbDVmg
// note we may be replacing the value in a placeholder text node
targetNode.nodeValue = toString(expression);
}
if (newNode) {
instanceBinding.targetNode = newNode;
}
}
}
}
function parse (tokens) {
let htmlString = '';
let withinTag = false;
let withinAttribute = false;
let elementIndexCounter = -1; // depth-first traversal order
const elementsToBindings = new Map();
const elementIndexes = [];
let skipTokenChars = 0;
for (let i = 0, len = tokens.length; i < len; i++) {
const token = tokens[i];
htmlString += token.slice(skipTokenChars);
if (i === len - 1) {
break // no need to process characters - no more expressions to be found
}
for (let j = 0; j < token.length; j++) {
const char = token.charAt(j);
switch (char) {
case '<': {
const nextChar = token.charAt(j + 1);
if (nextChar === '/') { // closing tag
// leaving an element
elementIndexes.pop();
} else { // not a closing tag
withinTag = true;
elementIndexes.push(++elementIndexCounter);
}
break
}
case '>': {
withinTag = false;
withinAttribute = false;
break
}
case '=': {
withinAttribute = true;
break
}
}
}
const elementIndex = elementIndexes[elementIndexes.length - 1];
const bindings = getFromMap(elementsToBindings, elementIndex, () => []);
let attributeName;
let attributeValuePre;
let attributeValuePost;
if (withinAttribute) {
// I never use single-quotes for attribute values in HTML, so just support double-quotes or no-quotes
const attributePreMatch = /(\S+)="?([^"=]*)$/.exec(token);
attributeName = attributePreMatch[1];
attributeValuePre = attributePreMatch[2];
const attributePostMatch = /^([^">]*)("?)/.exec(tokens[i + 1]);
attributeValuePost = attributePostMatch[1];
// Optimization: remove the attribute itself, so we don't create a default attribute which is either empty or just
// the "pre" text, e.g. `<div foo>` or `<div foo="prefix">`. It will be replaced by the expression anyway.
htmlString = htmlString.slice(0, -1 * attributePreMatch[0].length);
skipTokenChars = attributePostMatch[0].length;
} else {
skipTokenChars = 0;
}
const binding = {
attributeName,
attributeValuePre,
attributeValuePost,
expressionIndex: i
};
bindings.push(binding);
if (!withinTag && !withinAttribute) {
// Add a placeholder text node, so we can find it later. Note we only support one dynamic child text node
htmlString += ' ';
}
}
const template = parseTemplate(htmlString);
return {
template,
elementsToBindings
}
}
function applyBindings (bindings, element, instanceBindings) {
for (let i = 0; i < bindings.length; i++) {
const binding = bindings[i];
const targetNode = binding.attributeName
? element // attribute binding, just use the element itself
: element.firstChild; // not an attribute binding, so has a placeholder text node
const instanceBinding = {
binding,
targetNode,
targetParentNode: undefined,
currentExpression: undefined
};
instanceBindings.push(instanceBinding);
}
}
function traverseAndSetupBindings (rootElement, elementsToBindings) {
const instanceBindings = [];
let topLevelBindings;
if (elementsToBindings.size === 1 && (topLevelBindings = elementsToBindings.get(0))) {
// Optimization for the common case where there's only one element and one binding
// Skip creating a TreeWalker entirely and just handle the root DOM element
applyBindings(topLevelBindings, rootElement, instanceBindings);
} else {
// traverse dom
const treeWalker = document.createTreeWalker(rootElement, NodeFilter.SHOW_ELEMENT);
let element = rootElement;
let elementIndex = -1;
do {
const bindings = elementsToBindings.get(++elementIndex);
if (bindings) {
applyBindings(bindings, element, instanceBindings);
}
} while ((element = treeWalker.nextNode()))
}
return instanceBindings
}
function parseHtml (tokens) {
// All templates and bound expressions are unique per tokens array
const { template, elementsToBindings } = getFromMap(parseCache, tokens, () => parse(tokens));
// When we parseHtml, we always return a fresh DOM instance ready to be updated
const dom = template.cloneNode(true).content.firstElementChild;
const instanceBindings = traverseAndSetupBindings(dom, elementsToBindings);
return function updateDomInstance (expressions) {
patch(expressions, instanceBindings);
return dom
}
}
function createFramework (state) {
const domInstances = getFromMap(domInstancesCache, state, () => new Map());
let domInstanceCacheKey = unkeyedSymbol;
function html (tokens, ...expressions) {
// Each unique lexical usage of map() is considered unique due to the html`` tagged template call it makes,
// which has lexically unique tokens. The unkeyed symbol is just used for html`` usage outside of a map().
const domInstancesForTokens = getFromMap(domInstances, tokens, () => new Map());
const updateDomInstance = getFromMap(domInstancesForTokens, domInstanceCacheKey, () => parseHtml(tokens));
return updateDomInstance(expressions) // update with expressions
}
function map (array, callback, keyFunction) {
return array.map((item, index) => {
const originalCacheKey = domInstanceCacheKey;
domInstanceCacheKey = keyFunction(item);
try {
return callback(item, index)
} finally {
domInstanceCacheKey = originalCacheKey;
}
})
}
return { map, html }
}
function render (container, state, helpers, events, actions, refs, abortSignal, actionContext, firstRender) {
const { labelWithSkin, titleForEmoji, unicodeWithSkin } = helpers;
const { html, map } = createFramework(state);
function emojiList (emojis, searchMode, prefix) {
return map(emojis, (emoji, i) => {
return html`<button role="${searchMode ? 'option' : 'menuitem'}" aria-selected="${searchMode ? i === state.activeSearchItem : null}" aria-label="${labelWithSkin(emoji, state.currentSkinTone)}" title="${titleForEmoji(emoji)}" class="${
'emoji' +
(searchMode && i === state.activeSearchItem ? ' active' : '') +
(emoji.unicode ? '' : ' custom-emoji')
}" id="${`${prefix}-${emoji.id}`}" style="${emoji.unicode ? null : `--custom-emoji-background: url(${JSON.stringify(emoji.url)})`}">${
emoji.unicode
? unicodeWithSkin(emoji, state.currentSkinTone)
: ''
}</button>`
// It's important for the cache key to be unique based on the prefix, because the framework caches based on the
// unique tokens + cache key, and the same emoji may be used in the tab as well as in the fav bar
}, emoji => `${prefix}-${emoji.id}`)
}
const section = () => {
return html`<section data-ref="rootElement" class="picker" aria-label="${state.i18n.regionLabel}" style="${state.pickerStyle || ''}"><div class="pad-top"></div><div class="search-row"><div class="search-wrapper"><input id="search" class="search" type="search" role="combobox" enterkeyhint="search" placeholder="${state.i18n.searchLabel}" autocapitalize="none" autocomplete="off" spellcheck="true" aria-expanded="${!!(state.searchMode && state.currentEmojis.length)}" aria-controls="search-results" aria-describedby="search-description" aria-autocomplete="list" aria-activedescendant="${state.activeSearchItemId ? `emo-${state.activeSearchItemId}` : null}" data-ref="searchElement" data-on-input="onSearchInput" data-on-keydown="onSearchKeydown"><label class="sr-only" for="search">${state.i18n.searchLabel}</label> <span id="search-description" class="sr-only">${state.i18n.searchDescription}</span></div><div class="skintone-button-wrapper ${state.skinTonePickerExpandedAfterAnimation ? 'expanded' : ''}"><button id="skintone-button" class="emoji ${state.skinTonePickerExpanded ? 'hide-focus' : ''}" aria-label="${state.skinToneButtonLabel}" title="${state.skinToneButtonLabel}" aria-describedby="skintone-description" aria-haspopup="listbox" aria-expanded="${state.skinTonePickerExpanded}" aria-controls="skintone-list" data-on-click="onClickSkinToneButton">${state.skinToneButtonText || ''}</button></div><span id="skintone-description" class="sr-only">${state.i18n.skinToneDescription}</span><div data-ref="skinToneDropdown" id="skintone-list" class="skintone-list hide-focus ${state.skinTonePickerExpanded ? '' : 'hidden no-animate'}" style="transform:translateY(${state.skinTonePickerExpanded ? 0 : 'calc(-1 * var(--num-skintones) * var(--total-emoji-size))'})" role="listbox" aria-label="${state.i18n.skinTonesLabel}" aria-activedescendant="skintone-${state.activeSkinTone}" aria-hidden="${!state.skinTonePickerExpanded}" tabIndex="-1" data-on-focusout="onSkinToneOptionsFocusOut" data-on-click="onSkinToneOptionsClick" data-on-keydown="onSkinToneOptionsKeydown" data-on-keyup="onSkinToneOptionsKeyup">${
map(state.skinTones, (skinTone, i) => {
return html`<div id="skintone-${i}" class="emoji ${i === state.activeSkinTone ? 'active' : ''}" aria-selected="${i === state.activeSkinTone}" role="option" title="${state.i18n.skinTones[i]}" aria-label="${state.i18n.skinTones[i]}">${skinTone}</div>`
}, skinTone => skinTone)
}</div></div><div class="nav" role="tablist" style="grid-template-columns:repeat(${state.groups.length},1fr)" aria-label="${state.i18n.categoriesLabel}" data-on-keydown="onNavKeydown" data-on-click="onNavClick">${
map(state.groups, (group) => {
return html`<button role="tab" class="nav-button" aria-controls="tab-${group.id}" aria-label="${state.i18n.categories[group.name]}" aria-selected="${!state.searchMode && state.currentGroup.id === group.id}" title="${state.i18n.categories[group.name]}" data-group-id="${group.id}"><div class="nav-emoji emoji">${group.emoji}</div></button>`
}, group => group.id)
}</div><div class="indicator-wrapper"><div class="indicator" style="transform:translateX(${(/* istanbul ignore next */ (state.isRtl ? -1 : 1)) * state.currentGroupIndex * 100}%)"></div></div><div class="message ${state.message ? '' : 'gone'}" role="alert" aria-live="polite">${state.message || ''}</div><div data-ref="tabpanelElement" class="tabpanel ${(!state.databaseLoaded || state.message) ? 'gone' : ''}" role="${state.searchMode ? 'region' : 'tabpanel'}" aria-label="${state.searchMode ? state.i18n.searchResultsLabel : state.i18n.categories[state.currentGroup.name]}" id="${state.searchMode ? null : `tab-${state.currentGroup.id}`}" tabIndex="0" data-on-click="onEmojiClick"><div data-action="calculateEmojiGridStyle">${
map(state.currentEmojisWithCategories, (emojiWithCategory, i) => {
return html`<div><div id="menu-label-${i}" class="category ${state.currentEmojisWithCategories.length === 1 && state.currentEmojisWithCategories[0].category === '' ? 'gone' : ''}" aria-hidden="true">${
state.searchMode
? state.i18n.searchResultsLabel
: (
emojiWithCategory.category
? emojiWithCategory.category
: (
state.currentEmojisWithCategories.length > 1
? state.i18n.categories.custom
: state.i18n.categories[state.currentGroup.name]
)
)
}</div><div class="emoji-menu ${i !== 0 && !state.searchMode && state.currentGroup.id === -1 ? 'visibility-auto' : ''}" style="${`--num-rows: ${Math.ceil(emojiWithCategory.emojis.length / state.numColumns)}`}" data-action="updateOnIntersection" role="${state.searchMode ? 'listbox' : 'menu'}" aria-labelledby="menu-label-${i}" id="${state.searchMode ? 'search-results' : null}">${
emojiList(emojiWithCategory.emojis, state.searchMode, /* prefix */ 'emo')
}</div></div>`
}, emojiWithCategory => emojiWithCategory.category)
}</div></div><div class="favorites onscreen emoji-menu ${state.message ? 'gone' : ''}" role="menu" aria-label="${state.i18n.favoritesLabel}" data-on-click="onEmojiClick">${
emojiList(state.currentFavorites, /* searchMode */ false, /* prefix */ 'fav')
}</div><button data-ref="baselineEmoji" aria-hidden="true" tabindex="-1" class="abs-pos hidden emoji baseline-emoji">π</button></section>`
};
const rootDom = section();
// helper for traversing the dom, finding elements by an attribute, and getting the attribute value
const forElementWithAttribute = (attributeName, callback) => {
for (const element of container.querySelectorAll(`[${attributeName}]`)) {
callback(element, element.getAttribute(attributeName));
}
};
if (firstRender) { // not a re-render
container.appendChild(rootDom);
// we only bind events/refs once - there is no need to find them again given this component structure
// bind events
for (const eventName of ['click', 'focusout', 'input', 'keydown', 'keyup']) {
forElementWithAttribute(`data-on-${eventName}`, (element, listenerName) => {
element.addEventListener(eventName, events[listenerName]);
});
}
// find refs
forElementWithAttribute('data-ref', (element, ref) => {
refs[ref] = element;
});
// destroy/abort logic
abortSignal.addEventListener('abort', () => {
container.removeChild(rootDom);
});
}
// set up actions - these are re-bound on every render
forElementWithAttribute('data-action', (element, action) => {
let boundActions = actionContext.get(action);
if (!boundActions) {
actionContext.set(action, (boundActions = new WeakSet()));
}
// avoid applying the same action to the same element multiple times
if (!boundActions.has(element)) {
boundActions.add(element);
actions[action](element);
}
});
}
/* istanbul ignore next */
const qM = typeof queueMicrotask === 'function' ? queueMicrotask : callback => Promise.resolve().then(callback);
function createState (abortSignal) {
let destroyed = false;
let currentObserver;
const propsToObservers = new Map();
const dirtyObservers = new Set();
let queued;
const flush = () => {
if (destroyed) {
return
}
const observersToRun = [...dirtyObservers];
dirtyObservers.clear(); // clear before running to force any new updates to run in another tick of the loop
try {
for (const observer of observersToRun) {
observer();
}
} finally {
queued = false;
if (dirtyObservers.size) { // new updates, queue another one
queued = true;
qM(flush);
}
}
};
const state = new Proxy({}, {
get (target, prop) {
if (currentObserver) {
let observers = propsToObservers.get(prop);
if (!observers) {
observers = new Set();
propsToObservers.set(prop, observers);
}
observers.add(currentObserver);
}
return target[prop]
},
set (target, prop, newValue) {
if (target[prop] !== newValue) {
target[prop] = newValue;
const observers = propsToObservers.get(prop);
if (observers) {
for (const observer of observers) {
dirtyObservers.add(observer);
}
if (!queued) {
queued = true;
qM(flush);
}
}
}
return true
}
});
const createEffect = (callback) => {
const runnable = () => {
const oldObserver = currentObserver;
currentObserver = runnable;
try {
return callback()
} finally {
currentObserver = oldObserver;
}
};
return runnable()
};
// destroy logic
abortSignal.addEventListener('abort', () => {
destroyed = true;
});
return {
state,
createEffect
}
}
// Compare two arrays, with a function called on each item in the two arrays that returns true if the items are equal
function arraysAreEqualByFunction (left, right, areEqualFunc) {
if (left.length !== right.length) {
return false
}
for (let i = 0; i < left.length; i++) {
if (!areEqualFunc(left[i], right[i])) {
return false
}
}
return true
}
const intersectionObserverCache = new WeakMap();
function intersectionObserverAction (node, abortSignal, listener) {
/* istanbul ignore else */
{
// The scroll root is always `.tabpanel`
const root = node.closest('.tabpanel');
let observer = intersectionObserverCache.get(root);
if (!observer) {
// TODO: replace this with the contentvisibilityautostatechange event when all supported browsers support it.
// For now we use IntersectionObserver because it has better cross-browser support, and it would be bad for
// old Safari versions if they eagerly downloaded all custom emoji all at once.
observer = new IntersectionObserver(listener, {
root,
// trigger if we are 1/2 scroll container height away so that the images load a bit quicker while scrolling
rootMargin: '50% 0px 50% 0px',
// trigger if any part of the emoji grid is intersecting
threshold: 0
});
// avoid creating a new IntersectionObserver for every category; just use one for the whole root
intersectionObserverCache.set(root, observer);
// assume that the abortSignal is always the same for this root node; just add one event listener
abortSignal.addEventListener('abort', () => {
observer.disconnect();
});
}
observer.observe(node);
}
}
/* eslint-disable prefer-const,no-labels,no-inner-declarations */
// constants
const EMPTY_ARRAY = [];
const { assign } = Object;
function createRoot (shadowRoot, props) {
const refs = {};
const abortController = new AbortController();
const abortSignal = abortController.signal;
const { state, createEffect } = createState(abortSignal);
const actionContext = new Map();
// initial state
assign(state, {
skinToneEmoji: undefined,
i18n: undefined,
database: undefined,
customEmoji: undefined,
customCategorySorting: undefined,
emojiVersion: undefined
});
// public props
assign(state, props);
// private props
assign(state, {
initialLoad: true,
currentEmojis: [],
currentEmojisWithCategories: [],
rawSearchText: '',
searchText: '',
searchMode: false,
activeSearchItem: -1,
message: undefined,
skinTonePickerExpanded: false,
skinTonePickerExpandedAfterAnimation: false,
currentSkinTone: 0,
activeSkinTone: 0,
skinToneButtonText: undefined,
pickerStyle: undefined,
skinToneButtonLabel: '',
skinTones: [],
currentFavorites: [],
defaultFavoriteEmojis: undefined,
numColumns: DEFAULT_NUM_COLUMNS,
isRtl: false,
currentGroupIndex: 0,
groups: groups,
databaseLoaded: false,
activeSearchItemId: undefined
});
//
// Update the current group based on the currentGroupIndex
//
createEffect(() => {
if (state.currentGroup !== state.groups[state.currentGroupIndex]) {
state.currentGroup = state.groups[state.currentGroupIndex];
}
});
//
// Utils/helpers
//
const focus = id => {
shadowRoot.getElementById(id).focus();
};
const emojiToDomNode = emoji => shadowRoot.getElementById(`emo-${emoji.id}`);
// fire a custom event that crosses the shadow boundary
const fireEvent = (name, detail) => {
refs.rootElement.dispatchEvent(new CustomEvent(name, {
detail,
bubbles: true,
composed: true
}));
};
//
// Comparison utils
//
const compareEmojiArrays = (a, b) => a.id === b.id;
const compareCurrentEmojisWithCategories = (a, b) => {
const { category: aCategory, emojis: aEmojis } = a;
const { category: bCategory, emojis: bEmojis } = b;
if (aCategory !== bCategory) {
return false
}
return arraysAreEqualByFunction(aEmojis, bEmojis, compareEmojiArrays)
};
//
// Update utils to avoid excessive re-renders
//
// avoid excessive re-renders by checking the value before setting
const updateCurrentEmojis = (newEmojis) => {
if (!arraysAreEqualByFunction(state.currentEmojis, newEmojis, compareEmojiArrays)) {
state.currentEmojis = newEmojis;
}
};
// avoid excessive re-renders
const updateSearchMode = (newSearchMode) => {
if (state.searchMode !== newSearchMode) {
state.searchMode = newSearchMode;
}
};
// avoid excessive re-renders
const updateCurrentEmojisWithCategories = (newEmojisWithCategories) => {
if (!arraysAreEqualByFunction(state.currentEmojisWithCategories, newEmojisWithCategories, compareCurrentEmojisWithCategories)) {
state.currentEmojisWithCategories = newEmojisWithCategories;
}
};
// Helpers used by PickerTemplate
const unicodeWithSkin = (emoji, currentSkinTone) => (
(currentSkinTone && emoji.skins && emoji.skins[currentSkinTone]) || emoji.unicode
);
const labelWithSkin = (emoji, currentSkinTone) => (
uniq([
(emoji.name || unicodeWithSkin(emoji, currentSkinTone)),
emoji.annotation,
...(emoji.shortcodes || EMPTY_ARRAY)
].filter(Boolean)).join(', ')
);
const titleForEmoji = (emoji) => (
emoji.annotation || (emoji.shortcodes || EMPTY_ARRAY).join(', ')
);
const helpers = {
labelWithSkin, titleForEmoji, unicodeWithSkin
};
const events = {
onClickSkinToneButton,
onEmojiClick,
onNavClick,
onNavKeydown,
onSearchKeydown,
onSkinToneOptionsClick,
onSkinToneOptionsFocusOut,
onSkinToneOptionsKeydown,
onSkinToneOptionsKeyup,
onSearchInput
};
const actions = {
calculateEmojiGridStyle,
updateOnIntersection
};
let firstRender = true;
createEffect(() => {
render(shadowRoot, state, helpers, events, actions, refs, abortSignal, actionContext, firstRender);
firstRender = false;
});
//
// Determine the emoji support level (in requestIdleCallback)
//
// mount logic
if (!state.emojiVersion) {
detectEmojiSupportLevel().then(level => {
// Can't actually test emoji support in Jest/Vitest/JSDom, emoji never render in color in Cairo
/* istanbul ignore next */
if (!level) {
state.message = state.i18n.emojiUnsupportedMessage;
}
});
}
//
// Set or update the database object
//
createEffect(() => {
// show a Loading message if it takes a long time, or show an error if there's a network/IDB error
async function handleDatabaseLoading () {
let showingLoadingMessage = false;
const timeoutHandle = setTimeout(() => {
showingLoadingMessage = true;
state.message = state.i18n.loadingMessage;
}, TIMEOUT_BEFORE_LOADING_MESSAGE);
try {
await state.database.ready();
state.databaseLoaded = true; // eslint-disable-line no-unused-vars
} catch (err) {
console.error(err);
state.message = state.i18n.networkErrorMessage;
} finally {
clearTimeout(timeoutHandle);
if (showingLoadingMessage) { // Seems safer than checking the i18n string, which may change
showingLoadingMessage = false;
state.message = ''; // eslint-disable-line no-unused-vars
}
}
}
if (state.database) {
/* no await */
handleDatabaseLoading();
}
});
//
// Global styles for the entire picker
//
createEffect(() => {
state.pickerStyle = `
--num-groups: ${state.groups.length};
--indicator-opacity: ${state.searchMode ? 0 : 1};
--num-skintones: ${NUM_SKIN_TONES};`;
});
//
// Set or update the customEmoji
//
createEffect(() => {
if (state.customEmoji && state.database) {
updateCustomEmoji(); // re-run whenever customEmoji change
}
});
createEffect(() => {
if (state.customEmoji && state.customEmoji.length) {
if (state.groups !== allGroups) { // don't update unnecessarily
state.groups = allGroups;
}
} else if (state.groups !== groups) {
if (state.currentGroupIndex) {
// If the current group is anything other than "custom" (which is first), decrement.
// This fixes the odd case where you set customEmoji, then pick a category, then unset customEmoji
state.currentGroupIndex--;
}
state.groups = groups;
}
});
//
// Set or update the preferred skin tone
//
createEffect(() => {
async function updatePreferredSkinTone () {
if (state.databaseLoaded) {
state.currentSkinTone = await state.database.getPreferredSkinTone();
}
}
/* no await */ updatePreferredSkinTone();
});
createEffect(() => {
state.skinTones = Array(NUM_SKIN_TONES).fill().map((_, i) => applySkinTone(state.skinToneEmoji, i));
});
createEffect(() => {
state.skinToneButtonText = state.skinTones[state.currentSkinTone];
});
createEffect(() => {
state.skinToneButtonLabel = state.i18n.skinToneLabel.replace('{skinTone}', state.i18n.skinTones[state.currentSkinTone]);
});
//
// Set or update the favorites emojis
//
createEffect(() => {
async function updateDefaultFavoriteEmojis () {
const { database } = state;
const favs = (await Promise.all(MOST_COMMONLY_USED_EMOJI.map(unicode => (
database.getEmojiByUnicodeOrName(unicode)
)))).filter(Boolean); // filter because in Jest/Vitest tests we don't have all the emoji in the DB
state.defaultFavoriteEmojis = favs;
}
if (state.databaseLoaded) {
/* no await */ updateDefaultFavoriteEmojis();
}
});
function updateCustomEmoji () {
// Certain effects have an implicit dependency on customEmoji since it affects the database
// Getting it here on the state ensures this effect re-runs when customEmoji change.
const { customEmoji, database } = state;
const databaseCustomEmoji = customEmoji || EMPTY_ARRAY;
if (database.customEmoji !== databaseCustomEmoji) {
// Avoid setting this if the customEmoji have _not_ changed, because the setter triggers a re-computation of the
// `customEmojiIndex`. Note we don't bother with deep object changes.
database.customEmoji = databaseCustomEmoji;
}
}
createEffect(() => {
async function updateFavorites () {
updateCustomEmoji(); // re-run whenever customEmoji change
const { database, defaultFavoriteEmojis, numColumns } = state;
const dbFavorites = await database.getTopFavoriteEmoji(numColumns);
const favorites = await summarizeEmojis(uniqBy([
...dbFavorites,
...defaultFavoriteEmojis
], _ => (_.unicode || _.name)).slice(0, numColumns));
state.currentFavorites = favorites;
}
if (state.databaseLoaded && state.defaultFavoriteEmojis) {
/* no await */ updateFavorites();
}
});
//
// Re-run whenever the emoji grid changes size, and re-calc style/layout-related state variables:
// 1) Re-calculate the --num-columns var because it may have changed
// 2) Re-calculate whether we're in RTL mode or not.
//
// The benefit of doing this in one place is to align with rAF/ResizeObserver
// and do all the calculations in one go. RTL vs LTR is not strictly layout-related,
// but since we're already reading the style here, and since it's already aligned with
// the rAF loop, this is the most appropriate place to do it perf-wise.
//
function calculateEmojiGridStyle (node) {
resizeObserverAction(node, abortSignal, () => {
/* istanbul ignore next */
{ // jsdom throws errors for this kind of fancy stuff
// read all the style/layout calculations we need to make
const style = getComputedStyle(refs.rootElement);
const newNumColumns = parseInt(style.getPropertyValue('--num-columns'), 10);
const newIsRtl = style.getPropertyValue('direction') === 'rtl';
// write to state variables
state.numColumns = newNumColumns;
state.isRtl = newIsRtl;
}
});
}
// Re-run whenever the custom emoji in a category are shown/hidden. This is an optimization that simulates
// what we'd get from `<img loading=lazy>` but without rendering an `<img>`.
function updateOnIntersection (node) {
intersectionObserverAction(node, abortSignal, (entries) => {
for (const { target, isIntersecting } of entries) {
target.classList.toggle('onscreen', isIntersecting);
}
});
}
//
// Set or update the currentEmojis. Check for invalid ZWJ renderings
// (i.e. double emoji).
//
createEffect(() => {
async function updateEmojis () {
const { searchText, currentGroup, databaseLoaded, customEmoji } = state;
if (!databaseLoaded) {
state.currentEmojis = [];
state.searchMode = false;
} else if (searchText.length >= MIN_SEARCH_TEXT_LENGTH) {
const newEmojis = await getEmojisBySearchQuery(searchText);
if (state.searchText === searchText) { // if the situation changes asynchronously, do not update
updateCurrentEmojis(newEmojis);
updateSearchMode(true);
}
} else { // database is loaded and we're not in search mode, so we're in normal category mode
const { id: currentGroupId } = currentGroup;
// avoid race condition where currentGroupId is -1 and customEmoji is undefined/empty
if (currentGroupId !== -1 || (customEmoji && customEmoji.length)) {
const newEmojis = await getEmojisByGroup(currentGroupId);
if (state.currentGroup.id === currentGroupId) { // if the situation changes asynchronously, do not update
updateCurrentEmojis(newEmojis);
updateSearchMode(false);
}
}
}
}
/* no await */ updateEmojis();
});
const resetScrollTopInRaf = () => {
rAF(() => resetScrollTopIfPossible(refs.tabpanelElement));
};
// Some emojis have their ligatures rendered as two or more consecutive emojis
// We want to treat these the same as unsupported emojis, so we compare their
// widths against the baseline widths and remove them as necessary
createEffect(() => {
const { currentEmojis, emojiVersion } = state;
const zwjEmojisToCheck = currentEmojis
.filter(emoji => emoji.unicode) // filter custom emoji
.filter(emoji => hasZwj(emoji) && !supportedZwjEmojis.has(emoji.unicode));
if (!emojiVersion && zwjEmojisToCheck.length) {
// render now, check their length later
updateCurrentEmojis(currentEmojis);
rAF(() => checkZwjSupportAndUpdate(zwjEmojisToCheck));
} else {
const newEmojis = emojiVersion ? currentEmojis : currentEmojis.filter(isZwjSupported);
updateCurrentEmojis(newEmojis);
// Reset scroll top to 0 when emojis change
resetScrollTopInRaf();
}
});
function checkZwjSupportAndUpdate (zwjEmojisToCheck) {
const allSupported = checkZwjSupport(zwjEmojisToCheck, refs.baselineEmoji, emojiToDomNode);
if (allSupported) {
// Even if all emoji are supported, we still need to reset the scroll top to 0 when emojis change
resetScrollTopInRaf();
} else {
// Force update. We only do this if there are any unsupported ZWJ characters since otherwise,
// for browsers that support all emoji, it would be an unnecessary extra re-render.
state.currentEmojis = [...state.currentEmojis];
}
}
function isZwjSupported (emoji) {
return !emoji.unicode || !hasZwj(emoji) || supportedZwjEmojis.get(emoji.unicode)
}
async function filterEmojisByVersion (emojis) {
const emojiSupportLevel = state.emojiVersion || await detectEmojiSupportLevel();
// !version corresponds to custom emoji
return emojis.filter(({ version }) => !version || version <= emojiSupportLevel)
}
async function summarizeEmojis (emojis) {
return summarizeEmojisForUI(emojis, state.emojiVersion || await detectEmojiSupportLevel())
}
async function getEmojisByGroup (group) {
// -1 is custom emoji
const emoji = group === -1 ? state.customEmoji : await state.database.getEmojiByGroup(group);
return summarizeEmojis(await filterEmojisByVersion(emoji))
}
async function getEmojisBySearchQuery (query) {
return summarizeEmojis(await filterEmojisByVersion(await state.database.getEmojiBySearchQuery(query)))
}
createEffect(() => {
});
//
// Derive currentEmojisWithCategories from currentEmojis. This is always done even if there
// are no categories, because it's just easier to code the HTML this way.
//
createEffect(() => {
function calculateCurrentEmojisWithCategories () {
const { searchMode, currentEmojis } = state;
if (searchMode) {
return [
{
category: '',
emojis: currentEmojis
}
]
}
const categoriesToEmoji = new Map();
for (const emoji of currentEmojis) {
const category = emoji.category || '';
let emojis = categoriesToEmoji.get(category);
if (!emojis) {
emojis = [];
categoriesToEmoji.set(category, emojis);
}
emojis.push(emoji);
}
return [...categoriesToEmoji.entries()]
.map(([cate