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 = {
'π«ͺ' : 17 , // distorted face
'π«©' : 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
}
}
const BASELINE _EMOJI = 'π' ;
let baselineEmojiWidth ;
let fallbackNode ;
function calculateTextWidthWithFallback ( unicode , domNode , baselineEmojiNode ) {
const result = calculateTextWidth ( domNode ) ;
/* istanbul ignore if */
if ( ! result ) {
// If result is 0 then very likely the emoji-picker has `display:none` or equivalent. In that case, we fall back to
// cloning the baseline emoji, putting that in the `document.body`, and measuring that instead. This is a perf hit,
// but it's better than mistakenly filtering emoji: https://github.com/nolanlawson/emoji-picker-element/issues/514
if ( ! fallbackNode ) {
fallbackNode = baselineEmojiNode . cloneNode ( true ) ;
// We have to copy styles because we're copying from an element in the shadow DOM to the light DOM
// We can't use the shadow DOM because it's likely the entire picker is `display:none`
const styles = getComputedStyle ( baselineEmojiNode ) ;
// probably don't need display/align-items/justify-content but let's play it safe
for ( const prop of [ 'font-family' , 'line-height' , 'width' , 'height' , 'font-size' , 'display' , 'align-items' , 'justify-content' ] ) {
// set `!important` just in case some global styles might try to overwrite this
fallbackNode . style . setProperty ( prop , styles . getPropertyValue ( prop ) , 'important' ) ;
}
}
try {
document . body . appendChild ( fallbackNode ) ;
fallbackNode . firstChild . nodeValue = unicode ;
return calculateTextWidth ( fallbackNode )
} finally {
// avoid actually rendering the test emoji
fallbackNode . remove ( ) ;
}
}
return result
}
/**
* 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 baselineEmojiNode
* @param emojiToDomNode
*/
function checkZwjSupport ( zwjEmojisToCheck , baselineEmojiNode , 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
}
if ( typeof baselineEmojiWidth === 'undefined' ) { // calculate the baseline emoji width only once
baselineEmojiWidth = calculateTextWidthWithFallback ( BASELINE _EMOJI , baselineEmojiNode , baselineEmojiNode ) ;
}
const emojiWidth = calculateTextWidthWithFallback ( emoji . unicode , domNode , baselineEmojiNode ) ;
// 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 ( ( [ category , emojis ] ) => ( { category , emojis } ) )
. sort ( ( a , b ) => state . customCategorySorting ( a . category , b . category ) )
}
const newEmojisWithCategories = calculateCurrentEmojisWithCategories ( ) ;
updateCurrentEmojisWithCategories ( newEmojisWithCategories ) ;
} ) ;
//
// Handle active search item (i.e. pressing up or down while searching)
//
createEffect ( ( ) => {
state . activeSearchItemId = state . activeSearchItem !== - 1 && state . currentEmojis [ state . activeSearchItem ] . id ;
} ) ;
//
// Handle user input on the search input
//
createEffect ( ( ) => {
const { rawSearchText } = state ;
rIC ( ( ) => {
state . searchText = ( rawSearchText || '' ) . trim ( ) ; // defer to avoid input delays, plus we can trim here
state . activeSearchItem = - 1 ;
} ) ;
} ) ;
function onSearchKeydown ( event ) {
if ( ! state . searchMode || ! state . currentEmojis . length ) {
return
}
const goToNextOrPrevious = ( previous ) => {
halt ( event ) ;
state . activeSearchItem = incrementOrDecrement ( previous , state . activeSearchItem , state . currentEmojis ) ;
} ;
switch ( event . key ) {
case 'ArrowDown' :
return goToNextOrPrevious ( false )
case 'ArrowUp' :
return goToNextOrPrevious ( true )
case 'Enter' :
if ( state . activeSearchItem === - 1 ) {
// focus the first option in the list since the list must be non-empty at this point (it's verified above)
state . activeSearchItem = 0 ;
} else { // there is already an active search item
halt ( event ) ;
return clickEmoji ( state . currentEmojis [ state . activeSearchItem ] . id )
}
}
}
//
// Handle user input on nav
//
function onNavClick ( event ) {
const { target } = event ;
const closestTarget = target . closest ( '.nav-button' ) ;
/* istanbul ignore if */
if ( ! closestTarget ) {
return // This should never happen, but makes me nervous not to have it
}
const groupId = parseInt ( closestTarget . dataset . groupId , 10 ) ;
refs . searchElement . value = '' ; // clear search box input
state . rawSearchText = '' ;
state . searchText = '' ;
state . activeSearchItem = - 1 ;
state . currentGroupIndex = state . groups . findIndex ( _ => _ . id === groupId ) ;
}
function onNavKeydown ( event ) {
const { target , key } = event ;
const doFocus = el => {
if ( el ) {
halt ( event ) ;
el . focus ( ) ;
}
} ;
switch ( key ) {
case 'ArrowLeft' :
return doFocus ( target . previousElementSibling )
case 'ArrowRight' :
return doFocus ( target . nextElementSibling )
case 'Home' :
return doFocus ( target . parentElement . firstElementChild )
case 'End' :
return doFocus ( target . parentElement . lastElementChild )
}
}
async function getDetailForClickEvent ( unicodeOrName ) {
const emoji = await state . database . getEmojiByUnicodeOrName ( unicodeOrName ) ;
const emojiSummary = [ ... state . currentEmojis , ... state . currentFavorites ]
. find ( _ => ( _ . id === unicodeOrName ) ) ;
const skinTonedUnicode = emojiSummary . unicode && unicodeWithSkin ( emojiSummary , state . currentSkinTone ) ;
await state . database . incrementFavoriteEmojiCount ( unicodeOrName ) ;
return {
emoji ,
skinTone : state . currentSkinTone ,
... ( skinTonedUnicode && { unicode : skinTonedUnicode } ) ,
... ( emojiSummary . name && { name : emojiSummary . name } )
}
}
//
// Handle user input on an emoji
//
async function clickEmoji ( unicodeOrName ) {
const promiseForDetail = getDetailForClickEvent ( unicodeOrName ) ;
// sync event to work around a safari bug: https://bugs.webkit.org/show_bug.cgi?id=222262
fireEvent ( 'emoji-click-sync' , promiseForDetail ) ;
// async event for most normal use cases that don't need to work around the safari bug
fireEvent ( 'emoji-click' , await promiseForDetail ) ;
}
function onEmojiClick ( event ) {
const { target } = event ;
/* istanbul ignore if */
if ( ! target . classList . contains ( 'emoji' ) ) {
// This should never happen, but makes me nervous not to have it
return
}
halt ( event ) ;
const id = target . id . substring ( 4 ) ; // replace 'emo-' or 'fav-' prefix
/* no await */ clickEmoji ( id ) ;
}
//
// Handle user input on the skintone picker
//
function changeSkinTone ( skinTone ) {
state . currentSkinTone = skinTone ;
state . skinTonePickerExpanded = false ;
focus ( 'skintone-button' ) ;
fireEvent ( 'skin-tone-change' , { skinTone } ) ;
/* no await */ state . database . setPreferredSkinTone ( skinTone ) ;
}
function onSkinToneOptionsClick ( event ) {
const { target : { id } } = event ;
const match = id && id . match ( /^skintone-(\d)/ ) ; // skintone option format
/* istanbul ignore if */
if ( ! match ) { // not a skintone option
return // This should never happen, but makes me nervous not to have it
}
halt ( event ) ;
const skinTone = parseInt ( match [ 1 ] , 10 ) ; // remove 'skintone-' prefix
changeSkinTone ( skinTone ) ;
}
function onClickSkinToneButton ( event ) {
state . skinTonePickerExpanded = ! state . skinTonePickerExpanded ;
state . activeSkinTone = state . currentSkinTone ;
// this should always be true, since the button is obscured by the listbox, so this `if` is just to be sure
if ( state . skinTonePickerExpanded ) {
halt ( event ) ;
rAF ( ( ) => focus ( 'skintone-list' ) ) ;
}
}
// To make the animation nicer, change the z-index of the skintone picker button
// *after* the animation has played. This makes it appear that the picker box
// is expanding "below" the button
createEffect ( ( ) => {
if ( state . skinTonePickerExpanded ) {
refs . skinToneDropdown . addEventListener ( 'transitionend' , ( ) => {
state . skinTonePickerExpandedAfterAnimation = true ; // eslint-disable-line no-unused-vars
} , { once : true } ) ;
} else {
state . skinTonePickerExpandedAfterAnimation = false ; // eslint-disable-line no-unused-vars
}
} ) ;
function onSkinToneOptionsKeydown ( event ) {
// this should never happen, but makes me nervous not to have it
/* istanbul ignore if */
if ( ! state . skinTonePickerExpanded ) {
return
}
const changeActiveSkinTone = async nextSkinTone => {
halt ( event ) ;
state . activeSkinTone = nextSkinTone ;
} ;
switch ( event . key ) {
case 'ArrowUp' :
return changeActiveSkinTone ( incrementOrDecrement ( true , state . activeSkinTone , state . skinTones ) )
case 'ArrowDown' :
return changeActiveSkinTone ( incrementOrDecrement ( false , state . activeSkinTone , state . skinTones ) )
case 'Home' :
return changeActiveSkinTone ( 0 )
case 'End' :
return changeActiveSkinTone ( state . skinTones . length - 1 )
case 'Enter' :
// enter on keydown, space on keyup. this is just how browsers work for buttons
// https://lists.w3.org/Archives/Public/w3c-wai-ig/2019JanMar/0086.html
halt ( event ) ;
return changeSkinTone ( state . activeSkinTone )
case 'Escape' :
halt ( event ) ;
state . skinTonePickerExpanded = false ;
return focus ( 'skintone-button' )
}
}
function onSkinToneOptionsKeyup ( event ) {
// this should never happen, but makes me nervous not to have it
/* istanbul ignore if */
if ( ! state . skinTonePickerExpanded ) {
return
}
switch ( event . key ) {
case ' ' :
// enter on keydown, space on keyup. this is just how browsers work for buttons
// https://lists.w3.org/Archives/Public/w3c-wai-ig/2019JanMar/0086.html
halt ( event ) ;
return changeSkinTone ( state . activeSkinTone )
}
}
async function onSkinToneOptionsFocusOut ( event ) {
// On blur outside of the skintone listbox, collapse the skintone picker.
const { relatedTarget } = event ;
// The `else` should never happen, but makes me nervous not to have it
/* istanbul ignore else */
if ( ! relatedTarget || relatedTarget . id !== 'skintone-list' ) {
state . skinTonePickerExpanded = false ;
}
}
function onSearchInput ( event ) {
state . rawSearchText = event . target . value ;
}
return {
$set ( newState ) {
assign ( state , newState ) ;
} ,
$destroy ( ) {
abortController . abort ( ) ;
}
}
}
const DEFAULT _DATA _SOURCE = 'https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/en/emojibase/data.json' ;
const DEFAULT _LOCALE = 'en' ;
var enI18n = {
categoriesLabel : 'Categories' ,
emojiUnsupportedMessage : 'Your browser does not support color emoji.' ,
favoritesLabel : 'Favorites' ,
loadingMessage : 'Loadingβ¦' ,
networkErrorMessage : 'Could not load emoji.' ,
regionLabel : 'Emoji picker' ,
searchDescription : 'When search results are available, press up or down to select and enter to choose.' ,
searchLabel : 'Search' ,
searchResultsLabel : 'Search results' ,
skinToneDescription : 'When expanded, press up or down to select and enter to choose.' ,
skinToneLabel : 'Choose a skin tone (currently {skinTone})' ,
skinTonesLabel : 'Skin tones' ,
skinTones : [
'Default' ,
'Light' ,
'Medium-Light' ,
'Medium' ,
'Medium-Dark' ,
'Dark'
] ,
categories : {
custom : 'Custom' ,
'smileys-emotion' : 'Smileys and emoticons' ,
'people-body' : 'People and body' ,
'animals-nature' : 'Animals and nature' ,
'food-drink' : 'Food and drink' ,
'travel-places' : 'Travel and places' ,
activities : 'Activities' ,
objects : 'Objects' ,
symbols : 'Symbols' ,
flags : 'Flags'
}
} ;
var baseStyles = ":host{--emoji-size:1.375rem;--emoji-padding:0.5rem;--category-emoji-size:var(--emoji-size);--category-emoji-padding:var(--emoji-padding);--indicator-height:3px;--input-border-radius:0.5rem;--input-border-size:1px;--input-font-size:1rem;--input-line-height:1.5;--input-padding:0.25rem;--num-columns:8;--outline-size:2px;--border-size:1px;--border-radius:0;--skintone-border-radius:1rem;--category-font-size:1rem;display:flex;width:min-content;height:400px}:host,:host(.light){color-scheme:light;--background:#fff;--border-color:#e0e0e0;--indicator-color:#385ac1;--input-border-color:#999;--input-font-color:#111;--input-placeholder-color:#999;--outline-color:#999;--category-font-color:#111;--button-active-background:#e6e6e6;--button-hover-background:#d9d9d9}:host(.dark){color-scheme:dark;--background:#222;--border-color:#444;--indicator-color:#5373ec;--input-border-color:#ccc;--input-font-color:#efefef;--input-placeholder-color:#ccc;--outline-color:#fff;--category-font-color:#efefef;--button-active-background:#555555;--button-hover-background:#484848}@media (prefers-color-scheme:dark){:host{color-scheme:dark;--background:#222;--border-color:#444;--indicator-color:#5373ec;--input-border-color:#ccc;--input-font-color:#efefef;--input-placeholder-color:#ccc;--outline-color:#fff;--category-font-color:#efefef;--button-active-background:#555555;--button-hover-background:#484848}}:host([hidden]){display:none}button{margin:0;padding:0;border:0;background:0 0;box-shadow:none;-webkit-tap-highlight-color:transparent}button::-moz-focus-inner{border:0}input{padding:0;margin:0;line-height:1.15;font-family:inherit}input[type=search]{-webkit-appearance:none}:focus{outline:var(--outline-color) solid var(--outline-size);outline-offset:calc(-1*var(--outline-size))}:host([data-js-focus-visible]) :focus:not([data-focus-visible-added]){outline:0}:focus:not(:focus-visible){outline:0}.hide-focus{outline:0}*{box-sizing:border-box}.picker{contain:content;display:flex;flex-direction:column;background:var(--background);border:var(--border-size) solid var(--border-color);border-radius:var(--border-radius);width:100%;height:100%;overflow:hidden;--total-emoji-size:calc(var(--emoji-size) + (2 * var(--emoji-padding)));--total-category-emoji-size:calc(var(--category-emoji-size) + (2 * var(--category-emoji-padding)))}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.hidden{opacity:0;pointer-events:none}.abs-pos{position:absolute;left:0;top:0}.gone{display:none!important}.skintone-button-wrapper,.skintone-list{background:var(--background);z-index:3}.skintone-button-wrapper.expanded{z-index:1}.skintone-list{position:absolute;inset-inline-end:0;top:0;z-index:2;overflow:visible;border-bottom:var(--border-size) solid var(--border-color);border-radius:0 0 var(--skintone-border-radius) var(--skintone-border-radius);will-change:transform;transition:transform .2s ease-in-out;transform-origin:center 0}@media (prefers-reduced-motion:reduce){.skintone-list{transition-duration:.001s}}@supports not (inset-inline-end:0){.skintone-list{right:0}}.skintone-list.no-animate{transition:none}.tabpanel{overflow-y:auto;scrollbar-gutter:stable;-webkit-overflow-scrolling:touch;will-change:transform;min-height:0;flex:1;contain:content}.emoji-menu{display:grid;grid-template-columns:repeat(var(--num-columns),var(--total-emoji-size));justify-content:space-around;align-items:flex-start;width:100%}.emoji-menu.visibility-auto{content-visibility:auto;contain-intrinsic-size:calc(var(--num-columns)*var(--total-emoji-size)) calc(var(--num-rows)*var(--total-emoji-size))}.category{padding:var(--emoji-padding);font-size:var(--category-font-size);color:var(--category-font-color)}.emoji,button.emoji{font-size:var(--emoji-size);display:flex;align-items:center;justify-content:center;border-radius:100%;height:var(--total-emoji-size);width:var(--total-emoji-size);line-height:1;overflow:hidden;font-family:var(--emoji-font-family);cursor:pointer}@media (hover:hover) and (pointer:fine){.emoji:hover,button.emoji:hover{background:var(--button-hover-background)}}.emoji.active,.emoji:active,button.emoji.active,button.emoji:active{background:var(--button-active-background)}.onscreen .custom-emoji::after{content:\"\";width:var(--emoji-size);height:var(--emoji-size);background-repeat:no-repeat;background-position:center center;background-size:contain;background-image:var(--custom-emoji-background)}.nav,.nav-button{align-items:center}.nav{display:grid;justify-content:space-between;contain:content}.nav-button{display:flex;justify-content:center}.nav-emoji{font-size:var(--category-emoji-size);width:var(--total-category-emoji-size);height:var(--total-category-emoji-size)}.indicator-wrapper{display:flex;border-bottom:1px solid var(--border-color)}.indicator{width:calc(100%/var(--num-groups));height:var(--indicator-height);opacity:var(--indicator-opacity);background-color:var(--indicator-color);will-change:transform,opacity;transition:opacity .1s linear,transform .25s ease-in-out}@media (prefers-reduced-motion:reduce){.indicator{will-change:opacity;transition:opacity .1s linear}}.pad-top,input.search{background:var(--background);width:100%}.pad-top{height:var(--emoji-padding);z-index:3}.search-row{display:flex;align-items:center;position:relative;padding-inline-start:var(--emoji-padding);padding-bottom:var(--emoji-padding)}.search-wrapper{flex:1;min-width:0}input.search{padding:var(--input-padding);border-radius:var(--input-border-radius);border:var(--input-border-size) solid var(--input-border-color);color:var(--input-font-color);font-size:var(--input-font-size);line-height:var(--input-line-height)}input.search::placeholder{color:var(--input-placeholder-color)}.favorites{overflow-y:auto;scrollbar-gutter:stable;display:flex;flex-direction:row;border-top:var(--border-size) solid var(--border-color);contain:content}.message{padding:var(--emoji-padding)}" ;
const PROPS = [
'customEmoji' ,
'customCategorySorting' ,
'database' ,
'dataSource' ,
'i18n' ,
'locale' ,
'skinToneEmoji' ,
'emojiVersion'
] ;
// Styles injected ourselves, so we can declare the FONT_FAMILY variable in one place
const EXTRA _STYLES = ` :host{--emoji-font-family: ${ FONT _FAMILY } } ` ;
class PickerElement extends HTMLElement {
constructor ( props ) {
super ( ) ;
this . attachShadow ( { mode : 'open' } ) ;
const style = document . createElement ( 'style' ) ;
style . textContent = baseStyles + EXTRA _STYLES ;
this . shadowRoot . appendChild ( style ) ;
this . _ctx = {
// Set defaults
locale : DEFAULT _LOCALE ,
dataSource : DEFAULT _DATA _SOURCE ,
skinToneEmoji : DEFAULT _SKIN _TONE _EMOJI ,
customCategorySorting : DEFAULT _CATEGORY _SORTING ,
customEmoji : null ,
i18n : enI18n ,
emojiVersion : null ,
... props
} ;
// Handle properties set before the element was upgraded
for ( const prop of PROPS ) {
if ( prop !== 'database' && Object . prototype . hasOwnProperty . call ( this , prop ) ) {
this . _ctx [ prop ] = this [ prop ] ;
delete this [ prop ] ;
}
}
this . _dbFlush ( ) ; // wait for a flush before creating the db, in case the user calls e.g. a setter or setAttribute
}
connectedCallback ( ) {
rescueElementPrototype ( this ) ;
// The _cmp may be defined if the component was immediately disconnected and then reconnected. In that case,
// do nothing (preserve the state)
if ( ! this . _cmp ) {
this . _cmp = createRoot ( this . shadowRoot , this . _ctx ) ;
}
}
disconnectedCallback ( ) {
rescueElementPrototype ( this ) ;
// Check in a microtask if the element is still connected. If so, treat this as a "move" rather than a disconnect
// Inspired by Vue: https://vuejs.org/guide/extras/web-components.html#building-custom-elements-with-vue
qM ( ( ) => {
// this._cmp may be defined if connect-disconnect-connect-disconnect occurs synchronously
if ( ! this . isConnected && this . _cmp ) {
this . _cmp . $destroy ( ) ;
this . _cmp = undefined ;
const { database } = this . _ctx ;
database . close ( )
// only happens if the database failed to load in the first place, so we don't care
. catch ( err => console . error ( err ) ) ;
}
} ) ;
}
static get observedAttributes ( ) {
return [ 'locale' , 'data-source' , 'skin-tone-emoji' , 'emoji-version' ] // complex objects aren't supported, also use kebab-case
}
attributeChangedCallback ( attrName , oldValue , newValue ) {
this . _set (
// convert from kebab-case to camelcase
// see https://github.com/sveltejs/svelte/issues/3852#issuecomment-665037015
attrName . replace ( /-([a-z])/g , ( _ , up ) => up . toUpperCase ( ) ) ,
// convert string attribute to float if necessary
attrName === 'emoji-version' ? parseFloat ( newValue ) : newValue
) ;
}
_set ( prop , newValue ) {
this . _ctx [ prop ] = newValue ;
if ( this . _cmp ) {
this . _cmp . $set ( { [ prop ] : newValue } ) ;
}
if ( [ 'locale' , 'dataSource' ] . includes ( prop ) ) {
this . _dbFlush ( ) ;
}
}
_dbCreate ( ) {
const { locale , dataSource , database } = this . _ctx ;
// only create a new database if we really need to
if ( ! database || database . locale !== locale || database . dataSource !== dataSource ) {
this . _set ( 'database' , new Database ( { locale , dataSource } ) ) ;
}
}
// Update the Database in one microtask if the locale/dataSource change. We do one microtask
// so we don't create two Databases if e.g. both the locale and the dataSource change
_dbFlush ( ) {
qM ( ( ) => (
this . _dbCreate ( )
) ) ;
}
}
const definitions = { } ;
for ( const prop of PROPS ) {
definitions [ prop ] = {
get ( ) {
if ( prop === 'database' ) {
// in rare cases, the microtask may not be flushed yet, so we need to instantiate the DB
// now if the user is asking for it
this . _dbCreate ( ) ;
}
return this . _ctx [ prop ]
} ,
set ( val ) {
if ( prop === 'database' ) {
throw new Error ( 'database is read-only' )
}
this . _set ( prop , val ) ;
}
} ;
}
Object . defineProperties ( PickerElement . prototype , definitions ) ;
// See https://jakearchibald.com/2025/firefox-custom-elements-iframes-bug/
// TODO: remove when the Firefox bug is fixed: https://bugzilla.mozilla.org/show_bug.cgi?id=1502814
function rescueElementPrototype ( element ) {
/* istanbul ignore if */
if ( ! ( element instanceof PickerElement ) ) {
Object . setPrototypeOf ( element , customElements . get ( element . tagName . toLowerCase ( ) ) . prototype ) ;
}
}
/* istanbul ignore else */
if ( ! customElements . get ( 'emoji-picker' ) ) { // if already defined, do nothing (e.g. same script imported twice)
customElements . define ( 'emoji-picker' , PickerElement ) ;
}
export { PickerElement as default , rescueElementPrototype } ;