feat: implement favorites

This commit is contained in:
Nolan Lawson 2020-06-12 20:14:47 -07:00
parent af7180bada
commit 596a702cb9
13 changed files with 263 additions and 74 deletions

View File

@ -108,6 +108,7 @@
"customElements",
"CustomEvent",
"fetch",
"getComputedStyle",
"indexedDB",
"IDBKeyRange",
"matchMedia",

View File

@ -1,4 +1,4 @@
import { uniqBy } from './uniqBy'
import { uniqBy } from '../../shared/uniqBy'
export function uniqEmoji (emojis) {
return uniqBy(emojis, _ => _.unicode)

View File

@ -93,7 +93,7 @@
aria-hidden="true">
<div class="indicator"
style={indicatorStyle}
use:calculateWidth>
use:calculateIndicatorWidth>
</div>
</div>
{#if message}
@ -110,14 +110,15 @@
role={searchMode ? 'listbox' : 'menu'}
aria-label={searchMode ? i18n.searchResultsLabel : i18n.categories[currentCategory.name]}
id="search-results"
use:calculateEmojiGridWith
>
{#each currentEmojis as emoji, i (emoji.unicode)}
<button role={searchMode ? 'option' : 'menuitem'}
{...(searchMode ? { 'aria-selected': i == activeSearchItem } : null)}
aria-label={emoji.unicode + ', ' + emoji.shortcodes.join(', ')}
title={emoji.shortcodes.join(', ')}
class="tabpanel-emoji emoji {searchMode && i === activeSearchItem ? 'active' : ''}"
id="emoji-{emoji.unicode}">
aria-label={emojiLabel(emoji)}
title={emojiTitle(emoji)}
class="emoji {searchMode && i === activeSearchItem ? 'active' : ''}"
data-emoji={emoji.unicode}>
{#if currentSkinTone > 0 && emoji.skins && emoji.skins[currentSkinTone]}
{emoji.skins[currentSkinTone]}
{:else}
@ -128,6 +129,26 @@
</div>
</div>
{/if}
<div class="favorites emoji-menu"
role="menu"
aria-label={i18n.favoritesLabel}
style="padding-right: {scrollbarWidth}px;"
on:click={onEmojiClick}
data-testid="favorites">
{#each currentFavorites as emoji, i (emoji.unicode)}
<button role="menuitem"
aria-label={emojiLabel(emoji)}
title={emojiTitle(emoji)}
class="emoji"
data-emoji={emoji.unicode}>
{#if currentSkinTone > 0 && emoji.skins && emoji.skins[currentSkinTone]}
{emoji.skins[currentSkinTone]}
{:else}
{emoji.unicode}
{/if}
</button>
{/each}
</div>
<div aria-hidden="true" class="hidden abs-pos">
<button tabindex="-1" class="emoji baseline-emoji" bind:this={baselineEmoji}>😀</button>
</div>

View File

@ -16,7 +16,13 @@ import { applySkinTone } from '../../utils/applySkinTone'
import { halt } from '../../utils/halt'
import { incrementOrDecrement } from '../../utils/incrementOrDecrement'
import { tick } from 'svelte'
import { DEFAULT_SKIN_TONE_EMOJI, TIMEOUT_BEFORE_LOADING_MESSAGE } from '../../constants'
import {
DEFAULT_NUM_COLUMNS,
DEFAULT_SKIN_TONE_EMOJI,
MOST_COMMONLY_USED_EMOJI,
TIMEOUT_BEFORE_LOADING_MESSAGE
} from '../../constants'
import { uniqBy } from '../../../shared/uniqBy'
let skinToneEmoji = DEFAULT_SKIN_TONE_EMOJI
let i18n = enI18n
@ -42,6 +48,11 @@ let style = '' // eslint-disable-line no-unused-vars
let skinToneButtonLabel = '' // eslint-disable-line no-unused-vars
let skinToneTextForSkinTone = ''
let skinTones = []
let currentFavorites = [] // eslint-disable-line no-unused-vars
let defaultFavoriteEmojisPromise
let numColumns = DEFAULT_NUM_COLUMNS
let scrollbarWidth = 0 // eslint-disable-line no-unused-vars
let shouldUpdateFavorites = {} // hack to force svelte to recalc favorites
const getBaselineEmojiWidth = thunk(() => calculateTextWidth(baselineEmoji))
@ -62,7 +73,6 @@ $: {
}, TIMEOUT_BEFORE_LOADING_MESSAGE)
try {
await database.ready()
currentSkinTone = await database.getPreferredSkinTone()
} catch (err) {
console.error(err)
message = i18n.networkError
@ -107,15 +117,80 @@ $: indicatorStyle = (resizeObserverSupported
: `transform: translateX(${currentCategoryIndex * 100}%);`// fallback to percent-based
)
$: {
async function updatePreferredSkinTone () {
if (database) {
currentSkinTone = await database.getPreferredSkinTone()
}
}
updatePreferredSkinTone()
}
$: {
async function updateDefaultFavoriteEmojis () {
if (database) {
defaultFavoriteEmojisPromise = (await Promise.all(MOST_COMMONLY_USED_EMOJI.map(unicode => (
database.getEmojiByUnicode(unicode)
)))).filter(Boolean) // filter because in Jest tests we don't have all the emoji in the DB
}
}
updateDefaultFavoriteEmojis()
}
$: {
async function getFavorites () {
const dbFavorites = await database.getTopFavoriteEmoji(numColumns)
const defaultFavorites = await defaultFavoriteEmojisPromise
const favs = uniqBy([
...dbFavorites,
...defaultFavorites
], _ => _.unicode).slice(0, numColumns)
return summarizeEmojis(favs)
}
async function updateFavorites () {
currentFavorites = await getFavorites()
}
if (database && shouldUpdateFavorites) {
updateFavorites()
}
}
// eslint-disable-next-line no-unused-vars
function calculateWidth (indicator) {
function calculateIndicatorWidth (node) {
return calculateWidth(node, width => {
computedIndicatorWidth = width
})
}
// eslint-disable-next-line no-unused-vars
function calculateEmojiGridWith (node) {
return calculateWidth(node, width => {
// Whenever the main emoji grid changes size, we need to
// 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)
const propValue = getComputedStyle(rootElement).getPropertyValue('--num-columns')
const newNumColumns = parseInt(propValue, 10) || DEFAULT_NUM_COLUMNS // in Jest we can't compute custom props
const parentWidth = node.parentElement.getBoundingClientRect().width
const newScrollbarWidth = parentWidth - width
numColumns = newNumColumns
scrollbarWidth = newScrollbarWidth
})
}
function calculateWidth (node, onUpdate) {
let resizeObserver
/* istanbul ignore if */
if (resizeObserverSupported) {
resizeObserver = new ResizeObserver(entries => {
computedIndicatorWidth = entries[0].contentRect.width
onUpdate(entries[0].contentRect.width)
})
resizeObserver.observe(node)
} else {
requestAnimationFrame(() => {
onUpdate(node.getBoundingClientRect().width)
})
resizeObserver.observe(indicator)
}
return {
@ -167,7 +242,7 @@ function checkZwjSupport (zwjEmojisToCheck) {
mark('checkZwjSupport')
const rootNode = rootElement.getRootNode()
for (const emoji of zwjEmojisToCheck) {
const domNode = rootNode.getElementById(`emoji-${emoji.unicode}`)
const domNode = rootNode.querySelector(`[data-emoji=${JSON.stringify(emoji.unicode)}]`)
if (!domNode) { // happens rarely, mostly in jest tests
continue
}
@ -295,16 +370,19 @@ async function clickEmoji (unicode) {
emojiSupportLevelPromise,
database.getEmojiByUnicode(unicode)
])
let unicodeWithSkin = unicode
if (currentSkinTone) {
const foundSkin = (emoji.skins || []).find(_ => _.tone === currentSkinTone)
if (foundSkin && foundSkin.version <= emojiSupportLevel) {
unicode = foundSkin.unicode
unicodeWithSkin = foundSkin.unicode
}
}
await database.incrementFavoriteEmojiCount(unicode)
shouldUpdateFavorites = shouldUpdateFavorites // eslint-disable-line no-self-assign
fireEvent('emoji-click', {
emoji,
skinTone: currentSkinTone,
unicode
unicode: unicodeWithSkin
})
}
@ -315,7 +393,7 @@ async function onEmojiClick (event) {
return
}
halt(event)
const unicode = target.id.substring(6) // remove 'emoji-'
const unicode = target.dataset.emoji
clickEmoji(unicode)
}
@ -386,6 +464,15 @@ async function onSkinToneOptionsBlur () {
skinTonePickerExpanded = false
}
}
// eslint-disable-next-line no-unused-vars
function emojiLabel (emoji) {
return emoji.unicode + ', ' + emoji.shortcodes.join(', ')
}
// eslint-disable-next-line no-unused-vars
function emojiTitle (emoji) {
return emoji.shortcodes.join(', ')
}
export {
database,

View File

@ -156,6 +156,15 @@ input.search {
}
}
// favorites
.favorites {
display: flex;
flex-direction: row;
border-top: var(--border-size) solid var(--border-color);
contain: content;
}
//
// unsupported warning
//

View File

@ -1,2 +1,24 @@
export const TIMEOUT_BEFORE_LOADING_MESSAGE = 1000 // 1 second
export const DEFAULT_SKIN_TONE_EMOJI = '🖐️'
export const DEFAULT_NUM_COLUMNS = 8
// Based on https://fivethirtyeight.com/features/the-100-most-used-emojis/ and
// https://blog.emojipedia.org/facebook-reveals-most-and-least-used-emojis/ with
// a bit of my own curation. (E.g. avoid the "OK" gesture because of connotations:
// https://emojipedia.org/ok-hand/)
export const MOST_COMMONLY_USED_EMOJI = [
'😊',
'😒',
'♥️',
'👍️',
'😍',
'😂',
'😭',
'☺️',
'😔',
'😩',
'😏',
'💕',
'🙌',
'😘'
]

View File

@ -4,6 +4,7 @@ export default {
networkError: 'Could not load emoji. Try refreshing.',
regionLabel: 'Emoji picker',
search: 'Search',
favoritesLabel: 'Favorites',
skinToneLabel: 'Choose a skin tone (currently {skinTone})',
skinToneDescription: 'When expanded, press up or down to select and enter to choose.',
skinTonesTitle: 'Skin tones',

View File

@ -52,6 +52,7 @@ export interface I18n {
skinTones: string[]
searchDescription: string
skinToneDescription: string
favoritesLabel: string
}
export interface I18nCategories {

View File

@ -11,13 +11,14 @@ describe('Picker tests', () => {
let picker
let container
const { getAllByRole, getByRole, queryAllByRole } = new Proxy(testingLibrary, {
const proxy = new Proxy(testingLibrary, {
get (obj, prop) {
return function (...args) {
return obj[prop](container, ...args)
}
}
})
const { getAllByRole, getByRole, queryAllByRole } = proxy
const activeElement = () => container.getRootNode().activeElement
@ -26,7 +27,9 @@ describe('Picker tests', () => {
picker = new Picker({ dataSource: ALL_EMOJI, locale: 'en' })
document.body.appendChild(picker)
container = picker.shadowRoot.querySelector('.picker')
await waitFor(() => expect(getAllByRole('menuitem')).toHaveLength(numInGroup1))
await waitFor(() => expect(
testingLibrary.getAllByRole(getByRole('tabpanel'), 'menuitem')).toHaveLength(numInGroup1)
)
})
afterEach(async () => {
basicAfterEach()
@ -43,12 +46,12 @@ describe('Picker tests', () => {
expect(getAllByRole('tab')).toHaveLength(9)
expect(getByRole('tab', { name: 'Smileys and emoticons', selected: true })).toBeVisible()
expect(getAllByRole('menuitem')).toHaveLength(numInGroup1)
expect(testingLibrary.getAllByRole(getByRole('tabpanel'), 'menuitem')).toHaveLength(numInGroup1)
expect(getByRole('tab', { name: 'People and body' })).toBeVisible()
fireEvent.click(getByRole('tab', { name: 'People and body' }))
await waitFor(() => expect(getAllByRole('menuitem')).toHaveLength(numInGroup2))
await waitFor(() => expect(testingLibrary.getAllByRole(getByRole('tabpanel'), 'menuitem')).toHaveLength(numInGroup2))
expect(getByRole('tab', { name: 'People and body', selected: true })).toBeVisible()
})
@ -102,7 +105,7 @@ describe('Picker tests', () => {
getByRole('tab', { name: 'Smileys and emoticons', selected: true }).focus()
const expectGroupLength = async group => {
await waitFor(() => expect(getAllByRole('menuitem'))
await waitFor(() => expect(testingLibrary.getAllByRole(getByRole('tabpanel'), 'menuitem'))
.toHaveLength(truncatedEmoji.filter(_ => _.group === group).length))
}
@ -129,7 +132,7 @@ describe('Picker tests', () => {
test('measures zwj emoji', async () => {
getByRole('tab', { name: 'Flags' }).click()
await tick(20)
await waitFor(() => expect(getAllByRole('menuitem'))
await waitFor(() => expect(testingLibrary.getAllByRole(getByRole('tabpanel'), 'menuitem'))
.toHaveLength(truncatedEmoji.filter(_ => _.group === 9).length))
})

View File

@ -0,0 +1,46 @@
import { waitFor, getByTestId, getAllByRole, getByRole, fireEvent } from '@testing-library/dom'
import { basicAfterEach, basicBeforeEach, tick, truncatedEmoji } from '../shared'
import Picker from '../../../src/picker/PickerElement'
import allData from 'emojibase-data/en/data.json'
import { MOST_COMMONLY_USED_EMOJI } from '../../../src/picker/constants'
import { uniqBy } from '../../../src/shared/uniqBy'
describe('Favorites UI', () => {
beforeEach(basicBeforeEach)
afterEach(basicAfterEach)
test('Favorites UI basic test', async () => {
const dataSource = 'with-favs.json'
const dataWithFavorites = uniqBy([
...truncatedEmoji,
...allData.filter(_ => MOST_COMMONLY_USED_EMOJI.includes(_.emoji))
], _ => _.emoji)
fetch.get(dataSource, () => new Response(JSON.stringify(dataWithFavorites), { headers: { ETag: 'W/favs' } }))
fetch.head(dataSource, () => new Response(null, { headers: { ETag: 'W/favs' } }))
const picker = new Picker({ dataSource, locale: 'en' })
document.body.appendChild(picker)
const container = picker.shadowRoot.querySelector('.picker')
// using a testId because testing-library seems to think role=menu has no aria-label
const favoritesBar = getByTestId(container, 'favorites')
expect(favoritesBar).toBeVisible()
await waitFor(() => expect(getAllByRole(favoritesBar, 'menuitem')).toHaveLength(8))
expect(getAllByRole(favoritesBar, 'menuitem').map(_ => _.getAttribute('data-emoji'))).toStrictEqual(
MOST_COMMONLY_USED_EMOJI.slice(0, 8)
)
fireEvent.click(getByRole(container, 'menuitem', { name: /🤣/ }))
await waitFor(() => expect(getAllByRole(favoritesBar, 'menuitem')
.map(_ => _.getAttribute('data-emoji'))).toStrictEqual([
'🤣',
...MOST_COMMONLY_USED_EMOJI.slice(0, 7)
]
))
await tick(20)
await picker.database.delete()
document.body.removeChild(picker)
})
})

View File

@ -0,0 +1,51 @@
import allEmojis from 'emojibase-data/en/data.json'
import { applySkinTone } from '../../../src/picker/utils/applySkinTone'
describe('skin tones tests', () => {
test.skip('can compute unicode based on tones', () => {
const debugIt = (str) => {
const res = []
for (let i = 0; i < str.length; i++) {
res.push(str.charCodeAt(i).toString(16))
}
return res
}
const wrong = []
const right = []
for (const emoji of allEmojis) {
if (emoji.skins) {
for (const skin of emoji.skins) {
if (typeof skin.tone === 'number') {
const actualUnicode = applySkinTone(emoji.emoji, skin.tone)
if (actualUnicode !== skin.emoji) {
wrong.push({ emoji, actualUnicode, skin })
} else {
right.push({ emoji, actualUnicode, skin })
}
// expect(actualUnicode).toBe(skin.emoji)
}
}
}
}
console.log('wrong', wrong.length, 'right', right.length)
for (const w of wrong) {
console.log('\n' + w.emoji.emoji + '\n' + debugIt(w.emoji.emoji).join(',') + '\n' +
debugIt(w.skin.emoji).join(',') + '\n' +
debugIt(w.actualUnicode).join(',') + '\n\n')
}
console.log('Right')
for (const r of right) {
console.log('\n' + r.emoji.emoji + '\n' + debugIt(r.emoji.emoji).join(',') + '\n' +
debugIt(r.skin.emoji).join(',') + '\n' +
debugIt(r.actualUnicode).join(',') + '\n\n')
}
})
test('can compute some correct unicode tones', () => {
expect(applySkinTone('👍', 0)).toBe('👍')
expect(applySkinTone('👍', 3)).toBe('👍🏽')
expect(applySkinTone('🧘‍♀️', 3)).toBe('🧘🏽‍♀️')
expect(applySkinTone('🤌', 2)).toBe('🤌🏼')
expect(applySkinTone('🖐️', 5)).toBe('🖐🏿')
})
})

View File

@ -1,53 +0,0 @@
import allEmojis from 'emojibase-data/en/data.json'
import { applySkinTone } from '../../../src/picker/utils/applySkinTone'
describe('skintones tests', () => {
describe('data tests', () => {
test.skip('can compute unicode based on tones', () => {
const debugIt = (str) => {
const res = []
for (let i = 0; i < str.length; i++) {
res.push(str.charCodeAt(i).toString(16))
}
return res
}
const wrong = []
const right = []
for (const emoji of allEmojis) {
if (emoji.skins) {
for (const skin of emoji.skins) {
if (typeof skin.tone === 'number') {
const actualUnicode = applySkinTone(emoji.emoji, skin.tone)
if (actualUnicode !== skin.emoji) {
wrong.push({ emoji, actualUnicode, skin })
} else {
right.push({ emoji, actualUnicode, skin })
}
// expect(actualUnicode).toBe(skin.emoji)
}
}
}
}
console.log('wrong', wrong.length, 'right', right.length)
for (const w of wrong) {
console.log('\n' + w.emoji.emoji + '\n' + debugIt(w.emoji.emoji).join(',') + '\n' +
debugIt(w.skin.emoji).join(',') + '\n' +
debugIt(w.actualUnicode).join(',') + '\n\n')
}
console.log('Right')
for (const r of right) {
console.log('\n' + r.emoji.emoji + '\n' + debugIt(r.emoji.emoji).join(',') + '\n' +
debugIt(r.skin.emoji).join(',') + '\n' +
debugIt(r.actualUnicode).join(',') + '\n\n')
}
})
test('can compute some correct unicode tones', () => {
expect(applySkinTone('👍', 0)).toBe('👍')
expect(applySkinTone('👍', 3)).toBe('👍🏽')
expect(applySkinTone('🧘‍♀️', 3)).toBe('🧘🏽‍♀️')
expect(applySkinTone('🤌', 2)).toBe('🤌🏼')
expect(applySkinTone('🖐️', 5)).toBe('🖐🏿')
})
})
})