Combobox in Textarea
Textarea에서 @ 토큰을 입력하면 Combobox 후보를 띄워 멘션을 삽입합니다.
강민수 프론트엔드
김서연 프로덕트
박지훈 데이터
오하린 디자인
이준호 운영
<script lang="ts">
import { Textarea } from '@odbd/svelte'
import * as Combobox from '@odbd/svelte/combobox'
import { useListCollection } from '@odbd/svelte/combobox'
const members = ['강민수', '김서연', '박지훈', '오하린', '이준호']
const memberRoles: Record<string, string> = {
강민수: '프론트엔드',
김서연: '프로덕트',
박지훈: '데이터',
오하린: '디자인',
이준호: '운영',
}
const memberCollection = useListCollection({
initialItems: members,
filter: (item, query) =>
`${item} ${memberRoles[item]}`.toLowerCase().includes(query.toLowerCase()),
})
const initialDraft = '오늘 배포 노트 검토는 @'
let draft = $state(initialDraft)
let cursor = $state(initialDraft.length)
let selectedMention = $state<string[]>([])
const findActiveMention = (text: string, position: number) => {
const beforeCursor = text.slice(0, position)
const match = /(?:^|\s)@([0-9A-Za-z가-힣_-]*)$/.exec(beforeCursor)
if (!match) return null
const token = match[0]
const atIndex = token.lastIndexOf('@')
const start = match.index + atIndex
return {
start,
end: position,
query: match[1] ?? '',
}
}
const activeMention = $derived(findActiveMention(draft, cursor))
const mentionQuery = $derived(activeMention?.query ?? '')
$effect(() => {
memberCollection.filter(mentionQuery)
})
const syncTextarea = (event: Event) => {
const textarea = event.currentTarget as HTMLTextAreaElement
draft = textarea.value
cursor = textarea.selectionStart ?? textarea.value.length
}
const syncCursor = (event: Event) => {
const textarea = event.currentTarget as HTMLTextAreaElement
cursor = textarea.selectionStart ?? textarea.value.length
}
const updateMentionQuery = (details: { inputValue: string }) => {
if (!activeMention) return
draft = `${draft.slice(0, activeMention.start + 1)}${details.inputValue}${draft.slice(
activeMention.end,
)}`
cursor = activeMention.start + 1 + details.inputValue.length
}
const insertMention = (details: { value: string[] }) => {
const selected = details.value[details.value.length - 1]
if (!selected || !activeMention) return
draft = `${draft.slice(0, activeMention.start)}@${selected} ${draft.slice(activeMention.end)}`
cursor = activeMention.start + selected.length + 2
selectedMention = []
}
</script>
<div class="mention-textarea">
<label class="mention-textarea__label" for="mention-draft">댓글</label>
<Textarea
id="mention-draft"
class="mention-textarea__field"
textareaSize="md"
rows={5}
value={draft}
placeholder="댓글을 입력하세요"
oninput={syncTextarea}
onkeyup={syncCursor}
onclick={syncCursor}
/>
{#if activeMention}
<Combobox.Root
collection={memberCollection.collection}
value={selectedMention}
inputValue={mentionQuery}
open={true}
onOpenChange={() => {}}
onInteractOutside={(event: Event) => event.preventDefault()}
onInputValueChange={updateMentionQuery}
onValueChange={insertMention}
>
<Combobox.Label>멘션 후보</Combobox.Label>
<Combobox.Control>
<Combobox.Input placeholder="@ 뒤 이름을 입력하세요" />
<Combobox.ClearTrigger aria-label="멘션 검색어 지우기">×</Combobox.ClearTrigger>
</Combobox.Control>
<Combobox.Content class="mention-textarea__list">
<Combobox.List>
<Combobox.Empty>일치하는 멤버가 없어요.</Combobox.Empty>
{#each memberCollection.collection().items as item}
<Combobox.Item {item} class="mention-textarea__option">
<span>
<Combobox.ItemText>{item}</Combobox.ItemText>
<small>{memberRoles[item]}</small>
</span>
<Combobox.ItemIndicator>↵</Combobox.ItemIndicator>
</Combobox.Item>
{/each}
</Combobox.List>
</Combobox.Content>
</Combobox.Root>
{/if}
</div>
<style>
.mention-textarea {
display: grid;
width: min(100%, 30rem);
gap: var(--odbd-space-3);
}
.mention-textarea__label {
color: var(--odbd-color-foreground);
font-weight: 650;
}
:global(.mention-textarea__field) {
min-height: 8rem;
resize: vertical;
}
:global(.mention-textarea__option) {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--odbd-space-4);
}
:global(.mention-textarea__option) span {
display: grid;
min-width: 0;
gap: var(--odbd-space-1);
}
:global(.mention-textarea__option) small {
color: var(--odbd-color-muted-foreground);
font-size: var(--odbd-font-size-xs);
}
</style>