@ohos-ports/emoji-picker-element 1.29.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +1025 -0
  3. package/custom-elements.json +257 -0
  4. package/database.d.ts +108 -0
  5. package/database.js +1004 -0
  6. package/database.js.map +1 -0
  7. package/i18n/ar.d.ts +3 -0
  8. package/i18n/ar.js +34 -0
  9. package/i18n/de.d.ts +3 -0
  10. package/i18n/de.js +34 -0
  11. package/i18n/en.d.ts +3 -0
  12. package/i18n/en.js +34 -0
  13. package/i18n/es.d.ts +3 -0
  14. package/i18n/es.js +34 -0
  15. package/i18n/fa.d.ts +3 -0
  16. package/i18n/fa.js +34 -0
  17. package/i18n/fr.d.ts +3 -0
  18. package/i18n/fr.js +34 -0
  19. package/i18n/hi.d.ts +3 -0
  20. package/i18n/hi.js +34 -0
  21. package/i18n/hu.d.ts +3 -0
  22. package/i18n/hu.js +34 -0
  23. package/i18n/id.d.ts +3 -0
  24. package/i18n/id.js +34 -0
  25. package/i18n/it.d.ts +3 -0
  26. package/i18n/it.js +34 -0
  27. package/i18n/ja.d.ts +3 -0
  28. package/i18n/ja.js +34 -0
  29. package/i18n/ms_MY.d.ts +3 -0
  30. package/i18n/ms_MY.js +34 -0
  31. package/i18n/nl.d.ts +3 -0
  32. package/i18n/nl.js +34 -0
  33. package/i18n/pl.d.ts +3 -0
  34. package/i18n/pl.js +34 -0
  35. package/i18n/pt_BR.d.ts +3 -0
  36. package/i18n/pt_BR.js +34 -0
  37. package/i18n/pt_PT.d.ts +3 -0
  38. package/i18n/pt_PT.js +34 -0
  39. package/i18n/ru_RU.d.ts +3 -0
  40. package/i18n/ru_RU.js +34 -0
  41. package/i18n/tr.d.ts +3 -0
  42. package/i18n/tr.js +34 -0
  43. package/i18n/vi.d.ts +3 -0
  44. package/i18n/vi.js +34 -0
  45. package/i18n/zh_CN.d.ts +3 -0
  46. package/i18n/zh_CN.js +27 -0
  47. package/index.d.ts +7 -0
  48. package/index.js +6 -0
  49. package/package.json +196 -0
  50. package/picker.d.ts +30 -0
  51. package/picker.js +1836 -0
  52. package/picker.js.map +1 -0
  53. package/polyfill.js +62 -0
  54. package/shared.d.ts +99 -0
  55. package/svelte.js +4 -0
  56. package/trimEmojiData.cjs +54 -0
  57. package/trimEmojiData.cjs.map +1 -0
  58. package/trimEmojiData.d.ts +8 -0
  59. package/trimEmojiData.js +52 -0
  60. package/trimEmojiData.js.map +1 -0
