forked from BRETT/BRETT
115 lines
4.1 KiB
Vue
115 lines
4.1 KiB
Vue
<script setup lang="ts">
|
|
import { activeMention } from '~~/shared/utils/mentions'
|
|
import type { MentionUser } from '~~/shared/utils/mentions'
|
|
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
const model = defineModel<string>({ required: true })
|
|
const props = defineProps<{ placeholder?: string }>()
|
|
const { data: users } = useMentionUsers()
|
|
const listId = useId()
|
|
const caret = shallowRef(0)
|
|
const focused = shallowRef(false)
|
|
const dismissed = shallowRef(false)
|
|
const selected = shallowRef(0)
|
|
let textarea: HTMLTextAreaElement | null = null
|
|
const mention = computed(() => activeMention(model.value, caret.value))
|
|
const suggestions = computed(() => {
|
|
if (!mention.value) return []
|
|
const query = mention.value.query.toLowerCase()
|
|
return users.value.filter(u => u.name.toLowerCase().startsWith(query)).slice(0, 8)
|
|
})
|
|
const open = computed(() => focused.value && !dismissed.value && suggestions.value.length > 0)
|
|
|
|
function updateCaret(event: Event) {
|
|
textarea = event.target as HTMLTextAreaElement
|
|
caret.value = textarea.selectionStart
|
|
focused.value = true
|
|
dismissed.value = false
|
|
selected.value = 0
|
|
}
|
|
|
|
async function choose(user: MentionUser) {
|
|
if (!mention.value || !textarea) return
|
|
const { start, end } = mention.value
|
|
const suffix = model.value.slice(end).replace(/^[\p{L}\p{N}_-]*/u, '')
|
|
const insertion = `@${user.name} `
|
|
model.value = model.value.slice(0, start) + insertion + suffix
|
|
dismissed.value = true
|
|
await nextTick()
|
|
textarea.focus({ preventScroll: true })
|
|
textarea.setSelectionRange(start + insertion.length, start + insertion.length)
|
|
caret.value = start + insertion.length
|
|
}
|
|
|
|
function onKeydown(event: KeyboardEvent) {
|
|
if (!open.value || event.isComposing) return
|
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
event.preventDefault()
|
|
selected.value = (selected.value + (event.key === 'ArrowDown' ? 1 : -1) + suggestions.value.length) % suggestions.value.length
|
|
}
|
|
else if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault()
|
|
const user = suggestions.value[selected.value]
|
|
if (user) choose(user)
|
|
}
|
|
else if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
dismissed.value = true
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Popover :open="open" @update:open="value => { if (!value) dismissed = true }">
|
|
<PopoverAnchor as-child>
|
|
<div class="relative">
|
|
<Textarea
|
|
v-model="model"
|
|
:placeholder="props.placeholder"
|
|
class="min-h-32 pb-9"
|
|
aria-label="Kommentar"
|
|
aria-autocomplete="list"
|
|
:aria-expanded="open"
|
|
:aria-controls="open ? listId : undefined"
|
|
:aria-activedescendant="open ? `${listId}-${selected}` : undefined"
|
|
@input="updateCaret"
|
|
@click="updateCaret"
|
|
@keyup="event => { if (!['ArrowUp', 'ArrowDown', 'Enter', 'Escape'].includes(event.key)) updateCaret(event) }"
|
|
@focus="updateCaret"
|
|
@blur="focused = false"
|
|
@keydown="onKeydown"
|
|
/>
|
|
<slot />
|
|
</div>
|
|
</PopoverAnchor>
|
|
<PopoverContent
|
|
side="top"
|
|
align="start"
|
|
class="gap-1 rounded-md p-1"
|
|
@open-auto-focus.prevent
|
|
@close-auto-focus.prevent
|
|
@interact-outside="event => { if (event.target === textarea) event.preventDefault() }"
|
|
>
|
|
<div :id="listId" role="listbox" aria-label="Benutzer erwähnen">
|
|
<button
|
|
v-for="(user, index) in suggestions"
|
|
:id="`${listId}-${index}`"
|
|
:key="user.id"
|
|
type="button"
|
|
role="option"
|
|
:aria-selected="index === selected"
|
|
:class="cn('flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-2 text-left', index === selected && 'bg-accent text-accent-foreground')"
|
|
@pointerdown.prevent
|
|
@pointermove="selected = index"
|
|
@click="choose(user)"
|
|
>
|
|
<Icon name="ph:user-fill" class="size-4 shrink-0" :style="user.color ? { color: user.color } : undefined" />
|
|
<span class="truncate">@{{ user.name }}</span>
|
|
</button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</template>
|