diff --git a/README.md b/README.md index 202f8e5..ab96bf9 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ A lightweight emoji picker, distributed as a web component. - Supports [Emoji v14.0](https://emojipedia.org/emoji-14.0/) (depending on OS) and custom emoji - Uses IndexedDB, so it consumes [far less memory](https://nolanlawson.com/2020/06/28/introducing-emoji-picker-element-a-memory-efficient-emoji-picker-for-the-web/) than other emoji pickers -- [Small bundle size](https://bundlephobia.com/result?p=emoji-picker-element) (41kB minified, ~14.4kB gzipped) -- Renders native emoji only, no spritesheets +- [Small bundle size](https://bundlephobia.com/result?p=emoji-picker-element) (<15kB min+gz) +- Renders native emoji by default, with support for custom fonts - [Accessible by default](https://nolanlawson.com/2020/07/01/building-an-accessible-emoji-picker/) - Framework and bundler not required, just add a ` + + \ No newline at end of file diff --git a/docs/demos/twemoji/index.html b/docs/demos/twemoji/index.html deleted file mode 100644 index 3fbc8f1..0000000 --- a/docs/demos/twemoji/index.html +++ /dev/null @@ -1,44 +0,0 @@ - - - - emoji-picker-element: using Twemoji - - -

emoji-picker-element: using Twemoji

-

- This demo shows how to use emoji-picker-element with Twemoji. - Note that this carries a performance cost because of 1) using MutationObserver to monitor for DOM changes, and 2) downloading Twemoji images. - Use this approach with care. -

