`;
};
const rootDom = section();
if (firstRender) {
// not a re-render
container.appendChild(rootDom);
// we only bind events/refs/actions once - there is no need to find them again given this component structure
// helper for traversing the dom, finding elements by an attribute, and getting the attribute value
const forElementWithAttribute = (attributeName, callback) => {
for (const element of container.querySelectorAll(`[${attributeName}]`)) {
callback(element, element.getAttribute(attributeName));
}
};
// bind events
for (const eventName of [
"click",
"focusout",
"input",
"keydown",
"keyup",
]) {
forElementWithAttribute(
`data-on-${eventName}`,
(element, listenerName) => {
element.addEventListener(eventName, events[listenerName]);
}
);
}
// find refs
forElementWithAttribute("data-ref", (element, ref) => {
refs[ref] = element;
});
// set up actions
forElementWithAttribute("data-action", (element, action) => {
actions[action](element);
});
// destroy/abort logic
abortSignal.addEventListener("abort", () => {
container.removeChild(rootDom);
});
}
}
/* istanbul ignore next */
const qM =
typeof queueMicrotask === "function"
? queueMicrotask
: (callback) => Promise.resolve().then(callback);
function createState(abortSignal) {
let destroyed = false;
let currentObserver;
const propsToObservers = new Map();
const dirtyObservers = new Set();
let queued;
const flush = () => {
if (destroyed) {
return;
}
const observersToRun = [...dirtyObservers];
dirtyObservers.clear(); // clear before running to force any new updates to run in another tick of the loop
try {
for (const observer of observersToRun) {
observer();
}
} finally {
queued = false;
if (dirtyObservers.size) {
// new updates, queue another one
queued = true;
qM(flush);
}
}
};
const state = new Proxy(
{},
{
get(target, prop) {
if (currentObserver) {
let observers = propsToObservers.get(prop);
if (!observers) {
observers = new Set();
propsToObservers.set(prop, observers);
}
observers.add(currentObserver);
}
return target[prop];
},
set(target, prop, newValue) {
target[prop] = newValue;
const observers = propsToObservers.get(prop);
if (observers) {
for (const observer of observers) {
dirtyObservers.add(observer);
}
if (!queued) {
queued = true;
qM(flush);
}
}
return true;
},
}
);
const createEffect = (callback) => {
const runnable = () => {
const oldObserver = currentObserver;
currentObserver = runnable;
try {
return callback();
} finally {
currentObserver = oldObserver;
}
};
return runnable();
};
// destroy logic
abortSignal.addEventListener("abort", () => {
destroyed = true;
});
return {
state,
createEffect,
};
}
// Compare two arrays, with a function called on each item in the two arrays that returns true if the items are equal
function arraysAreEqualByFunction(left, right, areEqualFunc) {
if (left.length !== right.length) {
return false;
}
for (let i = 0; i < left.length; i++) {
if (!areEqualFunc(left[i], right[i])) {
return false;
}
}
return true;
}
/* eslint-disable prefer-const,no-labels,no-inner-declarations */
// constants
const EMPTY_ARRAY = [];
const { assign } = Object;
function createRoot(shadowRoot, props) {
const refs = {};
const abortController = new AbortController();
const abortSignal = abortController.signal;
const { state, createEffect } = createState(abortSignal);
// initial state
assign(state, {
skinToneEmoji: undefined,
i18n: undefined,
database: undefined,
customEmoji: undefined,
customCategorySorting: undefined,
emojiVersion: undefined,
});
// public props
assign(state, props);
// private props
assign(state, {
initialLoad: true,
currentEmojis: [],
currentEmojisWithCategories: [],
rawSearchText: "",
searchText: "",
searchMode: false,
activeSearchItem: -1,
message: undefined,
skinTonePickerExpanded: false,
skinTonePickerExpandedAfterAnimation: false,
currentSkinTone: 0,
activeSkinTone: 0,
skinToneButtonText: undefined,
pickerStyle: undefined,
skinToneButtonLabel: "",
skinTones: [],
currentFavorites: [],
defaultFavoriteEmojis: undefined,
numColumns: DEFAULT_NUM_COLUMNS,
isRtl: false,
scrollbarWidth: 0,
currentGroupIndex: 0,
groups: groups,
databaseLoaded: false,
activeSearchItemId: undefined,
});
//
// Update the current group based on the currentGroupIndex
//
createEffect(() => {
if (state.currentGroup !== state.groups[state.currentGroupIndex]) {
state.currentGroup = state.groups[state.currentGroupIndex];
}
});
//
// Utils/helpers
//
const focus = (id) => {
shadowRoot.getElementById(id).focus();
};
const emojiToDomNode = (emoji) =>
shadowRoot.getElementById(`emo-${emoji.id}`);
// fire a custom event that crosses the shadow boundary
const fireEvent = (name, detail) => {
refs.rootElement.dispatchEvent(
new CustomEvent(name, {
detail,
bubbles: true,
composed: true,
})
);
};
//
// Comparison utils
//
const compareEmojiArrays = (a, b) => a.id === b.id;
const compareCurrentEmojisWithCategories = (a, b) => {
const { category: aCategory, emojis: aEmojis } = a;
const { category: bCategory, emojis: bEmojis } = b;
if (aCategory !== bCategory) {
return false;
}
return arraysAreEqualByFunction(aEmojis, bEmojis, compareEmojiArrays);
};
//
// Update utils to avoid excessive re-renders
//
// avoid excessive re-renders by checking the value before setting
const updateCurrentEmojis = (newEmojis) => {
if (
!arraysAreEqualByFunction(
state.currentEmojis,
newEmojis,
compareEmojiArrays
)
) {
state.currentEmojis = newEmojis;
}
};
// avoid excessive re-renders
const updateSearchMode = (newSearchMode) => {
if (state.searchMode !== newSearchMode) {
state.searchMode = newSearchMode;
}
};
// avoid excessive re-renders
const updateCurrentEmojisWithCategories = (newEmojisWithCategories) => {
if (
!arraysAreEqualByFunction(
state.currentEmojisWithCategories,
newEmojisWithCategories,
compareCurrentEmojisWithCategories
)
) {
state.currentEmojisWithCategories = newEmojisWithCategories;
}
};
// Helpers used by PickerTemplate
const unicodeWithSkin = (emoji, currentSkinTone) =>
(currentSkinTone && emoji.skins && emoji.skins[currentSkinTone]) ||
emoji.unicode;
const labelWithSkin = (emoji, currentSkinTone) =>
uniq(
[
emoji.name || unicodeWithSkin(emoji, currentSkinTone),
emoji.annotation,
...(emoji.shortcodes || EMPTY_ARRAY),
].filter(Boolean)
).join(", ");
const titleForEmoji = (emoji) =>
emoji.annotation || (emoji.shortcodes || EMPTY_ARRAY).join(", ");
const helpers = {
labelWithSkin,
titleForEmoji,
unicodeWithSkin,
};
const events = {
onClickSkinToneButton,
onEmojiClick,
onNavClick,
onNavKeydown,
onSearchKeydown,
onSkinToneOptionsClick,
onSkinToneOptionsFocusOut,
onSkinToneOptionsKeydown,
onSkinToneOptionsKeyup,
onSearchInput,
};
const actions = {
calculateEmojiGridStyle,
};
let firstRender = true;
createEffect(() => {
render(
shadowRoot,
state,
helpers,
events,
actions,
refs,
abortSignal,
firstRender
);
firstRender = false;
});
//
// Determine the emoji support level (in requestIdleCallback)
//
// mount logic
if (!state.emojiVersion) {
detectEmojiSupportLevel().then((level) => {
// Can't actually test emoji support in Jest/Vitest/JSDom, emoji never render in color in Cairo
/* istanbul ignore next */
if (!level) {
state.message = state.i18n.emojiUnsupportedMessage;
}
});
}
//
// Set or update the database object
//
createEffect(() => {
// show a Loading message if it takes a long time, or show an error if there's a network/IDB error
async function handleDatabaseLoading() {
let showingLoadingMessage = false;
const timeoutHandle = setTimeout(() => {
showingLoadingMessage = true;
state.message = state.i18n.loadingMessage;
}, TIMEOUT_BEFORE_LOADING_MESSAGE);
try {
await state.database.ready();
state.databaseLoaded = true; // eslint-disable-line no-unused-vars
} catch (err) {
console.error(err);
state.message = state.i18n.networkErrorMessage;
} finally {
clearTimeout(timeoutHandle);
if (showingLoadingMessage) {
// Seems safer than checking the i18n string, which may change
showingLoadingMessage = false;
state.message = ""; // eslint-disable-line no-unused-vars
}
}
}
if (state.database) {
/* no await */
handleDatabaseLoading();
}
});
//
// Global styles for the entire picker
//
createEffect(() => {
state.pickerStyle = `
--num-groups: ${state.groups.length};
--indicator-opacity: ${state.searchMode ? 0 : 1};
--num-skintones: ${NUM_SKIN_TONES};`;
});
//
// Set or update the customEmoji
//
createEffect(() => {
if (state.customEmoji && state.database) {
updateCustomEmoji(); // re-run whenever customEmoji change
}
});
createEffect(() => {
if (state.customEmoji && state.customEmoji.length) {
if (state.groups !== allGroups) {
// don't update unnecessarily
state.groups = allGroups;
}
} else if (state.groups !== groups) {
if (state.currentGroupIndex) {
// If the current group is anything other than "custom" (which is first), decrement.
// This fixes the odd case where you set customEmoji, then pick a category, then unset customEmoji
state.currentGroupIndex--;
}
state.groups = groups;
}
});
//
// Set or update the preferred skin tone
//
createEffect(() => {
async function updatePreferredSkinTone() {
if (state.databaseLoaded) {
state.currentSkinTone = await state.database.getPreferredSkinTone();
}
}
/* no await */ updatePreferredSkinTone();
});
createEffect(() => {
state.skinTones = Array(NUM_SKIN_TONES)
.fill()
.map((_, i) => applySkinTone(state.skinToneEmoji, i));
});
createEffect(() => {
state.skinToneButtonText = state.skinTones[state.currentSkinTone];
});
createEffect(() => {
state.skinToneButtonLabel = state.i18n.skinToneLabel.replace(
"{skinTone}",
state.i18n.skinTones[state.currentSkinTone]
);
});
//
// Set or update the favorites emojis
//
createEffect(() => {
async function updateDefaultFavoriteEmojis() {
const { database } = state;
const favs = (
await Promise.all(
MOST_COMMONLY_USED_EMOJI.map((unicode) =>
database.getEmojiByUnicodeOrName(unicode)
)
)
).filter(Boolean); // filter because in Jest/Vitest tests we don't have all the emoji in the DB
state.defaultFavoriteEmojis = favs;
}
if (state.databaseLoaded) {
/* no await */ updateDefaultFavoriteEmojis();
}
});
function updateCustomEmoji() {
// Certain effects have an implicit dependency on customEmoji since it affects the database
// Getting it here on the state ensures this effect re-runs when customEmoji change.
// Setting it on the database is pointless but prevents this code from being removed by a minifier.
state.database.customEmoji = state.customEmoji || EMPTY_ARRAY;
}
createEffect(() => {
async function updateFavorites() {
updateCustomEmoji(); // re-run whenever customEmoji change
const { database, defaultFavoriteEmojis, numColumns } = state;
const dbFavorites = await database.getTopFavoriteEmoji(numColumns);
const favorites = await summarizeEmojis(
uniqBy(
[...dbFavorites, ...defaultFavoriteEmojis],
(_) => _.unicode || _.name
).slice(0, numColumns)
);
state.currentFavorites = favorites;
}
if (state.databaseLoaded && state.defaultFavoriteEmojis) {
/* no await */ updateFavorites();
}
});
//
// Calculate the width of the emoji grid. This serves two purposes:
// 1) Re-calculate the --num-columns var because it may have changed
// 2) Re-calculate the scrollbar width because it may have changed
// (i.e. because the number of items changed)
// 3) Re-calculate whether we're in RTL mode or not.
//
// The benefit of doing this in one place is to align with rAF/ResizeObserver
// and do all the calculations in one go. RTL vs LTR is not strictly width-related,
// but since we're already reading the style here, and since it's already aligned with
// the rAF loop, this is the most appropriate place to do it perf-wise.
//
function calculateEmojiGridStyle(node) {
calculateWidth(node, abortSignal, (width) => {
/* istanbul ignore next */
{
// jsdom throws errors for this kind of fancy stuff
// read all the style/layout calculations we need to make
const style = getComputedStyle(refs.rootElement);
const newNumColumns = parseInt(
style.getPropertyValue("--num-columns"),
10
);
const newIsRtl = style.getPropertyValue("direction") === "rtl";
const parentWidth = node.parentElement.getBoundingClientRect().width;
const newScrollbarWidth = parentWidth - width;
// write to state variables
state.numColumns = newNumColumns;
state.scrollbarWidth = newScrollbarWidth; // eslint-disable-line no-unused-vars
state.isRtl = newIsRtl; // eslint-disable-line no-unused-vars
}
});
}
//
// Set or update the currentEmojis. Check for invalid ZWJ renderings
// (i.e. double emoji).
//
createEffect(() => {
async function updateEmojis() {
const { searchText, currentGroup, databaseLoaded, customEmoji } = state;
if (!databaseLoaded) {
state.currentEmojis = [];
state.searchMode = false;
} else if (searchText.length >= MIN_SEARCH_TEXT_LENGTH) {
const newEmojis = await getEmojisBySearchQuery(searchText);
if (state.searchText === searchText) {
// if the situation changes asynchronously, do not update
updateCurrentEmojis(newEmojis);
updateSearchMode(true);
}
} else {
// database is loaded and we're not in search mode, so we're in normal category mode
const { id: currentGroupId } = currentGroup;
// avoid race condition where currentGroupId is -1 and customEmoji is undefined/empty
if (currentGroupId !== -1 || (customEmoji && customEmoji.length)) {
const newEmojis = await getEmojisByGroup(currentGroupId);
if (state.currentGroup.id === currentGroupId) {
// if the situation changes asynchronously, do not update
updateCurrentEmojis(newEmojis);
updateSearchMode(false);
}
}
}
}
/* no await */ updateEmojis();
});
// Some emojis have their ligatures rendered as two or more consecutive emojis
// We want to treat these the same as unsupported emojis, so we compare their
// widths against the baseline widths and remove them as necessary
createEffect(() => {
const { currentEmojis, emojiVersion } = state;
const zwjEmojisToCheck = currentEmojis
.filter((emoji) => emoji.unicode) // filter custom emoji
.filter(
(emoji) => hasZwj(emoji) && !supportedZwjEmojis.has(emoji.unicode)
);
if (!emojiVersion && zwjEmojisToCheck.length) {
// render now, check their length later
updateCurrentEmojis(currentEmojis);
rAF(() => checkZwjSupportAndUpdate(zwjEmojisToCheck));
} else {
const newEmojis = emojiVersion
? currentEmojis
: currentEmojis.filter(isZwjSupported);
updateCurrentEmojis(newEmojis);
// Reset scroll top to 0 when emojis change
rAF(() => resetScrollTopIfPossible(refs.tabpanelElement));
}
});
function checkZwjSupportAndUpdate(zwjEmojisToCheck) {
checkZwjSupport(zwjEmojisToCheck, refs.baselineEmoji, emojiToDomNode);
// force update
// eslint-disable-next-line no-self-assign
state.currentEmojis = state.currentEmojis;
}
function isZwjSupported(emoji) {
return (
!emoji.unicode || !hasZwj(emoji) || supportedZwjEmojis.get(emoji.unicode)
);
}
async function filterEmojisByVersion(emojis) {
const emojiSupportLevel =
state.emojiVersion || (await detectEmojiSupportLevel());
// !version corresponds to custom emoji
return emojis.filter(
({ version }) => !version || version <= emojiSupportLevel
);
}
async function summarizeEmojis(emojis) {
return summarizeEmojisForUI(
emojis,
state.emojiVersion || (await detectEmojiSupportLevel())
);
}
async function getEmojisByGroup(group) {
// -1 is custom emoji
const emoji =
group === -1
? state.customEmoji
: await state.database.getEmojiByGroup(group);
return summarizeEmojis(await filterEmojisByVersion(emoji));
}
async function getEmojisBySearchQuery(query) {
return summarizeEmojis(
await filterEmojisByVersion(
await state.database.getEmojiBySearchQuery(query)
)
);
}
createEffect(() => {});
//
// Derive currentEmojisWithCategories from currentEmojis. This is always done even if there
// are no categories, because it's just easier to code the HTML this way.
//
createEffect(() => {
function calculateCurrentEmojisWithCategories() {
const { searchMode, currentEmojis } = state;
if (searchMode) {
return [
{
category: "",
emojis: currentEmojis,
},
];
}
const categoriesToEmoji = new Map();
for (const emoji of currentEmojis) {
const category = emoji.category || "";
let emojis = categoriesToEmoji.get(category);
if (!emojis) {
emojis = [];
categoriesToEmoji.set(category, emojis);
}
emojis.push(emoji);
}
return [...categoriesToEmoji.entries()]
.map(([category, emojis]) => ({ category, emojis }))
.sort((a, b) => state.customCategorySorting(a.category, b.category));
}
const newEmojisWithCategories = calculateCurrentEmojisWithCategories();
updateCurrentEmojisWithCategories(newEmojisWithCategories);
});
//
// Handle active search item (i.e. pressing up or down while searching)
//
createEffect(() => {
state.activeSearchItemId =
state.activeSearchItem !== -1 &&
state.currentEmojis[state.activeSearchItem].id;
});
//
// Handle user input on the search input
//
createEffect(() => {
const { rawSearchText } = state;
rIC(() => {
state.searchText = (rawSearchText || "").trim(); // defer to avoid input delays, plus we can trim here
state.activeSearchItem = -1;
});
});
function onSearchKeydown(event) {
if (!state.searchMode || !state.currentEmojis.length) {
return;
}
const goToNextOrPrevious = (previous) => {
halt(event);
state.activeSearchItem = incrementOrDecrement(
previous,
state.activeSearchItem,
state.currentEmojis
);
};
switch (event.key) {
case "ArrowDown":
return goToNextOrPrevious(false);
case "ArrowUp":
return goToNextOrPrevious(true);
case "Enter":
if (state.activeSearchItem === -1) {
// focus the first option in the list since the list must be non-empty at this point (it's verified above)
state.activeSearchItem = 0;
} else {
// there is already an active search item
halt(event);
return clickEmoji(state.currentEmojis[state.activeSearchItem].id);
}
}
}
//
// Handle user input on nav
//
function onNavClick(event) {
const { target } = event;
const closestTarget = target.closest(".nav-button");
/* istanbul ignore if */
if (!closestTarget) {
return; // This should never happen, but makes me nervous not to have it
}
const groupId = parseInt(closestTarget.dataset.groupId, 10);
refs.searchElement.value = ""; // clear search box input
state.rawSearchText = "";
state.searchText = "";
state.activeSearchItem = -1;
state.currentGroupIndex = state.groups.findIndex((_) => _.id === groupId);
}
function onNavKeydown(event) {
const { target, key } = event;
const doFocus = (el) => {
if (el) {
halt(event);
el.focus();
}
};
switch (key) {
case "ArrowLeft":
return doFocus(target.previousElementSibling);
case "ArrowRight":
return doFocus(target.nextElementSibling);
case "Home":
return doFocus(target.parentElement.firstElementChild);
case "End":
return doFocus(target.parentElement.lastElementChild);
}
}
//
// Handle user input on an emoji
//
async function clickEmoji(unicodeOrName) {
const emoji = await state.database.getEmojiByUnicodeOrName(unicodeOrName);
const emojiSummary = [
...state.currentEmojis,
...state.currentFavorites,
].find((_) => _.id === unicodeOrName);
const skinTonedUnicode =
emojiSummary.unicode &&
unicodeWithSkin(emojiSummary, state.currentSkinTone);
await state.database.incrementFavoriteEmojiCount(unicodeOrName);
fireEvent("emoji-click", {
emoji,
skinTone: state.currentSkinTone,
...(skinTonedUnicode && { unicode: skinTonedUnicode }),
...(emojiSummary.name && { name: emojiSummary.name }),
});
}
async function onEmojiClick(event) {
const { target } = event;
/* istanbul ignore if */
if (!target.classList.contains("emoji")) {
// This should never happen, but makes me nervous not to have it
return;
}
halt(event);
const id = target.id.substring(4); // replace 'emo-' or 'fav-' prefix
/* no await */ clickEmoji(id);
}
//
// Handle user input on the skintone picker
//
function changeSkinTone(skinTone) {
state.currentSkinTone = skinTone;
state.skinTonePickerExpanded = false;
focus("skintone-button");
fireEvent("skin-tone-change", { skinTone });
/* no await */ state.database.setPreferredSkinTone(skinTone);
}
function onSkinToneOptionsClick(event) {
const {
target: { id },
} = event;
const match = id && id.match(/^skintone-(\d)/); // skintone option format
/* istanbul ignore if */
if (!match) {
// not a skintone option
return; // This should never happen, but makes me nervous not to have it
}
halt(event);
const skinTone = parseInt(match[1], 10); // remove 'skintone-' prefix
changeSkinTone(skinTone);
}
function onClickSkinToneButton(event) {
state.skinTonePickerExpanded = !state.skinTonePickerExpanded;
state.activeSkinTone = state.currentSkinTone;
// this should always be true, since the button is obscured by the listbox, so this `if` is just to be sure
if (state.skinTonePickerExpanded) {
halt(event);
rAF(() => focus("skintone-list"));
}
}
// To make the animation nicer, change the z-index of the skintone picker button
// *after* the animation has played. This makes it appear that the picker box
// is expanding "below" the button
createEffect(() => {
if (state.skinTonePickerExpanded) {
refs.skinToneDropdown.addEventListener(
"transitionend",
() => {
state.skinTonePickerExpandedAfterAnimation = true; // eslint-disable-line no-unused-vars
},
{ once: true }
);
} else {
state.skinTonePickerExpandedAfterAnimation = false; // eslint-disable-line no-unused-vars
}
});
function onSkinToneOptionsKeydown(event) {
// this should never happen, but makes me nervous not to have it
/* istanbul ignore if */
if (!state.skinTonePickerExpanded) {
return;
}
const changeActiveSkinTone = async (nextSkinTone) => {
halt(event);
state.activeSkinTone = nextSkinTone;
};
switch (event.key) {
case "ArrowUp":
return changeActiveSkinTone(
incrementOrDecrement(true, state.activeSkinTone, state.skinTones)
);
case "ArrowDown":
return changeActiveSkinTone(
incrementOrDecrement(false, state.activeSkinTone, state.skinTones)
);
case "Home":
return changeActiveSkinTone(0);
case "End":
return changeActiveSkinTone(state.skinTones.length - 1);
case "Enter":
// enter on keydown, space on keyup. this is just how browsers work for buttons
// https://lists.w3.org/Archives/Public/w3c-wai-ig/2019JanMar/0086.html
halt(event);
return changeSkinTone(state.activeSkinTone);
case "Escape":
halt(event);
state.skinTonePickerExpanded = false;
return focus("skintone-button");
}
}
function onSkinToneOptionsKeyup(event) {
// this should never happen, but makes me nervous not to have it
/* istanbul ignore if */
if (!state.skinTonePickerExpanded) {
return;
}
switch (event.key) {
case " ":
// enter on keydown, space on keyup. this is just how browsers work for buttons
// https://lists.w3.org/Archives/Public/w3c-wai-ig/2019JanMar/0086.html
halt(event);
return changeSkinTone(state.activeSkinTone);
}
}
async function onSkinToneOptionsFocusOut(event) {
// On blur outside of the skintone listbox, collapse the skintone picker.
const { relatedTarget } = event;
// The `else` should never happen, but makes me nervous not to have it
/* istanbul ignore else */
if (!relatedTarget || relatedTarget.id !== "skintone-list") {
state.skinTonePickerExpanded = false;
}
}
function onSearchInput(event) {
state.rawSearchText = event.target.value;
}
return {
$set(newState) {
assign(state, newState);
},
$destroy() {
abortController.abort();
},
};
}
const DEFAULT_DATA_SOURCE =
"https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/en/emojibase/data.json";
const DEFAULT_LOCALE = "en";
var enI18n = {
categoriesLabel: "Categories",
emojiUnsupportedMessage: "Your browser does not support color emoji.",
favoritesLabel: "Favorites",
loadingMessage: "Loadingβ¦",
networkErrorMessage: "Could not load emoji.",
regionLabel: "Emoji picker",
searchDescription:
"When search results are available, press up or down to select and enter to choose.",
searchLabel: "Search",
searchResultsLabel: "Search results",
skinToneDescription:
"When expanded, press up or down to select and enter to choose.",
skinToneLabel: "Choose a skin tone (currently {skinTone})",
skinTonesLabel: "Skin tones",
skinTones: [
"Default",
"Light",
"Medium-Light",
"Medium",
"Medium-Dark",
"Dark",
],
categories: {
custom: "Custom",
"smileys-emotion": "Smileys and emoticons",
"people-body": "People and body",
"animals-nature": "Animals and nature",
"food-drink": "Food and drink",
"travel-places": "Travel and places",
activities: "Activities",
objects: "Objects",
symbols: "Symbols",
flags: "Flags",
},
};
var baseStyles =
":host{--emoji-size:1.375rem;--emoji-padding:0.5rem;--category-emoji-size:var(--emoji-size);--category-emoji-padding:var(--emoji-padding);--indicator-height:3px;--input-border-radius:0.5rem;--input-border-size:1px;--input-font-size:1rem;--input-line-height:1.5;--input-padding:0.25rem;--num-columns:8;--outline-size:2px;--border-size:1px;--skintone-border-radius:1rem;--category-font-size:1rem;display:flex;width:min-content;height:400px}:host,:host(.light){color-scheme:light;--background:#fff;--border-color:#e0e0e0;--indicator-color:#385ac1;--input-border-color:#999;--input-font-color:#111;--input-placeholder-color:#999;--outline-color:#999;--category-font-color:#111;--button-active-background:#e6e6e6;--button-hover-background:#d9d9d9}:host(.dark){color-scheme:dark;--background:#222;--border-color:#444;--indicator-color:#5373ec;--input-border-color:#ccc;--input-font-color:#efefef;--input-placeholder-color:#ccc;--outline-color:#fff;--category-font-color:#efefef;--button-active-background:#555555;--button-hover-background:#484848}@media (prefers-color-scheme:dark){:host{color-scheme:dark;--background:#222;--border-color:#444;--indicator-color:#5373ec;--input-border-color:#ccc;--input-font-color:#efefef;--input-placeholder-color:#ccc;--outline-color:#fff;--category-font-color:#efefef;--button-active-background:#555555;--button-hover-background:#484848}}:host([hidden]){display:none}button{margin:0;padding:0;border:0;background:0 0;box-shadow:none;-webkit-tap-highlight-color:transparent}button::-moz-focus-inner{border:0}input{padding:0;margin:0;line-height:1.15;font-family:inherit}input[type=search]{-webkit-appearance:none}:focus{outline:var(--outline-color) solid var(--outline-size);outline-offset:calc(-1*var(--outline-size))}:host([data-js-focus-visible]) :focus:not([data-focus-visible-added]){outline:0}:focus:not(:focus-visible){outline:0}.hide-focus{outline:0}*{box-sizing:border-box}.picker{contain:content;display:flex;flex-direction:column;background:var(--background);border:var(--border-size) solid var(--border-color);width:100%;height:100%;overflow:hidden;--total-emoji-size:calc(var(--emoji-size) + (2 * var(--emoji-padding)));--total-category-emoji-size:calc(var(--category-emoji-size) + (2 * var(--category-emoji-padding)))}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.hidden{opacity:0;pointer-events:none}.abs-pos{position:absolute;left:0;top:0}.gone{display:none!important}.skintone-button-wrapper,.skintone-list{background:var(--background);z-index:3}.skintone-button-wrapper.expanded{z-index:1}.skintone-list{position:absolute;inset-inline-end:0;top:0;z-index:2;overflow:visible;border-bottom:var(--border-size) solid var(--border-color);border-radius:0 0 var(--skintone-border-radius) var(--skintone-border-radius);will-change:transform;transition:transform .2s ease-in-out;transform-origin:center 0}@media (prefers-reduced-motion:reduce){.skintone-list{transition-duration:.001s}}@supports not (inset-inline-end:0){.skintone-list{right:0}}.skintone-list.no-animate{transition:none}.tabpanel{overflow-y:auto;-webkit-overflow-scrolling:touch;will-change:transform;min-height:0;flex:1;contain:content}.emoji-menu{display:grid;grid-template-columns:repeat(var(--num-columns),var(--total-emoji-size));justify-content:space-around;align-items:flex-start;width:100%}.category{padding:var(--emoji-padding);font-size:var(--category-font-size);color:var(--category-font-color)}.custom-emoji,.emoji,button.emoji{height:var(--total-emoji-size);width:var(--total-emoji-size)}.emoji,button.emoji{font-size:var(--emoji-size);display:flex;align-items:center;justify-content:center;border-radius:100%;line-height:1;overflow:hidden;font-family:var(--emoji-font-family);cursor:pointer}@media (hover:hover) and (pointer:fine){.emoji:hover,button.emoji:hover{background:var(--button-hover-background)}}.emoji.active,.emoji:active,button.emoji.active,button.emoji:active{background:var(--button-active-background)}.custom-emoji{padding:var(--emoji-padding);object-fit:contain;pointer-events:none;background-repeat:no-repeat;background-position:center center;background-size:var(--emoji-size) var(--emoji-size)}.nav,.nav-button{align-items:center}.nav{display:grid;justify-content:space-between;contain:content}.nav-button{display:flex;justify-content:center}.nav-emoji{font-size:var(--category-emoji-size);width:var(--total-category-emoji-size);height:var(--total-category-emoji-size)}.indicator-wrapper{display:flex;border-bottom:1px solid var(--border-color)}.indicator{width:calc(100%/var(--num-groups));height:var(--indicator-height);opacity:var(--indicator-opacity);background-color:var(--indicator-color);will-change:transform,opacity;transition:opacity .1s linear,transform .25s ease-in-out}@media (prefers-reduced-motion:reduce){.indicator{will-change:opacity;transition:opacity .1s linear}}.pad-top,input.search{background:var(--background);width:100%}.pad-top{height:var(--emoji-padding);z-index:3}.search-row{display:flex;align-items:center;position:relative;padding-inline-start:var(--emoji-padding);padding-bottom:var(--emoji-padding)}.search-wrapper{flex:1;min-width:0}input.search{padding:var(--input-padding);border-radius:var(--input-border-radius);border:var(--input-border-size) solid var(--input-border-color);color:var(--input-font-color);font-size:var(--input-font-size);line-height:var(--input-line-height)}input.search::placeholder{color:var(--input-placeholder-color)}.favorites{display:flex;flex-direction:row;border-top:var(--border-size) solid var(--border-color);contain:content}.message{padding:var(--emoji-padding)}";
const PROPS = [
"customEmoji",
"customCategorySorting",
"database",
"dataSource",
"i18n",
"locale",
"skinToneEmoji",
"emojiVersion",
];
// Styles injected ourselves, so we can declare the FONT_FAMILY variable in one place
const EXTRA_STYLES = `:host{--emoji-font-family:${FONT_FAMILY}}`;
class PickerElement extends HTMLElement {
constructor(props) {
super();
this.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = baseStyles + EXTRA_STYLES;
this.shadowRoot.appendChild(style);
this._ctx = {
// Set defaults
locale: DEFAULT_LOCALE,
dataSource: DEFAULT_DATA_SOURCE,
skinToneEmoji: DEFAULT_SKIN_TONE_EMOJI,
customCategorySorting: DEFAULT_CATEGORY_SORTING,
customEmoji: null,
i18n: enI18n,
emojiVersion: null,
...props,
};
// Handle properties set before the element was upgraded
for (const prop of PROPS) {
if (
prop !== "database" &&
Object.prototype.hasOwnProperty.call(this, prop)
) {
this._ctx[prop] = this[prop];
delete this[prop];
}
}
this._dbFlush(); // wait for a flush before creating the db, in case the user calls e.g. a setter or setAttribute
}
connectedCallback() {
// The _cmp may be defined if the component was immediately disconnected and then reconnected. In that case,
// do nothing (preserve the state)
if (!this._cmp) {
this._cmp = createRoot(this.shadowRoot, this._ctx);
}
}
disconnectedCallback() {
// Check in a microtask if the element is still connected. If so, treat this as a "move" rather than a disconnect
// Inspired by Vue: https://vuejs.org/guide/extras/web-components.html#building-custom-elements-with-vue
qM(() => {
// this._cmp may be defined if connect-disconnect-connect-disconnect occurs synchronously
if (!this.isConnected && this._cmp) {
this._cmp.$destroy();
this._cmp = undefined;
const { database } = this._ctx;
database
.close()
// only happens if the database failed to load in the first place, so we don't care
.catch((err) => console.error(err));
}
});
}
static get observedAttributes() {
return ["locale", "data-source", "skin-tone-emoji", "emoji-version"]; // complex objects aren't supported, also use kebab-case
}
attributeChangedCallback(attrName, oldValue, newValue) {
this._set(
// convert from kebab-case to camelcase
// see https://github.com/sveltejs/svelte/issues/3852#issuecomment-665037015
attrName.replace(/-([a-z])/g, (_, up) => up.toUpperCase()),
// convert string attribute to float if necessary
attrName === "emoji-version" ? parseFloat(newValue) : newValue
);
}
_set(prop, newValue) {
this._ctx[prop] = newValue;
if (this._cmp) {
this._cmp.$set({ [prop]: newValue });
}
if (["locale", "dataSource"].includes(prop)) {
this._dbFlush();
}
}
_dbCreate() {
const { locale, dataSource, database } = this._ctx;
// only create a new database if we really need to
if (
!database ||
database.locale !== locale ||
database.dataSource !== dataSource
) {
this._set("database", new Database({ locale, dataSource }));
}
}
// Update the Database in one microtask if the locale/dataSource change. We do one microtask
// so we don't create two Databases if e.g. both the locale and the dataSource change
_dbFlush() {
qM(() => this._dbCreate());
}
}
const definitions = {};
for (const prop of PROPS) {
definitions[prop] = {
get() {
if (prop === "database") {
// in rare cases, the microtask may not be flushed yet, so we need to instantiate the DB
// now if the user is asking for it
this._dbCreate();
}
return this._ctx[prop];
},
set(val) {
if (prop === "database") {
throw new Error("database is read-only");
}
this._set(prop, val);
},
};
}
Object.defineProperties(PickerElement.prototype, definitions);
/* istanbul ignore else */
if (!customElements.get("emoji-picker")) {
// if already defined, do nothing (e.g. same script imported twice)
customElements.define("emoji-picker", PickerElement);
}
export { PickerElement as default };