PageSourceSearch

https://give.sparkraise.com/js/react/emoji-picker-element.mjs

js sparkraise.com collected 2026-09-24 10:23:26 UTC 102,084 bytes, 2,835 lines download raw bytes

vendor: 7,012 bytes, lines 1-220
1function assertNonEmptyString (str) {
2  if (typeof str !== 'string' || !str) {
3    throw new Error('expected a non-empty string, got: ' + str)
4  }
5}
6
7function assertNumber (number) {
8  if (typeof number !== 'number') {
9    throw new Error('expected a number, got: ' + number)
10  }
11}
12
13const DB_VERSION_CURRENT = 1;
14const DB_VERSION_INITIAL = 1;
15const STORE_EMOJI = 'emoji';
16const STORE_KEYVALUE = 'keyvalue';
17const STORE_FAVORITES = 'favorites';
18const FIELD_TOKENS = 'tokens';
19const INDEX_TOKENS = 'tokens';
20const FIELD_UNICODE = 'unicode';
21const INDEX_COUNT = 'count';
22const FIELD_GROUP = 'group';
23const FIELD_ORDER = 'order';
24const INDEX_GROUP_AND_ORDER = 'group-order';
25const KEY_ETAG = 'eTag';
26const KEY_URL = 'url';
27const KEY_PREFERRED_SKINTONE = 'skinTone';
28const MODE_READONLY = 'readonly';
29const MODE_READWRITE = 'readwrite';
30const INDEX_SKIN_UNICODE = 'skinUnicodes';
31const FIELD_SKIN_UNICODE = 'skinUnicodes';
32
33const DEFAULT_DATA_SOURCE$1 = 'https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/en/emojibase/data.json';
34const DEFAULT_LOCALE$1 = 'en';
35
36// like lodash's uniqBy but much smaller
37function uniqBy$1 (arr, func) {
38  const set = new Set();
39  const res = [];
40  for (const item of arr) {
41    const key = func(item);
42    if (!set.has(key)) {
43      set.add(key);
44      res.push(item);
45    }
46  }
47  return res
48}
49
50function uniqEmoji (emojis) {
51  return uniqBy$1(emojis, _ => _.unicode)
52}
53
54function initialMigration (db) {
55  function createObjectStore (name, keyPath, indexes) {
56    const store = keyPath
57      ? db.createObjectStore(name, { keyPath })
58      : db.createObjectStore(name);
59    if (indexes) {
60      for (const [indexName, [keyPath, multiEntry]] of Object.entries(indexes)) {
61        store.createIndex(indexName, keyPath, { multiEntry });
62      }
63    }
64    return store
65  }
66
67  createObjectStore(STORE_KEYVALUE);
68  createObjectStore(STORE_EMOJI, /* keyPath */ FIELD_UNICODE, {
69    [INDEX_TOKENS]: [FIELD_TOKENS, /* multiEntry */ true],
70    [INDEX_GROUP_AND_ORDER]: [[FIELD_GROUP, FIELD_ORDER]],
71    [INDEX_SKIN_UNICODE]: [FIELD_SKIN_UNICODE, /* multiEntry */ true]
72  });
73  createObjectStore(STORE_FAVORITES, undefined, {
74    [INDEX_COUNT]: ['']
75  });
76}
77
78const openIndexedDBRequests = {};
79const databaseCache = {};
80const onCloseListeners = {};
81
82function handleOpenOrDeleteReq (resolve, reject, req) {
83  // These things are almost impossible to test with fakeIndexedDB sadly
84  /* istanbul ignore next */
85  req.onerror = () => reject(req.error);
86  /* istanbul ignore next */
87  req.onblocked = () => reject(new Error('IDB blocked'));
88  req.onsuccess = () => resolve(req.result);
89}
90
91async function createDatabase (dbName) {
92  const db = await new Promise((resolve, reject) => {
93    const req = indexedDB.open(dbName, DB_VERSION_CURRENT);
94    openIndexedDBRequests[dbName] = req;
95    req.onupgradeneeded = e => {
96      // Technically there is only one version, so we don't need this `if` check
97      // But if an old version of the JS is in another browser tab
98      // and it gets upgraded in the future and we have a new DB version, well...
99      // better safe than sorry.
100      /* istanbul ignore else */
101      if (e.oldVersion < DB_VERSION_INITIAL) {
102        initialMigration(req.result);
103      }
104    };
105    handleOpenOrDeleteReq(resolve, reject, req);
106  });
107  // Handle abnormal closes, e.g. "delete database" in chrome dev tools.
108  // No need for removeEventListener, because once the DB can no longer
109  // fire "close" events, it will auto-GC.
110  db.onclose = () => closeDatabase(dbName);
111  return db
112}
113
114function openDatabase (dbName) {
115  if (!databaseCache[dbName]) {
116    databaseCache[dbName] = createDatabase(dbName);
117  }
118  return databaseCache[dbName]
119}
120
121function dbPromise (db, storeName, readOnlyOrReadWrite, cb) {
122  return new Promise((resolve, reject) => {
123    // Use relaxed durability because neither the emoji data nor the favorites/preferred skin tone
124    // are really irreplaceable data. IndexedDB is just a cache in this case.
125    const txn = db.transaction(storeName, readOnlyOrReadWrite, { durability: 'relaxed' });
126    const store = typeof storeName === 'string'
127      ? txn.objectStore(storeName)
128      : storeName.map(name => txn.objectStore(name));
129    let res;
130    cb(store, txn, (result) => {
131      res = result;
132    });
133
134    txn.oncomplete = () => resolve(res);
135    /* istanbul ignore next */
136    txn.onerror = () => reject(txn.error);
137  })
138}
139
140function closeDatabase (dbName) {
141  // close any open requests
142  const req = openIndexedDBRequests[dbName];
143  const db = req && req.result;
144  if (db) {
145    db.close();
146    const listeners = onCloseListeners[dbName];
147    /* istanbul ignore else */
148    if (listeners) {
149      for (const listener of listeners) {
150        listener();
151      }
152    }
153  }
154  delete openIndexedDBRequests[dbName];
155  delete databaseCache[dbName];
156  delete onCloseListeners[dbName];
157}
158
159function deleteDatabase (dbName) {
160  return new Promise((resolve, reject) => {
161    // close any open requests
162    closeDatabase(dbName);
163    const req = indexedDB.deleteDatabase(dbName);
164    handleOpenOrDeleteReq(resolve, reject, req);
165  })
166}
167
168// The "close" event occurs during an abnormal shutdown, e.g. a user clearing their browser data.
169// However, it doesn't occur with the normal "close" event, so we handle that separately.
170// https://www.w3.org/TR/IndexedDB/#close-a-database-connection
171function addOnCloseListener (dbName, listener) {
172  let listeners = onCloseListeners[dbName];
173  if (!listeners) {
174    listeners = onCloseListeners[dbName] = [];
175  }
176  listeners.push(listener);
177}
178
179// list of emoticons that don't match a simple \W+ regex
180// extracted using:
181// require('emoji-picker-element-data/en/emojibase/data.json').map(_ => _.emoticon).filter(Boolean).filter(_ => !/^\W+$/.test(_))
182const irregularEmoticons = new Set([
183  ':D', 'XD', ":'D", 'O:)',
184  ':X', ':P', ';P', 'XP',
185  ':L', ':Z', ':j', '8D',
186  'XO', '8)', ':B', ':O',
187  ':S', ":'o", 'Dx', 'X(',
188  'D:', ':C', '>0)', ':3',
189  '</3', '<3', '\\M/', ':E',
190  '8#'
191]);
192
193function extractTokens (str) {
194  return str
195    .split(/[\s_]+/)
196    .map(word => {
197      if (!word.match(/\w/) || irregularEmoticons.has(word)) {
198        // for pure emoticons like :) or :-), just leave them as-is
199        return word.toLowerCase()
200      }
201
202      return word
203        .replace(/[)(:,]/g, '')
204        .replace(/’/g, "'")
205        .toLowerCase()
206    }).filter(Boolean)
207}
208
209const MIN_SEARCH_TEXT_LENGTH$1 = 2;
210
211// This is an extra step in addition to extractTokens(). The difference here is that we expect
212// the input to have already been run through extractTokens(). This is useful for cases like
213// emoticons, where we don't want to do any tokenization (because it makes no sense to split up
214// ">:)" by the colon) but we do want to lowercase it to have consistent search results, so that
215// the user can type ':P' or ':p' and still get the same result.
216function normalizeTokens (str) {
217  return str
218    .filter(Boolean)
219    .map(_ => _.toLowerCase())
220    .filter(_ => _.length >= MIN_SEARCH_TEXT_LENGTH$1
vendor: 19,087 bytes, lines 220-867
220)
221}
222
223// Transform emoji data for storage in IDB
224function transformEmojiData (emojiData) {
225  const res = emojiData.map(({ annotation, emoticon, group, order, shortcodes, skins, tags, emoji, version }) => {
226    const tokens = [...new Set(
227      normalizeTokens([
228        ...(shortcodes || []).map(extractTokens).flat(),
229        ...(tags || []).map(extractTokens).flat(),
230        ...extractTokens(annotation),
231        emoticon
232      ])
233    )].sort();
234    const res = {
235      annotation,
236      group,
237      order,
238      tags,
239      tokens,
240      unicode: emoji,
241      version
242    };
243    if (emoticon) {
244      res.emoticon = emoticon;
245    }
246    if (shortcodes) {
247      res.shortcodes = shortcodes;
248    }
249    if (skins) {
250      res.skinTones = [];
251      res.skinUnicodes = [];
252      res.skinVersions = [];
253      for (const { tone, emoji, version } of skins) {
254        res.skinTones.push(tone);
255        res.skinUnicodes.push(emoji);
256        res.skinVersions.push(version);
257      }
258    }
259    return res
260  });
261  return res
262}
263
264// helper functions that help compress the code better
265
266function callStore (store, method, key, cb) {
267  store[method](key).onsuccess = e => (cb && cb(e.target.result));
268}
269
270function getIDB (store, key, cb) {
271  callStore(store, 'get', key, cb);
272}
273
274function getAllIDB (store, key, cb) {
275  callStore(store, 'getAll', key, cb);
276}
277
278function commit (txn) {
279  /* istanbul ignore else */
280  if (txn.commit) {
281    txn.commit();
282  }
283}
284
285// like lodash's minBy
286function minBy (array, func) {
287  let minItem = array[0];
288  for (let i = 1; i < array.length; i++) {
289    const item = array[i];
290    if (func(minItem) > func(item)) {
291      minItem = item;
292    }
293  }
294  return minItem
295}
296
297// return an array of results representing all items that are found in each one of the arrays
298//
299
300function findCommonMembers (arrays, uniqByFunc) {
301  const shortestArray = minBy(arrays, _ => _.length);
302  const results = [];
303  for (const item of shortestArray) {
304    // if this item is included in every array in the intermediate results, add it to the final results
305    if (!arrays.some(array => array.findIndex(_ => uniqByFunc(_) === uniqByFunc(item)) === -1)) {
306      results.push(item);
307    }
308  }
309  return results
310}
311
312async function isEmpty (db) {
313  return !(await get(db, STORE_KEYVALUE, KEY_URL))
314}
315
316async function hasData (db, url, eTag) {
317  const [oldETag, oldUrl] = await Promise.all([KEY_ETAG, KEY_URL]
318    .map(key => get(db, STORE_KEYVALUE, key)));
319  return (oldETag === eTag && oldUrl === url)
320}
321
322async function doFullDatabaseScanForSingleResult (db, predicate) {
323  // This batching algorithm is just a perf improvement over a basic
324  // cursor. The BATCH_SIZE is an estimate of what would give the best
325  // perf for doing a full DB scan (worst case).
326  //
327  // Mini-benchmark for determining the best batch size:
328  //
329  // PERF=1 pnpm build:rollup && pnpm test:adhoc
330  //
331  // (async () => {
332  //   performance.mark('start')
333  //   await $('emoji-picker').database.getEmojiByShortcode('doesnotexist')
334  //   performance.measure('total', 'start')
335  //   console.log(performance.getEntriesByName('total').slice(-1)[0].duration)
336  // })()
337  const BATCH_SIZE = 50; // Typically around 150ms for 6x slowdown in Chrome for above benchmark
338  return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => {
339    let lastKey;
340
341    const processNextBatch = () => {
342      emojiStore.getAll(lastKey && IDBKeyRange.lowerBound(lastKey, true), BATCH_SIZE).onsuccess = e => {
343        const results = e.target.result;
344        for (const result of results) {
345          lastKey = result.unicode;
346          if (predicate(result)) {
347            return cb(result)
348          }
349        }
350        if (results.length < BATCH_SIZE) {
351          return cb()
352        }
353        processNextBatch();
354      };
355    };
356    processNextBatch();
357  })
358}
359
360async function loadData (db, emojiData, url, eTag) {
361  try {
362    const transformedData = transformEmojiData(emojiData);
363    await dbPromise(db, [STORE_EMOJI, STORE_KEYVALUE], MODE_READWRITE, ([emojiStore, metaStore], txn) => {
364      let oldETag;
365      let oldUrl;
366      let todo = 0;
367
368      function checkFetched () {
369        if (++todo === 2) { // 2 requests made
370          onFetched();
371        }
372      }
373
374      function onFetched () {
375        if (oldETag === eTag && oldUrl === url) {
376          // check again within the transaction to guard against concurrency, e.g. multiple browser tabs
377          return
378        }
379        // delete old data
380        emojiStore.clear();
381        // insert new data
382        for (const data of transformedData) {
383          emojiStore.put(data);
384        }
385        metaStore.put(eTag, KEY_ETAG);
386        metaStore.put(url, KEY_URL);
387        commit(txn);
388      }
389
390      getIDB(metaStore, KEY_ETAG, result => {
391        oldETag = result;
392        checkFetched();
393      });
394
395      getIDB(metaStore, KEY_URL, result => {
396        oldUrl = result;
397        checkFetched();
398      });
399    });
400  } finally {
401  }
402}
403
404async function getEmojiByGroup (db, group) {
405  return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => {
406    const range = IDBKeyRange.bound([group, 0], [group + 1, 0], false, true);
407    getAllIDB(emojiStore.index(INDEX_GROUP_AND_ORDER), range, cb);
408  })
409}
410
411async function getEmojiBySearchQuery (db, query) {
412  const tokens = normalizeTokens(extractTokens(query));
413
414  if (!tokens.length) {
415    return []
416  }
417
418  return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => {
419    // get all results that contain all tokens (i.e. an AND query)
420    const intermediateResults = [];
421
422    const checkDone = () => {
423      if (intermediateResults.length === tokens.length) {
424        onDone();
425      }
426    };
427
428    const onDone = () => {
429      const results = findCommonMembers(intermediateResults, _ => _.unicode);
430      cb(results.sort((a, b) => a.order < b.order ? -1 : 1));
431    };
432
433    for (let i = 0; i < tokens.length; i++) {
434      const token = tokens[i];
435      const range = i === tokens.length - 1
436        ? IDBKeyRange.bound(token, token + '\uffff', false, true) // treat last token as a prefix search
437        : IDBKeyRange.only(token); // treat all other tokens as an exact match
438      getAllIDB(emojiStore.index(INDEX_TOKENS), range, result => {
439        intermediateResults.push(result);
440        checkDone();
441      });
442    }
443  })
444}
445
446// This could have been implemented as an IDB index on shortcodes, but it seemed wasteful to do that
447// when we can already query by tokens and this will give us what we're looking for 99.9% of the time
448async function getEmojiByShortcode (db, shortcode) {
449  const emojis = await getEmojiBySearchQuery(db, shortcode);
450
451  // In very rare cases (e.g. the shortcode "v" as in "v for victory"), we cannot search because
452  // there are no usable tokens (too short in this case). In that case, we have to do an inefficient
453  // full-database scan, which I believe is an acceptable tradeoff for not having to have an extra
454  // index on shortcodes.
455
456  if (!emojis.length) {
457    const predicate = _ => ((_.shortcodes || []).includes(shortcode.toLowerCase()));
458    return (await doFullDatabaseScanForSingleResult(db, predicate)) || null
459  }
460
461  return emojis.filter(_ => {
462    const lowerShortcodes = (_.shortcodes || []).map(_ => _.toLowerCase());
463    return lowerShortcodes.includes(shortcode.toLowerCase())
464  })[0] || null
465}
466
467async function getEmojiByUnicode (db, unicode) {
468  return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => (
469    getIDB(emojiStore, unicode, result => {
470      if (result) {
471        return cb(result)
472      }
473      getIDB(emojiStore.index(INDEX_SKIN_UNICODE), unicode, result => cb(result || null));
474    })
475  ))
476}
477
478function get (db, storeName, key) {
479  return dbPromise(db, storeName, MODE_READONLY, (store, txn, cb) => (
480    getIDB(store, key, cb)
481  ))
482}
483
484function set (db, storeName, key, value) {
485  return dbPromise(db, storeName, MODE_READWRITE, (store, txn) => {
486    store.put(value, key);
487    commit(txn);
488  })
489}
490
491function incrementFavoriteEmojiCount (db, unicode) {
492  return dbPromise(db, STORE_FAVORITES, MODE_READWRITE, (store, txn) => (
493    getIDB(store, unicode, result => {
494      store.put((result || 0) + 1, unicode);
495      commit(txn);
496    })
497  ))
498}
499
500function getTopFavoriteEmoji (db, customEmojiIndex, limit) {
501  if (limit === 0) {
502    return []
503  }
504  return dbPromise(db, [STORE_FAVORITES, STORE_EMOJI], MODE_READONLY, ([favoritesStore, emojiStore], txn, cb) => {
505    const results = [];
506    favoritesStore.index(INDEX_COUNT).openCursor(undefined, 'prev').onsuccess = e => {
507      const cursor = e.target.result;
508      if (!cursor) { // no more results
509        return cb(results)
510      }
511
512      function addResult (result) {
513        results.push(result);
514        if (results.length === limit) {
515          return cb(results) // done, reached the limit
516        }
517        cursor.continue();
518      }
519
520      const unicodeOrName = cursor.primaryKey;
521      const custom = customEmojiIndex.byName(unicodeOrName);
522      if (custom) {
523        return addResult(custom)
524      }
525      // This could be done in parallel (i.e. make the cursor and the get()s parallelized),
526      // but my testing suggests it's not actually faster.
527      getIDB(emojiStore, unicodeOrName, emoji => {
528        if (emoji) {
529          return addResult(emoji)
530        }
531        // emoji not found somehow, ignore (may happen if custom emoji change)
532        cursor.continue();
533      });
534    };
535  })
536}
537
538// trie data structure for prefix searches
539// loosely based on https://github.com/nolanlawson/substring-trie
540
541const CODA_MARKER = ''; // marks the end of the string
542
543function trie (arr, itemToTokens) {
544  const map = new Map();
545  for (const item of arr) {
546    const tokens = itemToTokens(item);
547    for (const token of tokens) {
548      let currentMap = map;
549      for (let i = 0; i < token.length; i++) {
550        const char = token.charAt(i);
551        let nextMap = currentMap.get(char);
552        if (!nextMap) {
553          nextMap = new Map();
554          currentMap.set(char, nextMap);
555        }
556        currentMap = nextMap;
557      }
558      let valuesAtCoda = currentMap.get(CODA_MARKER);
559      if (!valuesAtCoda) {
560        valuesAtCoda = [];
561        currentMap.set(CODA_MARKER, valuesAtCoda);
562      }
563      valuesAtCoda.push(item);
564    }
565  }
566
567  const search = (query, exact) => {
568    let currentMap = map;
569    for (let i = 0; i < query.length; i++) {
570      const char = query.charAt(i);
571      const nextMap = currentMap.get(char);
572      if (nextMap) {
573        currentMap = nextMap;
574      } else {
575        return []
576      }
577    }
578
579    if (exact) {
580      const results = currentMap.get(CODA_MARKER);
581      return results || []
582    }
583
584    const results = [];
585    // traverse
586    const queue = [currentMap];
587    while (queue.length) {
588      const currentMap = queue.shift();
589      const entriesSortedByKey = [...currentMap.entries()].sort((a, b) => a[0] < b[0] ? -1 : 1);
590      for (const [key, value] of entriesSortedByKey) {
591        if (key === CODA_MARKER) { // CODA_MARKER always comes first; it's the empty string
592          results.push(...value);
593        } else {
594          queue.push(value);
595        }
596      }
597    }
598    return results
599  };
600
601  return search
602}
603
604const requiredKeys$1 = [
605  'name',
606  'url'
607];
608
609function assertCustomEmojis (customEmojis) {
610  const isArray = customEmojis && Array.isArray(customEmojis);
611  const firstItemIsFaulty = isArray &&
612    customEmojis.length &&
613    (!customEmojis[0] || requiredKeys$1.some(key => !(key in customEmojis[0])));
614  if (!isArray || firstItemIsFaulty) {
615    throw new Error('Custom emojis are in the wrong format')
616  }
617}
618
619function customEmojiIndex (customEmojis) {
620  assertCustomEmojis(customEmojis);
621
622  const sortByName = (a, b) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
623
624  //
625  // all()
626  //
627  const all = customEmojis.sort(sortByName);
628
629  //
630  // search()
631  //
632  const emojiToTokens = emoji => {
633    const set = new Set();
634    if (emoji.shortcodes) {
635      for (const shortcode of emoji.shortcodes) {
636        for (const token of extractTokens(shortcode)) {
637          set.add(token);
638        }
639      }
640    }
641    return set
642  };
643  const searchTrie = trie(customEmojis, emojiToTokens);
644  const searchByExactMatch = _ => searchTrie(_, true);
645  const searchByPrefix = _ => searchTrie(_, false);
646
647  // Search by query for custom emoji. Similar to how we do this in IDB, the last token
648  // is treated as a prefix search, but every other one is treated as an exact match.
649  // Then we AND the results together
650  const search = query => {
651    const tokens = extractTokens(query);
652    const intermediateResults = tokens.map((token, i) => (
653      (i < tokens.length - 1 ? searchByExactMatch : searchByPrefix)(token)
654    ));
655    return findCommonMembers(intermediateResults, _ => _.name).sort(sortByName)
656  };
657
658  //
659  // byShortcode, byName
660  //
661  const shortcodeToEmoji = new Map();
662  const nameToEmoji = new Map();
663  for (const customEmoji of customEmojis) {
664    nameToEmoji.set(customEmoji.name.toLowerCase(), customEmoji);
665    for (const shortcode of (customEmoji.shortcodes || [])) {
666      shortcodeToEmoji.set(shortcode.toLowerCase(), customEmoji);
667    }
668  }
669
670  const byShortcode = shortcode => shortcodeToEmoji.get(shortcode.toLowerCase());
671  const byName = name => nameToEmoji.get(name.toLowerCase());
672
673  return {
674    all,
675    search,
676    byShortcode,
677    byName
678  }
679}
680
681const isFirefoxContentScript = typeof wrappedJSObject !== 'undefined';
682
683// remove some internal implementation details, i.e. the "tokens" array on the emoji object
684// essentially, convert the emoji from the version stored in IDB to the version used in-memory
685function cleanEmoji (emoji) {
686  if (!emoji) {
687    return emoji
688  }
689  // if inside a Firefox content script, need to clone the emoji object to prevent Firefox from complaining about
690  // cross-origin object. See: https://github.com/nolanlawson/emoji-picker-element/issues/356
691  /* istanbul ignore if */
692  if (isFirefoxContentScript) {
693    emoji = structuredClone(emoji);
694  }
695  delete emoji.tokens;
696  if (emoji.skinTones) {
697    const len = emoji.skinTones.length;
698    emoji.skins = Array(len);
699    for (let i = 0; i < len; i++) {
700      emoji.skins[i] = {
701        tone: emoji.skinTones[i],
702        unicode: emoji.skinUnicodes[i],
703        version: emoji.skinVersions[i]
704      };
705    }
706    delete emoji.skinTones;
707    delete emoji.skinUnicodes;
708    delete emoji.skinVersions;
709  }
710  return emoji
711}
712
713function warnETag (eTag) {
714  if (!eTag) {
715    console.warn('emoji-picker-element is more efficient if the dataSource server exposes an ETag header.');
716  }
717}
718
719const requiredKeys = [
720  'annotation',
721  'emoji',
722  'group',
723  'order',
724  'version'
725];
726
727function assertEmojiData (emojiData) {
728  if (!emojiData ||
729    !Array.isArray(emojiData) ||
730    !emojiData[0] ||
731    (typeof emojiData[0] !== 'object') ||
732    requiredKeys.some(key => (!(key in emojiData[0])))) {
733    throw new Error('Emoji data is in the wrong format')
734  }
735}
736
737function assertStatus (response, dataSource) {
738  if (Math.floor(response.status / 100) !== 2) {
739    throw new Error('Failed to fetch: ' + dataSource + ':  ' + response.status)
740  }
741}
742
743async function getETag (dataSource) {
744  const response = await fetch(dataSource, { method: 'HEAD' });
745  assertStatus(response, dataSource);
746  const eTag = response.headers.get('etag');
747  warnETag(eTag);
748  return eTag
749}
750
751async function getETagAndData (dataSource) {
752  const response = await fetch(dataSource);
753  assertStatus(response, dataSource);
754  const eTag = response.headers.get('etag');
755  warnETag(eTag);
756  const emojiData = await response.json();
757  assertEmojiData(emojiData);
758  return [eTag, emojiData]
759}
760
761// TODO: including these in blob-util.ts causes typedoc to generate docs for them,
762// even with --excludePrivate ¯\_(ツ)_/¯
763/** @private */
764/**
765 * Convert an `ArrayBuffer` to a binary string.
766 *
767 * Example:
768 *
769 * ```js
770 * var myString = blobUtil.arrayBufferToBinaryString(arrayBuff)
771 * ```
772 *
773 * @param buffer - array buffer
774 * @returns binary string
775 */
776function arrayBufferToBinaryString(buffer) {
777    var binary = '';
778    var bytes = new Uint8Array(buffer);
779    var length = bytes.byteLength;
780    var i = -1;
781    while (++i < length) {
782        binary += String.fromCharCode(bytes[i]);
783    }
784    return binary;
785}
786/**
787 * Convert a binary string to an `ArrayBuffer`.
788 *
789 * ```js
790 * var myBuffer = blobUtil.binaryStringToArrayBuffer(binaryString)
791 * ```
792 *
793 * @param binary - binary string
794 * @returns array buffer
795 */
796function binaryStringToArrayBuffer(binary) {
797    var length = binary.length;
798    var buf = new ArrayBuffer(length);
799    var arr = new Uint8Array(buf);
800    var i = -1;
801    while (++i < length) {
802        arr[i] = binary.charCodeAt(i);
803    }
804    return buf;
805}
806
807// generate a checksum based on the stringified JSON
808async function jsonChecksum (object) {
809  const inString = JSON.stringify(object);
810  let inBuffer = binaryStringToArrayBuffer(inString);
811
812  // this does not need to be cryptographically secure, SHA-1 is fine
813  const outBuffer = await crypto.subtle.digest('SHA-1', inBuffer);
814  const outBinString = arrayBufferToBinaryString(outBuffer);
815  const res = btoa(outBinString);
816  return res
817}
818
819async function doCheckForUpdates (db, dataSource) {
820  // just do a simple HEAD request first to see if the eTags match
821  let emojiData;
822  let eTag = await getETag(dataSource);
823  if (!eTag) { // work around lack of ETag/Access-Control-Expose-Headers
824    const eTagAndData = await getETagAndData(dataSource);
825    eTag = eTagAndData[0];
826    emojiData = eTagAndData[1];
827    if (!eTag) {
828      eTag = await jsonChecksum(emojiData);
829    }
830  }
831  if (await hasData(db, dataSource, eTag)) ; else {
832    if (!emojiData) {
833      const eTagAndData = await getETagAndData(dataSource);
834      emojiData = eTagAndData[1];
835    }
836    await loadData(db, emojiData, dataSource, eTag);
837  }
838}
839
840async function loadDataForFirstTime (db, dataSource) {
841  let [eTag, emojiData] = await getETagAndData(dataSource);
842  if (!eTag) {
843    // Handle lack of support for ETag or Access-Control-Expose-Headers
844    // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers#Browser_compatibility
845    eTag = await jsonChecksum(emojiData);
846  }
847
848  await loadData(db, emojiData, dataSource, eTag);
849}
850
851async function checkForUpdates (db, dataSource) {
852  try {
853    await doCheckForUpdates(db, dataSource);
854  } catch (err) {
855    // Checking for updates is not a critical operation, and it can fail if e.g. the picker is quickly removed and
856    // re-added to the DOM. In those cases, we may get an IndexedDB InvalidStateError because we are attempting to close
857    // the database connection, possibly while another request is inflight. So there's effectively no way to prevent
858    // InvalidStateErrors unless we were to carefully sequence our IndexedDB operations. Much more simply, we can just
859    // ignore IndexedDB InvalidStateErrors here and give users one less useless error message in their console.
860    if (err.name !== 'InvalidStateError') {
861      throw err
862    }
863  }
864}
865
866class Database {
867  constructor ({ dataSource = DEFAULT_DATA_SOURCE
vendor: 4,054 bytes, lines 867-1002
867$1, locale = DEFAULT_LOCALE$1, customEmoji = [] } = {}) {
868    this.dataSource = dataSource;
869    this.locale = locale;
870    this._dbName = `emoji-picker-element-${this.locale}`;
871    this._db = undefined;
872    this._lazyUpdate = undefined;
873    this._custom = customEmojiIndex(customEmoji);
874
875    this._clear = this._clear.bind(this);
876    this._ready = this._init();
877  }
878
879  async _init () {
880    const db = this._db = await openDatabase(this._dbName);
881
882    addOnCloseListener(this._dbName, this._clear);
883    const dataSource = this.dataSource;
884    const empty = await isEmpty(db);
885
886    if (empty) {
887      await loadDataForFirstTime(db, dataSource);
888    } else { // offline-first - do an update asynchronously
889      this._lazyUpdate = checkForUpdates(db, dataSource);
890    }
891  }
892
893  async ready () {
894    const checkReady = async () => {
895      if (!this._ready) {
896        this._ready = this._init();
897      }
898      return this._ready
899    };
900    await checkReady();
901    // There's a possibility of a race condition where the element gets added, removed, and then added again
902    // with a particular timing, which would set the _db to undefined.
903    // We *could* do a while loop here, but that seems excessive and could lead to an infinite loop.
904    if (!this._db) {
905      await checkReady();
906    }
907  }
908
909  async getEmojiByGroup (group) {
910    assertNumber(group);
911    await this.ready();
912    return uniqEmoji(await getEmojiByGroup(this._db, group)).map(cleanEmoji)
913  }
914
915  async getEmojiBySearchQuery (query) {
916    assertNonEmptyString(query);
917    await this.ready();
918    const customs = this._custom.search(query);
919    const natives = uniqEmoji(await getEmojiBySearchQuery(this._db, query)).map(cleanEmoji);
920    return [
921      ...customs,
922      ...natives
923    ]
924  }
925
926  async getEmojiByShortcode (shortcode) {
927    assertNonEmptyString(shortcode);
928    await this.ready();
929    const custom = this._custom.byShortcode(shortcode);
930    if (custom) {
931      return custom
932    }
933    return cleanEmoji(await getEmojiByShortcode(this._db, shortcode))
934  }
935
936  async getEmojiByUnicodeOrName (unicodeOrName) {
937    assertNonEmptyString(unicodeOrName);
938    await this.ready();
939    const custom = this._custom.byName(unicodeOrName);
940    if (custom) {
941      return custom
942    }
943    return cleanEmoji(await getEmojiByUnicode(this._db, unicodeOrName))
944  }
945
946  async getPreferredSkinTone () {
947    await this.ready();
948    return (await get(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE)) || 0
949  }
950
951  async setPreferredSkinTone (skinTone) {
952    assertNumber(skinTone);
953    await this.ready();
954    return set(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE, skinTone)
955  }
956
957  async incrementFavoriteEmojiCount (unicodeOrName) {
958    assertNonEmptyString(unicodeOrName);
959    await this.ready();
960    return incrementFavoriteEmojiCount(this._db, unicodeOrName)
961  }
962
963  async getTopFavoriteEmoji (limit) {
964    assertNumber(limit);
965    await this.ready();
966    return (await getTopFavoriteEmoji(this._db, this._custom, limit)).map(cleanEmoji)
967  }
968
969  set customEmoji (customEmojis) {
970    this._custom = customEmojiIndex(customEmojis);
971  }
972
973  get customEmoji () {
974    return this._custom.all
975  }
976
977  async _shutdown () {
978    await this.ready(); // reopen if we've already been closed/deleted
979    try {
980      await this._lazyUpdate; // allow any lazy updates to process before closing/deleting
981    } catch (err) { /* ignore network errors (offline-first) */ }
982  }
983
984  // clear references to IDB, e.g. during a close event
985  _clear () {
986    // We don't need to call removeEventListener or remove the manual "close" listeners.
987    // The memory leak tests prove this is unnecessary. It's because:
988    // 1) IDBDatabases that can no longer fire "close" automatically have listeners GCed
989    // 2) we clear the manual close listeners in databaseLifecycle.js.
990    this._db = this._ready = this._lazyUpdate = undefined;
991  }
992
993  async close () {
994    await this._shutdown();
995    await closeDatabase(this._dbName);
996  }
997
998  async delete () {
999    await this._shutdown();
1000    await deleteDatabase(this._dbName);
1001  }
1002}
vendor: 71,931 bytes, lines 1002-2835
1002
1003
1004// via https://unpkg.com/browse/[email protected]/meta/groups.json
1005const allGroups = [
1006  [-1, '✨', 'custom'],
1007  [0, '😀', 'smileys-emotion'],
1008  [1, '👋', 'people-body'],
1009  [3, '🐱', 'animals-nature'],
1010  [4, '🍎', 'food-drink'],
1011  [5, '🏠️', 'travel-places'],
1012  [6, '⚽', 'activities'],
1013  [7, '📝', 'objects'],
1014  [8, '⛔️', 'symbols'],
1015  [9, '🏁', 'flags']
1016].map(([id, emoji, name]) => ({ id, emoji, name }));
1017
1018const groups = allGroups.slice(1);
1019
1020const MIN_SEARCH_TEXT_LENGTH = 2;
1021const NUM_SKIN_TONES = 6;
1022
1023/* istanbul ignore next */
1024const rIC = typeof requestIdleCallback === 'function' ? requestIdleCallback : setTimeout;
1025
1026// check for ZWJ (zero width joiner) character
1027function hasZwj (emoji) {
1028  return emoji.unicode.includes('\u200d')
1029}
1030
1031// Find one good representative emoji from each version to test by checking its color.
1032// Ideally it should have color in the center. For some inspiration, see:
1033// https://about.gitlab.com/blog/2018/05/30/journey-in-native-unicode-emoji/
1034//
1035// Note that for certain versions (12.1, 13.1), there is no point in testing them explicitly, because
1036// all the emoji from this version are compound-emoji from previous versions. So they would pass a color
1037// test, even in browsers that display them as double emoji. (E.g. "face in clouds" might render as
1038// "face without mouth" plus "fog".) These emoji can only be filtered using the width test,
1039// which happens in checkZwjSupport.js.
1040const versionsAndTestEmoji = {
1041  '🫪': 17, // distorted face
1042  '🫩': 16, // face with bags under eyes
1043  '🫨': 15.1, // shaking head, technically from v15 but see note above
1044  '🫠': 14,
1045  '🥲': 13.1, // smiling face with tear, technically from v13 but see note above
1046  '🥻': 12.1, // sari, technically from v12 but see note above
1047  '🥰': 11,
1048  '🤩': 5,
1049  '👱‍♀️': 4,
1050  '🤣': 3,
1051  '👁️‍🗨️': 2,
1052  '😀': 1,
1053  '😐️': 0.7,
1054  '😃': 0.6
1055};
1056
1057const TIMEOUT_BEFORE_LOADING_MESSAGE = 1000; // 1 second
1058const DEFAULT_SKIN_TONE_EMOJI = '🖐️';
1059const DEFAULT_NUM_COLUMNS = 8;
1060
1061// Based on https://fivethirtyeight.com/features/the-100-most-used-emojis/ and
1062// https://blog.emojipedia.org/facebook-reveals-most-and-least-used-emojis/ with
1063// a bit of my own curation. (E.g. avoid the "OK" gesture because of connotations:
1064// https://emojipedia.org/ok-hand/)
1065const MOST_COMMONLY_USED_EMOJI = [
1066  '😊',
1067  '😒',
1068  '❤️',
1069  '👍️',
1070  '😍',
1071  '😂',
1072  '😭',
1073  '☺️',
1074  '😔',
1075  '😩',
1076  '😏',
1077  '💕',
1078  '🙌',
1079  '😘'
1080];
1081
1082// It's important to list Twemoji Mozilla before everything else, because Mozilla bundles their
1083// own font on some platforms (notably Windows and Linux as of this writing). Typically, Mozilla
1084// updates faster than the underlying OS, and we don't want to render older emoji in one font and
1085// newer emoji in another font:
1086// https://github.com/nolanlawson/emoji-picker-element/pull/268#issuecomment-1073347283
1087const FONT_FAMILY = '"Twemoji Mozilla","Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol",' +
1088  '"Noto Color Emoji","EmojiOne Color","Android Emoji",sans-serif';
1089
1090/* istanbul ignore next */
1091const DEFAULT_CATEGORY_SORTING = (a, b) => a < b ? -1 : a > b ? 1 : 0;
1092
1093// Test if an emoji is supported by rendering it to canvas and checking that the color is not black
1094// See https://about.gitlab.com/blog/2018/05/30/journey-in-native-unicode-emoji/
1095// and https://www.npmjs.com/package/if-emoji for inspiration
1096// This implementation is largely borrowed from if-emoji, adding the font-family
1097
1098
1099const getTextFeature = (text, color) => {
1100  const canvas = document.createElement('canvas');
1101  canvas.width = canvas.height = 1;
1102
1103  const ctx = canvas.getContext('2d', {
1104    // Improves the performance of `getImageData()`
1105    // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getContextAttributes#willreadfrequently
1106    willReadFrequently: true
1107  });
1108  ctx.textBaseline = 'top';
1109  ctx.font = `100px ${FONT_FAMILY}`;
1110  ctx.fillStyle = color;
1111  ctx.scale(0.01, 0.01);
1112  ctx.fillText(text, 0, 0);
1113
1114  return ctx.getImageData(0, 0, 1, 1).data
1115};
1116
1117const compareFeatures = (feature1, feature2) => {
1118  const feature1Str = [...feature1].join(',');
1119  const feature2Str = [...feature2].join(',');
1120  // This is RGBA, so for 0,0,0, we are checking that the first RGB is not all zeroes.
1121  // Most of the time when unsupported this is 0,0,0,0, but on Chrome on Mac it is
1122  // 0,0,0,61 - there is a transparency here.
1123  return feature1Str === feature2Str && !feature1Str.startsWith('0,0,0,')
1124};
1125
1126function testColorEmojiSupported (text) {
1127  // Render white and black and then compare them to each other and ensure they're the same
1128  // color, and neither one is black. This shows that the emoji was rendered in color.
1129  const feature1 = getTextFeature(text, '#000');
1130  const feature2 = getTextFeature(text, '#fff');
1131  return feature1 && feature2 && compareFeatures(feature1, feature2)
1132}
1133
1134// rather than check every emoji ever, which would be expensive, just check some representatives from the
1135// different emoji releases to determine what the font supports
1136
1137function determineEmojiSupportLevel () {
1138  const entries = Object.entries(versionsAndTestEmoji);
1139  try {
1140    // start with latest emoji and work backwards
1141    for (const [emoji, version] of entries) {
1142      if (testColorEmojiSupported(emoji)) {
1143        return version
1144      }
1145    }
1146  } catch (e) { // canvas error
1147  } finally {
1148  }
1149  // In case of an error, be generous and just assume all emoji are supported (e.g. for canvas errors
1150  // due to anti-fingerprinting add-ons). Better to show some gray boxes than nothing at all.
1151  return entries[0][1] // first one in the list is the most recent version
1152}
1153
1154// Check which emojis we know for sure aren't supported, based on Unicode version level
1155let promise;
1156const detectEmojiSupportLevel = () => {
1157  if (!promise) {
1158    // Delay so it can run while the IDB database is being created by the browser (on another thread).
1159    // This helps especially with first load – we want to start pre-populating the database on the main thread,
1160    // and then wait for IDB to commit everything, and while waiting we run this check.
1161    promise = new Promise(resolve => (
1162      rIC(() => (
1163        resolve(determineEmojiSupportLevel()) // delay so ideally this can run while IDB is first populating
1164      ))
1165    ));
1166  }
1167  return promise
1168};
1169// determine which emojis containing ZWJ (zero width joiner) characters
1170// are supported (rendered as one glyph) rather than unsupported (rendered as two or more glyphs)
1171const supportedZwjEmojis = new Map();
1172
1173const VARIATION_SELECTOR = '\ufe0f';
1174const SKINTONE_MODIFIER = '\ud83c';
1175const ZWJ = '\u200d';
1176const LIGHT_SKIN_TONE = 0x1F3FB;
1177const LIGHT_SKIN_TONE_MODIFIER = 0xdffb;
1178
1179// TODO: this is a naive implementation, we can improve it later
1180// It's only used for the skintone picker, so as long as people don't customize with
1181// really exotic emoji then it should work fine
1182function applySkinTone (str, skinTone) {
1183  if (skinTone === 0) {
1184    return str
1185  }
1186  const zwjIndex = str.indexOf(ZWJ);
1187  if (zwjIndex !== -1) {
1188    return str.substring(0, zwjIndex) +
1189      String.fromCodePoint(LIGHT_SKIN_TONE + skinTone - 1) +
1190      str.substring(zwjIndex)
1191  }
1192  if (str.endsWith(VARIATION_SELECTOR)) {
1193    str = str.substring(0, str.length - 1);
1194  }
1195  return str + SKINTONE_MODIFIER + String.fromCodePoint(LIGHT_SKIN_TONE_MODIFIER + skinTone - 1)
1196}
1197
1198function halt (event) {
1199  event.preventDefault();
1200  event.stopPropagation();
1201}
1202
1203// Implementation left/right or up/down navigation, circling back when you
1204// reach the start/end of the list
1205function incrementOrDecrement (decrement, val, arr) {
1206  val += (decrement ? -1 : 1);
1207  if (val < 0) {
1208    val = arr.length - 1;
1209  } else if (val >= arr.length) {
1210    val = 0;
1211  }
1212  return val
1213}
1214
1215// like lodash's uniqBy but much smaller
1216function uniqBy (arr, func) {
1217  const set = new Set();
1218  const res = [];
1219  for (const item of arr) {
1220    const key = func(item);
1221    if (!set.has(key)) {
1222      set.add(key);
1223      res.push(item);
1224    }
1225  }
1226  return res
1227}
1228
1229// We don't need all the data on every emoji, and there are specific things we need
1230// for the UI, so build a "view model" from the emoji object we got from the database
1231
1232function summarizeEmojisForUI (emojis, emojiSupportLevel) {
1233  const toSimpleSkinsMap = skins => {
1234    const res = {};
1235    for (const skin of skins) {
1236      // ignore arrays like [1, 2] with multiple skin tones
1237      // also ignore variants that are in an unsupported emoji version
1238      // (these do exist - variants from a different version than their base emoji)
1239      if (typeof skin.tone === 'number' && skin.version <= emojiSupportLevel) {
1240        res[skin.tone] = skin.unicode;
1241      }
1242    }
1243    return res
1244  };
1245
1246  return emojis.map(({ unicode, skins, shortcodes, url, name, category, annotation }) => ({
1247    unicode,
1248    name,
1249    shortcodes,
1250    url,
1251    category,
1252    annotation,
1253    id: unicode || name,
1254    skins: skins && toSimpleSkinsMap(skins)
1255  }))
1256}
1257
1258// import rAF from one place so that the bundle size is a bit smaller
1259const rAF = requestAnimationFrame;
1260
1261// "Svelte action"-like utility to detect layout changes via ResizeObserver.
1262// If ResizeObserver is unsupported, we just use rAF once and don't bother to update.
1263
1264
1265let resizeObserverSupported = typeof ResizeObserver === 'function';
1266
1267function resizeObserverAction (node, abortSignal, onUpdate) {
1268  let resizeObserver;
1269  if (resizeObserverSupported) {
1270    resizeObserver = new ResizeObserver(onUpdate);
1271    resizeObserver.observe(node);
1272  } else { // just run once, don't bother trying to track it
1273    rAF(onUpdate);
1274  }
1275
1276  // cleanup function (called on destroy)
1277  abortSignal.addEventListener('abort', () => {
1278    if (resizeObserver) {
1279      resizeObserver.disconnect();
1280    }
1281  });
1282}
1283
1284// get the width of the text inside of a DOM node, via https://stackoverflow.com/a/59525891/680742
1285function calculateTextWidth (node) {
1286  // skip running this in jest/vitest because we don't need to check for emoji support in that environment
1287  /* istanbul ignore else */
1288  {
1289    const range = document.createRange();
1290    range.selectNode(node.firstChild);
1291    return range.getBoundingClientRect().width
1292  }
1293}
1294
1295const BASELINE_EMOJI = '😀';
1296
1297let baselineEmojiWidth;
1298let fallbackNode;
1299
1300function calculateTextWidthWithFallback (unicode, domNode, baselineEmojiNode) {
1301  const result = calculateTextWidth(domNode);
1302  /* istanbul ignore if */
1303  if (!result) {
1304    // If result is 0 then very likely the emoji-picker has `display:none` or equivalent. In that case, we fall back to
1305    // cloning the baseline emoji, putting that in the `document.body`, and measuring that instead. This is a perf hit,
1306    // but it's better than mistakenly filtering emoji: https://github.com/nolanlawson/emoji-picker-element/issues/514
1307    if (!fallbackNode) {
1308      fallbackNode = baselineEmojiNode.cloneNode(true);
1309      // We have to copy styles because we're copying from an element in the shadow DOM to the light DOM
1310      // We can't use the shadow DOM because it's likely the entire picker is `display:none`
1311      const styles = getComputedStyle(baselineEmojiNode);
1312      // probably don't need display/align-items/justify-content but let's play it safe
1313      for (const prop of ['font-family', 'line-height', 'width', 'height', 'font-size', 'display', 'align-items', 'justify-content']) {
1314        // set `!important` just in case some global styles might try to overwrite this
1315        fallbackNode.style.setProperty(prop, styles.getPropertyValue(prop), 'important');
1316      }
1317    }
1318    try {
1319      document.body.appendChild(fallbackNode);
1320      fallbackNode.firstChild.nodeValue = unicode;
1321      return calculateTextWidth(fallbackNode)
1322    } finally {
1323      // avoid actually rendering the test emoji
1324      fallbackNode.remove();
1325    }
1326  }
1327  return result
1328}
1329
1330/**
1331 * Check if the given emojis containing ZWJ characters are supported by the current browser (don't render
1332 * as double characters) and return true if all are supported.
1333 * @param zwjEmojisToCheck
1334 * @param baselineEmojiNode
1335 * @param emojiToDomNode
1336 */
1337function checkZwjSupport (zwjEmojisToCheck, baselineEmojiNode, emojiToDomNode) {
1338  let allSupported = true;
1339  for (const emoji of zwjEmojisToCheck) {
1340    const domNode = emojiToDomNode(emoji);
1341    // sanity check to make sure the node is defined properly
1342    /* istanbul ignore if */
1343    if (!domNode) {
1344      // This is a race condition that can occur when the component is unmounted/remounted
1345      // It doesn't really matter what we do here since the old context is not going to render anymore.
1346      // Just bail out of emoji support detection and return `allSupported=true` since the rendering context is gone
1347      continue
1348    }
1349    if (typeof baselineEmojiWidth === 'undefined') { // calculate the baseline emoji width only once
1350      baselineEmojiWidth = calculateTextWidthWithFallback(BASELINE_EMOJI, baselineEmojiNode, baselineEmojiNode);
1351    }
1352    const emojiWidth = calculateTextWidthWithFallback(emoji.unicode, domNode, baselineEmojiNode);
1353    // On Windows, some supported emoji are ~50% bigger than the baseline emoji, but what we really want to guard
1354    // against are the ones that are 2x the size, because those are truly broken (person with red hair = person with
1355    // floating red wig, black cat = cat with black square, polar bear = bear with snowflake, etc.)
1356    // So here we set the threshold at 1.8 times the size of the baseline emoji.
1357    const supported = emojiWidth / 1.8 < baselineEmojiWidth;
1358    supportedZwjEmojis.set(emoji.unicode, supported);
1359
1360    if (!supported) {
1361      allSupported = false;
1362    }
1363  }
1364  return allSupported
1365}
1366
1367// like lodash's uniq
1368
1369function uniq (arr) {
1370  return uniqBy(arr, _ => _)
1371}
1372
1373// Note we put this in its own function outside Picker.js to avoid Svelte doing an invalidation on the "setter" here.
1374// At best the invalidation is useless, at worst it can cause infinite loops:
1375// https://github.com/nolanlawson/emoji-picker-element/pull/180
1376// https://github.com/sveltejs/svelte/issues/6521
1377// Also note tabpanelElement can be null if the element is disconnected immediately after connected
1378function resetScrollTopIfPossible (element) {
1379  /* istanbul ignore else */
1380  if (element) { // Makes me nervous not to have this `if` guard
1381    element.scrollTop = 0;
1382  }
1383}
1384
1385function getFromMap (cache, key, func) {
1386  let cached = cache.get(key);
1387  if (!cached) {
1388    cached = func();
1389    cache.set(key, cached);
1390  }
1391  return cached
1392}
1393
1394function toString (value) {
1395  return '' + value
1396}
1397
1398function parseTemplate (htmlString) {
1399  const template = document.createElement('template');
1400  template.innerHTML = htmlString;
1401  return template
1402}
1403
1404const parseCache = new WeakMap();
1405const domInstancesCache = new WeakMap();
1406// This needs to be a symbol because it needs to be different from any possible output of a key function
1407const unkeyedSymbol = Symbol('un-keyed');
1408
1409// Not supported in Safari <=13
1410const hasReplaceChildren = 'replaceChildren' in Element.prototype;
1411function replaceChildren (parentNode, newChildren) {
1412  /* istanbul ignore else */
1413  if (hasReplaceChildren) {
1414    parentNode.replaceChildren(...newChildren);
1415  } else { // minimal polyfill for Element.prototype.replaceChildren
1416    parentNode.innerHTML = '';
1417    parentNode.append(...newChildren);
1418  }
1419}
1420
1421function doChildrenNeedRerender (parentNode, newChildren) {
1422  let oldChild = parentNode.firstChild;
1423  let oldChildrenCount = 0;
1424  // iterate using firstChild/nextSibling because browsers use a linked list under the hood
1425  while (oldChild) {
1426    const newChild = newChildren[oldChildrenCount];
1427    // check if the old child and new child are the same
1428    if (newChild !== oldChild) {
1429      return true
1430    }
1431    oldChild = oldChild.nextSibling;
1432    oldChildrenCount++;
1433  }
1434  // if new children length is different from old, we must re-render
1435  return oldChildrenCount !== newChildren.length
1436}
1437
1438function patchChildren (newChildren, instanceBinding) {
1439  const { targetNode } = instanceBinding;
1440  let { targetParentNode } = instanceBinding;
1441
1442  let needsRerender = false;
1443
1444  if (targetParentNode) { // already rendered once
1445    needsRerender = doChildrenNeedRerender(targetParentNode, newChildren);
1446  } else { // first render of list
1447    needsRerender = true;
1448    instanceBinding.targetNode = undefined; // placeholder node not needed anymore, free memory
1449    instanceBinding.targetParentNode = targetParentNode = targetNode.parentNode;
1450  }
1451  // avoid re-rendering list if the dom nodes are exactly the same before and after
1452  if (needsRerender) {
1453    replaceChildren(targetParentNode, newChildren);
1454  }
1455}
1456
1457function patch (expressions, instanceBindings) {
1458  for (const instanceBinding of instanceBindings) {
1459    const {
1460      targetNode,
1461      currentExpression,
1462      binding: {
1463        expressionIndex,
1464        attributeName,
1465        attributeValuePre,
1466        attributeValuePost
1467      }
1468    } = instanceBinding;
1469
1470    const expression = expressions[expressionIndex];
1471
1472    if (currentExpression === expression) {
1473      // no need to update, same as before
1474      continue
1475    }
1476
1477    instanceBinding.currentExpression = expression;
1478
1479    if (attributeName) { // attribute replacement
1480      if (expression === null) {
1481        // null is treated as a special case by the framework - we don't render an attribute at all in this case
1482        targetNode.removeAttribute(attributeName);
1483      } else {
1484        // attribute value is not null; set a new attribute
1485        const newValue = attributeValuePre + toString(expression) + attributeValuePost;
1486        targetNode.setAttribute(attributeName, newValue);
1487      }
1488    } else { // text node / child element / children replacement
1489      let newNode;
1490      if (Array.isArray(expression)) { // array of DOM elements produced by tag template literals
1491        patchChildren(expression, instanceBinding);
1492      } else if (expression instanceof Element) { // html tag template returning a DOM element
1493        newNode = expression;
1494        targetNode.replaceWith(newNode);
1495      } else { // primitive - string, number, etc
1496        // nodeValue is faster than textContent supposedly https://www.youtube.com/watch?v=LY6y3HbDVmg
1497        // note we may be replacing the value in a placeholder text node
1498        targetNode.nodeValue = toString(expression);
1499      }
1500      if (newNode) {
1501        instanceBinding.targetNode = newNode;
1502      }
1503    }
1504  }
1505}
1506
1507function parse (tokens) {
1508  let htmlString = '';
1509
1510  let withinTag = false;
1511  let withinAttribute = false;
1512  let elementIndexCounter = -1; // depth-first traversal order
1513
1514  const elementsToBindings = new Map();
1515  const elementIndexes = [];
1516
1517  let skipTokenChars = 0;
1518  for (let i = 0, len = tokens.length; i < len; i++) {
1519    const token = tokens[i];
1520    htmlString += token.slice(skipTokenChars);
1521
1522    if (i === len - 1) {
1523      break // no need to process characters - no more expressions to be found
1524    }
1525
1526    for (let j = 0; j < token.length; j++) {
1527      const char = token.charAt(j);
1528      switch (char) {
1529        case '<': {
1530          const nextChar = token.charAt(j + 1);
1531          if (nextChar === '/') { // closing tag
1532            // leaving an element
1533            elementIndexes.pop();
1534          } else { // not a closing tag
1535            withinTag = true;
1536            elementIndexes.push(++elementIndexCounter);
1537          }
1538          break
1539        }
1540        case '>': {
1541          withinTag = false;
1542          withinAttribute = false;
1543          break
1544        }
1545        case '=': {
1546          withinAttribute = true;
1547          break
1548        }
1549      }
1550    }
1551
1552    const elementIndex = elementIndexes[elementIndexes.length - 1];
1553    const bindings = getFromMap(elementsToBindings, elementIndex, () => []);
1554
1555    let attributeName;
1556    let attributeValuePre;
1557    let attributeValuePost;
1558    if (withinAttribute) {
1559      // I never use single-quotes for attribute values in HTML, so just support double-quotes or no-quotes
1560      const attributePreMatch = /(\S+)="?([^"=]*)$/.exec(token);
1561      attributeName = attributePreMatch[1];
1562      attributeValuePre = attributePreMatch[2];
1563      const attributePostMatch = /^([^">]*)("?)/.exec(tokens[i + 1]);
1564      attributeValuePost = attributePostMatch[1];
1565      // Optimization: remove the attribute itself, so we don't create a default attribute which is either empty or just
1566      // the "pre" text, e.g. `<div foo>` or `<div foo="prefix">`. It will be replaced by the expression anyway.
1567      htmlString = htmlString.slice(0, -1 * attributePreMatch[0].length);
1568      skipTokenChars = attributePostMatch[0].length;
1569    } else {
1570      skipTokenChars = 0;
1571    }
1572
1573    const binding = {
1574      attributeName,
1575      attributeValuePre,
1576      attributeValuePost,
1577      expressionIndex: i
1578    };
1579
1580    bindings.push(binding);
1581
1582    if (!withinTag && !withinAttribute) {
1583      // Add a placeholder text node, so we can find it later. Note we only support one dynamic child text node
1584      htmlString += ' ';
1585    }
1586  }
1587
1588  const template = parseTemplate(htmlString);
1589
1590  return {
1591    template,
1592    elementsToBindings
1593  }
1594}
1595
1596function applyBindings (bindings, element, instanceBindings) {
1597  for (let i = 0; i < bindings.length; i++) {
1598    const binding = bindings[i];
1599
1600    const targetNode = binding.attributeName
1601      ? element // attribute binding, just use the element itself
1602      : element.firstChild; // not an attribute binding, so has a placeholder text node
1603
1604    const instanceBinding = {
1605      binding,
1606      targetNode,
1607      targetParentNode: undefined,
1608      currentExpression: undefined
1609    };
1610
1611    instanceBindings.push(instanceBinding);
1612  }
1613}
1614
1615function traverseAndSetupBindings (rootElement, elementsToBindings) {
1616  const instanceBindings = [];
1617
1618  let topLevelBindings;
1619  if (elementsToBindings.size === 1 && (topLevelBindings = elementsToBindings.get(0))) {
1620    // Optimization for the common case where there's only one element and one binding
1621    // Skip creating a TreeWalker entirely and just handle the root DOM element
1622    applyBindings(topLevelBindings, rootElement, instanceBindings);
1623  } else {
1624    // traverse dom
1625    const treeWalker = document.createTreeWalker(rootElement, NodeFilter.SHOW_ELEMENT);
1626
1627    let element = rootElement;
1628    let elementIndex = -1;
1629    do {
1630      const bindings = elementsToBindings.get(++elementIndex);
1631      if (bindings) {
1632        applyBindings(bindings, element, instanceBindings);
1633      }
1634    } while ((element = treeWalker.nextNode()))
1635  }
1636
1637  return instanceBindings
1638}
1639
1640function parseHtml (tokens) {
1641  // All templates and bound expressions are unique per tokens array
1642  const { template, elementsToBindings } = getFromMap(parseCache, tokens, () => parse(tokens));
1643
1644  // When we parseHtml, we always return a fresh DOM instance ready to be updated
1645  const dom = template.cloneNode(true).content.firstElementChild;
1646  const instanceBindings = traverseAndSetupBindings(dom, elementsToBindings);
1647
1648  return function updateDomInstance (expressions) {
1649    patch(expressions, instanceBindings);
1650    return dom
1651  }
1652}
1653
1654function createFramework (state) {
1655  const domInstances = getFromMap(domInstancesCache, state, () => new Map());
1656  let domInstanceCacheKey = unkeyedSymbol;
1657
1658  function html (tokens, ...expressions) {
1659    // Each unique lexical usage of map() is considered unique due to the html`` tagged template call it makes,
1660    // which has lexically unique tokens. The unkeyed symbol is just used for html`` usage outside of a map().
1661    const domInstancesForTokens = getFromMap(domInstances, tokens, () => new Map());
1662    const updateDomInstance = getFromMap(domInstancesForTokens, domInstanceCacheKey, () => parseHtml(tokens));
1663
1664    return updateDomInstance(expressions) // update with expressions
1665  }
1666
1667  function map (array, callback, keyFunction) {
1668    return array.map((item, index) => {
1669      const originalCacheKey = domInstanceCacheKey;
1670      domInstanceCacheKey = keyFunction(item);
1671      try {
1672        return callback(item, index)
1673      } finally {
1674        domInstanceCacheKey = originalCacheKey;
1675      }
1676    })
1677  }
1678
1679  return { map, html }
1680}
1681
1682function render (container, state, helpers, events, actions, refs, abortSignal, actionContext, firstRender) {
1683  const { labelWithSkin, titleForEmoji, unicodeWithSkin } = helpers;
1684  const { html, map } = createFramework(state);
1685
1686  function emojiList (emojis, searchMode, prefix) {
1687    return map(emojis, (emoji, i) => {
1688      return html`<button role="${searchMode ? 'option' : 'menuitem'}" aria-selected="${searchMode ? i === state.activeSearchItem : null}" aria-label="${labelWithSkin(emoji, state.currentSkinTone)}" title="${titleForEmoji(emoji)}" class="${
1689                'emoji' +
1690                (searchMode && i === state.activeSearchItem ? ' active' : '') +
1691                (emoji.unicode ? '' : ' custom-emoji')
1692              }" id="${`${prefix}-${emoji.id}`}" style="${emoji.unicode ? null : `--custom-emoji-background: url(${JSON.stringify(emoji.url)})`}">${
1693        emoji.unicode
1694          ? unicodeWithSkin(emoji, state.currentSkinTone)
1695          : ''
1696      }</button>`
1697      // It's important for the cache key to be unique based on the prefix, because the framework caches based on the
1698      // unique tokens + cache key, and the same emoji may be used in the tab as well as in the fav bar
1699    }, emoji => `${prefix}-${emoji.id}`)
1700  }
1701
1702  const section = () => {
1703    return html`<section data-ref="rootElement" class="picker" aria-label="${state.i18n.regionLabel}" style="${state.pickerStyle || ''}"><div class="pad-top"></div><div class="search-row"><div class="search-wrapper"><input id="search" class="search" type="search" role="combobox" enterkeyhint="search" placeholder="${state.i18n.searchLabel}" autocapitalize="none" autocomplete="off" spellcheck="true" aria-expanded="${!!(state.searchMode && state.currentEmojis.length)}" aria-controls="search-results" aria-describedby="search-description" aria-autocomplete="list" aria-activedescendant="${state.activeSearchItemId ? `emo-${state.activeSearchItemId}` : null}" data-ref="searchElement" data-on-input="onSearchInput" data-on-keydown="onSearchKeydown"><label class="sr-only" for="search">${state.i18n.searchLabel}</label> <span id="search-description" class="sr-only">${state.i18n.searchDescription}</span></div><div class="skintone-button-wrapper ${state.skinTonePickerExpandedAfterAnimation ? 'expanded' : ''}"><button id="skintone-button" class="emoji ${state.skinTonePickerExpanded ? 'hide-focus' : ''}" aria-label="${state.skinToneButtonLabel}" title="${state.skinToneButtonLabel}" aria-describedby="skintone-description" aria-haspopup="listbox" aria-expanded="${state.skinTonePickerExpanded}" aria-controls="skintone-list" data-on-click="onClickSkinToneButton">${state.skinToneButtonText || ''}</button></div><span id="skintone-description" class="sr-only">${state.i18n.skinToneDescription}</span><div data-ref="skinToneDropdown" id="skintone-list" class="skintone-list hide-focus ${state.skinTonePickerExpanded ? '' : 'hidden no-animate'}" style="transform:translateY(${state.skinTonePickerExpanded ? 0 : 'calc(-1 * var(--num-skintones) * var(--total-emoji-size))'})" role="listbox" aria-label="${state.i18n.skinTonesLabel}" aria-activedescendant="skintone-${state.activeSkinTone}" aria-hidden="${!state.skinTonePickerExpanded}" tabIndex="-1" data-on-focusout="onSkinToneOptionsFocusOut" data-on-click="onSkinToneOptionsClick" data-on-keydown="onSkinToneOptionsKeydown" data-on-keyup="onSkinToneOptionsKeyup">${
1704    map(state.skinTones, (skinTone, i) => {
1705    return html`<div id="skintone-${i}" class="emoji ${i === state.activeSkinTone ? 'active' : ''}" aria-selected="${i === state.activeSkinTone}" role="option" title="${state.i18n.skinTones[i]}" aria-label="${state.i18n.skinTones[i]}">${skinTone}</div>`
1706    }, skinTone => skinTone)
1707        }</div></div><div class="nav" role="tablist" style="grid-template-columns:repeat(${state.groups.length},1fr)" aria-label="${state.i18n.categoriesLabel}" data-on-keydown="onNavKeydown" data-on-click="onNavClick">${
1708            map(state.groups, (group) => {
1709              return html`<button role="tab" class="nav-button" aria-controls="tab-${group.id}" aria-label="${state.i18n.categories[group.name]}" aria-selected="${!state.searchMode && state.currentGroup.id === group.id}" title="${state.i18n.categories[group.name]}" data-group-id="${group.id}"><div class="nav-emoji emoji">${group.emoji}</div></button>`
1710            }, group => group.id)
1711          }</div><div class="indicator-wrapper"><div class="indicator" style="transform:translateX(${(/* istanbul ignore next */ (state.isRtl ? -1 : 1)) * state.currentGroupIndex * 100}%)"></div></div><div class="message ${state.message ? '' : 'gone'}" role="alert" aria-live="polite">${state.message || ''}</div><div data-ref="tabpanelElement" class="tabpanel ${(!state.databaseLoaded || state.message) ? 'gone' : ''}" role="${state.searchMode ? 'region' : 'tabpanel'}" aria-label="${state.searchMode ? state.i18n.searchResultsLabel : state.i18n.categories[state.currentGroup.name]}" id="${state.searchMode ? null : `tab-${state.currentGroup.id}`}" tabIndex="0" data-on-click="onEmojiClick"><div data-action="calculateEmojiGridStyle">${
1712              map(state.currentEmojisWithCategories, (emojiWithCategory, i) => {
1713                return html`<div><div id="menu-label-${i}" class="category ${state.currentEmojisWithCategories.length === 1 && state.currentEmojisWithCategories[0].category === '' ? 'gone' : ''}" aria-hidden="true">${
1714                  state.searchMode
1715                    ? state.i18n.searchResultsLabel
1716                    : (
1717                      emojiWithCategory.category
1718                        ? emojiWithCategory.category
1719                        : (
1720                          state.currentEmojisWithCategories.length > 1
1721                            ? state.i18n.categories.custom
1722                            : state.i18n.categories[state.currentGroup.name]
1723                        )
1724                    )
1725                }</div><div class="emoji-menu ${i !== 0 && !state.searchMode && state.currentGroup.id === -1 ? 'visibility-auto' : ''}" style="${`--num-rows: ${Math.ceil(emojiWithCategory.emojis.length / state.numColumns)}`}" data-action="updateOnIntersection" role="${state.searchMode ? 'listbox' : 'menu'}" aria-labelledby="menu-label-${i}" id="${state.searchMode ? 'search-results' : null}">${
1726              emojiList(emojiWithCategory.emojis, state.searchMode, /* prefix */ 'emo')
1727            }</div></div>`
1728              }, emojiWithCategory => emojiWithCategory.category)
1729            }</div></div><div class="favorites onscreen emoji-menu ${state.message ? 'gone' : ''}" role="menu" aria-label="${state.i18n.favoritesLabel}" data-on-click="onEmojiClick">${
1730            emojiList(state.currentFavorites, /* searchMode */ false, /* prefix */ 'fav')
1731          }</div><button data-ref="baselineEmoji" aria-hidden="true" tabindex="-1" class="abs-pos hidden emoji baseline-emoji">😀</button></section>`
1732  };
1733
1734  const rootDom = section();
1735
1736  // helper for traversing the dom, finding elements by an attribute, and getting the attribute value
1737  const forElementWithAttribute = (attributeName, callback) => {
1738    for (const element of container.querySelectorAll(`[${attributeName}]`)) {
1739      callback(element, element.getAttribute(attributeName));
1740    }
1741  };
1742
1743  if (firstRender) { // not a re-render
1744    container.appendChild(rootDom);
1745
1746    // we only bind events/refs once - there is no need to find them again given this component structure
1747
1748    // bind events
1749    for (const eventName of ['click', 'focusout', 'input', 'keydown', 'keyup']) {
1750      forElementWithAttribute(`data-on-${eventName}`, (element, listenerName) => {
1751        element.addEventListener(eventName, events[listenerName]);
1752      });
1753    }
1754
1755    // find refs
1756    forElementWithAttribute('data-ref', (element, ref) => {
1757      refs[ref] = element;
1758    });
1759
1760    // destroy/abort logic
1761    abortSignal.addEventListener('abort', () => {
1762      container.removeChild(rootDom);
1763    });
1764  }
1765
1766  // set up actions - these are re-bound on every render
1767  forElementWithAttribute('data-action', (element, action) => {
1768    let boundActions = actionContext.get(action);
1769    if (!boundActions) {
1770      actionContext.set(action, (boundActions = new WeakSet()));
1771    }
1772
1773    // avoid applying the same action to the same element multiple times
1774    if (!boundActions.has(element)) {
1775      boundActions.add(element);
1776      actions[action](element);
1777    }
1778  });
1779}
1780
1781/* istanbul ignore next */
1782const qM = typeof queueMicrotask === 'function' ? queueMicrotask : callback => Promise.resolve().then(callback);
1783
1784function createState (abortSignal) {
1785  let destroyed = false;
1786  let currentObserver;
1787
1788  const propsToObservers = new Map();
1789  const dirtyObservers = new Set();
1790
1791  let queued;
1792
1793  const flush = () => {
1794    if (destroyed) {
1795      return
1796    }
1797    const observersToRun = [...dirtyObservers];
1798    dirtyObservers.clear(); // clear before running to force any new updates to run in another tick of the loop
1799    try {
1800      for (const observer of observersToRun) {
1801        observer();
1802      }
1803    } finally {
1804      queued = false;
1805      if (dirtyObservers.size) { // new updates, queue another one
1806        queued = true;
1807        qM(flush);
1808      }
1809    }
1810  };
1811
1812  const state = new Proxy({}, {
1813    get (target, prop) {
1814      if (currentObserver) {
1815        let observers = propsToObservers.get(prop);
1816        if (!observers) {
1817          observers = new Set();
1818          propsToObservers.set(prop, observers);
1819        }
1820        observers.add(currentObserver);
1821      }
1822      return target[prop]
1823    },
1824    set (target, prop, newValue) {
1825      if (target[prop] !== newValue) {
1826        target[prop] = newValue;
1827        const observers = propsToObservers.get(prop);
1828        if (observers) {
1829          for (const observer of observers) {
1830            dirtyObservers.add(observer);
1831          }
1832          if (!queued) {
1833            queued = true;
1834            qM(flush);
1835          }
1836        }
1837      }
1838      return true
1839    }
1840  });
1841
1842  const createEffect = (callback) => {
1843    const runnable = () => {
1844      const oldObserver = currentObserver;
1845      currentObserver = runnable;
1846      try {
1847        return callback()
1848      } finally {
1849        currentObserver = oldObserver;
1850      }
1851    };
1852    return runnable()
1853  };
1854
1855  // destroy logic
1856  abortSignal.addEventListener('abort', () => {
1857    destroyed = true;
1858  });
1859
1860  return {
1861    state,
1862    createEffect
1863  }
1864}
1865
1866// Compare two arrays, with a function called on each item in the two arrays that returns true if the items are equal
1867function arraysAreEqualByFunction (left, right, areEqualFunc) {
1868  if (left.length !== right.length) {
1869    return false
1870  }
1871  for (let i = 0; i < left.length; i++) {
1872    if (!areEqualFunc(left[i], right[i])) {
1873      return false
1874    }
1875  }
1876  return true
1877}
1878
1879const intersectionObserverCache = new WeakMap();
1880
1881function intersectionObserverAction (node, abortSignal, listener) {
1882  /* istanbul ignore else */
1883  {
1884    // The scroll root is always `.tabpanel`
1885    const root = node.closest('.tabpanel');
1886
1887    let observer = intersectionObserverCache.get(root);
1888    if (!observer) {
1889      // TODO: replace this with the contentvisibilityautostatechange event when all supported browsers support it.
1890      // For now we use IntersectionObserver because it has better cross-browser support, and it would be bad for
1891      // old Safari versions if they eagerly downloaded all custom emoji all at once.
1892      observer = new IntersectionObserver(listener, {
1893        root,
1894        // trigger if we are 1/2 scroll container height away so that the images load a bit quicker while scrolling
1895        rootMargin: '50% 0px 50% 0px',
1896        // trigger if any part of the emoji grid is intersecting
1897        threshold: 0
1898      });
1899
1900      // avoid creating a new IntersectionObserver for every category; just use one for the whole root
1901      intersectionObserverCache.set(root, observer);
1902
1903      // assume that the abortSignal is always the same for this root node; just add one event listener
1904      abortSignal.addEventListener('abort', () => {
1905        observer.disconnect();
1906      });
1907    }
1908
1909    observer.observe(node);
1910  }
1911}
1912
1913/* eslint-disable prefer-const,no-labels,no-inner-declarations */
1914
1915// constants
1916const EMPTY_ARRAY = [];
1917
1918const { assign } = Object;
1919
1920function createRoot (shadowRoot, props) {
1921  const refs = {};
1922  const abortController = new AbortController();
1923  const abortSignal = abortController.signal;
1924  const { state, createEffect } = createState(abortSignal);
1925  const actionContext = new Map();
1926
1927  // initial state
1928  assign(state, {
1929    skinToneEmoji: undefined,
1930    i18n: undefined,
1931    database: undefined,
1932    customEmoji: undefined,
1933    customCategorySorting: undefined,
1934    emojiVersion: undefined
1935  });
1936
1937  // public props
1938  assign(state, props);
1939
1940  // private props
1941  assign(state, {
1942    initialLoad: true,
1943    currentEmojis: [],
1944    currentEmojisWithCategories: [],
1945    rawSearchText: '',
1946    searchText: '',
1947    searchMode: false,
1948    activeSearchItem: -1,
1949    message: undefined,
1950    skinTonePickerExpanded: false,
1951    skinTonePickerExpandedAfterAnimation: false,
1952    currentSkinTone: 0,
1953    activeSkinTone: 0,
1954    skinToneButtonText: undefined,
1955    pickerStyle: undefined,
1956    skinToneButtonLabel: '',
1957    skinTones: [],
1958    currentFavorites: [],
1959    defaultFavoriteEmojis: undefined,
1960    numColumns: DEFAULT_NUM_COLUMNS,
1961    isRtl: false,
1962    currentGroupIndex: 0,
1963    groups: groups,
1964    databaseLoaded: false,
1965    activeSearchItemId: undefined
1966  });
1967
1968  //
1969  // Update the current group based on the currentGroupIndex
1970  //
1971  createEffect(() => {
1972    if (state.currentGroup !== state.groups[state.currentGroupIndex]) {
1973      state.currentGroup = state.groups[state.currentGroupIndex];
1974    }
1975  });
1976
1977  //
1978  // Utils/helpers
1979  //
1980
1981  const focus = id => {
1982    shadowRoot.getElementById(id).focus();
1983  };
1984
1985  const emojiToDomNode = emoji => shadowRoot.getElementById(`emo-${emoji.id}`);
1986
1987  // fire a custom event that crosses the shadow boundary
1988  const fireEvent = (name, detail) => {
1989    refs.rootElement.dispatchEvent(new CustomEvent(name, {
1990      detail,
1991      bubbles: true,
1992      composed: true
1993    }));
1994  };
1995
1996  //
1997  // Comparison utils
1998  //
1999
2000  const compareEmojiArrays = (a, b) => a.id === b.id;
2001
2002  const compareCurrentEmojisWithCategories = (a, b) => {
2003    const { category: aCategory, emojis: aEmojis } = a;
2004    const { category: bCategory, emojis: bEmojis } = b;
2005
2006    if (aCategory !== bCategory) {
2007      return false
2008    }
2009
2010    return arraysAreEqualByFunction(aEmojis, bEmojis, compareEmojiArrays)
2011  };
2012
2013  //
2014  // Update utils to avoid excessive re-renders
2015  //
2016
2017  // avoid excessive re-renders by checking the value before setting
2018  const updateCurrentEmojis = (newEmojis) => {
2019    if (!arraysAreEqualByFunction(state.currentEmojis, newEmojis, compareEmojiArrays)) {
2020      state.currentEmojis = newEmojis;
2021    }
2022  };
2023
2024  // avoid excessive re-renders
2025  const updateSearchMode = (newSearchMode) => {
2026    if (state.searchMode !== newSearchMode) {
2027      state.searchMode = newSearchMode;
2028    }
2029  };
2030
2031  // avoid excessive re-renders
2032  const updateCurrentEmojisWithCategories = (newEmojisWithCategories) => {
2033    if (!arraysAreEqualByFunction(state.currentEmojisWithCategories, newEmojisWithCategories, compareCurrentEmojisWithCategories)) {
2034      state.currentEmojisWithCategories = newEmojisWithCategories;
2035    }
2036  };
2037
2038  // Helpers used by PickerTemplate
2039
2040  const unicodeWithSkin = (emoji, currentSkinTone) => (
2041    (currentSkinTone && emoji.skins && emoji.skins[currentSkinTone]) || emoji.unicode
2042  );
2043
2044  const labelWithSkin = (emoji, currentSkinTone) => (
2045    uniq([
2046      (emoji.name || unicodeWithSkin(emoji, currentSkinTone)),
2047      emoji.annotation,
2048      ...(emoji.shortcodes || EMPTY_ARRAY)
2049    ].filter(Boolean)).join(', ')
2050  );
2051
2052  const titleForEmoji = (emoji) => (
2053    emoji.annotation || (emoji.shortcodes || EMPTY_ARRAY).join(', ')
2054  );
2055
2056  const helpers = {
2057    labelWithSkin, titleForEmoji, unicodeWithSkin
2058  };
2059  const events = {
2060    onClickSkinToneButton,
2061    onEmojiClick,
2062    onNavClick,
2063    onNavKeydown,
2064    onSearchKeydown,
2065    onSkinToneOptionsClick,
2066    onSkinToneOptionsFocusOut,
2067    onSkinToneOptionsKeydown,
2068    onSkinToneOptionsKeyup,
2069    onSearchInput
2070  };
2071  const actions = {
2072    calculateEmojiGridStyle,
2073    updateOnIntersection
2074  };
2075
2076  let firstRender = true;
2077  createEffect(() => {
2078    render(shadowRoot, state, helpers, events, actions, refs, abortSignal, actionContext, firstRender);
2079    firstRender = false;
2080  });
2081
2082  //
2083  // Determine the emoji support level (in requestIdleCallback)
2084  //
2085
2086  // mount logic
2087  if (!state.emojiVersion) {
2088    detectEmojiSupportLevel().then(level => {
2089      // Can't actually test emoji support in Jest/Vitest/JSDom, emoji never render in color in Cairo
2090      /* istanbul ignore next */
2091      if (!level) {
2092        state.message = state.i18n.emojiUnsupportedMessage;
2093      }
2094    });
2095  }
2096
2097  //
2098  // Set or update the database object
2099  //
2100
2101  createEffect(() => {
2102    // show a Loading message if it takes a long time, or show an error if there's a network/IDB error
2103    async function handleDatabaseLoading () {
2104      let showingLoadingMessage = false;
2105      const timeoutHandle = setTimeout(() => {
2106        showingLoadingMessage = true;
2107        state.message = state.i18n.loadingMessage;
2108      }, TIMEOUT_BEFORE_LOADING_MESSAGE);
2109      try {
2110        await state.database.ready();
2111        state.databaseLoaded = true; // eslint-disable-line no-unused-vars
2112      } catch (err) {
2113        console.error(err);
2114        state.message = state.i18n.networkErrorMessage;
2115      } finally {
2116        clearTimeout(timeoutHandle);
2117        if (showingLoadingMessage) { // Seems safer than checking the i18n string, which may change
2118          showingLoadingMessage = false;
2119          state.message = ''; // eslint-disable-line no-unused-vars
2120        }
2121      }
2122    }
2123
2124    if (state.database) {
2125      /* no await */
2126      handleDatabaseLoading();
2127    }
2128  });
2129
2130  //
2131  // Global styles for the entire picker
2132  //
2133
2134  createEffect(() => {
2135    state.pickerStyle = `
2136      --num-groups: ${state.groups.length}; 
2137      --indicator-opacity: ${state.searchMode ? 0 : 1}; 
2138      --num-skintones: ${NUM_SKIN_TONES};`;
2139  });
2140
2141  //
2142  // Set or update the customEmoji
2143  //
2144
2145  createEffect(() => {
2146    if (state.customEmoji && state.database) {
2147      updateCustomEmoji(); // re-run whenever customEmoji change
2148    }
2149  });
2150
2151  createEffect(() => {
2152    if (state.customEmoji && state.customEmoji.length) {
2153      if (state.groups !== allGroups) { // don't update unnecessarily
2154        state.groups = allGroups;
2155      }
2156    } else if (state.groups !== groups) {
2157      if (state.currentGroupIndex) {
2158        // If the current group is anything other than "custom" (which is first), decrement.
2159        // This fixes the odd case where you set customEmoji, then pick a category, then unset customEmoji
2160        state.currentGroupIndex--;
2161      }
2162      state.groups = groups;
2163    }
2164  });
2165
2166  //
2167  // Set or update the preferred skin tone
2168  //
2169
2170  createEffect(() => {
2171    async function updatePreferredSkinTone () {
2172      if (state.databaseLoaded) {
2173        state.currentSkinTone = await state.database.getPreferredSkinTone();
2174      }
2175    }
2176
2177    /* no await */ updatePreferredSkinTone();
2178  });
2179
2180  createEffect(() => {
2181    state.skinTones = Array(NUM_SKIN_TONES).fill().map((_, i) => applySkinTone(state.skinToneEmoji, i));
2182  });
2183
2184  createEffect(() => {
2185    state.skinToneButtonText = state.skinTones[state.currentSkinTone];
2186  });
2187
2188  createEffect(() => {
2189    state.skinToneButtonLabel = state.i18n.skinToneLabel.replace('{skinTone}', state.i18n.skinTones[state.currentSkinTone]);
2190  });
2191
2192  //
2193  // Set or update the favorites emojis
2194  //
2195
2196  createEffect(() => {
2197    async function updateDefaultFavoriteEmojis () {
2198      const { database } = state;
2199      const favs = (await Promise.all(MOST_COMMONLY_USED_EMOJI.map(unicode => (
2200        database.getEmojiByUnicodeOrName(unicode)
2201      )))).filter(Boolean); // filter because in Jest/Vitest tests we don't have all the emoji in the DB
2202      state.defaultFavoriteEmojis = favs;
2203    }
2204
2205    if (state.databaseLoaded) {
2206      /* no await */ updateDefaultFavoriteEmojis();
2207    }
2208  });
2209
2210  function updateCustomEmoji () {
2211    // Certain effects have an implicit dependency on customEmoji since it affects the database
2212    // Getting it here on the state ensures this effect re-runs when customEmoji change.
2213    const { customEmoji, database } = state;
2214    const databaseCustomEmoji = customEmoji || EMPTY_ARRAY;
2215    if (database.customEmoji !== databaseCustomEmoji) {
2216      // Avoid setting this if the customEmoji have _not_ changed, because the setter triggers a re-computation of the
2217      // `customEmojiIndex`. Note we don't bother with deep object changes.
2218      database.customEmoji = databaseCustomEmoji;
2219    }
2220  }
2221
2222  createEffect(() => {
2223    async function updateFavorites () {
2224      updateCustomEmoji(); // re-run whenever customEmoji change
2225      const { database, defaultFavoriteEmojis, numColumns } = state;
2226      const dbFavorites = await database.getTopFavoriteEmoji(numColumns);
2227      const favorites = await summarizeEmojis(uniqBy([
2228        ...dbFavorites,
2229        ...defaultFavoriteEmojis
2230      ], _ => (_.unicode || _.name)).slice(0, numColumns));
2231      state.currentFavorites = favorites;
2232    }
2233
2234    if (state.databaseLoaded && state.defaultFavoriteEmojis) {
2235      /* no await */ updateFavorites();
2236    }
2237  });
2238
2239  //
2240  // Re-run whenever the emoji grid changes size, and re-calc style/layout-related state variables:
2241  // 1) Re-calculate the --num-columns var because it may have changed
2242  // 2) Re-calculate whether we're in RTL mode or not.
2243  //
2244  // The benefit of doing this in one place is to align with rAF/ResizeObserver
2245  // and do all the calculations in one go. RTL vs LTR is not strictly layout-related,
2246  // but since we're already reading the style here, and since it's already aligned with
2247  // the rAF loop, this is the most appropriate place to do it perf-wise.
2248  //
2249
2250  function calculateEmojiGridStyle (node) {
2251    resizeObserverAction(node, abortSignal, () => {
2252      /* istanbul ignore next */
2253      { // jsdom throws errors for this kind of fancy stuff
2254        // read all the style/layout calculations we need to make
2255        const style = getComputedStyle(refs.rootElement);
2256        const newNumColumns = parseInt(style.getPropertyValue('--num-columns'), 10);
2257        const newIsRtl = style.getPropertyValue('direction') === 'rtl';
2258
2259        // write to state variables
2260        state.numColumns = newNumColumns;
2261        state.isRtl = newIsRtl;
2262      }
2263    });
2264  }
2265
2266  // Re-run whenever the custom emoji in a category are shown/hidden. This is an optimization that simulates
2267  // what we'd get from `<img loading=lazy>` but without rendering an `<img>`.
2268  function updateOnIntersection (node) {
2269    intersectionObserverAction(node, abortSignal, (entries) => {
2270      for (const { target, isIntersecting } of entries) {
2271        target.classList.toggle('onscreen', isIntersecting);
2272      }
2273    });
2274  }
2275
2276  //
2277  // Set or update the currentEmojis. Check for invalid ZWJ renderings
2278  // (i.e. double emoji).
2279  //
2280
2281  createEffect(() => {
2282    async function updateEmojis () {
2283      const { searchText, currentGroup, databaseLoaded, customEmoji } = state;
2284      if (!databaseLoaded) {
2285        state.currentEmojis = [];
2286        state.searchMode = false;
2287      } else if (searchText.length >= MIN_SEARCH_TEXT_LENGTH) {
2288        const newEmojis = await getEmojisBySearchQuery(searchText);
2289        if (state.searchText === searchText) { // if the situation changes asynchronously, do not update
2290          updateCurrentEmojis(newEmojis);
2291          updateSearchMode(true);
2292        }
2293      } else { // database is loaded and we're not in search mode, so we're in normal category mode
2294        const { id: currentGroupId } = currentGroup;
2295        // avoid race condition where currentGroupId is -1 and customEmoji is undefined/empty
2296        if (currentGroupId !== -1 || (customEmoji && customEmoji.length)) {
2297          const newEmojis = await getEmojisByGroup(currentGroupId);
2298          if (state.currentGroup.id === currentGroupId) { // if the situation changes asynchronously, do not update
2299            updateCurrentEmojis(newEmojis);
2300            updateSearchMode(false);
2301          }
2302        }
2303      }
2304    }
2305
2306    /* no await */ updateEmojis();
2307  });
2308
2309  const resetScrollTopInRaf = () => {
2310    rAF(() => resetScrollTopIfPossible(refs.tabpanelElement));
2311  };
2312
2313  // Some emojis have their ligatures rendered as two or more consecutive emojis
2314  // We want to treat these the same as unsupported emojis, so we compare their
2315  // widths against the baseline widths and remove them as necessary
2316  createEffect(() => {
2317    const { currentEmojis, emojiVersion } = state;
2318    const zwjEmojisToCheck = currentEmojis
2319      .filter(emoji => emoji.unicode) // filter custom emoji
2320      .filter(emoji => hasZwj(emoji) && !supportedZwjEmojis.has(emoji.unicode));
2321    if (!emojiVersion && zwjEmojisToCheck.length) {
2322      // render now, check their length later
2323      updateCurrentEmojis(currentEmojis);
2324      rAF(() => checkZwjSupportAndUpdate(zwjEmojisToCheck));
2325    } else {
2326      const newEmojis = emojiVersion ? currentEmojis : currentEmojis.filter(isZwjSupported);
2327      updateCurrentEmojis(newEmojis);
2328      // Reset scroll top to 0 when emojis change
2329      resetScrollTopInRaf();
2330    }
2331  });
2332
2333  function checkZwjSupportAndUpdate (zwjEmojisToCheck) {
2334    const allSupported = checkZwjSupport(zwjEmojisToCheck, refs.baselineEmoji, emojiToDomNode);
2335    if (allSupported) {
2336      // Even if all emoji are supported, we still need to reset the scroll top to 0 when emojis change
2337      resetScrollTopInRaf();
2338    } else {
2339      // Force update. We only do this if there are any unsupported ZWJ characters since otherwise,
2340      // for browsers that support all emoji, it would be an unnecessary extra re-render.
2341      state.currentEmojis = [...state.currentEmojis];
2342    }
2343  }
2344
2345  function isZwjSupported (emoji) {
2346    return !emoji.unicode || !hasZwj(emoji) || supportedZwjEmojis.get(emoji.unicode)
2347  }
2348
2349  async function filterEmojisByVersion (emojis) {
2350    const emojiSupportLevel = state.emojiVersion || await detectEmojiSupportLevel();
2351    // !version corresponds to custom emoji
2352    return emojis.filter(({ version }) => !version || version <= emojiSupportLevel)
2353  }
2354
2355  async function summarizeEmojis (emojis) {
2356    return summarizeEmojisForUI(emojis, state.emojiVersion || await detectEmojiSupportLevel())
2357  }
2358
2359  async function getEmojisByGroup (group) {
2360    // -1 is custom emoji
2361    const emoji = group === -1 ? state.customEmoji : await state.database.getEmojiByGroup(group);
2362    return summarizeEmojis(await filterEmojisByVersion(emoji))
2363  }
2364
2365  async function getEmojisBySearchQuery (query) {
2366    return summarizeEmojis(await filterEmojisByVersion(await state.database.getEmojiBySearchQuery(query)))
2367  }
2368
2369  createEffect(() => {
2370  });
2371
2372  //
2373  // Derive currentEmojisWithCategories from currentEmojis. This is always done even if there
2374  // are no categories, because it's just easier to code the HTML this way.
2375  //
2376
2377  createEffect(() => {
2378    function calculateCurrentEmojisWithCategories () {
2379      const { searchMode, currentEmojis } = state;
2380      if (searchMode) {
2381        return [
2382          {
2383            category: '',
2384            emojis: currentEmojis
2385          }
2386        ]
2387      }
2388      const categoriesToEmoji = new Map();
2389      for (const emoji of currentEmojis) {
2390        const category = emoji.category || '';
2391        let emojis = categoriesToEmoji.get(category);
2392        if (!emojis) {
2393          emojis = [];
2394          categoriesToEmoji.set(category, emojis);
2395        }
2396        emojis.push(emoji);
2397      }
2398      return [...categoriesToEmoji.entries()]
2399        .map(([category, emojis]) => ({ category, emojis }))
2400        .sort((a, b) => state.customCategorySorting(a.category, b.category))
2401    }
2402
2403    const newEmojisWithCategories = calculateCurrentEmojisWithCategories();
2404    updateCurrentEmojisWithCategories(newEmojisWithCategories);
2405  });
2406
2407  //
2408  // Handle active search item (i.e. pressing up or down while searching)
2409  //
2410
2411  createEffect(() => {
2412    state.activeSearchItemId = state.activeSearchItem !== -1 && state.currentEmojis[state.activeSearchItem].id;
2413  });
2414
2415  //
2416  // Handle user input on the search input
2417  //
2418
2419  createEffect(() => {
2420    const { rawSearchText } = state;
2421    rIC(() => {
2422      state.searchText = (rawSearchText || '').trim(); // defer to avoid input delays, plus we can trim here
2423      state.activeSearchItem = -1;
2424    });
2425  });
2426
2427  function onSearchKeydown (event) {
2428    if (!state.searchMode || !state.currentEmojis.length) {
2429      return
2430    }
2431
2432    const goToNextOrPrevious = (previous) => {
2433      halt(event);
2434      state.activeSearchItem = incrementOrDecrement(previous, state.activeSearchItem, state.currentEmojis);
2435    };
2436
2437    switch (event.key) {
2438      case 'ArrowDown':
2439        return goToNextOrPrevious(false)
2440      case 'ArrowUp':
2441        return goToNextOrPrevious(true)
2442      case 'Enter':
2443        if (state.activeSearchItem === -1) {
2444          // focus the first option in the list since the list must be non-empty at this point (it's verified above)
2445          state.activeSearchItem = 0;
2446        } else { // there is already an active search item
2447          halt(event);
2448          return clickEmoji(state.currentEmojis[state.activeSearchItem].id)
2449        }
2450    }
2451  }
2452
2453  //
2454  // Handle user input on nav
2455  //
2456
2457  function onNavClick (event) {
2458    const { target } = event;
2459    const closestTarget = target.closest('.nav-button');
2460    /* istanbul ignore if */
2461    if (!closestTarget) {
2462      return // This should never happen, but makes me nervous not to have it
2463    }
2464    const groupId = parseInt(closestTarget.dataset.groupId, 10);
2465    refs.searchElement.value = ''; // clear search box input
2466    state.rawSearchText = '';
2467    state.searchText = '';
2468    state.activeSearchItem = -1;
2469    state.currentGroupIndex = state.groups.findIndex(_ => _.id === groupId);
2470  }
2471
2472  function onNavKeydown (event) {
2473    const { target, key } = event;
2474
2475    const doFocus = el => {
2476      if (el) {
2477        halt(event);
2478        el.focus();
2479      }
2480    };
2481
2482    switch (key) {
2483      case 'ArrowLeft':
2484        return doFocus(target.previousElementSibling)
2485      case 'ArrowRight':
2486        return doFocus(target.nextElementSibling)
2487      case 'Home':
2488        return doFocus(target.parentElement.firstElementChild)
2489      case 'End':
2490        return doFocus(target.parentElement.lastElementChild)
2491    }
2492  }
2493
2494  async function getDetailForClickEvent (unicodeOrName) {
2495    const emoji = await state.database.getEmojiByUnicodeOrName(unicodeOrName);
2496    const emojiSummary = [...state.currentEmojis, ...state.currentFavorites]
2497      .find(_ => (_.id === unicodeOrName));
2498    const skinTonedUnicode = emojiSummary.unicode && unicodeWithSkin(emojiSummary, state.currentSkinTone);
2499    await state.database.incrementFavoriteEmojiCount(unicodeOrName);
2500    return {
2501      emoji,
2502      skinTone: state.currentSkinTone,
2503      ...(skinTonedUnicode && { unicode: skinTonedUnicode }),
2504      ...(emojiSummary.name && { name: emojiSummary.name })
2505    }
2506  }
2507
2508  //
2509  // Handle user input on an emoji
2510  //
2511  async function clickEmoji (unicodeOrName) {
2512    const promiseForDetail = getDetailForClickEvent(unicodeOrName);
2513    // sync event to work around a safari bug: https://bugs.webkit.org/show_bug.cgi?id=222262
2514    fireEvent('emoji-click-sync', promiseForDetail);
2515    // async event for most normal use cases that don't need to work around the safari bug
2516    fireEvent('emoji-click', await promiseForDetail);
2517  }
2518
2519  function onEmojiClick (event) {
2520    const { target } = event;
2521    /* istanbul ignore if */
2522    if (!target.classList.contains('emoji')) {
2523      // This should never happen, but makes me nervous not to have it
2524      return
2525    }
2526    halt(event);
2527    const id = target.id.substring(4); // replace 'emo-' or 'fav-' prefix
2528
2529    /* no await */ clickEmoji(id);
2530  }
2531
2532  //
2533  // Handle user input on the skintone picker
2534  //
2535
2536  function changeSkinTone (skinTone) {
2537    state.currentSkinTone = skinTone;
2538    state.skinTonePickerExpanded = false;
2539    focus('skintone-button');
2540    fireEvent('skin-tone-change', { skinTone });
2541    /* no await */ state.database.setPreferredSkinTone(skinTone);
2542  }
2543
2544  function onSkinToneOptionsClick (event) {
2545    const { target: { id } } = event;
2546    const match = id && id.match(/^skintone-(\d)/); // skintone option format
2547    /* istanbul ignore if */
2548    if (!match) { // not a skintone option
2549      return // This should never happen, but makes me nervous not to have it
2550    }
2551    halt(event);
2552    const skinTone = parseInt(match[1], 10); // remove 'skintone-' prefix
2553    changeSkinTone(skinTone);
2554  }
2555
2556  function onClickSkinToneButton (event) {
2557    state.skinTonePickerExpanded = !state.skinTonePickerExpanded;
2558    state.activeSkinTone = state.currentSkinTone;
2559    // this should always be true, since the button is obscured by the listbox, so this `if` is just to be sure
2560    if (state.skinTonePickerExpanded) {
2561      halt(event);
2562      rAF(() => focus('skintone-list'));
2563    }
2564  }
2565
2566  // To make the animation nicer, change the z-index of the skintone picker button
2567  // *after* the animation has played. This makes it appear that the picker box
2568  // is expanding "below" the button
2569  createEffect(() => {
2570    if (state.skinTonePickerExpanded) {
2571      refs.skinToneDropdown.addEventListener('transitionend', () => {
2572        state.skinTonePickerExpandedAfterAnimation = true; // eslint-disable-line no-unused-vars
2573      }, { once: true });
2574    } else {
2575      state.skinTonePickerExpandedAfterAnimation = false; // eslint-disable-line no-unused-vars
2576    }
2577  });
2578
2579  function onSkinToneOptionsKeydown (event) {
2580    // this should never happen, but makes me nervous not to have it
2581    /* istanbul ignore if */
2582    if (!state.skinTonePickerExpanded) {
2583      return
2584    }
2585    const changeActiveSkinTone = async nextSkinTone => {
2586      halt(event);
2587      state.activeSkinTone = nextSkinTone;
2588    };
2589
2590    switch (event.key) {
2591      case 'ArrowUp':
2592        return changeActiveSkinTone(incrementOrDecrement(true, state.activeSkinTone, state.skinTones))
2593      case 'ArrowDown':
2594        return changeActiveSkinTone(incrementOrDecrement(false, state.activeSkinTone, state.skinTones))
2595      case 'Home':
2596        return changeActiveSkinTone(0)
2597      case 'End':
2598        return changeActiveSkinTone(state.skinTones.length - 1)
2599      case 'Enter':
2600        // enter on keydown, space on keyup. this is just how browsers work for buttons
2601        // https://lists.w3.org/Archives/Public/w3c-wai-ig/2019JanMar/0086.html
2602        halt(event);
2603        return changeSkinTone(state.activeSkinTone)
2604      case 'Escape':
2605        halt(event);
2606        state.skinTonePickerExpanded = false;
2607        return focus('skintone-button')
2608    }
2609  }
2610
2611  function onSkinToneOptionsKeyup (event) {
2612    // this should never happen, but makes me nervous not to have it
2613    /* istanbul ignore if */
2614    if (!state.skinTonePickerExpanded) {
2615      return
2616    }
2617    switch (event.key) {
2618      case ' ':
2619        // enter on keydown, space on keyup. this is just how browsers work for buttons
2620        // https://lists.w3.org/Archives/Public/w3c-wai-ig/2019JanMar/0086.html
2621        halt(event);
2622        return changeSkinTone(state.activeSkinTone)
2623    }
2624  }
2625
2626  async function onSkinToneOptionsFocusOut (event) {
2627    // On blur outside of the skintone listbox, collapse the skintone picker.
2628    const { relatedTarget } = event;
2629    // The `else` should never happen, but makes me nervous not to have it
2630    /* istanbul ignore else */
2631    if (!relatedTarget || relatedTarget.id !== 'skintone-list') {
2632      state.skinTonePickerExpanded = false;
2633    }
2634  }
2635
2636  function onSearchInput (event) {
2637    state.rawSearchText = event.target.value;
2638  }
2639
2640  return {
2641    $set (newState) {
2642      assign(state, newState);
2643    },
2644    $destroy () {
2645      abortController.abort();
2646    }
2647  }
2648}
2649
2650const DEFAULT_DATA_SOURCE = 'https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/en/emojibase/data.json';
2651const DEFAULT_LOCALE = 'en';
2652
2653var enI18n = {
2654  categoriesLabel: 'Categories',
2655  emojiUnsupportedMessage: 'Your browser does not support color emoji.',
2656  favoritesLabel: 'Favorites',
2657  loadingMessage: 'Loading…',
2658  networkErrorMessage: 'Could not load emoji.',
2659  regionLabel: 'Emoji picker',
2660  searchDescription: 'When search results are available, press up or down to select and enter to choose.',
2661  searchLabel: 'Search',
2662  searchResultsLabel: 'Search results',
2663  skinToneDescription: 'When expanded, press up or down to select and enter to choose.',
2664  skinToneLabel: 'Choose a skin tone (currently {skinTone})',
2665  skinTonesLabel: 'Skin tones',
2666  skinTones: [
2667    'Default',
2668    'Light',
2669    'Medium-Light',
2670    'Medium',
2671    'Medium-Dark',
2672    'Dark'
2673  ],
2674  categories: {
2675    custom: 'Custom',
2676    'smileys-emotion': 'Smileys and emoticons',
2677    'people-body': 'People and body',
2678    'animals-nature': 'Animals and nature',
2679    'food-drink': 'Food and drink',
2680    'travel-places': 'Travel and places',
2681    activities: 'Activities',
2682    objects: 'Objects',
2683    symbols: 'Symbols',
2684    flags: 'Flags'
2685  }
2686};
2687
2688var baseStyles = ":host{--emoji-size:1.375rem;--emoji-padding:0.5rem;--category-emoji-size:var(--emoji-size);--category-emoji-padding:var(--emoji-padding);--indicator-height:3px;--input-border-radius:0.5rem;--input-border-size:1px;--input-font-size:1rem;--input-line-height:1.5;--input-padding:0.25rem;--num-columns:8;--outline-size:2px;--border-size:1px;--border-radius:0;--skintone-border-radius:1rem;--category-font-size:1rem;display:flex;width:min-content;height:400px}:host,:host(.light){color-scheme:light;--background:#fff;--border-color:#e0e0e0;--indicator-color:#385ac1;--input-border-color:#999;--input-font-color:#111;--input-placeholder-color:#999;--outline-color:#999;--category-font-color:#111;--button-active-background:#e6e6e6;--button-hover-background:#d9d9d9}:host(.dark){color-scheme:dark;--background:#222;--border-color:#444;--indicator-color:#5373ec;--input-border-color:#ccc;--input-font-color:#efefef;--input-placeholder-color:#ccc;--outline-color:#fff;--category-font-color:#efefef;--button-active-background:#555555;--button-hover-background:#484848}@media (prefers-color-scheme:dark){:host{color-scheme:dark;--background:#222;--border-color:#444;--indicator-color:#5373ec;--input-border-color:#ccc;--input-font-color:#efefef;--input-placeholder-color:#ccc;--outline-color:#fff;--category-font-color:#efefef;--button-active-background:#555555;--button-hover-background:#484848}}:host([hidden]){display:none}button{margin:0;padding:0;border:0;background:0 0;box-shadow:none;-webkit-tap-highlight-color:transparent}button::-moz-focus-inner{border:0}input{padding:0;margin:0;line-height:1.15;font-family:inherit}input[type=search]{-webkit-appearance:none}:focus{outline:var(--outline-color) solid var(--outline-size);outline-offset:calc(-1*var(--outline-size))}:host([data-js-focus-visible]) :focus:not([data-focus-visible-added]){outline:0}:focus:not(:focus-visible){outline:0}.hide-focus{outline:0}*{box-sizing:border-box}.picker{contain:content;display:flex;flex-direction:column;background:var(--background);border:var(--border-size) solid var(--border-color);border-radius:var(--border-radius);width:100%;height:100%;overflow:hidden;--total-emoji-size:calc(var(--emoji-size) + (2 * var(--emoji-padding)));--total-category-emoji-size:calc(var(--category-emoji-size) + (2 * var(--category-emoji-padding)))}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.hidden{opacity:0;pointer-events:none}.abs-pos{position:absolute;left:0;top:0}.gone{display:none!important}.skintone-button-wrapper,.skintone-list{background:var(--background);z-index:3}.skintone-button-wrapper.expanded{z-index:1}.skintone-list{position:absolute;inset-inline-end:0;top:0;z-index:2;overflow:visible;border-bottom:var(--border-size) solid var(--border-color);border-radius:0 0 var(--skintone-border-radius) var(--skintone-border-radius);will-change:transform;transition:transform .2s ease-in-out;transform-origin:center 0}@media (prefers-reduced-motion:reduce){.skintone-list{transition-duration:.001s}}@supports not (inset-inline-end:0){.skintone-list{right:0}}.skintone-list.no-animate{transition:none}.tabpanel{overflow-y:auto;scrollbar-gutter:stable;-webkit-overflow-scrolling:touch;will-change:transform;min-height:0;flex:1;contain:content}.emoji-menu{display:grid;grid-template-columns:repeat(var(--num-columns),var(--total-emoji-size));justify-content:space-around;align-items:flex-start;width:100%}.emoji-menu.visibility-auto{content-visibility:auto;contain-intrinsic-size:calc(var(--num-columns)*var(--total-emoji-size)) calc(var(--num-rows)*var(--total-emoji-size))}.category{padding:var(--emoji-padding);font-size:var(--category-font-size);color:var(--category-font-color)}.emoji,button.emoji{font-size:var(--emoji-size);display:flex;align-items:center;justify-content:center;border-radius:100%;height:var(--total-emoji-size);width:var(--total-emoji-size);line-height:1;overflow:hidden;font-family:var(--emoji-font-family);cursor:pointer}@media (hover:hover) and (pointer:fine){.emoji:hover,button.emoji:hover{background:var(--button-hover-background)}}.emoji.active,.emoji:active,button.emoji.active,button.emoji:active{background:var(--button-active-background)}.onscreen .custom-emoji::after{content:\"\";width:var(--emoji-size);height:var(--emoji-size);background-repeat:no-repeat;background-position:center center;background-size:contain;background-image:var(--custom-emoji-background)}.nav,.nav-button{align-items:center}.nav{display:grid;justify-content:space-between;contain:content}.nav-button{display:flex;justify-content:center}.nav-emoji{font-size:var(--category-emoji-size);width:var(--total-category-emoji-size);height:var(--total-category-emoji-size)}.indicator-wrapper{display:flex;border-bottom:1px solid var(--border-color)}.indicator{width:calc(100%/var(--num-groups));height:var(--indicator-height);opacity:var(--indicator-opacity);background-color:var(--indicator-color);will-change:transform,opacity;transition:opacity .1s linear,transform .25s ease-in-out}@media (prefers-reduced-motion:reduce){.indicator{will-change:opacity;transition:opacity .1s linear}}.pad-top,input.search{background:var(--background);width:100%}.pad-top{height:var(--emoji-padding);z-index:3}.search-row{display:flex;align-items:center;position:relative;padding-inline-start:var(--emoji-padding);padding-bottom:var(--emoji-padding)}.search-wrapper{flex:1;min-width:0}input.search{padding:var(--input-padding);border-radius:var(--input-border-radius);border:var(--input-border-size) solid var(--input-border-color);color:var(--input-font-color);font-size:var(--input-font-size);line-height:var(--input-line-height)}input.search::placeholder{color:var(--input-placeholder-color)}.favorites{overflow-y:auto;scrollbar-gutter:stable;display:flex;flex-direction:row;border-top:var(--border-size) solid var(--border-color);contain:content}.message{padding:var(--emoji-padding)}";
2689
2690const PROPS = [
2691  'customEmoji',
2692  'customCategorySorting',
2693  'database',
2694  'dataSource',
2695  'i18n',
2696  'locale',
2697  'skinToneEmoji',
2698  'emojiVersion'
2699];
2700
2701// Styles injected ourselves, so we can declare the FONT_FAMILY variable in one place
2702const EXTRA_STYLES = `:host{--emoji-font-family:${FONT_FAMILY}}`;
2703
2704class PickerElement extends HTMLElement {
2705  constructor (props) {
2706    super();
2707    this.attachShadow({ mode: 'open' });
2708    const style = document.createElement('style');
2709    style.textContent = baseStyles + EXTRA_STYLES;
2710    this.shadowRoot.appendChild(style);
2711    this._ctx = {
2712      // Set defaults
2713      locale: DEFAULT_LOCALE,
2714      dataSource: DEFAULT_DATA_SOURCE,
2715      skinToneEmoji: DEFAULT_SKIN_TONE_EMOJI,
2716      customCategorySorting: DEFAULT_CATEGORY_SORTING,
2717      customEmoji: null,
2718      i18n: enI18n,
2719      emojiVersion: null,
2720      ...props
2721    };
2722    // Handle properties set before the element was upgraded
2723    for (const prop of PROPS) {
2724      if (prop !== 'database' && Object.prototype.hasOwnProperty.call(this, prop)) {
2725        this._ctx[prop] = this[prop];
2726        delete this[prop];
2727      }
2728    }
2729    this._dbFlush(); // wait for a flush before creating the db, in case the user calls e.g. a setter or setAttribute
2730  }
2731
2732  connectedCallback () {
2733    rescueElementPrototype(this);
2734    // The _cmp may be defined if the component was immediately disconnected and then reconnected. In that case,
2735    // do nothing (preserve the state)
2736    if (!this._cmp) {
2737      this._cmp = createRoot(this.shadowRoot, this._ctx);
2738    }
2739  }
2740
2741  disconnectedCallback () {
2742    rescueElementPrototype(this);
2743    // Check in a microtask if the element is still connected. If so, treat this as a "move" rather than a disconnect
2744    // Inspired by Vue: https://vuejs.org/guide/extras/web-components.html#building-custom-elements-with-vue
2745    qM(() => {
2746      // this._cmp may be defined if connect-disconnect-connect-disconnect occurs synchronously
2747      if (!this.isConnected && this._cmp) {
2748        this._cmp.$destroy();
2749        this._cmp = undefined;
2750
2751        const { database } = this._ctx;
2752        database.close()
2753          // only happens if the database failed to load in the first place, so we don't care
2754          .catch(err => console.error(err));
2755      }
2756    });
2757  }
2758
2759  static get observedAttributes () {
2760    return ['locale', 'data-source', 'skin-tone-emoji', 'emoji-version'] // complex objects aren't supported, also use kebab-case
2761  }
2762
2763  attributeChangedCallback (attrName, oldValue, newValue) {
2764    this._set(
2765      // convert from kebab-case to camelcase
2766      // see https://github.com/sveltejs/svelte/issues/3852#issuecomment-665037015
2767      attrName.replace(/-([a-z])/g, (_, up) => up.toUpperCase()),
2768      // convert string attribute to float if necessary
2769      attrName === 'emoji-version' ? parseFloat(newValue) : newValue
2770    );
2771  }
2772
2773  _set (prop, newValue) {
2774    this._ctx[prop] = newValue;
2775    if (this._cmp) {
2776      this._cmp.$set({ [prop]: newValue });
2777    }
2778    if (['locale', 'dataSource'].includes(prop)) {
2779      this._dbFlush();
2780    }
2781  }
2782
2783  _dbCreate () {
2784    const { locale, dataSource, database } = this._ctx;
2785    // only create a new database if we really need to
2786    if (!database || database.locale !== locale || database.dataSource !== dataSource) {
2787      this._set('database', new Database({ locale, dataSource }));
2788    }
2789  }
2790
2791  // Update the Database in one microtask if the locale/dataSource change. We do one microtask
2792  // so we don't create two Databases if e.g. both the locale and the dataSource change
2793  _dbFlush () {
2794    qM(() => (
2795      this._dbCreate()
2796    ));
2797  }
2798}
2799
2800const definitions = {};
2801
2802for (const prop of PROPS) {
2803  definitions[prop] = {
2804    get () {
2805      if (prop === 'database') {
2806        // in rare cases, the microtask may not be flushed yet, so we need to instantiate the DB
2807        // now if the user is asking for it
2808        this._dbCreate();
2809      }
2810      return this._ctx[prop]
2811    },
2812    set (val) {
2813      if (prop === 'database') {
2814        throw new Error('database is read-only')
2815      }
2816      this._set(prop, val);
2817    }
2818  };
2819}
2820
2821Object.defineProperties(PickerElement.prototype, definitions);
2822
2823// See https://jakearchibald.com/2025/firefox-custom-elements-iframes-bug/
2824// TODO: remove when the Firefox bug is fixed: https://bugzilla.mozilla.org/show_bug.cgi?id=1502814
2825function rescueElementPrototype (element) {
2826  /* istanbul ignore if */
2827  if (!(element instanceof PickerElement)) {
2828    Object.setPrototypeOf(element, customElements.get(element.tagName.toLowerCase()).prototype);
2829  }
2830}
2831
2832/* istanbul ignore else */
2833if (!customElements.get('emoji-picker')) { // if already defined, do nothing (e.g. same script imported twice)
2834  customElements.define('emoji-picker', PickerElement);
2835}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.