+
{{ value }} {{ users?.length }}
-
- {{ $t('reaction.add') }}
-
-
-
- addReaction(detail.unicode)"
- />
-
+
+
+ {{ $t('reaction.add') }}
+
+
+
+
+
+
+
@@ -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;
-}
diff --git a/frontend/src/components/input/editor/emoji/emojiData.test.ts b/frontend/src/components/input/editor/emoji/emojiData.test.ts
index f010c727d..461803a84 100644
--- a/frontend/src/components/input/editor/emoji/emojiData.test.ts
+++ b/frontend/src/components/input/editor/emoji/emojiData.test.ts
@@ -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 () => {
diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json
index 1ff9657fe..b80648567 100644
--- a/frontend/src/i18n/lang/en.json
+++ b/frontend/src/i18n/lang/en.json
@@ -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",
diff --git a/frontend/src/views/tasks/TaskDetailView.test.ts b/frontend/src/views/tasks/TaskDetailView.test.ts
index 91b14898b..934292f93 100644
--- a/frontend/src/views/tasks/TaskDetailView.test.ts
+++ b/frontend/src/views/tasks/TaskDetailView.test.ts
@@ -19,7 +19,6 @@ vi.mock('@/message', () => message)
const projects = vi.hoisted(() => ({} as Record
))
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(),
useI18n: () => ({t: (key: string) => key}),
diff --git a/frontend/tests/e2e/task/reactions.spec.ts b/frontend/tests/e2e/task/reactions.spec.ts
index dd009cad1..66d477aab 100644
--- a/frontend/tests/e2e/task/reactions.spec.ts
+++ b/frontend/tests/e2e/task/reactions.spec.ts
@@ -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)
+})