import Database from "./shx-npm-emoji-picker-element-database.js"; const test123 = "test"; // 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 = { "🫨": 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"); 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 to calculate the width of an element and auto-update // using ResizeObserver. If ResizeObserver is unsupported, we just use rAF once // and don't bother to update. let resizeObserverSupported = typeof ResizeObserver === "function"; function calculateWidth(node, abortSignal, onUpdate) { let resizeObserver; if (resizeObserverSupported) { resizeObserver = new ResizeObserver((entries) => onUpdate(entries[0].contentRect.width) ); resizeObserver.observe(node); } else { // just set the width once, don't bother trying to track it rAF(() => onUpdate(node.getBoundingClientRect().width)); } // 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) { /* istanbul ignore else */ { const range = document.createRange(); range.selectNode(node.firstChild); return range.getBoundingClientRect().width; } } let baselineEmojiWidth; function checkZwjSupport(zwjEmojisToCheck, baselineEmoji, emojiToDomNode) { for (const emoji of zwjEmojisToCheck) { const domNode = emojiToDomNode(emoji); 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); } } // 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 targetNode.setAttribute( attributeName, attributeValuePre + toString(expression) + attributeValuePost ); } 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 = []; for (let i = 0, len = tokens.length; i < len; i++) { const token = tokens[i]; htmlString += token; 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 match = /(\S+)="?([^"=]*)$/.exec(token); attributeName = match[1]; attributeValuePre = match[2]; attributeValuePost = /^[^">]*/.exec(tokens[i + 1])[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 traverseAndSetupBindings(dom, elementsToBindings) { const instanceBindings = []; // traverse dom const treeWalker = document.createTreeWalker(dom, NodeFilter.SHOW_ELEMENT); let element = dom; let elementIndex = -1; do { const bindings = elementsToBindings.get(++elementIndex); if (bindings) { 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); } } } 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, firstRender ) { const { labelWithSkin, titleForEmoji, unicodeWithSkin } = helpers; const { html, map } = createFramework(state); function emojiList(emojis, searchMode, prefix) { return map( emojis, (emoji, i) => { return html``; // 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`
${state.i18n.searchDescription}
${state.i18n.skinToneDescription}
${map( state.skinTones, (skinTone, i) => { return html`
${skinTone}
`; }, (skinTone) => skinTone )}
${map( state.currentEmojisWithCategories, (emojiWithCategory, i) => { return html`
${emojiList( emojiWithCategory.emojis, state.searchMode, /* prefix */ "emo" )}
`; }, (emojiWithCategory) => emojiWithCategory.category )}
`; }; const rootDom = section(); if (firstRender) { // not a re-render container.appendChild(rootDom); // we only bind events/refs/actions once - there is no need to find them again given this component structure // 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)); } }; // 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; }); // set up actions forElementWithAttribute("data-action", (element, action) => { actions[action](element); }); // destroy/abort logic abortSignal.addEventListener("abort", () => { container.removeChild(rootDom); }); } } /* 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) { 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; } /* 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); // 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, scrollbarWidth: 0, 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, }; let firstRender = true; createEffect(() => { render( shadowRoot, state, helpers, events, actions, refs, abortSignal, 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. // Setting it on the database is pointless but prevents this code from being removed by a minifier. state.database.customEmoji = state.customEmoji || EMPTY_ARRAY; } 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(); } }); // // Calculate the width of the emoji grid. This serves two purposes: // 1) Re-calculate the --num-columns var because it may have changed // 2) Re-calculate the scrollbar width because it may have changed // (i.e. because the number of items changed) // 3) 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 width-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) { calculateWidth(node, abortSignal, (width) => { /* 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"; const parentWidth = node.parentElement.getBoundingClientRect().width; const newScrollbarWidth = parentWidth - width; // write to state variables state.numColumns = newNumColumns; state.scrollbarWidth = newScrollbarWidth; // eslint-disable-line no-unused-vars state.isRtl = newIsRtl; // eslint-disable-line no-unused-vars } }); } // // 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(); }); // 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 rAF(() => resetScrollTopIfPossible(refs.tabpanelElement)); } }); function checkZwjSupportAndUpdate(zwjEmojisToCheck) { checkZwjSupport(zwjEmojisToCheck, refs.baselineEmoji, emojiToDomNode); // force update // eslint-disable-next-line no-self-assign 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); } } // // Handle user input on an emoji // async function clickEmoji(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); fireEvent("emoji-click", { emoji, skinTone: state.currentSkinTone, ...(skinTonedUnicode && { unicode: skinTonedUnicode }), ...(emojiSummary.name && { name: emojiSummary.name }), }); } async 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;--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);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;-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%}.category{padding:var(--emoji-padding);font-size:var(--category-font-size);color:var(--category-font-color)}.custom-emoji,.emoji,button.emoji{height:var(--total-emoji-size);width:var(--total-emoji-size)}.emoji,button.emoji{font-size:var(--emoji-size);display:flex;align-items:center;justify-content:center;border-radius:100%;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)}.custom-emoji{padding:var(--emoji-padding);object-fit:contain;pointer-events:none;background-repeat:no-repeat;background-position:center center;background-size:var(--emoji-size) var(--emoji-size)}.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{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() { // 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() { // 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); /* 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 };