Initial Commit

This commit is contained in:
2026-10-03 20:07:49 +02:00
commit a1346c98b1
427 changed files with 59320 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
<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>