From af289045d21cc45fd87996ec136d0fd0200dffeb Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 6 Nov 2026 10:49:49 +0100 Subject: [PATCH] feat(emoji): replace reaction picker with native Vue component --- frontend/package.json | 1 - frontend/pnpm-lock.yaml | 23 - .../components/input/EmojiPicker.story.vue | 15 + frontend/src/components/input/EmojiPicker.vue | 419 ++++++++++++++++++ .../src/components/input/Reactions.test.ts | 12 +- frontend/src/components/input/Reactions.vue | 99 ++--- .../input/editor/emoji/emojiData.test.ts | 3 +- frontend/src/i18n/lang/en.json | 27 ++ .../src/views/tasks/TaskDetailView.test.ts | 1 - frontend/tests/e2e/task/reactions.spec.ts | 78 +++- 10 files changed, 581 insertions(+), 97 deletions(-) create mode 100644 frontend/src/components/input/EmojiPicker.story.vue create mode 100644 frontend/src/components/input/EmojiPicker.vue diff --git a/frontend/package.json b/frontend/package.json index 81770d3e1..5c0bcade2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index d6b92ca68..1d7cd003f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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: diff --git a/frontend/src/components/input/EmojiPicker.story.vue b/frontend/src/components/input/EmojiPicker.story.vue new file mode 100644 index 000000000..c6871fca0 --- /dev/null +++ b/frontend/src/components/input/EmojiPicker.story.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/input/EmojiPicker.vue b/frontend/src/components/input/EmojiPicker.vue new file mode 100644 index 000000000..574f72168 --- /dev/null +++ b/frontend/src/components/input/EmojiPicker.vue @@ -0,0 +1,419 @@ + + + + + diff --git a/frontend/src/components/input/Reactions.test.ts b/frontend/src/components/input/Reactions.test.ts index 9db3c4429..89d8faef8 100644 --- a/frontend/src/components/input/Reactions.test.ts +++ b/frontend/src/components/input/Reactions.test.ts @@ -34,11 +34,11 @@ vi.mock('@/message', () => ({ success: vi.fn(), })) -vi.mock('vuemoji-picker', () => ({ - VuemojiPicker: { - name: 'VuemojiPicker', - template: '
', - emits: ['emojiClick'], +vi.mock('@/components/input/EmojiPicker.vue', () => ({ + default: { + name: 'EmojiPicker', + template: '
', + 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}) diff --git a/frontend/src/components/input/Reactions.vue b/frontend/src/components/input/Reactions.vue index 79097a16d..8fe6dc27c 100644 --- a/frontend/src/components/input/Reactions.vue +++ b/frontend/src/components/input/Reactions.vue @@ -1,15 +1,13 @@ @@ -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) +})