package/picker.js ADDED
@@ -0,0 +1,1836 @@
1
+ import Database from './database.js';
2
+
3
+ // via https://unpkg.com/browse/emojibase-data@6.0.0/meta/groups.json
4
+ const allGroups = [
5
+ [-1, '✨', 'custom'],
6
+ [0, '😀', 'smileys-emotion'],
7
+ [1, '👋', 'people-body'],
8
+ [3, '🐱', 'animals-nature'],
9
+ [4, '🍎', 'food-drink'],
10
+ [5, '🏠️', 'travel-places'],
11
+ [6, '⚽', 'activities'],
12
+ [7, '📝', 'objects'],
13
+ [8, '⛔️', 'symbols'],
14
+ [9, '🏁', 'flags']
15
+ ].map(([id, emoji, name]) => ({ id, emoji, name }));
16
+
17
+ const groups = allGroups.slice(1);
18
+
19
+ const MIN_SEARCH_TEXT_LENGTH = 2;
20
+ const NUM_SKIN_TONES = 6;
21
+
22
+ /* istanbul ignore next */
23
+ const rIC = typeof requestIdleCallback === 'function' ? requestIdleCallback : setTimeout;
24
+
25
+ // check for ZWJ (zero width joiner) character
26
+ function hasZwj (emoji) {
27
+ return emoji.unicode.includes('\u200d')
28
+ }
29
+
30
+ // Find one good representative emoji from each version to test by checking its color.
31
+ // Ideally it should have color in the center. For some inspiration, see:
32
+ // https://about.gitlab.com/blog/2018/05/30/journey-in-native-unicode-emoji/
33
+ //
34
+ // Note that for certain versions (12.1, 13.1), there is no point in testing them explicitly, because
35
+ // all the emoji from this version are compound-emoji from previous versions. So they would pass a color
36
+ // test, even in browsers that display them as double emoji. (E.g. "face in clouds" might render as
37
+ // "face without mouth" plus "fog".) These emoji can only be filtered using the width test,
38
+ // which happens in checkZwjSupport.js.
39
+ const versionsAndTestEmoji = {
40
+ '🫪': 17, // distorted face
41
+ '🫩': 16, // face with bags under eyes
42
+ '🫨': 15.1, // shaking head, technically from v15 but see note above
43
+ '🫠': 14,
44
+ '🥲': 13.1, // smiling face with tear, technically from v13 but see note above
45
+ '🥻': 12.1, // sari, technically from v12 but see note above
46
+ '🥰': 11,
47
+ '🤩': 5,
48
+ '👱‍♀️': 4,
49
+ '🤣': 3,
50
+ '👁️‍🗨️': 2,
51
+ '😀': 1,
52
+ '😐️': 0.7,
53
+ '😃': 0.6
54
+ };
55
+
56
+ const TIMEOUT_BEFORE_LOADING_MESSAGE = 1000; // 1 second
57
+ const DEFAULT_SKIN_TONE_EMOJI = '🖐️';
58
+ const DEFAULT_NUM_COLUMNS = 8;
59
+
60
+ // Based on https://fivethirtyeight.com/features/the-100-most-used-emojis/ and
61
+ // https://blog.emojipedia.org/facebook-reveals-most-and-least-used-emojis/ with
62
+ // a bit of my own curation. (E.g. avoid the "OK" gesture because of connotations:
63
+ // https://emojipedia.org/ok-hand/)
64
+ const MOST_COMMONLY_USED_EMOJI = [
65
+ '😊',
66
+ '😒',
67
+ '❤️',
68
+ '👍️',
69
+ '😍',
70
+ '😂',
71
+ '😭',
72
+ '☺️',
73
+ '😔',
74
+ '😩',
75
+ '😏',
76
+ '💕',
77
+ '🙌',
78
+ '😘'
79
+ ];
80
+
81
+ // It's important to list Twemoji Mozilla before everything else, because Mozilla bundles their
82
+ // own font on some platforms (notably Windows and Linux as of this writing). Typically, Mozilla
83
+ // updates faster than the underlying OS, and we don't want to render older emoji in one font and
84
+ // newer emoji in another font:
85
+ // https://github.com/nolanlawson/emoji-picker-element/pull/268#issuecomment-1073347283
86
+ const FONT_FAMILY = '"Twemoji Mozilla","Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol",' +
87
+ '"Noto Color Emoji","EmojiOne Color","Android Emoji",sans-serif';
88
+
89
+ /* istanbul ignore next */
90
+ const DEFAULT_CATEGORY_SORTING = (a, b) => a < b ? -1 : a > b ? 1 : 0;
91
+
92
+ // Test if an emoji is supported by rendering it to canvas and checking that the color is not black
93
+ // See https://about.gitlab.com/blog/2018/05/30/journey-in-native-unicode-emoji/
94
+ // and https://www.npmjs.com/package/if-emoji for inspiration
95
+ // This implementation is largely borrowed from if-emoji, adding the font-family
96
+
97
+
98
+ const getTextFeature = (text, color) => {
99
+ const canvas = document.createElement('canvas');
100
+ canvas.width = canvas.height = 1;
101
+
102
+ const ctx = canvas.getContext('2d', {
103
+ // Improves the performance of `getImageData()`
104
+ // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getContextAttributes#willreadfrequently
105
+ willReadFrequently: true
106
+ });
107
+ ctx.textBaseline = 'top';
108
+ ctx.font = `100px ${FONT_FAMILY}`;
109
+ ctx.fillStyle = color;
110
+ ctx.scale(0.01, 0.01);
111
+ ctx.fillText(text, 0, 0);
112
+
113
+ return ctx.getImageData(0, 0, 1, 1).data
114
+ };
115
+
116
+ const compareFeatures = (feature1, feature2) => {
117
+ const feature1Str = [...feature1].join(',');
118
+ const feature2Str = [...feature2].join(',');
119
+ // This is RGBA, so for 0,0,0, we are checking that the first RGB is not all zeroes.
120
+ // Most of the time when unsupported this is 0,0,0,0, but on Chrome on Mac it is
121
+ // 0,0,0,61 - there is a transparency here.
122
+ return feature1Str === feature2Str && !feature1Str.startsWith('0,0,0,')
123
+ };
124
+
125
+ function testColorEmojiSupported (text) {
126
+ // Render white and black and then compare them to each other and ensure they're the same
127
+ // color, and neither one is black. This shows that the emoji was rendered in color.
128
+ const feature1 = getTextFeature(text, '#000');
129
+ const feature2 = getTextFeature(text, '#fff');
130
+ return feature1 && feature2 && compareFeatures(feature1, feature2)
131
+ }
132
+
133
+ // rather than check every emoji ever, which would be expensive, just check some representatives from the
134
+ // different emoji releases to determine what the font supports
135
+
136
+ function determineEmojiSupportLevel () {
137
+ const entries = Object.entries(versionsAndTestEmoji);
138
+ try {
139
+ // start with latest emoji and work backwards
140
+ for (const [emoji, version] of entries) {
141
+ if (testColorEmojiSupported(emoji)) {
142
+ return version
143
+ }
144
+ }
145
+ } catch (e) { // canvas error
146
+ } finally {
147
+ }
148
+ // In case of an error, be generous and just assume all emoji are supported (e.g. for canvas errors
149
+ // due to anti-fingerprinting add-ons). Better to show some gray boxes than nothing at all.
150
+ return entries[0][1] // first one in the list is the most recent version
151
+ }
152
+
153
+ // Check which emojis we know for sure aren't supported, based on Unicode version level
154
+ let promise;
155
+ const detectEmojiSupportLevel = () => {
156
+ if (!promise) {
157
+ // Delay so it can run while the IDB database is being created by the browser (on another thread).
158
+ // This helps especially with first load – we want to start pre-populating the database on the main thread,
159
+ // and then wait for IDB to commit everything, and while waiting we run this check.
160
+ promise = new Promise(resolve => (
161
+ rIC(() => (
162
+ resolve(determineEmojiSupportLevel()) // delay so ideally this can run while IDB is first populating
163
+ ))
164
+ ));
165
+ }
166
+ return promise
167
+ };
168
+ // determine which emojis containing ZWJ (zero width joiner) characters
169
+ // are supported (rendered as one glyph) rather than unsupported (rendered as two or more glyphs)
170
+ const supportedZwjEmojis = new Map();
171
+
172
+ const VARIATION_SELECTOR = '\ufe0f';
173
+ const SKINTONE_MODIFIER = '\ud83c';
174
+ const ZWJ = '\u200d';
175
+ const LIGHT_SKIN_TONE = 0x1F3FB;
176
+ const LIGHT_SKIN_TONE_MODIFIER = 0xdffb;
177
+
178
+ // TODO: this is a naive implementation, we can improve it later
179
+ // It's only used for the skintone picker, so as long as people don't customize with
180
+ // really exotic emoji then it should work fine
181
+ function applySkinTone (str, skinTone) {
182
+ if (skinTone === 0) {
183
+ return str
184
+ }
185
+ const zwjIndex = str.indexOf(ZWJ);
186
+ if (zwjIndex !== -1) {
187
+ return str.substring(0, zwjIndex) +
188
+ String.fromCodePoint(LIGHT_SKIN_TONE + skinTone - 1) +
189
+ str.substring(zwjIndex)
190
+ }
191
+ if (str.endsWith(VARIATION_SELECTOR)) {
192
+ str = str.substring(0, str.length - 1);
193
+ }
194
+ return str + SKINTONE_MODIFIER + String.fromCodePoint(LIGHT_SKIN_TONE_MODIFIER + skinTone - 1)
195
+ }
196
+
197
+ function halt (event) {
198
+ event.preventDefault();
199
+ event.stopPropagation();
200
+ }
201
+
202
+ // Implementation left/right or up/down navigation, circling back when you
203
+ // reach the start/end of the list
204
+ function incrementOrDecrement (decrement, val, arr) {
205
+ val += (decrement ? -1 : 1);
206
+ if (val < 0) {
207
+ val = arr.length - 1;
208
+ } else if (val >= arr.length) {
209
+ val = 0;
210
+ }
211
+ return val
212
+ }
213
+
214
+ // like lodash's uniqBy but much smaller
215
+ function uniqBy (arr, func) {
216
+ const set = new Set();
217
+ const res = [];
218
+ for (const item of arr) {
219
+ const key = func(item);
220
+ if (!set.has(key)) {
221
+ set.add(key);
222
+ res.push(item);
223
+ }
224
+ }
225
+ return res
226
+ }
227
+
228
+ // We don't need all the data on every emoji, and there are specific things we need
229
+ // for the UI, so build a "view model" from the emoji object we got from the database
230
+
231
+ function summarizeEmojisForUI (emojis, emojiSupportLevel) {
232
+ const toSimpleSkinsMap = skins => {
233
+ const res = {};
234
+ for (const skin of skins) {
235
+ // ignore arrays like [1, 2] with multiple skin tones
236
+ // also ignore variants that are in an unsupported emoji version
237
+ // (these do exist - variants from a different version than their base emoji)
238
+ if (typeof skin.tone === 'number' && skin.version <= emojiSupportLevel) {
239
+ res[skin.tone] = skin.unicode;
240
+ }
241
+ }
242
+ return res
243
+ };
244
+
245
+ return emojis.map(({ unicode, skins, shortcodes, url, name, category, annotation }) => ({
246
+ unicode,
247
+ name,
248
+ shortcodes,
249
+ url,
250
+ category,
251
+ annotation,
252
+ id: unicode || name,
253
+ skins: skins && toSimpleSkinsMap(skins)
254
+ }))
255
+ }
256
+
257
+ // import rAF from one place so that the bundle size is a bit smaller
258
+ const rAF = requestAnimationFrame;
259
+
260
+ // "Svelte action"-like utility to detect layout changes via ResizeObserver.
261
+ // If ResizeObserver is unsupported, we just use rAF once and don't bother to update.
262
+
263
+
264
+ let resizeObserverSupported = typeof ResizeObserver === 'function';
265
+
266
+ function resizeObserverAction (node, abortSignal, onUpdate) {
267
+ let resizeObserver;
268
+ if (resizeObserverSupported) {
269
+ resizeObserver = new ResizeObserver(onUpdate);
270
+ resizeObserver.observe(node);
271
+ } else { // just run once, don't bother trying to track it
272
+ rAF(onUpdate);
273
+ }
274
+
275
+ // cleanup function (called on destroy)
276
+ abortSignal.addEventListener('abort', () => {
277
+ if (resizeObserver) {
278
+ resizeObserver.disconnect();
279
+ }
280
+ });
281
+ }
282
+
283
+ // get the width of the text inside of a DOM node, via https://stackoverflow.com/a/59525891/680742
284
+ function calculateTextWidth (node) {
285
+ // skip running this in jest/vitest because we don't need to check for emoji support in that environment
286
+ /* istanbul ignore else */
287
+ {
288
+ const range = document.createRange();
289
+ range.selectNode(node.firstChild);
290
+ return range.getBoundingClientRect().width
291
+ }
292
+ }
293
+
294
+ const BASELINE_EMOJI = '😀';
295
+
296
+ let baselineEmojiWidth;
297
+ let fallbackNode;
298
+
299
+ function calculateTextWidthWithFallback (unicode, domNode, baselineEmojiNode) {
300
+ const result = calculateTextWidth(domNode);
301
+ /* istanbul ignore if */
302
+ if (!result) {
303
+ // If result is 0 then very likely the emoji-picker has `display:none` or equivalent. In that case, we fall back to
304
+ // cloning the baseline emoji, putting that in the `document.body`, and measuring that instead. This is a perf hit,
305
+ // but it's better than mistakenly filtering emoji: https://github.com/nolanlawson/emoji-picker-element/issues/514
306
+ if (!fallbackNode) {
307
+ fallbackNode = baselineEmojiNode.cloneNode(true);
308
+ // We have to copy styles because we're copying from an element in the shadow DOM to the light DOM
309
+ // We can't use the shadow DOM because it's likely the entire picker is `display:none`
310
+ const styles = getComputedStyle(baselineEmojiNode);
311
+ // probably don't need display/align-items/justify-content but let's play it safe
312
+ for (const prop of ['font-family', 'line-height', 'width', 'height', 'font-size', 'display', 'align-items', 'justify-content']) {
313
+ // set `!important` just in case some global styles might try to overwrite this
314
+ fallbackNode.style.setProperty(prop, styles.getPropertyValue(prop), 'important');
315
+ }
316
+ }
317
+ try {
318
+ document.body.appendChild(fallbackNode);
319
+ fallbackNode.firstChild.nodeValue = unicode;
320
+ return calculateTextWidth(fallbackNode)
321
+ } finally {
322
+ // avoid actually rendering the test emoji
323
+ fallbackNode.remove();
324
+ }
325
+ }
326
+ return result
327
+ }
328
+
329
+ /**
330
+ * Check if the given emojis containing ZWJ characters are supported by the current browser (don't render
331
+ * as double characters) and return true if all are supported.
332
+ * @param zwjEmojisToCheck
333
+ * @param baselineEmojiNode
334
+ * @param emojiToDomNode
335
+ */
336
+ function checkZwjSupport (zwjEmojisToCheck, baselineEmojiNode, emojiToDomNode) {
337
+ let allSupported = true;
338
+ for (const emoji of zwjEmojisToCheck) {
339
+ const domNode = emojiToDomNode(emoji);
340
+ // sanity check to make sure the node is defined properly
341
+ /* istanbul ignore if */
342
+ if (!domNode) {
343
+ // This is a race condition that can occur when the component is unmounted/remounted
344
+ // It doesn't really matter what we do here since the old context is not going to render anymore.
345
+ // Just bail out of emoji support detection and return `allSupported=true` since the rendering context is gone
346
+ continue
347
+ }
348
+ if (typeof baselineEmojiWidth === 'undefined') { // calculate the baseline emoji width only once
349
+ baselineEmojiWidth = calculateTextWidthWithFallback(BASELINE_EMOJI, baselineEmojiNode, baselineEmojiNode);
350
+ }
351
+ const emojiWidth = calculateTextWidthWithFallback(emoji.unicode, domNode, baselineEmojiNode);
352
+ // On Windows, some supported emoji are ~50% bigger than the baseline emoji, but what we really want to guard
353
+ // against are the ones that are 2x the size, because those are truly broken (person with red hair = person with
354
+ // floating red wig, black cat = cat with black square, polar bear = bear with snowflake, etc.)
355
+ // So here we set the threshold at 1.8 times the size of the baseline emoji.
356
+ const supported = emojiWidth / 1.8 < baselineEmojiWidth;
357
+ supportedZwjEmojis.set(emoji.unicode, supported);
358
+
359
+ if (!supported) {
360
+ allSupported = false;
361
+ }
362
+ }
363
+ return allSupported
364
+ }
365
+
366
+ // like lodash's uniq
367
+
368
+ function uniq (arr) {
369
+ return uniqBy(arr, _ => _)
370
+ }
371
+
372
+ // Note we put this in its own function outside Picker.js to avoid Svelte doing an invalidation on the "setter" here.
373
+ // At best the invalidation is useless, at worst it can cause infinite loops:
374
+ // https://github.com/nolanlawson/emoji-picker-element/pull/180
375
+ // https://github.com/sveltejs/svelte/issues/6521
376
+ // Also note tabpanelElement can be null if the element is disconnected immediately after connected
377
+ function resetScrollTopIfPossible (element) {
378
+ /* istanbul ignore else */
379
+ if (element) { // Makes me nervous not to have this `if` guard
380
+ element.scrollTop = 0;
381
+ }
382
+ }
383
+
384
+ function getFromMap (cache, key, func) {
385
+ let cached = cache.get(key);
386
+ if (!cached) {
387
+ cached = func();
388
+ cache.set(key, cached);
389
+ }
390
+ return cached
391
+ }
392
+
393
+ function toString (value) {
394
+ return '' + value
395
+ }
396
+
397
+ function parseTemplate (htmlString) {
398
+ const template = document.createElement('template');
399
+ template.innerHTML = htmlString;
400
+ return template
401
+ }
402
+
403
+ const parseCache = new WeakMap();
404
+ const domInstancesCache = new WeakMap();
405
+ // This needs to be a symbol because it needs to be different from any possible output of a key function
406
+ const unkeyedSymbol = Symbol('un-keyed');
407
+
408
+ // Not supported in Safari <=13
409
+ const hasReplaceChildren = 'replaceChildren' in Element.prototype;
410
+ function replaceChildren (parentNode, newChildren) {
411
+ /* istanbul ignore else */
412
+ if (hasReplaceChildren) {
413
+ parentNode.replaceChildren(...newChildren);
414
+ } else { // minimal polyfill for Element.prototype.replaceChildren
415
+ parentNode.innerHTML = '';
416
+ parentNode.append(...newChildren);
417
+ }
418
+ }
419
+
420
+ function doChildrenNeedRerender (parentNode, newChildren) {
421
+ let oldChild = parentNode.firstChild;
422
+ let oldChildrenCount = 0;
423
+ // iterate using firstChild/nextSibling because browsers use a linked list under the hood
424
+ while (oldChild) {
425
+ const newChild = newChildren[oldChildrenCount];
426
+ // check if the old child and new child are the same
427
+ if (newChild !== oldChild) {
428
+ return true
429
+ }
430
+ oldChild = oldChild.nextSibling;
431
+ oldChildrenCount++;
432
+ }
433
+ // if new children length is different from old, we must re-render
434
+ return oldChildrenCount !== newChildren.length
435
+ }
436
+
437
+ function patchChildren (newChildren, instanceBinding) {
438
+ const { targetNode } = instanceBinding;
439
+ let { targetParentNode } = instanceBinding;
440
+
441
+ let needsRerender = false;
442
+
443
+ if (targetParentNode) { // already rendered once
444
+ needsRerender = doChildrenNeedRerender(targetParentNode, newChildren);
445
+ } else { // first render of list
446
+ needsRerender = true;
447
+ instanceBinding.targetNode = undefined; // placeholder node not needed anymore, free memory
448
+ instanceBinding.targetParentNode = targetParentNode = targetNode.parentNode;
449
+ }
450
+ // avoid re-rendering list if the dom nodes are exactly the same before and after
451
+ if (needsRerender) {
452
+ replaceChildren(targetParentNode, newChildren);
453
+ }
454
+ }
455
+
456
+ function patch (expressions, instanceBindings) {
457
+ for (const instanceBinding of instanceBindings) {
458
+ const {
459
+ targetNode,
460
+ currentExpression,
461
+ binding: {
462
+ expressionIndex,
463
+ attributeName,
464
+ attributeValuePre,
465
+ attributeValuePost
466
+ }
467
+ } = instanceBinding;
468
+
469
+ const expression = expressions[expressionIndex];
470
+
471
+ if (currentExpression === expression) {
472
+ // no need to update, same as before
473
+ continue
474
+ }
475
+
476
+ instanceBinding.currentExpression = expression;
477
+
478
+ if (attributeName) { // attribute replacement
479
+ if (expression === null) {
480
+ // null is treated as a special case by the framework - we don't render an attribute at all in this case
481
+ targetNode.removeAttribute(attributeName);
482
+ } else {
483
+ // attribute value is not null; set a new attribute
484
+ const newValue = attributeValuePre + toString(expression) + attributeValuePost;
485
+ targetNode.setAttribute(attributeName, newValue);
486
+ }
487
+ } else { // text node / child element / children replacement
488
+ let newNode;
489
+ if (Array.isArray(expression)) { // array of DOM elements produced by tag template literals
490
+ patchChildren(expression, instanceBinding);
491
+ } else if (expression instanceof Element) { // html tag template returning a DOM element
492
+ newNode = expression;
493
+ targetNode.replaceWith(newNode);
494
+ } else { // primitive - string, number, etc
495
+ // nodeValue is faster than textContent supposedly https://www.youtube.com/watch?v=LY6y3HbDVmg
496
+ // note we may be replacing the value in a placeholder text node
497
+ targetNode.nodeValue = toString(expression);
498
+ }
499
+ if (newNode) {
500
+ instanceBinding.targetNode = newNode;
501
+ }
502
+ }
503
+ }
504
+ }
505
+
506
+ function parse (tokens) {
507
+ let htmlString = '';
508
+
509
+ let withinTag = false;
510
+ let withinAttribute = false;
511
+ let elementIndexCounter = -1; // depth-first traversal order
512
+
513
+ const elementsToBindings = new Map();
514
+ const elementIndexes = [];
515
+
516
+ let skipTokenChars = 0;
517
+ for (let i = 0, len = tokens.length; i < len; i++) {
518
+ const token = tokens[i];
519
+ htmlString += token.slice(skipTokenChars);
520
+
521
+ if (i === len - 1) {
522
+ break // no need to process characters - no more expressions to be found
523
+ }
524
+
525
+ for (let j = 0; j < token.length; j++) {
526
+ const char = token.charAt(j);
527
+ switch (char) {
528
+ case '<': {
529
+ const nextChar = token.charAt(j + 1);
530
+ if (nextChar === '/') { // closing tag
531
+ // leaving an element
532
+ elementIndexes.pop();
533
+ } else { // not a closing tag
534
+ withinTag = true;
535
+ elementIndexes.push(++elementIndexCounter);
536
+ }
537
+ break
538
+ }
539
+ case '>': {
540
+ withinTag = false;
541
+ withinAttribute = false;
542
+ break
543
+ }
544
+ case '=': {
545
+ withinAttribute = true;
546
+ break
547
+ }
548
+ }
549
+ }
550
+
551
+ const elementIndex = elementIndexes[elementIndexes.length - 1];
552
+ const bindings = getFromMap(elementsToBindings, elementIndex, () => []);
553
+
554
+ let attributeName;
555
+ let attributeValuePre;
556
+ let attributeValuePost;
557
+ if (withinAttribute) {
558
+ // I never use single-quotes for attribute values in HTML, so just support double-quotes or no-quotes
559
+ const attributePreMatch = /(\S+)="?([^"=]*)$/.exec(token);
560
+ attributeName = attributePreMatch[1];
561
+ attributeValuePre = attributePreMatch[2];
562
+ const attributePostMatch = /^([^">]*)("?)/.exec(tokens[i + 1]);
563
+ attributeValuePost = attributePostMatch[1];
564
+ // Optimization: remove the attribute itself, so we don't create a default attribute which is either empty or just
565
+ // the "pre" text, e.g. `<div foo>` or `<div foo="prefix">`. It will be replaced by the expression anyway.
566
+ htmlString = htmlString.slice(0, -1 * attributePreMatch[0].length);
567
+ skipTokenChars = attributePostMatch[0].length;
568
+ } else {
569
+ skipTokenChars = 0;
570
+ }
571
+
572
+ const binding = {
573
+ attributeName,
574
+ attributeValuePre,
575
+ attributeValuePost,
576
+ expressionIndex: i
577
+ };
578
+
579
+ bindings.push(binding);
580
+
581
+ if (!withinTag && !withinAttribute) {
582
+ // Add a placeholder text node, so we can find it later. Note we only support one dynamic child text node
583
+ htmlString += ' ';
584
+ }
585
+ }
586
+
587
+ const template = parseTemplate(htmlString);
588
+
589
+ return {
590
+ template,
591
+ elementsToBindings
592
+ }
593
+ }
594
+
595
+ function applyBindings (bindings, element, instanceBindings) {
596
+ for (let i = 0; i < bindings.length; i++) {
597
+ const binding = bindings[i];
598
+
599
+ const targetNode = binding.attributeName
600
+ ? element // attribute binding, just use the element itself
601
+ : element.firstChild; // not an attribute binding, so has a placeholder text node
602
+
603
+ const instanceBinding = {
604
+ binding,
605
+ targetNode,
606
+ targetParentNode: undefined,
607
+ currentExpression: undefined
608
+ };
609
+
610
+ instanceBindings.push(instanceBinding);
611
+ }
612
+ }
613
+
614
+ function traverseAndSetupBindings (rootElement, elementsToBindings) {
615
+ const instanceBindings = [];
616
+
617
+ let topLevelBindings;
618
+ if (elementsToBindings.size === 1 && (topLevelBindings = elementsToBindings.get(0))) {
619
+ // Optimization for the common case where there's only one element and one binding
620
+ // Skip creating a TreeWalker entirely and just handle the root DOM element
621
+ applyBindings(topLevelBindings, rootElement, instanceBindings);
622
+ } else {
623
+ // traverse dom
624
+ const treeWalker = document.createTreeWalker(rootElement, NodeFilter.SHOW_ELEMENT);
625
+
626
+ let element = rootElement;
627
+ let elementIndex = -1;
628
+ do {
629
+ const bindings = elementsToBindings.get(++elementIndex);
630
+ if (bindings) {
631
+ applyBindings(bindings, element, instanceBindings);
632
+ }
633
+ } while ((element = treeWalker.nextNode()))
634
+ }
635
+
636
+ return instanceBindings
637
+ }
638
+
639
+ function parseHtml (tokens) {
640
+ // All templates and bound expressions are unique per tokens array
641
+ const { template, elementsToBindings } = getFromMap(parseCache, tokens, () => parse(tokens));
642
+
643
+ // When we parseHtml, we always return a fresh DOM instance ready to be updated
644
+ const dom = template.cloneNode(true).content.firstElementChild;
645
+ const instanceBindings = traverseAndSetupBindings(dom, elementsToBindings);
646
+
647
+ return function updateDomInstance (expressions) {
648
+ patch(expressions, instanceBindings);
649
+ return dom
650
+ }
651
+ }
652
+
653
+ function createFramework (state) {
654
+ const domInstances = getFromMap(domInstancesCache, state, () => new Map());
655
+ let domInstanceCacheKey = unkeyedSymbol;
656
+
657
+ function html (tokens, ...expressions) {
658
+ // Each unique lexical usage of map() is considered unique due to the html`` tagged template call it makes,
659
+ // which has lexically unique tokens. The unkeyed symbol is just used for html`` usage outside of a map().
660
+ const domInstancesForTokens = getFromMap(domInstances, tokens, () => new Map());
661
+ const updateDomInstance = getFromMap(domInstancesForTokens, domInstanceCacheKey, () => parseHtml(tokens));
662
+
663
+ return updateDomInstance(expressions) // update with expressions
664
+ }
665
+
666
+ function map (array, callback, keyFunction) {
667
+ return array.map((item, index) => {
668
+ const originalCacheKey = domInstanceCacheKey;
669
+ domInstanceCacheKey = keyFunction(item);
670
+ try {
671
+ return callback(item, index)
672
+ } finally {
673
+ domInstanceCacheKey = originalCacheKey;
674
+ }
675
+ })
676
+ }
677
+
678
+ return { map, html }
679
+ }
680
+
681
+ function render (container, state, helpers, events, actions, refs, abortSignal, actionContext, firstRender) {
682
+ const { labelWithSkin, titleForEmoji, unicodeWithSkin } = helpers;
683
+ const { html, map } = createFramework(state);
684
+
685
+ function emojiList (emojis, searchMode, prefix) {
686
+ return map(emojis, (emoji, i) => {
687
+ return html`<button role="${searchMode ? 'option' : 'menuitem'}" aria-selected="${searchMode ? i === state.activeSearchItem : null}" aria-label="${labelWithSkin(emoji, state.currentSkinTone)}" title="${titleForEmoji(emoji)}" class="${
688
+ 'emoji' +
689
+ (searchMode && i === state.activeSearchItem ? ' active' : '') +
690
+ (emoji.unicode ? '' : ' custom-emoji')
691
+ }" id="${`${prefix}-${emoji.id}`}" style="${emoji.unicode ? null : `--custom-emoji-background: url(${JSON.stringify(emoji.url)})`}">${
692
+ emoji.unicode
693
+ ? unicodeWithSkin(emoji, state.currentSkinTone)
694
+ : ''
695
+ }</button>`
696
+ // It's important for the cache key to be unique based on the prefix, because the framework caches based on the
697
+ // unique tokens + cache key, and the same emoji may be used in the tab as well as in the fav bar
698
+ }, emoji => `${prefix}-${emoji.id}`)
699
+ }
700
+
701
+ const section = () => {
702
+ 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">${
703
+ map(state.skinTones, (skinTone, i) => {
704
+ 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>`
705
+ }, skinTone => skinTone)
706
+ }</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">${
707
+ map(state.groups, (group) => {
708
+ 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>`
709
+ }, group => group.id)
710
+ }</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">${
711
+ map(state.currentEmojisWithCategories, (emojiWithCategory, i) => {
712
+ return html`<div><div id="menu-label-${i}" class="category ${state.currentEmojisWithCategories.length === 1 && state.currentEmojisWithCategories[0].category === '' ? 'gone' : ''}" aria-hidden="true">${
713
+ state.searchMode
714
+ ? state.i18n.searchResultsLabel
715
+ : (
716
+ emojiWithCategory.category
717
+ ? emojiWithCategory.category
718
+ : (
719
+ state.currentEmojisWithCategories.length > 1
720
+ ? state.i18n.categories.custom
721
+ : state.i18n.categories[state.currentGroup.name]
722
+ )
723
+ )
724
+ }</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}">${
725
+ emojiList(emojiWithCategory.emojis, state.searchMode, /* prefix */ 'emo')
726
+ }</div></div>`
727
+ }, emojiWithCategory => emojiWithCategory.category)
728
+ }</div></div><div class="favorites onscreen emoji-menu ${state.message ? 'gone' : ''}" role="menu" aria-label="${state.i18n.favoritesLabel}" data-on-click="onEmojiClick">${
729
+ emojiList(state.currentFavorites, /* searchMode */ false, /* prefix */ 'fav')
730
+ }</div><button data-ref="baselineEmoji" aria-hidden="true" tabindex="-1" class="abs-pos hidden emoji baseline-emoji">😀</button></section>`
731
+ };
732
+
733
+ const rootDom = section();
734
+
735
+ // helper for traversing the dom, finding elements by an attribute, and getting the attribute value
736
+ const forElementWithAttribute = (attributeName, callback) => {
737
+ for (const element of container.querySelectorAll(`[${attributeName}]`)) {
738
+ callback(element, element.getAttribute(attributeName));
739
+ }
740
+ };
741
+
742
+ if (firstRender) { // not a re-render
743
+ container.appendChild(rootDom);
744
+
745
+ // we only bind events/refs once - there is no need to find them again given this component structure
746
+
747
+ // bind events
748
+ for (const eventName of ['click', 'focusout', 'input', 'keydown', 'keyup']) {
749
+ forElementWithAttribute(`data-on-${eventName}`, (element, listenerName) => {
750
+ element.addEventListener(eventName, events[listenerName]);
751
+ });
752
+ }
753
+
754
+ // find refs
755
+ forElementWithAttribute('data-ref', (element, ref) => {
756
+ refs[ref] = element;
757
+ });
758
+
759
+ // destroy/abort logic
760
+ abortSignal.addEventListener('abort', () => {
761
+ container.removeChild(rootDom);
762
+ });
763
+ }
764
+
765
+ // set up actions - these are re-bound on every render
766
+ forElementWithAttribute('data-action', (element, action) => {
767
+ let boundActions = actionContext.get(action);
768
+ if (!boundActions) {
769
+ actionContext.set(action, (boundActions = new WeakSet()));
770
+ }
771
+
772
+ // avoid applying the same action to the same element multiple times
773
+ if (!boundActions.has(element)) {
774
+ boundActions.add(element);
775
+ actions[action](element);
776
+ }
777
+ });
778
+ }
779
+
780
+ /* istanbul ignore next */
781
+ const qM = typeof queueMicrotask === 'function' ? queueMicrotask : callback => Promise.resolve().then(callback);
782
+
783
+ function createState (abortSignal) {
784
+ let destroyed = false;
785
+ let currentObserver;
786
+
787
+ const propsToObservers = new Map();
788
+ const dirtyObservers = new Set();
789
+
790
+ let queued;
791
+
792
+ const flush = () => {
793
+ if (destroyed) {
794
+ return
795
+ }
796
+ const observersToRun = [...dirtyObservers];
797
+ dirtyObservers.clear(); // clear before running to force any new updates to run in another tick of the loop
798
+ try {
799
+ for (const observer of observersToRun) {
800
+ observer();
801
+ }
802
+ } finally {
803
+ queued = false;
804
+ if (dirtyObservers.size) { // new updates, queue another one
805
+ queued = true;
806
+ qM(flush);
807
+ }
808
+ }
809
+ };
810
+
811
+ const state = new Proxy({}, {
812
+ get (target, prop) {
813
+ if (currentObserver) {
814
+ let observers = propsToObservers.get(prop);
815
+ if (!observers) {
816
+ observers = new Set();
817
+ propsToObservers.set(prop, observers);
818
+ }
819
+ observers.add(currentObserver);
820
+ }
821
+ return target[prop]
822
+ },
823
+ set (target, prop, newValue) {
824
+ if (target[prop] !== newValue) {
825
+ target[prop] = newValue;
826
+ const observers = propsToObservers.get(prop);
827
+ if (observers) {
828
+ for (const observer of observers) {
829
+ dirtyObservers.add(observer);
830
+ }
831
+ if (!queued) {
832
+ queued = true;
833
+ qM(flush);
834
+ }
835
+ }
836
+ }
837
+ return true
838
+ }
839
+ });
840
+
841
+ const createEffect = (callback) => {
842
+ const runnable = () => {
843
+ const oldObserver = currentObserver;
844
+ currentObserver = runnable;
845
+ try {
846
+ return callback()
847
+ } finally {
848
+ currentObserver = oldObserver;
849
+ }
850
+ };
851
+ return runnable()
852
+ };
853
+
854
+ // destroy logic
855
+ abortSignal.addEventListener('abort', () => {
856
+ destroyed = true;
857
+ });
858
+
859
+ return {
860
+ state,
861
+ createEffect
862
+ }
863
+ }
864
+
865
+ // Compare two arrays, with a function called on each item in the two arrays that returns true if the items are equal
866
+ function arraysAreEqualByFunction (left, right, areEqualFunc) {
867
+ if (left.length !== right.length) {
868
+ return false
869
+ }
870
+ for (let i = 0; i < left.length; i++) {
871
+ if (!areEqualFunc(left[i], right[i])) {
872
+ return false
873
+ }
874
+ }
875
+ return true
876
+ }
877
+
878
+ const intersectionObserverCache = new WeakMap();
879
+
880
+ function intersectionObserverAction (node, abortSignal, listener) {
881
+ /* istanbul ignore else */
882
+ {
883
+ // The scroll root is always `.tabpanel`
884
+ const root = node.closest('.tabpanel');
885
+
886
+ let observer = intersectionObserverCache.get(root);
887
+ if (!observer) {
888
+ // TODO: replace this with the contentvisibilityautostatechange event when all supported browsers support it.
889
+ // For now we use IntersectionObserver because it has better cross-browser support, and it would be bad for
890
+ // old Safari versions if they eagerly downloaded all custom emoji all at once.
891
+ observer = new IntersectionObserver(listener, {
892
+ root,
893
+ // trigger if we are 1/2 scroll container height away so that the images load a bit quicker while scrolling
894
+ rootMargin: '50% 0px 50% 0px',
895
+ // trigger if any part of the emoji grid is intersecting
896
+ threshold: 0
897
+ });
898
+
899
+ // avoid creating a new IntersectionObserver for every category; just use one for the whole root
900
+ intersectionObserverCache.set(root, observer);
901
+
902
+ // assume that the abortSignal is always the same for this root node; just add one event listener
903
+ abortSignal.addEventListener('abort', () => {
904
+ observer.disconnect();
905
+ });
906
+ }
907
+
908
+ observer.observe(node);
909
+ }
910
+ }
911
+
912
+ /* eslint-disable prefer-const,no-labels,no-inner-declarations */
913
+
914
+ // constants
915
+ const EMPTY_ARRAY = [];
916
+
917
+ const { assign } = Object;
918
+
919
+ function createRoot (shadowRoot, props) {
920
+ const refs = {};
921
+ const abortController = new AbortController();
922
+ const abortSignal = abortController.signal;
923
+ const { state, createEffect } = createState(abortSignal);
924
+ const actionContext = new Map();
925
+
926
+ // initial state
927
+ assign(state, {
928
+ skinToneEmoji: undefined,
929
+ i18n: undefined,
930
+ database: undefined,
931
+ customEmoji: undefined,
932
+ customCategorySorting: undefined,
933
+ emojiVersion: undefined
934
+ });
935
+
936
+ // public props
937
+ assign(state, props);
938
+
939
+ // private props
940
+ assign(state, {
941
+ initialLoad: true,
942
+ currentEmojis: [],
943
+ currentEmojisWithCategories: [],
944
+ rawSearchText: '',
945
+ searchText: '',
946
+ searchMode: false,
947
+ activeSearchItem: -1,
948
+ message: undefined,
949
+ skinTonePickerExpanded: false,
950
+ skinTonePickerExpandedAfterAnimation: false,
951
+ currentSkinTone: 0,
952
+ activeSkinTone: 0,
953
+ skinToneButtonText: undefined,
954
+ pickerStyle: undefined,
955
+ skinToneButtonLabel: '',
956
+ skinTones: [],
957
+ currentFavorites: [],
958
+ defaultFavoriteEmojis: undefined,
959
+ numColumns: DEFAULT_NUM_COLUMNS,
960
+ isRtl: false,
961
+ currentGroupIndex: 0,
962
+ groups: groups,
963
+ databaseLoaded: false,
964
+ activeSearchItemId: undefined
965
+ });
966
+
967
+ //
968
+ // Update the current group based on the currentGroupIndex
969
+ //
970
+ createEffect(() => {
971
+ if (state.currentGroup !== state.groups[state.currentGroupIndex]) {
972
+ state.currentGroup = state.groups[state.currentGroupIndex];
973
+ }
974
+ });
975
+
976
+ //
977
+ // Utils/helpers
978
+ //
979
+
980
+ const focus = id => {
981
+ shadowRoot.getElementById(id).focus();
982
+ };
983
+
984
+ const emojiToDomNode = emoji => shadowRoot.getElementById(`emo-${emoji.id}`);
985
+
986
+ // fire a custom event that crosses the shadow boundary
987
+ const fireEvent = (name, detail) => {
988
+ refs.rootElement.dispatchEvent(new CustomEvent(name, {
989
+ detail,
990
+ bubbles: true,
991
+ composed: true
992
+ }));
993
+ };
994
+
995
+ //
996
+ // Comparison utils
997
+ //
998
+
999
+ const compareEmojiArrays = (a, b) => a.id === b.id;
1000
+
1001
+ const compareCurrentEmojisWithCategories = (a, b) => {
1002
+ const { category: aCategory, emojis: aEmojis } = a;
1003
+ const { category: bCategory, emojis: bEmojis } = b;
1004
+
1005
+ if (aCategory !== bCategory) {
1006
+ return false
1007
+ }
1008
+
1009
+ return arraysAreEqualByFunction(aEmojis, bEmojis, compareEmojiArrays)
1010
+ };
1011
+
1012
+ //
1013
+ // Update utils to avoid excessive re-renders
1014
+ //
1015
+
1016
+ // avoid excessive re-renders by checking the value before setting
1017
+ const updateCurrentEmojis = (newEmojis) => {
1018
+ if (!arraysAreEqualByFunction(state.currentEmojis, newEmojis, compareEmojiArrays)) {
1019
+ state.currentEmojis = newEmojis;
1020
+ }
1021
+ };
1022
+
1023
+ // avoid excessive re-renders
1024
+ const updateSearchMode = (newSearchMode) => {
1025
+ if (state.searchMode !== newSearchMode) {
1026
+ state.searchMode = newSearchMode;
1027
+ }
1028
+ };
1029
+
1030
+ // avoid excessive re-renders
1031
+ const updateCurrentEmojisWithCategories = (newEmojisWithCategories) => {
1032
+ if (!arraysAreEqualByFunction(state.currentEmojisWithCategories, newEmojisWithCategories, compareCurrentEmojisWithCategories)) {
1033
+ state.currentEmojisWithCategories = newEmojisWithCategories;
1034
+ }
1035
+ };
1036
+
1037
+ // Helpers used by PickerTemplate
1038
+
1039
+ const unicodeWithSkin = (emoji, currentSkinTone) => (
1040
+ (currentSkinTone && emoji.skins && emoji.skins[currentSkinTone]) || emoji.unicode
1041
+ );
1042
+
1043
+ const labelWithSkin = (emoji, currentSkinTone) => (
1044
+ uniq([
1045
+ (emoji.name || unicodeWithSkin(emoji, currentSkinTone)),
1046
+ emoji.annotation,
1047
+ ...(emoji.shortcodes || EMPTY_ARRAY)
1048
+ ].filter(Boolean)).join(', ')
1049
+ );
1050
+
1051
+ const titleForEmoji = (emoji) => (
1052
+ emoji.annotation || (emoji.shortcodes || EMPTY_ARRAY).join(', ')
1053
+ );
1054
+
1055
+ const helpers = {
1056
+ labelWithSkin, titleForEmoji, unicodeWithSkin
1057
+ };
1058
+ const events = {
1059
+ onClickSkinToneButton,
1060
+ onEmojiClick,
1061
+ onNavClick,
1062
+ onNavKeydown,
1063
+ onSearchKeydown,
1064
+ onSkinToneOptionsClick,
1065
+ onSkinToneOptionsFocusOut,
1066
+ onSkinToneOptionsKeydown,
1067
+ onSkinToneOptionsKeyup,
1068
+ onSearchInput
1069
+ };
1070
+ const actions = {
1071
+ calculateEmojiGridStyle,
1072
+ updateOnIntersection
1073
+ };
1074
+
1075
+ let firstRender = true;
1076
+ createEffect(() => {
1077
+ render(shadowRoot, state, helpers, events, actions, refs, abortSignal, actionContext, firstRender);
1078
+ firstRender = false;
1079
+ });
1080
+
1081
+ //
1082
+ // Determine the emoji support level (in requestIdleCallback)
1083
+ //
1084
+
1085
+ // mount logic
1086
+ if (!state.emojiVersion) {
1087
+ detectEmojiSupportLevel().then(level => {
1088
+ // Can't actually test emoji support in Jest/Vitest/JSDom, emoji never render in color in Cairo
1089
+ /* istanbul ignore next */
1090
+ if (!level) {
1091
+ state.message = state.i18n.emojiUnsupportedMessage;
1092
+ }
1093
+ });
1094
+ }
1095
+
1096
+ //
1097
+ // Set or update the database object
1098
+ //
1099
+
1100
+ createEffect(() => {
1101
+ // show a Loading message if it takes a long time, or show an error if there's a network/IDB error
1102
+ async function handleDatabaseLoading () {
1103
+ let showingLoadingMessage = false;
1104
+ const timeoutHandle = setTimeout(() => {
1105
+ showingLoadingMessage = true;
1106
+ state.message = state.i18n.loadingMessage;
1107
+ }, TIMEOUT_BEFORE_LOADING_MESSAGE);
1108
+ try {
1109
+ await state.database.ready();
1110
+ state.databaseLoaded = true; // eslint-disable-line no-unused-vars
1111
+ } catch (err) {
1112
+ console.error(err);
1113
+ state.message = state.i18n.networkErrorMessage;
1114
+ } finally {
1115
+ clearTimeout(timeoutHandle);
1116
+ if (showingLoadingMessage) { // Seems safer than checking the i18n string, which may change
1117
+ showingLoadingMessage = false;
1118
+ state.message = ''; // eslint-disable-line no-unused-vars
1119
+ }
1120
+ }
1121
+ }
1122
+
1123
+ if (state.database) {
1124
+ /* no await */
1125
+ handleDatabaseLoading();
1126
+ }
1127
+ });
1128
+
1129
+ //
1130
+ // Global styles for the entire picker
1131
+ //
1132
+
1133
+ createEffect(() => {
1134
+ state.pickerStyle = `
1135
+ --num-groups: ${state.groups.length};
1136
+ --indicator-opacity: ${state.searchMode ? 0 : 1};
1137
+ --num-skintones: ${NUM_SKIN_TONES};`;
1138
+ });
1139
+
1140
+ //
1141
+ // Set or update the customEmoji
1142
+ //
1143
+
1144
+ createEffect(() => {
1145
+ if (state.customEmoji && state.database) {
1146
+ updateCustomEmoji(); // re-run whenever customEmoji change
1147
+ }
1148
+ });
1149
+
1150
+ createEffect(() => {
1151
+ if (state.customEmoji && state.customEmoji.length) {
1152
+ if (state.groups !== allGroups) { // don't update unnecessarily
1153
+ state.groups = allGroups;
1154
+ }
1155
+ } else if (state.groups !== groups) {
1156
+ if (state.currentGroupIndex) {
1157
+ // If the current group is anything other than "custom" (which is first), decrement.
1158
+ // This fixes the odd case where you set customEmoji, then pick a category, then unset customEmoji
1159
+ state.currentGroupIndex--;
1160
+ }
1161
+ state.groups = groups;
1162
+ }
1163
+ });
1164
+
1165
+ //
1166
+ // Set or update the preferred skin tone
1167
+ //
1168
+
1169
+ createEffect(() => {
1170
+ async function updatePreferredSkinTone () {
1171
+ if (state.databaseLoaded) {
1172
+ state.currentSkinTone = await state.database.getPreferredSkinTone();
1173
+ }
1174
+ }
1175
+
1176
+ /* no await */ updatePreferredSkinTone();
1177
+ });
1178
+
1179
+ createEffect(() => {
1180
+ state.skinTones = Array(NUM_SKIN_TONES).fill().map((_, i) => applySkinTone(state.skinToneEmoji, i));
1181
+ });
1182
+
1183
+ createEffect(() => {
1184
+ state.skinToneButtonText = state.skinTones[state.currentSkinTone];
1185
+ });
1186
+
1187
+ createEffect(() => {
1188
+ state.skinToneButtonLabel = state.i18n.skinToneLabel.replace('{skinTone}', state.i18n.skinTones[state.currentSkinTone]);
1189
+ });
1190
+
1191
+ //
1192
+ // Set or update the favorites emojis
1193
+ //
1194
+
1195
+ createEffect(() => {
1196
+ async function updateDefaultFavoriteEmojis () {
1197
+ const { database } = state;
1198
+ const favs = (await Promise.all(MOST_COMMONLY_USED_EMOJI.map(unicode => (
1199
+ database.getEmojiByUnicodeOrName(unicode)
1200
+ )))).filter(Boolean); // filter because in Jest/Vitest tests we don't have all the emoji in the DB
1201
+ state.defaultFavoriteEmojis = favs;
1202
+ }
1203
+
1204
+ if (state.databaseLoaded) {
1205
+ /* no await */ updateDefaultFavoriteEmojis();
1206
+ }
1207
+ });
1208
+
1209
+ function updateCustomEmoji () {
1210
+ // Certain effects have an implicit dependency on customEmoji since it affects the database
1211
+ // Getting it here on the state ensures this effect re-runs when customEmoji change.
1212
+ const { customEmoji, database } = state;
1213
+ const databaseCustomEmoji = customEmoji || EMPTY_ARRAY;
1214
+ if (database.customEmoji !== databaseCustomEmoji) {
1215
+ // Avoid setting this if the customEmoji have _not_ changed, because the setter triggers a re-computation of the
1216
+ // `customEmojiIndex`. Note we don't bother with deep object changes.
1217
+ database.customEmoji = databaseCustomEmoji;
1218
+ }
1219
+ }
1220
+
1221
+ createEffect(() => {
1222
+ async function updateFavorites () {
1223
+ updateCustomEmoji(); // re-run whenever customEmoji change
1224
+ const { database, defaultFavoriteEmojis, numColumns } = state;
1225
+ const dbFavorites = await database.getTopFavoriteEmoji(numColumns);
1226
+ const favorites = await summarizeEmojis(uniqBy([
1227
+ ...dbFavorites,
1228
+ ...defaultFavoriteEmojis
1229
+ ], _ => (_.unicode || _.name)).slice(0, numColumns));
1230
+ state.currentFavorites = favorites;
1231
+ }
1232
+
1233
+ if (state.databaseLoaded && state.defaultFavoriteEmojis) {
1234
+ /* no await */ updateFavorites();
1235
+ }
1236
+ });
1237
+
1238
+ //
1239
+ // Re-run whenever the emoji grid changes size, and re-calc style/layout-related state variables:
1240
+ // 1) Re-calculate the --num-columns var because it may have changed
1241
+ // 2) Re-calculate whether we're in RTL mode or not.
1242
+ //
1243
+ // The benefit of doing this in one place is to align with rAF/ResizeObserver
1244
+ // and do all the calculations in one go. RTL vs LTR is not strictly layout-related,
1245
+ // but since we're already reading the style here, and since it's already aligned with
1246
+ // the rAF loop, this is the most appropriate place to do it perf-wise.
1247
+ //
1248
+
1249
+ function calculateEmojiGridStyle (node) {
1250
+ resizeObserverAction(node, abortSignal, () => {
1251
+ /* istanbul ignore next */
1252
+ { // jsdom throws errors for this kind of fancy stuff
1253
+ // read all the style/layout calculations we need to make
1254
+ const style = getComputedStyle(refs.rootElement);
1255
+ const newNumColumns = parseInt(style.getPropertyValue('--num-columns'), 10);
1256
+ const newIsRtl = style.getPropertyValue('direction') === 'rtl';
1257
+
1258
+ // write to state variables
1259
+ state.numColumns = newNumColumns;
1260
+ state.isRtl = newIsRtl;
1261
+ }
1262
+ });
1263
+ }
1264
+
1265
+ // Re-run whenever the custom emoji in a category are shown/hidden. This is an optimization that simulates
1266
+ // what we'd get from `<img loading=lazy>` but without rendering an `<img>`.
1267
+ function updateOnIntersection (node) {
1268
+ intersectionObserverAction(node, abortSignal, (entries) => {
1269
+ for (const { target, isIntersecting } of entries) {
1270
+ target.classList.toggle('onscreen', isIntersecting);
1271
+ }
1272
+ });
1273
+ }
1274
+
1275
+ //
1276
+ // Set or update the currentEmojis. Check for invalid ZWJ renderings
1277
+ // (i.e. double emoji).
1278
+ //
1279
+
1280
+ createEffect(() => {
1281
+ async function updateEmojis () {
1282
+ const { searchText, currentGroup, databaseLoaded, customEmoji } = state;
1283
+ if (!databaseLoaded) {
1284
+ state.currentEmojis = [];
1285
+ state.searchMode = false;
1286
+ } else if (searchText.length >= MIN_SEARCH_TEXT_LENGTH) {
1287
+ const newEmojis = await getEmojisBySearchQuery(searchText);
1288
+ if (state.searchText === searchText) { // if the situation changes asynchronously, do not update
1289
+ updateCurrentEmojis(newEmojis);
1290
+ updateSearchMode(true);
1291
+ }
1292
+ } else { // database is loaded and we're not in search mode, so we're in normal category mode
1293
+ const { id: currentGroupId } = currentGroup;
1294
+ // avoid race condition where currentGroupId is -1 and customEmoji is undefined/empty
1295
+ if (currentGroupId !== -1 || (customEmoji && customEmoji.length)) {
1296
+ const newEmojis = await getEmojisByGroup(currentGroupId);
1297
+ if (state.currentGroup.id === currentGroupId) { // if the situation changes asynchronously, do not update
1298
+ updateCurrentEmojis(newEmojis);
1299
+ updateSearchMode(false);
1300
+ }
1301
+ }
1302
+ }
1303
+ }
1304
+
1305
+ /* no await */ updateEmojis();
1306
+ });
1307
+
1308
+ const resetScrollTopInRaf = () => {
1309
+ rAF(() => resetScrollTopIfPossible(refs.tabpanelElement));
1310
+ };
1311
+
1312
+ // Some emojis have their ligatures rendered as two or more consecutive emojis
1313
+ // We want to treat these the same as unsupported emojis, so we compare their
1314
+ // widths against the baseline widths and remove them as necessary
1315
+ createEffect(() => {
1316
+ const { currentEmojis, emojiVersion } = state;
1317
+ const zwjEmojisToCheck = currentEmojis
1318
+ .filter(emoji => emoji.unicode) // filter custom emoji
1319
+ .filter(emoji => hasZwj(emoji) && !supportedZwjEmojis.has(emoji.unicode));
1320
+ if (!emojiVersion && zwjEmojisToCheck.length) {
1321
+ // render now, check their length later
1322
+ updateCurrentEmojis(currentEmojis);
1323
+ rAF(() => checkZwjSupportAndUpdate(zwjEmojisToCheck));
1324
+ } else {
1325
+ const newEmojis = emojiVersion ? currentEmojis : currentEmojis.filter(isZwjSupported);
1326
+ updateCurrentEmojis(newEmojis);
1327
+ // Reset scroll top to 0 when emojis change
1328
+ resetScrollTopInRaf();
1329
+ }
1330
+ });
1331
+
1332
+ function checkZwjSupportAndUpdate (zwjEmojisToCheck) {
1333
+ const allSupported = checkZwjSupport(zwjEmojisToCheck, refs.baselineEmoji, emojiToDomNode);
1334
+ if (allSupported) {
1335
+ // Even if all emoji are supported, we still need to reset the scroll top to 0 when emojis change
1336
+ resetScrollTopInRaf();
1337
+ } else {
1338
+ // Force update. We only do this if there are any unsupported ZWJ characters since otherwise,
1339
+ // for browsers that support all emoji, it would be an unnecessary extra re-render.
1340
+ state.currentEmojis = [...state.currentEmojis];
1341
+ }
1342
+ }
1343
+
1344
+ function isZwjSupported (emoji) {
1345
+ return !emoji.unicode || !hasZwj(emoji) || supportedZwjEmojis.get(emoji.unicode)
1346
+ }
1347
+
1348
+ async function filterEmojisByVersion (emojis) {
1349
+ const emojiSupportLevel = state.emojiVersion || await detectEmojiSupportLevel();
1350
+ // !version corresponds to custom emoji
1351
+ return emojis.filter(({ version }) => !version || version <= emojiSupportLevel)
1352
+ }
1353
+
1354
+ async function summarizeEmojis (emojis) {
1355
+ return summarizeEmojisForUI(emojis, state.emojiVersion || await detectEmojiSupportLevel())
1356
+ }
1357
+
1358
+ async function getEmojisByGroup (group) {
1359
+ // -1 is custom emoji
1360
+ const emoji = group === -1 ? state.customEmoji : await state.database.getEmojiByGroup(group);
1361
+ return summarizeEmojis(await filterEmojisByVersion(emoji))
1362
+ }
1363
+
1364
+ async function getEmojisBySearchQuery (query) {
1365
+ return summarizeEmojis(await filterEmojisByVersion(await state.database.getEmojiBySearchQuery(query)))
1366
+ }
1367
+
1368
+ createEffect(() => {
1369
+ });
1370
+
1371
+ //
1372
+ // Derive currentEmojisWithCategories from currentEmojis. This is always done even if there
1373
+ // are no categories, because it's just easier to code the HTML this way.
1374
+ //
1375
+
1376
+ createEffect(() => {
1377
+ function calculateCurrentEmojisWithCategories () {
1378
+ const { searchMode, currentEmojis } = state;
1379
+ if (searchMode) {
1380
+ return [
1381
+ {
1382
+ category: '',
1383
+ emojis: currentEmojis
1384
+ }
1385
+ ]
1386
+ }
1387
+ const categoriesToEmoji = new Map();
1388
+ for (const emoji of currentEmojis) {
1389
+ const category = emoji.category || '';
1390
+ let emojis = categoriesToEmoji.get(category);
1391
+ if (!emojis) {
1392
+ emojis = [];
1393
+ categoriesToEmoji.set(category, emojis);
1394
+ }
1395
+ emojis.push(emoji);
1396
+ }
1397
+ return [...categoriesToEmoji.entries()]
1398
+ .map(([category, emojis]) => ({ category, emojis }))
1399
+ .sort((a, b) => state.customCategorySorting(a.category, b.category))
1400
+ }
1401
+
1402
+ const newEmojisWithCategories = calculateCurrentEmojisWithCategories();
1403
+ updateCurrentEmojisWithCategories(newEmojisWithCategories);
1404
+ });
1405
+
1406
+ //
1407
+ // Handle active search item (i.e. pressing up or down while searching)
1408
+ //
1409
+
1410
+ createEffect(() => {
1411
+ state.activeSearchItemId = state.activeSearchItem !== -1 && state.currentEmojis[state.activeSearchItem].id;
1412
+ });
1413
+
1414
+ //
1415
+ // Handle user input on the search input
1416
+ //
1417
+
1418
+ createEffect(() => {
1419
+ const { rawSearchText } = state;
1420
+ rIC(() => {
1421
+ state.searchText = (rawSearchText || '').trim(); // defer to avoid input delays, plus we can trim here
1422
+ state.activeSearchItem = -1;
1423
+ });
1424
+ });
1425
+
1426
+ function onSearchKeydown (event) {
1427
+ if (!state.searchMode || !state.currentEmojis.length) {
1428
+ return
1429
+ }
1430
+
1431
+ const goToNextOrPrevious = (previous) => {
1432
+ halt(event);
1433
+ state.activeSearchItem = incrementOrDecrement(previous, state.activeSearchItem, state.currentEmojis);
1434
+ };
1435
+
1436
+ switch (event.key) {
1437
+ case 'ArrowDown':
1438
+ return goToNextOrPrevious(false)
1439
+ case 'ArrowUp':
1440
+ return goToNextOrPrevious(true)
1441
+ case 'Enter':
1442
+ if (state.activeSearchItem === -1) {
1443
+ // focus the first option in the list since the list must be non-empty at this point (it's verified above)
1444
+ state.activeSearchItem = 0;
1445
+ } else { // there is already an active search item
1446
+ halt(event);
1447
+ return clickEmoji(state.currentEmojis[state.activeSearchItem].id)
1448
+ }
1449
+ }
1450
+ }
1451
+
1452
+ //
1453
+ // Handle user input on nav
1454
+ //
1455
+
1456
+ function onNavClick (event) {
1457
+ const { target } = event;
1458
+ const closestTarget = target.closest('.nav-button');
1459
+ /* istanbul ignore if */
1460
+ if (!closestTarget) {
1461
+ return // This should never happen, but makes me nervous not to have it
1462
+ }
1463
+ const groupId = parseInt(closestTarget.dataset.groupId, 10);
1464
+ refs.searchElement.value = ''; // clear search box input
1465
+ state.rawSearchText = '';
1466
+ state.searchText = '';
1467
+ state.activeSearchItem = -1;
1468
+ state.currentGroupIndex = state.groups.findIndex(_ => _.id === groupId);
1469
+ }
1470
+
1471
+ function onNavKeydown (event) {
1472
+ const { target, key } = event;
1473
+
1474
+ const doFocus = el => {
1475
+ if (el) {
1476
+ halt(event);
1477
+ el.focus();
1478
+ }
1479
+ };
1480
+
1481
+ switch (key) {
1482
+ case 'ArrowLeft':
1483
+ return doFocus(target.previousElementSibling)
1484
+ case 'ArrowRight':
1485
+ return doFocus(target.nextElementSibling)
1486
+ case 'Home':
1487
+ return doFocus(target.parentElement.firstElementChild)
1488
+ case 'End':
1489
+ return doFocus(target.parentElement.lastElementChild)
1490
+ }
1491
+ }
1492
+
1493
+ async function getDetailForClickEvent (unicodeOrName) {
1494
+ const emoji = await state.database.getEmojiByUnicodeOrName(unicodeOrName);
1495
+ const emojiSummary = [...state.currentEmojis, ...state.currentFavorites]
1496
+ .find(_ => (_.id === unicodeOrName));
1497
+ const skinTonedUnicode = emojiSummary.unicode && unicodeWithSkin(emojiSummary, state.currentSkinTone);
1498
+ await state.database.incrementFavoriteEmojiCount(unicodeOrName);
1499
+ return {
1500
+ emoji,
1501
+ skinTone: state.currentSkinTone,
1502
+ ...(skinTonedUnicode && { unicode: skinTonedUnicode }),
1503
+ ...(emojiSummary.name && { name: emojiSummary.name })
1504
+ }
1505
+ }
1506
+
1507
+ //
1508
+ // Handle user input on an emoji
1509
+ //
1510
+ async function clickEmoji (unicodeOrName) {
1511
+ const promiseForDetail = getDetailForClickEvent(unicodeOrName);
1512
+ // sync event to work around a safari bug: https://bugs.webkit.org/show_bug.cgi?id=222262
1513
+ fireEvent('emoji-click-sync', promiseForDetail);
1514
+ // async event for most normal use cases that don't need to work around the safari bug
1515
+ fireEvent('emoji-click', await promiseForDetail);
1516
+ }
1517
+
1518
+ function onEmojiClick (event) {
1519
+ const { target } = event;
1520
+ /* istanbul ignore if */
1521
+ if (!target.classList.contains('emoji')) {
1522
+ // This should never happen, but makes me nervous not to have it
1523
+ return
1524
+ }
1525
+ halt(event);
1526
+ const id = target.id.substring(4); // replace 'emo-' or 'fav-' prefix
1527
+
1528
+ /* no await */ clickEmoji(id);
1529
+ }
1530
+
1531
+ //
1532
+ // Handle user input on the skintone picker
1533
+ //
1534
+
1535
+ function changeSkinTone (skinTone) {
1536
+ state.currentSkinTone = skinTone;
1537
+ state.skinTonePickerExpanded = false;
1538
+ focus('skintone-button');
1539
+ fireEvent('skin-tone-change', { skinTone });
1540
+ /* no await */ state.database.setPreferredSkinTone(skinTone);
1541
+ }
1542
+
1543
+ function onSkinToneOptionsClick (event) {
1544
+ const { target: { id } } = event;
1545
+ const match = id && id.match(/^skintone-(\d)/); // skintone option format
1546
+ /* istanbul ignore if */
1547
+ if (!match) { // not a skintone option
1548
+ return // This should never happen, but makes me nervous not to have it
1549
+ }
1550
+ halt(event);
1551
+ const skinTone = parseInt(match[1], 10); // remove 'skintone-' prefix
1552
+ changeSkinTone(skinTone);
1553
+ }
1554
+
1555
+ function onClickSkinToneButton (event) {
1556
+ state.skinTonePickerExpanded = !state.skinTonePickerExpanded;
1557
+ state.activeSkinTone = state.currentSkinTone;
1558
+ // this should always be true, since the button is obscured by the listbox, so this `if` is just to be sure
1559
+ if (state.skinTonePickerExpanded) {
1560
+ halt(event);
1561
+ rAF(() => focus('skintone-list'));
1562
+ }
1563
+ }
1564
+
1565
+ // To make the animation nicer, change the z-index of the skintone picker button
1566
+ // *after* the animation has played. This makes it appear that the picker box
1567
+ // is expanding "below" the button
1568
+ createEffect(() => {
1569
+ if (state.skinTonePickerExpanded) {
1570
+ refs.skinToneDropdown.addEventListener('transitionend', () => {
1571
+ state.skinTonePickerExpandedAfterAnimation = true; // eslint-disable-line no-unused-vars
1572
+ }, { once: true });
1573
+ } else {
1574
+ state.skinTonePickerExpandedAfterAnimation = false; // eslint-disable-line no-unused-vars
1575
+ }
1576
+ });
1577
+
1578
+ function onSkinToneOptionsKeydown (event) {
1579
+ // this should never happen, but makes me nervous not to have it
1580
+ /* istanbul ignore if */
1581
+ if (!state.skinTonePickerExpanded) {
1582
+ return
1583
+ }
1584
+ const changeActiveSkinTone = async nextSkinTone => {
1585
+ halt(event);
1586
+ state.activeSkinTone = nextSkinTone;
1587
+ };
1588
+
1589
+ switch (event.key) {
1590
+ case 'ArrowUp':
1591
+ return changeActiveSkinTone(incrementOrDecrement(true, state.activeSkinTone, state.skinTones))
1592
+ case 'ArrowDown':
1593
+ return changeActiveSkinTone(incrementOrDecrement(false, state.activeSkinTone, state.skinTones))
1594
+ case 'Home':
1595
+ return changeActiveSkinTone(0)
1596
+ case 'End':
1597
+ return changeActiveSkinTone(state.skinTones.length - 1)
1598
+ case 'Enter':
1599
+ // enter on keydown, space on keyup. this is just how browsers work for buttons
1600
+ // https://lists.w3.org/Archives/Public/w3c-wai-ig/2019JanMar/0086.html
1601
+ halt(event);
1602
+ return changeSkinTone(state.activeSkinTone)
1603
+ case 'Escape':
1604
+ halt(event);
1605
+ state.skinTonePickerExpanded = false;
1606
+ return focus('skintone-button')
1607
+ }
1608
+ }
1609
+
1610
+ function onSkinToneOptionsKeyup (event) {
1611
+ // this should never happen, but makes me nervous not to have it
1612
+ /* istanbul ignore if */
1613
+ if (!state.skinTonePickerExpanded) {
1614
+ return
1615
+ }
1616
+ switch (event.key) {
1617
+ case ' ':
1618
+ // enter on keydown, space on keyup. this is just how browsers work for buttons
1619
+ // https://lists.w3.org/Archives/Public/w3c-wai-ig/2019JanMar/0086.html
1620
+ halt(event);
1621
+ return changeSkinTone(state.activeSkinTone)
1622
+ }
1623
+ }
1624
+
1625
+ async function onSkinToneOptionsFocusOut (event) {
1626
+ // On blur outside of the skintone listbox, collapse the skintone picker.
1627
+ const { relatedTarget } = event;
1628
+ // The `else` should never happen, but makes me nervous not to have it
1629
+ /* istanbul ignore else */
1630
+ if (!relatedTarget || relatedTarget.id !== 'skintone-list') {
1631
+ state.skinTonePickerExpanded = false;
1632
+ }
1633
+ }
1634
+
1635
+ function onSearchInput (event) {
1636
+ state.rawSearchText = event.target.value;
1637
+ }
1638
+
1639
+ return {
1640
+ $set (newState) {
1641
+ assign(state, newState);
1642
+ },
1643
+ $destroy () {
1644
+ abortController.abort();
1645
+ }
1646
+ }
1647
+ }
1648
+
1649
+ const DEFAULT_DATA_SOURCE = 'https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/en/emojibase/data.json';
1650
+ const DEFAULT_LOCALE = 'en';
1651
+
1652
+ var enI18n = {
1653
+ categoriesLabel: 'Categories',
1654
+ emojiUnsupportedMessage: 'Your browser does not support color emoji.',
1655
+ favoritesLabel: 'Favorites',
1656
+ loadingMessage: 'Loading…',
1657
+ networkErrorMessage: 'Could not load emoji.',
1658
+ regionLabel: 'Emoji picker',
1659
+ searchDescription: 'When search results are available, press up or down to select and enter to choose.',
1660
+ searchLabel: 'Search',
1661
+ searchResultsLabel: 'Search results',
1662
+ skinToneDescription: 'When expanded, press up or down to select and enter to choose.',
1663
+ skinToneLabel: 'Choose a skin tone (currently {skinTone})',
1664
+ skinTonesLabel: 'Skin tones',
1665
+ skinTones: [
1666
+ 'Default',
1667
+ 'Light',
1668
+ 'Medium-Light',
1669
+ 'Medium',
1670
+ 'Medium-Dark',
1671
+ 'Dark'
1672
+ ],
1673
+ categories: {
1674
+ custom: 'Custom',
1675
+ 'smileys-emotion': 'Smileys and emoticons',
1676
+ 'people-body': 'People and body',
1677
+ 'animals-nature': 'Animals and nature',
1678
+ 'food-drink': 'Food and drink',
1679
+ 'travel-places': 'Travel and places',
1680
+ activities: 'Activities',
1681
+ objects: 'Objects',
1682
+ symbols: 'Symbols',
1683
+ flags: 'Flags'
1684
+ }
1685
+ };
1686
+
1687
+ var baseStyles = ":host{--emoji-size:1.375rem;--emoji-padding:0.5rem;--category-emoji-size:var(--emoji-size);--category-emoji-padding:var(--emoji-padding);--indicator-height:3px;--input-border-radius:0.5rem;--input-border-size:1px;--input-font-size:1rem;--input-line-height:1.5;--input-padding:0.25rem;--num-columns:8;--outline-size:2px;--border-size:1px;--border-radius:0;--skintone-border-radius:1rem;--category-font-size:1rem;display:flex;width:min-content;height:400px}:host,:host(.light){color-scheme:light;--background:#fff;--border-color:#e0e0e0;--indicator-color:#385ac1;--input-border-color:#999;--input-font-color:#111;--input-placeholder-color:#999;--outline-color:#999;--category-font-color:#111;--button-active-background:#e6e6e6;--button-hover-background:#d9d9d9}:host(.dark){color-scheme:dark;--background:#222;--border-color:#444;--indicator-color:#5373ec;--input-border-color:#ccc;--input-font-color:#efefef;--input-placeholder-color:#ccc;--outline-color:#fff;--category-font-color:#efefef;--button-active-background:#555555;--button-hover-background:#484848}@media (prefers-color-scheme:dark){:host{color-scheme:dark;--background:#222;--border-color:#444;--indicator-color:#5373ec;--input-border-color:#ccc;--input-font-color:#efefef;--input-placeholder-color:#ccc;--outline-color:#fff;--category-font-color:#efefef;--button-active-background:#555555;--button-hover-background:#484848}}:host([hidden]){display:none}button{margin:0;padding:0;border:0;background:0 0;box-shadow:none;-webkit-tap-highlight-color:transparent}button::-moz-focus-inner{border:0}input{padding:0;margin:0;line-height:1.15;font-family:inherit}input[type=search]{-webkit-appearance:none}:focus{outline:var(--outline-color) solid var(--outline-size);outline-offset:calc(-1*var(--outline-size))}:host([data-js-focus-visible]) :focus:not([data-focus-visible-added]){outline:0}:focus:not(:focus-visible){outline:0}.hide-focus{outline:0}*{box-sizing:border-box}.picker{contain:content;display:flex;flex-direction:column;background:var(--background);border:var(--border-size) solid var(--border-color);border-radius:var(--border-radius);width:100%;height:100%;overflow:hidden;--total-emoji-size:calc(var(--emoji-size) + (2 * var(--emoji-padding)));--total-category-emoji-size:calc(var(--category-emoji-size) + (2 * var(--category-emoji-padding)))}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.hidden{opacity:0;pointer-events:none}.abs-pos{position:absolute;left:0;top:0}.gone{display:none!important}.skintone-button-wrapper,.skintone-list{background:var(--background);z-index:3}.skintone-button-wrapper.expanded{z-index:1}.skintone-list{position:absolute;inset-inline-end:0;top:0;z-index:2;overflow:visible;border-bottom:var(--border-size) solid var(--border-color);border-radius:0 0 var(--skintone-border-radius) var(--skintone-border-radius);will-change:transform;transition:transform .2s ease-in-out;transform-origin:center 0}@media (prefers-reduced-motion:reduce){.skintone-list{transition-duration:.001s}}@supports not (inset-inline-end:0){.skintone-list{right:0}}.skintone-list.no-animate{transition:none}.tabpanel{overflow-y:auto;scrollbar-gutter:stable;-webkit-overflow-scrolling:touch;will-change:transform;min-height:0;flex:1;contain:content}.emoji-menu{display:grid;grid-template-columns:repeat(var(--num-columns),var(--total-emoji-size));justify-content:space-around;align-items:flex-start;width:100%}.emoji-menu.visibility-auto{content-visibility:auto;contain-intrinsic-size:calc(var(--num-columns)*var(--total-emoji-size)) calc(var(--num-rows)*var(--total-emoji-size))}.category{padding:var(--emoji-padding);font-size:var(--category-font-size);color:var(--category-font-color)}.emoji,button.emoji{font-size:var(--emoji-size);display:flex;align-items:center;justify-content:center;border-radius:100%;height:var(--total-emoji-size);width:var(--total-emoji-size);line-height:1;overflow:hidden;font-family:var(--emoji-font-family);cursor:pointer}@media (hover:hover) and (pointer:fine){.emoji:hover,button.emoji:hover{background:var(--button-hover-background)}}.emoji.active,.emoji:active,button.emoji.active,button.emoji:active{background:var(--button-active-background)}.onscreen .custom-emoji::after{content:\"\";width:var(--emoji-size);height:var(--emoji-size);background-repeat:no-repeat;background-position:center center;background-size:contain;background-image:var(--custom-emoji-background)}.nav,.nav-button{align-items:center}.nav{display:grid;justify-content:space-between;contain:content}.nav-button{display:flex;justify-content:center}.nav-emoji{font-size:var(--category-emoji-size);width:var(--total-category-emoji-size);height:var(--total-category-emoji-size)}.indicator-wrapper{display:flex;border-bottom:1px solid var(--border-color)}.indicator{width:calc(100%/var(--num-groups));height:var(--indicator-height);opacity:var(--indicator-opacity);background-color:var(--indicator-color);will-change:transform,opacity;transition:opacity .1s linear,transform .25s ease-in-out}@media (prefers-reduced-motion:reduce){.indicator{will-change:opacity;transition:opacity .1s linear}}.pad-top,input.search{background:var(--background);width:100%}.pad-top{height:var(--emoji-padding);z-index:3}.search-row{display:flex;align-items:center;position:relative;padding-inline-start:var(--emoji-padding);padding-bottom:var(--emoji-padding)}.search-wrapper{flex:1;min-width:0}input.search{padding:var(--input-padding);border-radius:var(--input-border-radius);border:var(--input-border-size) solid var(--input-border-color);color:var(--input-font-color);font-size:var(--input-font-size);line-height:var(--input-line-height)}input.search::placeholder{color:var(--input-placeholder-color)}.favorites{overflow-y:auto;scrollbar-gutter:stable;display:flex;flex-direction:row;border-top:var(--border-size) solid var(--border-color);contain:content}.message{padding:var(--emoji-padding)}";
1688
+
1689
+ const PROPS = [
1690
+ 'customEmoji',
1691
+ 'customCategorySorting',
1692
+ 'database',
1693
+ 'dataSource',
1694
+ 'i18n',
1695
+ 'locale',
1696
+ 'skinToneEmoji',
1697
+ 'emojiVersion'
1698
+ ];
1699
+
1700
+ // Styles injected ourselves, so we can declare the FONT_FAMILY variable in one place
1701
+ const EXTRA_STYLES = `:host{--emoji-font-family:${FONT_FAMILY}}`;
1702
+
1703
+ class PickerElement extends HTMLElement {
1704
+ constructor (props) {
1705
+ super();
1706
+ this.attachShadow({ mode: 'open' });
1707
+ const style = document.createElement('style');
1708
+ style.textContent = baseStyles + EXTRA_STYLES;
1709
+ this.shadowRoot.appendChild(style);
1710
+ this._ctx = {
1711
+ // Set defaults
1712
+ locale: DEFAULT_LOCALE,
1713
+ dataSource: DEFAULT_DATA_SOURCE,
1714
+ skinToneEmoji: DEFAULT_SKIN_TONE_EMOJI,
1715
+ customCategorySorting: DEFAULT_CATEGORY_SORTING,
1716
+ customEmoji: null,
1717
+ i18n: enI18n,
1718
+ emojiVersion: null,
1719
+ ...props
1720
+ };
1721
+ // Handle properties set before the element was upgraded
1722
+ for (const prop of PROPS) {
1723
+ if (prop !== 'database' && Object.prototype.hasOwnProperty.call(this, prop)) {
1724
+ this._ctx[prop] = this[prop];
1725
+ delete this[prop];
1726
+ }
1727
+ }
1728
+ this._dbFlush(); // wait for a flush before creating the db, in case the user calls e.g. a setter or setAttribute
1729
+ }
1730
+
1731
+ connectedCallback () {
1732
+ rescueElementPrototype(this);
1733
+ // The _cmp may be defined if the component was immediately disconnected and then reconnected. In that case,
1734
+ // do nothing (preserve the state)
1735
+ if (!this._cmp) {
1736
+ this._cmp = createRoot(this.shadowRoot, this._ctx);
1737
+ }
1738
+ }
1739
+
1740
+ disconnectedCallback () {
1741
+ rescueElementPrototype(this);
1742
+ // Check in a microtask if the element is still connected. If so, treat this as a "move" rather than a disconnect
1743
+ // Inspired by Vue: https://vuejs.org/guide/extras/web-components.html#building-custom-elements-with-vue
1744
+ qM(() => {
1745
+ // this._cmp may be defined if connect-disconnect-connect-disconnect occurs synchronously
1746
+ if (!this.isConnected && this._cmp) {
1747
+ this._cmp.$destroy();
1748
+ this._cmp = undefined;
1749
+
1750
+ const { database } = this._ctx;
1751
+ database.close()
1752
+ // only happens if the database failed to load in the first place, so we don't care
1753
+ .catch(err => console.error(err));
1754
+ }
1755
+ });
1756
+ }
1757
+
1758
+ static get observedAttributes () {
1759
+ return ['locale', 'data-source', 'skin-tone-emoji', 'emoji-version'] // complex objects aren't supported, also use kebab-case
1760
+ }
1761
+
1762
+ attributeChangedCallback (attrName, oldValue, newValue) {
1763
+ this._set(
1764
+ // convert from kebab-case to camelcase
1765
+ // see https://github.com/sveltejs/svelte/issues/3852#issuecomment-665037015
1766
+ attrName.replace(/-([a-z])/g, (_, up) => up.toUpperCase()),
1767
+ // convert string attribute to float if necessary
1768
+ attrName === 'emoji-version' ? parseFloat(newValue) : newValue
1769
+ );
1770
+ }
1771
+
1772
+ _set (prop, newValue) {
1773
+ this._ctx[prop] = newValue;
1774
+ if (this._cmp) {
1775
+ this._cmp.$set({ [prop]: newValue });
1776
+ }
1777
+ if (['locale', 'dataSource'].includes(prop)) {
1778
+ this._dbFlush();
1779
+ }
1780
+ }
1781
+
1782
+ _dbCreate () {
1783
+ const { locale, dataSource, database } = this._ctx;
1784
+ // only create a new database if we really need to
1785
+ if (!database || database.locale !== locale || database.dataSource !== dataSource) {
1786
+ this._set('database', new Database({ locale, dataSource }));
1787
+ }
1788
+ }
1789
+
1790
+ // Update the Database in one microtask if the locale/dataSource change. We do one microtask
1791
+ // so we don't create two Databases if e.g. both the locale and the dataSource change
1792
+ _dbFlush () {
1793
+ qM(() => (
1794
+ this._dbCreate()
1795
+ ));
1796
+ }
1797
+ }
1798
+
1799
+ const definitions = {};
1800
+
1801
+ for (const prop of PROPS) {
1802
+ definitions[prop] = {
1803
+ get () {
1804
+ if (prop === 'database') {
1805
+ // in rare cases, the microtask may not be flushed yet, so we need to instantiate the DB
1806
+ // now if the user is asking for it
1807
+ this._dbCreate();
1808
+ }
1809
+ return this._ctx[prop]
1810
+ },
1811
+ set (val) {
1812
+ if (prop === 'database') {
1813
+ throw new Error('database is read-only')
1814
+ }
1815
+ this._set(prop, val);
1816
+ }
1817
+ };
1818
+ }
1819
+
1820
+ Object.defineProperties(PickerElement.prototype, definitions);
1821
+
1822
+ // See https://jakearchibald.com/2025/firefox-custom-elements-iframes-bug/
1823
+ // TODO: remove when the Firefox bug is fixed: https://bugzilla.mozilla.org/show_bug.cgi?id=1502814
1824
+ function rescueElementPrototype (element) {
1825
+ /* istanbul ignore if */
1826
+ if (!(element instanceof PickerElement)) {
1827
+ Object.setPrototypeOf(element, customElements.get(element.tagName.toLowerCase()).prototype);
1828
+ }
1829
+ }
1830
+
1831
+ /* istanbul ignore else */
1832
+ if (!customElements.get('emoji-picker')) { // if already defined, do nothing (e.g. same script imported twice)
1833
+ customElements.define('emoji-picker', PickerElement);
1834
+ }
1835
+
1836
+ export { PickerElement as default, rescueElementPrototype };