mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-11-09 00:22:38 -06:00
feat(emoji): replace reaction picker with native Vue component
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
-23
@@ -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})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user