fix: finish custom categories

This commit is contained in:
Nolan Lawson 2020-06-16 20:17:42 -07:00
parent 07cd30c8e0
commit fa16515fd0
8 changed files with 124 additions and 81 deletions

View File

@ -78,19 +78,19 @@
</div>
<div class="nav"
role="tablist"
style="grid-template-columns: repeat({categories.length}, 1fr);"
style="grid-template-columns: repeat({groups.length}, 1fr);"
aria-label={i18n.categoriesLabel}
on:keydown={onNavKeydown}>
{#each categories as category (category.group)}
{#each groups as group (group.id)}
<button role="tab"
class="nav-button"
aria-controls="tab-{category.group}"
aria-label={i18n.categories[category.name]}
aria-selected={currentCategory.group === category.group}
title={i18n.categories[category.name]}
on:click={() => onCategoryClick(category)}>
aria-controls="tab-{group.id}"
aria-label={i18n.categories[group.name]}
aria-selected={group.id === group.id}
title={i18n.categories[group.name]}
on:click={() => onNavClick(group)}>
<div class="emoji">
{category.emoji}
{group.emoji}
</div>
</button>
{/each}
@ -111,41 +111,41 @@
<div class="tabpanel {message ? 'gone': ''}"
role={searchMode ? 'region' : 'tabpanel'}
aria-label={searchMode ? i18n.searchResultsLabel : i18n.categories[currentCategory.name]}
id={searchMode ? '' : `tab-${currentCategory.group}`}
aria-label={searchMode ? i18n.searchResultsLabel : i18n.categories[currentGroup.name]}
id={searchMode ? '' : `tab-${currentGroup.id}`}
tabindex="0"
on:click={onEmojiClick}
>
{#each currentEmojisWithCategories as emojiWithCategory (emojiWithCategory.category)}
<div class="emoji-menu"
role={searchMode ? 'listbox' : 'menu'}
aria-label={searchMode ? i18n.searchResultsLabel : emojiWithCategory.category ? emojiWithCategory.category : i18n.categories[currentCategory.name]}
{...(searchMode ? { 'id': 'search-results' } : null)}
use:calculateEmojiGridWith
>
{#if currentEmojisWithCategories.length}
<span class="category" aria-hidden="true">
{emojiWithCategory.category ? emojiWithCategory.category : i18n.categories[currentCategory.name]}
</span>
{#each currentEmojisWithCategories as emojiWithCategory (emojiWithCategory.category)}
{#if currentEmojisWithCategories.length > 1}
<div class="category" aria-hidden="true">
{emojiWithCategory.category || i18n.categories[currentGroup.name]}
</div>
{/if}
{#each emojiWithCategory.emojis as emoji, i (emoji.id)}
<button role={searchMode ? 'option' : 'menuitem'}
{...(searchMode ? { 'aria-selected': i == activeSearchItem } : null)}
aria-label={emoji.label}
title={emoji.title}
class="emoji {searchMode && i === activeSearchItem ? 'active' : ''}"
data-emoji={emoji.id}>
{#if emoji.unicode}
{unicodeWithSkin(emoji, currentSkinTone)}
{:else}
<img class="custom-emoji"
src={emoji.url}
loading="lazy"
alt=""
/>
{/if}
</button>
{/each}
<div class="emoji-menu"
role={searchMode ? 'listbox' : 'menu'}
aria-label={searchMode ? i18n.searchResultsLabel : (emojiWithCategory.category || i18n.categories[currentGroup.name])}
{...(searchMode ? { 'id': 'search-results' } : null)}
use:calculateEmojiGridWith
>
{#each emojiWithCategory.emojis as emoji, i (emoji.id)}
<button role={searchMode ? 'option' : 'menuitem'}
{...(searchMode ? { 'aria-selected': i == activeSearchItem } : null)}
aria-label={emoji.label}
title={emoji.title}
class="emoji {searchMode && i === activeSearchItem ? 'active' : ''}"
data-emoji={emoji.id}>
{#if emoji.unicode}
{unicodeWithSkin(emoji, currentSkinTone)}
{:else}
<img class="custom-emoji"
src={emoji.url}
loading="lazy"
alt=""
/>
{/if}
</button>
{/each}
</div>
{/each}
</div>

View File

@ -2,7 +2,7 @@
import Database from '../../ImportedDatabase'
import enI18n from '../../i18n/en'
import { categories as defaultCategories, customCategory } from '../../categories'
import { groups as defaultGroups, customGroup } from '../../groups'
import { DEFAULT_LOCALE, DEFAULT_DATA_SOURCE } from '../../../database/constants'
import { MIN_SEARCH_TEXT_LENGTH, NUM_SKIN_TONES } from '../../../shared/constants'
import { requestIdleCallback } from '../../utils/requestIdleCallback'
@ -36,7 +36,7 @@ let customEmoji = null
// private
let initialLoad = true
let currentEmojis = []
let currentEmojisWithCategories = []
let currentEmojisWithCategories = [] // eslint-disable-line no-unused-vars
let rawSearchText = ''
let searchText = ''
let rootElement
@ -60,9 +60,9 @@ let currentFavorites = [] // eslint-disable-line no-unused-vars
let defaultFavoriteEmojis
let numColumns = DEFAULT_NUM_COLUMNS
let scrollbarWidth = 0 // eslint-disable-line no-unused-vars
let currentCategoryIndex = 0
let categories = defaultCategories
let currentCategory
let currentGroupIndex = 0
let groups = defaultGroups
let currentGroup
//
// Utils/helpers
@ -142,7 +142,7 @@ Promise.resolve().then(() => {
$: style = `
--font-family: ${FONT_FAMILY};
--num-categories: ${categories.length};
--num-groups: ${groups.length};
--indicator-opacity: ${searchMode ? 0 : 1};
--num-skintones: ${NUM_SKIN_TONES};`
@ -168,9 +168,9 @@ $: {
$: {
if (customEmoji && customEmoji.length) {
categories = [customCategory, ...defaultCategories]
} else if (categories !== defaultCategories) {
categories = defaultCategories
groups = [customGroup, ...defaultGroups]
} else if (groups !== defaultGroups) {
groups = defaultGroups
}
}
@ -248,10 +248,10 @@ function calculateEmojiGridWith (node) {
}
//
// Update the current category based on the currentCategoryIndex
// Update the current group based on the currentGroupIndex
//
$: currentCategory = categories[currentCategoryIndex]
$: currentGroup = groups[currentGroupIndex]
//
// Animate the indicator
@ -271,9 +271,9 @@ function calculateIndicatorWidth (node) {
$: {
/* istanbul ignore if */
if (resizeObserverSupported) {
indicatorStyle = `transform: translateX(${currentCategoryIndex * computedIndicatorWidth}px);` // exact pixels
indicatorStyle = `transform: translateX(${currentGroupIndex * computedIndicatorWidth}px);` // exact pixels
} else {
indicatorStyle = `transform: translateX(${currentCategoryIndex * 100}%);`// fallback to percent-based
indicatorStyle = `transform: translateX(${currentGroupIndex * 100}%);`// fallback to percent-based
}
}
@ -290,9 +290,9 @@ $: {
} else if (searchText.length >= MIN_SEARCH_TEXT_LENGTH) {
searchMode = true
currentEmojis = await getEmojisBySearchQuery(searchText)
} else if (currentCategory) {
} else if (currentGroup) {
searchMode = false
currentEmojis = await getEmojisByGroup(currentCategory.group)
currentEmojis = await getEmojisByGroup(currentGroup.id)
}
}
/* no await */ updateEmojis()
@ -368,20 +368,20 @@ async function getEmojisBySearchQuery (query) {
//
$: {
function calculateCurrentEmojisWithCategories() {
const categoriesToEmoji = {}
for (const currentEmoji of currentEmojis) {
const category = currentEmoji.category || ''
if (currentEmoji.category) {
categoriesToEmoji[category] = categoriesToEmoji[category] || []
categoriesToEmoji[category].push(currentEmoji)
function calculateCurrentEmojisWithCategories () {
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)
}
const res = []
for (const key of Object.keys(categoriesToEmoji).sort()) {
res.push({ category: key, emojis: categoriesToEmoji[key] })
}
return res
return [...categoriesToEmoji.entries()]
.map(([category, emojis]) => ({ category, emojis }))
.sort((a, b) => a.category < b.category ? -1 : 1)
}
currentEmojisWithCategories = calculateCurrentEmojisWithCategories()
@ -427,11 +427,11 @@ function onSearchKeydown (event) {
//
// eslint-disable-next-line no-unused-vars
function onCategoryClick (category) {
function onNavClick (group) {
rawSearchText = ''
searchText = ''
activeSearchItem = -1
currentCategoryIndex = categories.findIndex(_ => _.group === category.group)
currentGroupIndex = groups.findIndex(_ => _.id === group.id)
}
// eslint-disable-next-line no-unused-vars

View File

@ -68,6 +68,11 @@ $skintoneZIndex3: 3;
width: 100%;
}
.category {
padding: var(--emoji-padding);
font-size: var(--category-font-size);
}
//
// emoji
//
@ -137,7 +142,7 @@ button.emoji,
.indicator {
$opacityAnim: 0.1s;
$transformAnim: 0.25s;
width: calc(100% / var(--num-categories));
width: calc(100% / var(--num-groups));
height: var(--indicator-height);
opacity: var(--indicator-opacity);
background-color: var(--indicator-color);

View File

@ -1,5 +1,5 @@
// via https://unpkg.com/browse/emojibase-data@5.0.1/meta/groups.json
const allCategories = [
const allGroups = [
[-1, '✨', 'custom'],
[0, '😀', 'smileys-emotion'],
[1, '👋', 'people-body'],
@ -10,7 +10,7 @@ const allCategories = [
[7, '📝', 'objects'],
[8, '⛔️', 'symbols'],
[9, '🏁', 'flags']
].map(([group, emoji, name]) => ({ group, emoji, name }))
].map(([id, emoji, name]) => ({ id, emoji, name }))
export const categories = allCategories.slice(1)
export const customCategory = allCategories[0]
export const groups = allGroups.slice(1)
export const customGroup = allGroups[0]

View File

@ -11,6 +11,7 @@
--outline-size: 2px; /* Focus outline width */
--border-size: 1px; /* Width of border used in most of the picker */
--skintone-border-radius: 1rem; /* border radius of the skintone dropdown */
--category-font-size: 1rem; /* Font size of custom emoji category headings */
}
@mixin colors($dark) {

View File

@ -16,13 +16,14 @@ export function summarizeEmojisForUI (emojis, emojiSupportLevel) {
return res
}
return emojis.map(({ unicode, skins, shortcodes, url, name }) => ({
return emojis.map(({ unicode, skins, shortcodes, url, name, category }) => ({
unicode,
name,
skins: skins && toSimpleSkinsMap(skins),
label: uniq([(unicode || name), ...shortcodes]).join(', '),
title: shortcodes.join(', '),
url,
id: unicode || name
id: unicode || name,
category
}))
}

View File

@ -2,7 +2,7 @@ import { basicBeforeEach, basicAfterEach, ALL_EMOJI, truncatedEmoji, tick } from
import * as testingLibrary from '@testing-library/dom'
import Picker from '../../../src/picker/PickerElement.js'
import userEvent from '@testing-library/user-event'
import { categories } from '../../../src/picker/categories'
import { groups } from '../../../src/picker/groups'
const { waitFor, fireEvent } = testingLibrary
const { type } = userEvent
@ -44,7 +44,7 @@ describe('Picker tests', () => {
test('basic picker test', async () => {
await waitFor(() => expect(getByRole('button', { name: 'Choose a skin tone (currently Default)' })).toBeVisible())
expect(getAllByRole('tab')).toHaveLength(categories.length)
expect(getAllByRole('tab')).toHaveLength(groups.length)
expect(getByRole('tab', { name: 'Smileys and emoticons', selected: true })).toBeVisible()
await waitFor(() => expect(
@ -263,4 +263,40 @@ describe('Picker tests', () => {
fireEvent.focusOut(getByRole('listbox', { name: 'Skin tones' }))
await waitFor(() => expect(queryAllByRole('listbox', { name: 'Skin tones' })).toHaveLength(0))
})
test('Custom emoji with categories', async () => {
picker.customEmoji = [
{
name: 'monkey',
shortcodes: ['monkey'],
url: 'monkey.png',
category: 'Primates'
},
{
name: 'donkey',
shortcodes: ['donkey'],
url: 'donkey.png',
category: 'Ungulates'
},
{
name: 'horse',
shortcodes: ['horse'],
url: 'horse.png',
category: 'Ungulates'
},
{
name: 'human',
shortcodes: ['human'],
url: 'human.png'
}
]
await waitFor(() => expect(getAllByRole('tab')).toHaveLength(groups.length + 1))
await waitFor(() => expect(getAllByRole('menu')).toHaveLength(4)) // favorites + three custom categories
await waitFor(() => expect(getByRole('menuitem', { name: 'human' })).toBeVisible())
await waitFor(() => expect(getByRole('menuitem', { name: 'donkey' })).toBeVisible())
await waitFor(() => expect(getByRole('menuitem', { name: 'monkey' })).toBeVisible())
await waitFor(() => expect(getByRole('menuitem', { name: 'horse' })).toBeVisible())
// TODO: can't actually test the category names because they're only exposed as menus, and
// testing-library doesn't seem to understand that menus can have aria-labels
})
})

View File

@ -7,7 +7,7 @@ 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'
import { categories } from '../../../src/picker/categories'
import { groups } from '../../../src/picker/groups'
describe('Favorites UI', () => {
let picker
@ -73,13 +73,13 @@ describe('Favorites UI', () => {
}
]
await waitFor(() => expect(getAllByRole(container, 'tab')).toHaveLength(categories.length))
await waitFor(() => expect(getAllByRole(container, 'tab')).toHaveLength(groups.length))
// when setting custom emoji, they can appear in the favorites
picker.customEmoji = customEmoji
await waitFor(() => expect(getAllByRole(container, 'tab')).toHaveLength(categories.length + 1))
await waitFor(() => expect(getAllByRole(container, 'tab')).toHaveLength(groups.length + 1))
expect(getByRole(container, 'tab', { name: 'Custom', selected: true })).toBeVisible()
await waitFor(() => expect(getByRole(container, 'menuitem', { name: /transparent/i })).toBeVisible())
@ -98,7 +98,7 @@ describe('Favorites UI', () => {
// when setting custom emoji back to [], the favorites bar removes the custom emoji
picker.customEmoji = []
await waitFor(() => expect(getAllByRole(container, 'tab')).toHaveLength(categories.length))
await waitFor(() => expect(getAllByRole(container, 'tab')).toHaveLength(groups.length))
await waitFor(
() => expect(queryAllByRole(getByTestId(container, 'favorites'), 'menuitem', { name: /transparent/i })).toHaveLength(0)