feat(emoji): replace reaction picker with native Vue component

This commit is contained in:
kolaente
2026-11-07 11:13:53 +01:00
parent 7f993449af
commit af289045d2
10 changed files with 581 additions and 97 deletions
-1
View File
@@ -101,7 +101,6 @@
"vue-advanced-cropper": "2.8.9",
"vue-i18n": "11.4.14",
"vue-router": "5.3.1",
"vuemoji-picker": "0.3.2",
"workbox-core": "7.4.1",
"workbox-precaching": "7.4.1",
"workbox-routing": "7.4.1",
-23
View File
@@ -331,9 +331,6 @@ importers:
vue-router:
specifier: 5.3.1
version: 5.3.1(@vue/compiler-sfc@3.5.44)(esbuild@0.28.2)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3)))(rolldown@1.2.14)(rollup@4.63.3)(vite@8.3.3(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.42(typescript@6.0.3))
vuemoji-picker:
specifier: 0.3.2
version: 0.3.2(vue@3.5.42(typescript@6.0.3))
workbox-core:
specifier: 7.4.1
version: 7.4.1
@@ -4135,9 +4132,6 @@ packages:
electron-to-chromium@1.5.459:
resolution: {integrity: sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg==}
emoji-picker-element@1.29.1:
resolution: {integrity: sha512-TOiHzu9Dqib3x4MwcAi3wi3RdyT4SoeB4b15AvH1ks4SBwTl7DeebhZ0d3x6dNi4XfNU7IGRZ7NBQllj0RqwrQ==}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -7233,15 +7227,6 @@ packages:
typescript:
optional: true
vuemoji-picker@0.3.2:
resolution: {integrity: sha512-yjNUSlth1HHxrqvSReG/JRFd1RiKP4CMdkhGiehy/IcajtVTrmzJtZ7zyXS4gFDX2YZDY43zw+LQzypkWWO6Lw==}
peerDependencies:
'@vue/composition-api': ^1.7.0
vue: ^2.6.14 || ^3.2.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
@@ -11199,8 +11184,6 @@ snapshots:
electron-to-chromium@1.5.459: {}
emoji-picker-element@1.29.1: {}
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
@@ -14654,12 +14637,6 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
vuemoji-picker@0.3.2(vue@3.5.42(typescript@6.0.3)):
dependencies:
emoji-picker-element: 1.29.1
vue: 3.5.42(typescript@6.0.3)
vue-demi: 0.14.10(vue@3.5.42(typescript@6.0.3))
w3c-keyname@2.2.8: {}
w3c-xmlserializer@5.0.0:
@@ -0,0 +1,15 @@
<script setup lang="ts">
import {ref} from 'vue'
import EmojiPicker from './EmojiPicker.vue'
const selected = ref('')
</script>
<template>
<Story title="Emoji picker">
<EmojiPicker @select="selected = $event" />
<p v-if="selected">
{{ selected }}
</p>
</Story>
</template>
@@ -0,0 +1,419 @@
<script setup lang="ts">
import {computed, nextTick, onMounted, ref, shallowRef, useId, useTemplateRef, watch} from 'vue'
import {useStorage} from '@vueuse/core'
import {useI18n} from 'vue-i18n'
import {
filterEmojis,
loadEmojiCatalog,
loadEmojis,
type EmojiCatalogEntry,
type EmojiEntry,
} from '@/components/input/editor/emoji/emojiData'
const emit = defineEmits<{
select: [emoji: string]
}>()
const {t} = useI18n({useScope: 'global'})
const categories = [
{
group: -1,
icon: '🕒',
key: 'recent',
},
{
group: 0,
icon: '😀',
key: 'smileys',
},
{
group: 1,
icon: '👋',
key: 'people',
},
{
group: 3,
icon: '🌿',
key: 'nature',
},
{
group: 4,
icon: '🍋',
key: 'food',
},
{
group: 5,
icon: '🚀',
key: 'travel',
},
{
group: 6,
icon: '⚽',
key: 'activities',
},
{
group: 7,
icon: '💡',
key: 'objects',
},
{
group: 8,
icon: '❤️',
key: 'symbols',
},
{
group: 9,
icon: '🏁',
key: 'flags',
},
]
const COLUMNS = 8
const tones = [
{
glyph: '👋',
label: 'misc.default',
},
{
glyph: '👋🏻',
label: 'input.emojiPicker.tones.light',
},
{
glyph: '👋🏼',
label: 'input.emojiPicker.tones.mediumLight',
},
{
glyph: '👋🏽',
label: 'input.emojiPicker.tones.medium',
},
{
glyph: '👋🏾',
label: 'input.emojiPicker.tones.mediumDark',
},
{
glyph: '👋🏿',
label: 'input.emojiPicker.tones.dark',
},
]
const skinTone = useStorage('emoji-picker-skin-tone', 0)
const recent = useStorage<string[]>('emoji-picker-recent', [])
const catalog = shallowRef<EmojiCatalogEntry[]>([])
const searchIndex = shallowRef<EmojiEntry[]>([])
const loading = ref(true)
const failed = ref(false)
const query = ref('')
const group = ref(recent.value.length ? -1 : 0)
const activeIndex = ref(0)
const search = useTemplateRef<HTMLInputElement>('search')
const results = useTemplateRef<HTMLElement>('results')
const headingId = useId()
const resultsId = useId()
const searchQuery = computed(() => query.value.trim().replace(/^:|:$/g, ''))
const catalogByEmoji = computed(() => new Map(catalog.value.map(entry => [entry.emoji, entry])))
const visibleEmojis = computed(() => {
if (searchQuery.value) {
return [...new Set(filterEmojis(searchIndex.value, searchQuery.value, Infinity)
.flatMap(match => catalogByEmoji.value.get(match.emoji) ?? []))]
}
if (group.value === -1) {
return recent.value.flatMap(emoji => {
const entry = catalog.value.find(entry => entry.emoji === emoji
|| entry.skins?.some(skin => skin.emoji === emoji))
return entry ? [{
...entry,
emoji,
skins: undefined,
}] : []
})
}
return catalog.value.filter(entry => entry.group === group.value)
})
const categoryKey = computed(() => categories.find(category => category.group === group.value)?.key ?? 'smileys')
function glyph(entry: EmojiCatalogEntry) {
return entry.skins?.find(skin => skin.tone === skinTone.value)?.emoji ?? entry.emoji
}
function select(entry: EmojiCatalogEntry) {
const emoji = glyph(entry)
recent.value = [emoji, ...recent.value.filter(value => value !== emoji)].slice(0, 24)
emit('select', emoji)
}
function chooseCategory(value: number) {
group.value = value
query.value = ''
}
watch(visibleEmojis, () => {
activeIndex.value = 0
if (results.value) results.value.scrollTop = 0
})
async function focusEmoji(index: number) {
activeIndex.value = Math.max(0, Math.min(index, visibleEmojis.value.length - 1))
await nextTick()
const button = results.value?.querySelectorAll<HTMLButtonElement>('.emoji-button')[activeIndex.value]
button?.focus()
button?.scrollIntoView({block: 'nearest'})
}
function onResultsKeydown(event: KeyboardEvent) {
if (!(event.target instanceof HTMLButtonElement)) return
const rtl = getComputedStyle(event.currentTarget as HTMLElement).direction === 'rtl'
const movement: Record<string, number> = {
ArrowRight: rtl ? -1 : 1,
ArrowLeft: rtl ? 1 : -1,
ArrowDown: COLUMNS,
ArrowUp: -COLUMNS,
Home: -visibleEmojis.value.length,
End: visibleEmojis.value.length,
}
if (!(event.key in movement)) return
event.preventDefault()
if (event.key === 'ArrowUp' && activeIndex.value < COLUMNS) {
search.value?.focus()
return
}
focusEmoji(activeIndex.value + movement[event.key])
}
async function load() {
loading.value = true
failed.value = false
try {
const [entries, emojiIndex] = await Promise.all([loadEmojiCatalog(), loadEmojis()])
// group 2 holds lone skin tone and hair components, not postable emoji
catalog.value = entries.filter(entry => entry.group !== 2)
searchIndex.value = emojiIndex
} catch {
failed.value = true
} finally {
loading.value = false
}
}
async function retry() {
await load()
search.value?.focus()
}
onMounted(async () => {
load()
await nextTick()
search.value?.focus()
})
</script>
<template>
<section
class="emoji-picker"
role="region"
:aria-label="t('input.emojiPicker.title')"
>
<div class="emoji-toolbar">
<input
ref="search"
v-model="query"
type="search"
class="input search"
:placeholder="t('input.emojiPicker.search')"
:aria-label="t('input.emojiPicker.search')"
:aria-controls="resultsId"
@keydown.down.prevent="focusEmoji(0)"
>
<select
v-model="skinTone"
class="emoji-tone"
:aria-label="t('input.emojiPicker.skinTone')"
>
<option
v-for="(tone, index) in tones"
:key="index"
:value="index"
>
{{ tone.glyph }} {{ t(tone.label) }}
</option>
</select>
</div>
<nav
class="emoji-categories"
:aria-label="t('input.emojiPicker.categories')"
>
<button
v-for="category in categories"
:key="category.key"
type="button"
:aria-label="t(`input.emojiPicker.groups.${category.key}`)"
:title="t(`input.emojiPicker.groups.${category.key}`)"
:aria-pressed="!searchQuery && group === category.group"
@click="chooseCategory(category.group)"
>
<span aria-hidden="true">{{ category.icon }}</span>
</button>
</nav>
<h3
:id="headingId"
class="emoji-heading"
>
{{ searchQuery ? t('input.emojiPicker.results') : t(`input.emojiPicker.groups.${categoryKey}`) }}
</h3>
<div
:id="resultsId"
ref="results"
class="emoji-results"
role="group"
:aria-labelledby="headingId"
:aria-busy="loading"
@keydown="onResultsKeydown"
>
<p
v-if="loading || failed || !visibleEmojis.length"
class="emoji-status"
role="status"
>
<template v-if="loading">
{{ t('misc.loading') }}
</template>
<template v-else-if="failed">
{{ t('input.emojiPicker.loadError') }}
<XButton
variant="secondary"
:shadow="false"
@click="retry"
>
{{ t('project.background.retry') }}
</XButton>
</template>
<template v-else>
{{ t('input.editor.emoji.empty') }}
</template>
</p>
<button
v-for="(entry, index) in visibleEmojis"
v-else
:key="entry.emoji"
type="button"
class="emoji-button"
:aria-label="entry.annotation"
:title="entry.annotation"
:tabindex="index === activeIndex ? 0 : -1"
@focus="activeIndex = index"
@click="select(entry)"
>
{{ glyph(entry) }}
</button>
</div>
</section>
</template>
<style scoped lang="scss">
.emoji-picker {
display: flex;
flex-direction: column;
inline-size: min(344px, calc(100vw - 16px));
block-size: 400px;
max-block-size: calc(100dvh - 88px);
border: 1px solid var(--grey-200);
border-radius: $radius;
background: var(--scheme-main);
color: var(--grey-900);
box-shadow: var(--shadow-md);
overflow: hidden;
}
.emoji-toolbar {
display: flex;
gap: .5rem;
padding: .75rem;
.search {
min-inline-size: 0;
block-size: 34px;
font-size: 1rem;
}
}
.emoji-tone {
inline-size: 3rem;
flex-shrink: 0;
border: 1px solid var(--grey-300);
border-radius: $radius;
background: var(--scheme-main);
color: var(--grey-900);
font-size: 1rem;
}
.emoji-categories {
display: flex;
padding: 0 .25rem;
border-block-end: 1px solid var(--grey-200);
button {
flex: 1;
min-inline-size: 0;
block-size: 36px;
border-radius: $radius $radius 0 0;
font-size: 1rem;
&[aria-pressed="true"] {
box-shadow: inset 0 -2px var(--primary);
background: var(--grey-100);
}
}
}
// Nested to beat Bulma's `.content h3:not(:first-child)` margin in task comments
.emoji-picker .emoji-heading {
margin: 0;
padding: .75rem .75rem .5rem;
font-size: .875rem;
// Overrides the global `h3 {font-weight: 400 !important}` in theme.scss
font-weight: 600 !important;
}
.emoji-results {
display: grid;
grid-template-columns: repeat(v-bind(COLUMNS), minmax(0, 38px));
justify-content: space-between;
align-content: start;
min-block-size: 0;
padding: 0 .5rem .5rem;
overflow-y: auto;
overscroll-behavior: contain;
}
.emoji-categories button,
.emoji-button {
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
&:hover {
background: var(--grey-100);
}
&:focus-visible {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
}
.emoji-button {
block-size: 38px;
border-radius: $radius;
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
font-size: 22px;
line-height: 22px;
}
.emoji-status {
grid-column: 1 / -1;
padding: 1rem .25rem;
color: var(--grey-600);
font-size: .875rem;
}
</style>
@@ -34,11 +34,11 @@ vi.mock('@/message', () => ({
success: vi.fn(),
}))
vi.mock('vuemoji-picker', () => ({
VuemojiPicker: {
name: 'VuemojiPicker',
template: '<div />',
emits: ['emojiClick'],
vi.mock('@/components/input/EmojiPicker.vue', () => ({
default: {
name: 'EmojiPicker',
template: '<div class="emoji-picker" />',
emits: ['select'],
},
}))
@@ -124,7 +124,7 @@ describe('Reactions', () => {
await nextTick()
expect(wrapper.find('.emoji-picker').exists()).toBe(true)
wrapper.findComponent({name: 'VuemojiPicker'}).vm.$emit('emojiClick', {unicode: '👍'})
wrapper.findComponent({name: 'EmojiPicker'}).vm.$emit('select', '👍')
await nextTick()
await wrapper.setProps({entityId: 2})
+36 -63
View File
@@ -1,15 +1,13 @@
<script setup lang="ts">
import type {ReactionInput, ReactionUsers} from '@/client/queries/reactions'
import {VuemojiPicker} from 'vuemoji-picker'
import EmojiPicker from '@/components/input/EmojiPicker.vue'
import {useSetReactionMutation} from '@/client/queries/reactions'
import BaseButton from '@/components/base/BaseButton.vue'
import {getDisplayName} from '@/helpers/user'
import {useI18n} from 'vue-i18n'
import {nextTick, onBeforeUnmount, onMounted, ref} from 'vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {nextTick, ref, useId} from 'vue'
import Popup from '@/components/misc/Popup.vue'
import {useAuthStore} from '@/stores/auth'
import {useColorScheme} from '@/composables/useColorScheme'
type ReactionSubject = {
entityKind: 'tasks',
@@ -31,7 +29,6 @@ const props = withDefaults(defineProps<ReactionSubject & {
const authStore = useAuthStore()
const {t} = useI18n()
const reactionMutation = useSetReactionMutation()
const {isDark} = useColorScheme()
async function setReaction(value: string, remove: boolean) {
if (props.disabled || reactionMutation.isPending.value || !authStore.info) return
@@ -62,7 +59,7 @@ async function setReaction(value: string, remove: boolean) {
return
}
if (props.entityId !== input.id || props.entityKind !== input.kind) return
showEmojiPicker.value = false
if (showEmojiPicker.value) closeEmojiPicker()
}
function addReaction(value: string) {
@@ -97,35 +94,13 @@ function getReactionTooltip(users: ReactionUsers[string], value: string | number
}
const showEmojiPicker = ref(false)
const emojiPickerRef = ref<InstanceType<typeof VuemojiPicker> | null>(null)
function hideEmojiPicker(e: MouseEvent) {
if (showEmojiPicker.value && emojiPickerRef.value?.$el) {
closeWhenClickedOutside(e, emojiPickerRef.value.$el, () => showEmojiPicker.value = false)
}
}
onMounted(() => document.addEventListener('click', hideEmojiPicker))
onBeforeUnmount(() => document.removeEventListener('click', hideEmojiPicker))
const emojiPickerButtonRef = ref<InstanceType<typeof BaseButton> | null>(null)
const reactionContainerRef = ref<HTMLElement | null>(null)
const emojiPickerPosition = ref()
const pickerId = useId()
function toggleEmojiPicker() {
if (!showEmojiPicker.value) {
const rect = emojiPickerButtonRef.value?.$el?.getBoundingClientRect()
const container = reactionContainerRef.value?.getBoundingClientRect()
if (rect && container) {
const left = rect.left - container.left + rect.width
emojiPickerPosition.value = {
'inset-inline-start': left === 0 ? undefined : left,
}
}
}
nextTick(() => showEmojiPicker.value = !showEmojiPicker.value)
async function closeEmojiPicker() {
showEmojiPicker.value = false
await nextTick()
emojiPickerButtonRef.value?.$el?.focus()
}
function hasCurrentUserReactedWithEmoji(value: string | number): boolean {
@@ -145,10 +120,7 @@ async function toggleReaction(value: string | number) {
</script>
<template>
<div
ref="reactionContainerRef"
class="reactions"
>
<div class="reactions">
<BaseButton
v-for="(users, value) in modelValue"
:key="'button' + value"
@@ -162,27 +134,34 @@ async function toggleReaction(value: string | number) {
>
{{ value }} {{ users?.length }}
</BaseButton>
<BaseButton
<Popup
v-if="!disabled"
ref="emojiPickerButtonRef"
v-tooltip="$t('reaction.add')"
class="reaction-button"
@click.stop="toggleEmojiPicker"
v-model:open="showEmojiPicker"
:anchor="emojiPickerButtonRef?.$el"
placement="bottom-start"
>
<span class="is-sr-only">{{ $t('reaction.add') }}</span>
<Icon :icon="['far', 'face-laugh']" />
</BaseButton>
<CustomTransition name="fade">
<VuemojiPicker
v-if="showEmojiPicker"
ref="emojiPickerRef"
class="emoji-picker"
:style="{'inset-inline-start': emojiPickerPosition?.left + 'px'}"
data-source="/emojis.json"
:is-dark="isDark"
@emojiClick="detail => addReaction(detail.unicode)"
/>
</CustomTransition>
<template #trigger="{toggle}">
<BaseButton
ref="emojiPickerButtonRef"
v-tooltip="$t('reaction.add')"
class="reaction-button"
:aria-expanded="showEmojiPicker"
:aria-controls="showEmojiPicker ? pickerId : undefined"
@click.stop="toggle"
>
<span class="is-sr-only">{{ $t('reaction.add') }}</span>
<Icon :icon="['far', 'face-laugh']" />
</BaseButton>
</template>
<template #content>
<EmojiPicker
v-if="showEmojiPicker"
:id="pickerId"
@select="addReaction"
@keydown.esc.stop.prevent="closeEmojiPicker"
/>
</template>
</Popup>
</div>
</template>
@@ -201,10 +180,4 @@ async function toggleReaction(value: string | number) {
background-color: hsla(var(--primary-h), var(--primary-s), var(--primary-light-l), 0.5);
}
}
.emoji-picker {
position: absolute;
z-index: 99;
margin-block-start: .5rem;
}
</style>
@@ -43,11 +43,12 @@ describe('emojiData', () => {
expect(result[2].shortcode).toBe('smile')
})
it('limits results to 15', () => {
it('limits results to 15 by default', () => {
const big = Array.from({length: 100}, (_, i) => ({
shortcode: `foo_${String(i).padStart(3, '0')}`, emoji: '✨', annotation: '', tags: [],
}))
expect(filterEmojis(big, 'foo')).toHaveLength(15)
expect(filterEmojis(big, 'foo', Infinity)).toHaveLength(100)
})
it('caches the fetch promise across calls', async () => {
+27
View File
@@ -1028,6 +1028,33 @@
"empty": "No emoji found"
}
},
"emojiPicker": {
"title": "Emoji picker",
"search": "Search emoji",
"skinTone": "Skin tone",
"categories": "Emoji categories",
"results": "Search results",
"loadError": "Could not load emoji.",
"tones": {
"light": "Light",
"mediumLight": "Medium light",
"medium": "Medium",
"mediumDark": "Medium dark",
"dark": "Dark"
},
"groups": {
"recent": "Recently used",
"smileys": "Smileys & emotion",
"people": "People & body",
"nature": "Animals & nature",
"food": "Food & drink",
"travel": "Travel & places",
"activities": "Activities",
"objects": "Objects",
"symbols": "Symbols",
"flags": "Flags"
}
},
"multiselect": {
"createPlaceholder": "Create",
"selectPlaceholder": "Click or press enter to select",
@@ -19,7 +19,6 @@ vi.mock('@/message', () => message)
const projects = vi.hoisted(() => ({} as Record<number, unknown>))
vi.mock('@/composables/useProjects', () => ({useProjects: () => ({projects})}))
vi.mock('@/composables/useLabels', () => ({useLabels: () => ({filterLabelsByQuery: () => [], isPending: {value: false}, getLabelById: vi.fn()})}))
vi.mock('vuemoji-picker', () => ({VuemojiPicker: {render: () => null}}))
vi.mock('vue-i18n', async importOriginal => ({
...await importOriginal<typeof import('vue-i18n')>(),
useI18n: () => ({t: (key: string) => key}),
+76 -2
View File
@@ -24,8 +24,8 @@ test('a reaction added in the task detail survives navigating away and back', as
await reactions.getByRole('button', {name: 'Add your reaction'}).click()
const picker = page.locator('.emoji-picker')
await expect(picker).toBeVisible()
await picker.locator('input.search').fill('thumbs up')
const option = picker.locator('#search-results [role="option"]').first()
await picker.getByRole('searchbox', {name: 'Search emoji'}).fill('thumbs up')
const option = picker.getByRole('button', {name: 'thumbs up', exact: true})
await expect(option).toBeVisible()
const added = (await option.textContent())?.trim()
expect(added).toBeTruthy()
@@ -79,3 +79,77 @@ test('a reaction added in the task detail survives navigating away and back', as
expect(afterRemoval.ok()).toBeTruthy()
expect(Object.keys(await afterRemoval.json())).toEqual([SEEDED_REACTION])
})
test('the native picker supports categories, skin tones, recents and keyboard dismissal', async ({authenticatedPage: page, apiContext, userToken}) => {
const [project] = await ProjectFactory.create(1)
const [task] = await TaskFactory.create(1, {project_id: project.id})
await page.goto(`/tasks/${task.id}`)
const trigger = page.locator('.task-view .reactions.details').getByRole('button', {name: 'Add your reaction'})
await trigger.click()
const picker = page.getByRole('region', {name: 'Emoji picker'})
const search = picker.getByRole('searchbox', {name: 'Search emoji'})
await expect(search).toBeFocused()
await picker.getByRole('button', {
name: 'Animals & nature',
exact: true,
}).click()
await expect(picker.getByRole('heading', {name: 'Animals & nature'})).toBeVisible()
await picker.getByRole('combobox', {name: 'Skin tone'}).selectOption('3')
await search.fill(':wave:')
const wave = picker.getByRole('button', {
name: 'waving hand',
exact: true,
})
await expect(wave).toHaveText('👋🏽')
await search.press('ArrowDown')
await expect(wave).toBeFocused()
await wave.press('Enter')
await expect(picker).toHaveCount(0)
await expect(trigger).toBeFocused()
await page.reload()
await trigger.click()
await expect(picker.getByRole('combobox', {name: 'Skin tone'})).toHaveValue('3')
await expect(picker.getByRole('heading', {name: 'Recently used'})).toBeVisible()
await expect(picker.getByRole('button', {
name: 'waving hand',
exact: true,
})).toHaveText('👋🏽')
// Only the emojibase group 2 components (🏻, 🏼) match this query
await search.fill('light skin tone')
await expect(picker.getByText('No emoji found')).toBeVisible()
await search.press('Escape')
await expect(picker).toHaveCount(0)
await expect(trigger).toBeFocused()
const stored = await apiContext.get(`tasks/${task.id}/reactions`, {
headers: {Authorization: `Bearer ${userToken}`},
})
expect(stored.ok()).toBeTruthy()
expect(Object.keys(await stored.json())).toEqual(['👋🏽'])
})
test('the emoji picker retries a failed catalog request and dismisses on outside click', async ({authenticatedPage: page}) => {
const [project] = await ProjectFactory.create(1)
const [task] = await TaskFactory.create(1, {project_id: project.id})
await page.route('**/emojis.json', route => route.fulfill({
status: 503,
body: 'Unavailable',
}))
await page.goto(`/tasks/${task.id}`)
const trigger = page.locator('.task-view .reactions.details').getByRole('button', {name: 'Add your reaction'})
await trigger.click()
const picker = page.getByRole('region', {name: 'Emoji picker'})
await expect(picker.getByText('Could not load emoji.')).toBeVisible()
await page.unroute('**/emojis.json')
await picker.getByRole('button', {name: 'Try again'}).click()
await expect(picker.getByRole('button', {
name: 'grinning face',
exact: true,
})).toBeVisible()
await page.locator('.task-view h1').click()
await expect(picker).toHaveCount(0)
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
await trigger.click()
await expect(picker).toBeVisible()
await trigger.click()
await expect(picker).toHaveCount(0)
})