Appearance
Mention
@jennifersoft/apm-components의 멘션(@사용자) 모듈 문서입니다. 이 문서는 @jennifersoft/apm-components@1.4.1 기준으로 정리했습니다.
textarea에서 @를 입력했을 때 사용자 자동완성을 띄우고, 저장 시에는 이름이 아닌 id sentinel로 보존하는 유틸 모음입니다. 컴포넌트가 아니라 composable + 순수 함수입니다.
인터랙티브 데모
state.visible이 true인 동안 ↑↓/Enter를 누르면 handleKeydown이 true를 반환합니다. 위 로그에서 가로챔 표시를 확인해 보세요. 이 값을 무시하면 멘션 선택 시 폼이 전송됩니다.
이름 치환 규칙 확인
replaceMentionSentinelsWithNames()가 형식별로 다르게 동작하는 것을 볼 수 있습니다. 디렉터리에 있는 id는 hong.gildong, kim.younghee 등이고 unknown.person, nobody는 없습니다.
createMentionSentinel / isWordBoundaryBefore
저장 형식
멘션은 화면에 @홍길동으로 보이지만, 저장되는 원문은 id 기반 sentinel입니다.
[~hong.gildong]이름이 바뀌어도 참조가 깨지지 않게 하기 위해서입니다. 표시 직전에 replaceMentionSentinelsWithNames()로 현재 이름을 채웁니다.
레거시 데이터는 @id 형태로도 존재하며, 두 형식을 모두 인식합니다.
typescript
const SENTINEL_ID_RE: RegExp; // [~id] 형식
const LEGACY_ID_RE: RegExp; // @id 형식id로 허용되는 문자는 영숫자·언더스코어·한글이며, 중간에 .과 -를 포함할 수 있습니다 (단, 처음과 끝은 불가).
useMentionAutocomplete
textarea 기반 멘션 자동완성 composable입니다.
옵션
typescript
interface UseMentionAutocompleteOptions {
/** query가 바뀔 때마다 호출되는 디바운스 간격(ms). 기본 200 */
debounceMs?: number;
/** 텍스트 변경 시 콜백 (v-model 업데이트용) */
onUpdate: (value: string) => void;
/** 후보 검색 함수. 앱마다 다른 API를 주입한다 */
search: (query: string) => Promise<readonly MentionCandidate[]>;
/** textarea HTMLElement (또는 동일 shape의 mock) ref */
textareaRef: () => MentionTextareaLike | null;
}
interface MentionCandidate {
id: string;
name: string;
}
interface MentionTextareaLike {
focus(): void;
selectionStart: number;
setSelectionRange(start: number, end: number): void;
value: string;
}search를 주입받는 구조라 앱마다 다른 사용자 조회 API를 붙일 수 있고, 테스트에서는 MentionTextareaLike mock으로 DOM 없이 검증할 수 있습니다.
반환값
typescript
const { state, handleInput, handleKeydown, selectUser, close } =
useMentionAutocomplete(options);| 항목 | 타입 | 설명 |
|---|---|---|
state | Ref<MentionAutocompleteState> | 팝업 상태 |
handleInput | () => void | textarea input에 연결. 텍스트를 갱신하지 않음 |
handleKeydown | (e: MentionKeyboardEventLike) => boolean | 가로챘으면 true |
selectUser | (user: MentionCandidate) => void | 후보 클릭 시 |
close | () => void | 팝업 닫기 |
typescript
interface MentionAutocompleteState {
mentionStart: number; // @ 시작 위치 (없으면 -1)
query: string;
selectedIndex: number;
users: MentionCandidate[];
visible: boolean;
}textarea는 반드시 v-model로 묶으세요
WARNING
handleInput()은 onUpdate를 호출하지 않습니다. DOM의 textarea.value와 selectionStart를 읽어 멘션 상태(state)만 갱신합니다. onUpdate가 호출되는 시점은 selectUser()가 sentinel을 삽입할 때뿐입니다.
따라서 textarea를 :value="text"처럼 단방향으로 묶으면 타이핑한 글자가 사라집니다.state가 바뀌어 리렌더될 때 낡은 text로 DOM이 되돌아가기 때문입니다.
vue
<!-- 잘못된 예: 타이핑이 리렌더에 지워진다 -->
<textarea :value="text" @input="handleInput" />
<!-- 올바른 예: v-model 로 타이핑을 반영하고, handleInput 은 멘션 상태만 추적 -->
<textarea v-model="text" @input="handleInput" />v-model이 본문을 소유하고, onUpdate는 컴포저블이 본문을 바꿀 때만 되돌려받는 통로입니다.
handleKeydown의 반환값
IMPORTANT
handleKeydown이 true를 반환하면 자동완성이 그 키를 소비했다는 뜻입니다. 호출부는 preventDefault()를 호출하고 자체 처리(예: 질문 전송)를 건너뛰어야 합니다. 이를 무시하면 Enter로 멘션을 고를 때마다 폼이 전송됩니다.
typescript
function onKeydown(event: KeyboardEvent) {
if (handleKeydown(event)) {
event.preventDefault();
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
submit();
}
}IME 조합 처리
한글 등 IME 조합 중에는 Vue v-model이 값을 갱신하지 않습니다(조합 완료 시에만 commit). 그래서 이 composable은 DOM의 textarea.value와 selectionStart를 진실의 원천으로 삼습니다.
MentionKeyboardEventLike.isComposing을 확인해 조합 중 키 입력은 가로채지 않습니다.
typescript
interface MentionKeyboardEventLike {
isComposing?: boolean;
key: string;
keyCode?: number;
}stale 응답 방어
검색은 debounceMs(기본 200ms)로 디바운스되며, 응답이 도착했을 때 현재 query와 다르면 결과를 버립니다. 빠르게 타이핑할 때 이전 요청의 결과가 뒤늦게 덮어쓰는 문제가 발생하지 않습니다. 검색 실패 시에도 목록만 비우고 예외를 던지지 않습니다.
사용 예제
vue
<script setup lang="ts">
import { ref } from 'vue';
import { useMentionAutocomplete } from '@jennifersoft/apm-components';
import type { MentionCandidate } from '@jennifersoft/apm-components';
const text = ref('');
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const { state, handleInput, handleKeydown, selectUser, close } =
useMentionAutocomplete({
onUpdate: (value) => (text.value = value),
search: (query) => userApi.searchMembers(query),
textareaRef: () => textareaRef.value,
debounceMs: 200,
});
function onKeydown(event: KeyboardEvent) {
if (handleKeydown(event)) {
event.preventDefault();
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submit();
}
}
</script>
<template>
<div class="mention-field">
<textarea
ref="textareaRef"
v-model="text"
@input="handleInput"
@keydown="onKeydown"
@blur="close"
/>
<ul v-if="state.visible" class="mention-popup">
<li
v-for="(user, index) in state.users"
:key="user.id"
:class="{ selected: index === state.selectedIndex }"
@mousedown.prevent="selectUser(user)"
>
{{ user.name }}
</li>
</ul>
</div>
</template>후보 클릭에 @click이 아니라 @mousedown.prevent를 쓰는 이유는, blur가 먼저 발생해 팝업이 닫히면 클릭이 사라지기 때문입니다.
포맷 유틸
createMentionSentinel
typescript
function createMentionSentinel(id: string): string;자동완성에서 사용자를 선택했을 때 textarea에 삽입할 sentinel 텍스트를 만듭니다. selectUser()가 내부에서 사용하므로 보통 직접 호출할 일은 없습니다.
extractMentionIds
typescript
function extractMentionIds(text: string): readonly string[];텍스트 안의 멘션 id를 중복 없이 모두 추출합니다. 이름 일괄 조회에 사용합니다.
typescript
const ids = extractMentionIds(comment.body);
const names = await userApi.getNames(ids);replaceMentionSentinelsWithNames
typescript
function replaceMentionSentinelsWithNames(
text: string,
resolveName: (id: string) => string | undefined
): string;sentinel을 표시용 이름으로 치환합니다. 두 번째 인자는 Map이 아니라 id를 이름으로 바꾸는 함수입니다. 이름을 모르면 undefined를 반환하면 됩니다.
typescript
const nameById = new Map(users.map((u) => [u.id, u.name]));
const display = replaceMentionSentinelsWithNames(
comment.body,
(id) => nameById.get(id)
);알 수 없는 값의 처리 규칙이 형식마다 다릅니다.
| 입력 | 이름을 알 때 | 이름을 모를 때 |
|---|---|---|
[~id] (sentinel) | @이름 | @id |
@id (legacy) | @이름 | 원문 그대로 |
레거시를 원문 유지하는 이유는, 디렉터리에 없는 @문자열이 실제로는 멘션이 아닌 평범한 텍스트일 수 있기 때문입니다(방어적 처리).
isWordBoundaryBefore
typescript
function isWordBoundaryBefore(text: string, index: number): boolean;legacy @id가 멘션으로 인정되려면 @ 앞이 공백이거나 문자열 시작이어야 합니다. user@example.com 같은 이메일이 멘션으로 오인되지 않게 합니다.
주의사항
- 저장은 sentinel 원문으로, 표시는 치환 후로 분리하세요. 이름을 저장하면 개명 시 깨집니다.
handleKeydown의 반환값을 반드시 확인하세요.- 후보 목록은
mousedown으로 처리해야blur와 경합하지 않습니다. search는 실패해도 예외를 밖으로 던지지 않습니다. 오류 표시가 필요하면 주입 함수 안에서 처리하세요.