- - - - - - \ No newline at end of file diff --git a/picker.d.ts b/picker.d.ts index 8431443..e287072 100644 --- a/picker.d.ts +++ b/picker.d.ts @@ -8,14 +8,15 @@ export default class Picker extends HTMLElement { customCategorySorting?: (a: string, b: string) => number; /** * - * @param dataSource - URL to fetch the emoji data from (`data-source` when used as an attribute) - * @param locale - Locale string - * @param i18n - i18n object (see below for details) - * @param skinToneEmoji - The emoji to use for the skin tone picker (`skin-tone-emoji` when used as an attribute) - * @param customEmoji - Array of custom emoji - * @param customCategorySorting - Function to sort custom category strings (sorted alphabetically by default) + * @param dataSource - URL to fetch the emoji data from (`data-source` when used as an attribute). + * @param locale - Locale string. + * @param i18n - i18n object (see below for details). + * @param skinToneEmoji - The emoji to use for the skin tone picker (`skin-tone-emoji` when used as an attribute). + * @param customEmoji - Array of custom emoji. + * @param customCategorySorting - Function to sort custom category strings (sorted alphabetically by default). + * @param emojiVersion - Maximum supported emoji version as a number (e.g. `14.0` or `13.1`). Setting this disables the default emoji support detection. */ - constructor({ dataSource, locale, i18n, skinToneEmoji, customEmoji, customCategorySorting }?: PickerConstructorOptions); + constructor({ dataSource, locale, i18n, skinToneEmoji, customEmoji, customCategorySorting, emojiVersion }?: PickerConstructorOptions); addEventListener(type: K, listener: (this: Picker, ev: EmojiPickerEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; diff --git a/shared.d.ts b/shared.d.ts index 051b838..ae680c0 100644 --- a/shared.d.ts +++ b/shared.d.ts @@ -35,6 +35,7 @@ export interface PickerConstructorOptions { skinToneEmoji?: string; customEmoji?: CustomEmoji[]; customCategorySorting?: (a: string, b: string) => number; + emojiVersion?: number; } export interface I18n { emojiUnsupportedMessage: string; diff --git a/src/picker/PickerElement.js b/src/picker/PickerElement.js index 2b3358f..2186797 100644 --- a/src/picker/PickerElement.js +++ b/src/picker/PickerElement.js @@ -1,6 +1,6 @@ import SveltePicker from './components/Picker/Picker.svelte' import { DEFAULT_DATA_SOURCE, DEFAULT_LOCALE } from '../database/constants' -import { DEFAULT_CATEGORY_SORTING, DEFAULT_SKIN_TONE_EMOJI } from './constants' +import { DEFAULT_CATEGORY_SORTING, DEFAULT_SKIN_TONE_EMOJI, FONT_FAMILY } from './constants' import enI18n from './i18n/en.js' import Database from './ImportedDatabase' @@ -11,16 +11,20 @@ const PROPS = [ 'dataSource', 'i18n', 'locale', - 'skinToneEmoji' + '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}}` + export default class PickerElement extends HTMLElement { constructor (props) { performance.mark('initialLoad') super() this.attachShadow({ mode: 'open' }) const style = document.createElement('style') - style.textContent = process.env.STYLES + style.textContent = process.env.STYLES + EXTRA_STYLES this.shadowRoot.appendChild(style) this._ctx = { // Set defaults @@ -30,6 +34,7 @@ export default class PickerElement extends HTMLElement { customCategorySorting: DEFAULT_CATEGORY_SORTING, customEmoji: null, i18n: enI18n, + emojiVersion: null, ...props } // Handle properties set before the element was upgraded @@ -62,15 +67,16 @@ export default class PickerElement extends HTMLElement { } static get observedAttributes () { - return ['locale', 'data-source', 'skin-tone-emoji'] // complex objects aren't supported, also use kebab-case + return ['locale', 'data-source', 'skin-tone-emoji', 'emoji-version'] // complex objects aren't supported, also use kebab-case } attributeChangedCallback (attrName, oldValue, newValue) { - // convert from kebab-case to camelcase - // see https://github.com/sveltejs/svelte/issues/3852#issuecomment-665037015 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()), - newValue + // convert string attribute to float if necessary + attrName === 'emoji-version' ? parseFloat(newValue) : newValue ) } diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index 6b46999..6c9751c 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -1,16 +1,15 @@ /* eslint-disable prefer-const,no-labels,no-inner-declarations */ - +import { onMount, tick } from 'svelte' import { groups as defaultGroups, customGroup } from '../../groups' import { MIN_SEARCH_TEXT_LENGTH, NUM_SKIN_TONES } from '../../../shared/constants' import { requestIdleCallback } from '../../utils/requestIdleCallback' import { hasZwj } from '../../utils/hasZwj' -import { emojiSupportLevelPromise, supportedZwjEmojis } from '../../utils/emojiSupport' +import { detectEmojiSupportLevel, supportedZwjEmojis } from '../../utils/emojiSupport' import { applySkinTone } from '../../utils/applySkinTone' import { halt } from '../../utils/halt' import { incrementOrDecrement } from '../../utils/incrementOrDecrement' import { DEFAULT_NUM_COLUMNS, - FONT_FAMILY, MOST_COMMONLY_USED_EMOJI, TIMEOUT_BEFORE_LOADING_MESSAGE } from '../../constants' @@ -19,7 +18,6 @@ import { summarizeEmojisForUI } from '../../utils/summarizeEmojisForUI' import * as widthCalculator from '../../utils/widthCalculator' import { checkZwjSupport } from '../../utils/checkZwjSupport' import { requestPostAnimationFrame } from '../../utils/requestPostAnimationFrame' -import { tick } from 'svelte' import { requestAnimationFrame } from '../../utils/requestAnimationFrame' import { uniq } from '../../../shared/uniq' import { resetScrollTopIfPossible } from '../../utils/resetScrollTopIfPossible.js' @@ -30,6 +28,7 @@ export let i18n export let database export let customEmoji export let customCategorySorting +export let emojiVersion // private let initialLoad = true @@ -97,11 +96,15 @@ const isSkinToneOption = element => /^skintone-/.test(element.id) // Determine the emoji support level (in requestIdleCallback) // -emojiSupportLevelPromise.then(level => { - // Can't actually test emoji support in Jest/JSDom, emoji never render in color in Cairo - /* istanbul ignore next */ - if (!level) { - message = i18n.emojiUnsupportedMessage +onMount(() => { + if (!emojiVersion) { + detectEmojiSupportLevel().then(level => { + // Can't actually test emoji support in Jest/JSDom, emoji never render in color in Cairo + /* istanbul ignore next */ + if (!level) { + message = i18n.emojiUnsupportedMessage + } + }) } }) @@ -142,7 +145,6 @@ $: { /* eslint-disable no-unused-vars */ $: pickerStyle = ` - --font-family: ${FONT_FAMILY}; --num-groups: ${groups.length}; --indicator-opacity: ${searchMode ? 0 : 1}; --num-skintones: ${NUM_SKIN_TONES};` @@ -298,11 +300,11 @@ $: { const zwjEmojisToCheck = currentEmojis .filter(emoji => emoji.unicode) // filter custom emoji .filter(emoji => hasZwj(emoji) && !supportedZwjEmojis.has(emoji.unicode)) - if (zwjEmojisToCheck.length) { + if (!emojiVersion && zwjEmojisToCheck.length) { // render now, check their length later requestAnimationFrame(() => checkZwjSupportAndUpdate(zwjEmojisToCheck)) } else { - currentEmojis = currentEmojis.filter(isZwjSupported) + currentEmojis = emojiVersion ? currentEmojis : currentEmojis.filter(isZwjSupported) // Reset scroll top to 0 when emojis change requestAnimationFrame(() => resetScrollTopIfPossible(tabpanelElement)) } @@ -321,13 +323,13 @@ function isZwjSupported (emoji) { } async function filterEmojisByVersion (emojis) { - const emojiSupportLevel = await emojiSupportLevelPromise + const emojiSupportLevel = emojiVersion || await detectEmojiSupportLevel() // !version corresponds to custom emoji return emojis.filter(({ version }) => !version || version <= emojiSupportLevel) } async function summarizeEmojis (emojis) { - return summarizeEmojisForUI(emojis, await emojiSupportLevelPromise) + return summarizeEmojisForUI(emojis, emojiVersion || await detectEmojiSupportLevel()) } async function getEmojisByGroup (group) { diff --git a/src/picker/constants.js b/src/picker/constants.js index e6b2df6..ba1af5d 100644 --- a/src/picker/constants.js +++ b/src/picker/constants.js @@ -24,7 +24,7 @@ export const MOST_COMMONLY_USED_EMOJI = [ ] // It's important to list Twemoji Mozilla before everything else, because Mozilla bundles their -// own font on some platforms (notably Windows and Linux as of this writing). Typically Mozilla +// own font on some platforms (notably Windows and Linux as of this writing). Typically, Mozilla // updates faster than the underlying OS, and we don't want to render older emoji in one font and // newer emoji in another font: // https://github.com/nolanlawson/emoji-picker-element/pull/268#issuecomment-1073347283 diff --git a/src/picker/styles/picker.scss b/src/picker/styles/picker.scss index da4170d..1678380 100644 --- a/src/picker/styles/picker.scss +++ b/src/picker/styles/picker.scss @@ -89,7 +89,7 @@ button.emoji, width: var(--total-emoji-size); line-height: 1; overflow: hidden; - font-family: var(--font-family); + font-family: var(--emoji-font-family); cursor: pointer; // see https://css-tricks.com/solving-sticky-hover-states-with-media-hover-hover/ diff --git a/src/picker/utils/emojiSupport.js b/src/picker/utils/emojiSupport.js index 8bca299..45b3bff 100644 --- a/src/picker/utils/emojiSupport.js +++ b/src/picker/utils/emojiSupport.js @@ -1,18 +1,28 @@ import { determineEmojiSupportLevel } from './determineEmojiSupportLevel' -import { requestIdleCallback } from './requestIdleCallback' +import { requestIdleCallback } from './requestIdleCallback.js' + // Check which emojis we know for sure aren't supported, based on Unicode version level -export const emojiSupportLevelPromise = new Promise(resolve => ( - requestIdleCallback(() => ( - resolve(determineEmojiSupportLevel()) // delay so ideally this can run while IDB is first populating - )) -)) +let promise +export const detectEmojiSupportLevel = () => { + if (!promise) { + // Delay so it can run while the IDB database is being created by the browser (on another thread). + // This helps especially with first load – we want to start pre-populating the database on the main thread, + // and then wait for IDB to commit everything, and while waiting we run this check. + promise = new Promise(resolve => ( + requestIdleCallback(() => ( + resolve(determineEmojiSupportLevel()) // delay so ideally this can run while IDB is first populating + )) + )) + + /* istanbul ignore else */ + if (process.env.NODE_ENV !== 'production') { + promise.then(emojiSupportLevel => { + console.log('emoji support level', emojiSupportLevel) + }) + } + } + return promise +} // determine which emojis containing ZWJ (zero width joiner) characters // are supported (rendered as one glyph) rather than unsupported (rendered as two or more glyphs) export const supportedZwjEmojis = new Map() - -/* istanbul ignore else */ -if (process.env.NODE_ENV !== 'production') { - emojiSupportLevelPromise.then(emojiSupportLevel => { - console.log('emoji support level', emojiSupportLevel) - }) -} diff --git a/test/spec/picker/emojiVersion.test.js b/test/spec/picker/emojiVersion.test.js new file mode 100644 index 0000000..4de92f9 --- /dev/null +++ b/test/spec/picker/emojiVersion.test.js @@ -0,0 +1,45 @@ +import { ALL_EMOJI, basicAfterEach, basicBeforeEach, tick } from '../shared.js' +import Picker from '../../../src/picker/PickerElement.js' +import * as testingLibrary from '@testing-library/dom' +import { getByRole, waitFor } from '@testing-library/dom' +import Database from '../../../src/database/Database.js' + +describe('Picker custom emojiVersion tests', () => { + let picker + + beforeEach(async () => { + await basicBeforeEach() + }) + + afterEach(async () => { + await tick(20) + document.body.removeChild(picker) + await tick(20) + await new Database({ dataSource: ALL_EMOJI, locale: 'en' }).delete() + await tick(20) + await basicAfterEach() + }) + + test('can use the emojiVersion property', async () => { + picker = new Picker({ dataSource: ALL_EMOJI, locale: 'en', emojiVersion: '10.0' }) + document.body.appendChild(picker) + + await waitFor(() => expect( + // Normally this would be 20, but because we set the emoji version to 10.0, the 🥰 is excluded + testingLibrary.getAllByRole(getByRole(picker.shadowRoot, 'tabpanel'), 'menuitem')).toHaveLength(19), + { timeout: 2000 } + ) + }) + + test('can use the emoji-version attribute', async () => { + picker = new Picker({ dataSource: ALL_EMOJI, locale: 'en' }) + picker.setAttribute('emoji-version', '10.0') + document.body.appendChild(picker) + + await waitFor(() => expect( + // Normally this would be 20, but because we set the emoji version to 10.0, the 🥰 is excluded + testingLibrary.getAllByRole(getByRole(picker.shadowRoot, 'tabpanel'), 'menuitem')).toHaveLength(19), + { timeout: 2000 } + ) + }) +})