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 = { '🫨': 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) { // skip running this in jest/vitest because we don't need to check for emoji support in that environment /* istanbul ignore else */ { const range = document.createRange(); range.selectNode(node.firstChild); return range.getBoundingClientRect().width } } let baselineEmojiWidth; 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 = '/static/vendor/emoji-picker/emoji-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 };