Appearance
Mention
@jennifersoft/apm-components의 멘션(@사용자) 모듈 문서입니다. 이 문서는 @jennifersoft/apm-components@1.4.7 기준으로 정리했습니다.
textarea에서 @를 입력했을 때 사용자 자동완성을 띄우고, 저장 시에는 이름이 아닌 id sentinel로 보존하는 MentionAutocomplete 컴포넌트, 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로 허용되는 문자는 영숫자·언더스코어·한글이며, 중간에 .과 -를 포함할 수 있습니다 (단, 처음과 끝은 불가).
MentionAutocomplete
useMentionAutocomplete()의 검색 결과를 표시하는 후보 목록 컴포넌트입니다. 입력과 검색은 호스트가 관리하고, 컴포넌트는 후보 선택 시 select 이벤트로 선택한 MentionCandidate를 전달합니다. 기본적으로 textarea 커서 근처에 표시되며, 화면 가장자리를 벗어나지 않도록 위치를 조정합니다.
| Prop | 타입 | 설명 |
|---|---|---|
visible | boolean | 후보 목록 표시 여부 |
users | readonly MentionCandidate[] | 표시할 후보. id, name 필수, dept, jobTitle 선택 |
selectedIndex | number | 키보드로 선택 중인 후보의 인덱스 |
anchor | HTMLTextAreaElement | null | 팝업 위치를 잡을 textarea |
query | string | 일치하는 이름·ID 강조에 사용. 기본 '' |
label | string | 후보 목록의 접근성 이름 |
emptyLabel | string | 검색 결과가 없을 때 표시할 문구 |
avatarColor | (id: string) => string | 후보 아바타 배경색을 정하는 함수 |
inline | boolean | 목록을 현재 위치에 렌더링. 기본 false이며 이때 body로 이동해 커서 근처에 배치 |
visible이 참이어도 후보가 없고 query가 비어 있으면 목록을 표시하지 않습니다. 검색어가 있는데 후보가 없으면 emptyLabel을 표시합니다. 좁은 모바일 레이아웃에서는 inline을 사용해 목록을 문서 흐름 안에 둘 수 있습니다.
모바일 댓글 입력창처럼 팝업을 시트 내부에 붙여야 할 때는 다음처럼 사용합니다. inline이어도 후보 선택과 검색 상태는 호스트가 관리합니다.
vue
<MentionAutocomplete
inline
:visible="state.visible"
:users="state.users"
:selected-index="state.selectedIndex"
:query="state.query"
:anchor="textareaRef"
label="멘션 사용자 자동완성"
empty-label="일치하는 사용자가 없습니다."
@select="selectUser"
/>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;
dept?: string;
jobTitle?: 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 { MentionAutocomplete, 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"
/>
<MentionAutocomplete
label="멘션 사용자 자동완성"
empty-label="일치하는 사용자가 없습니다."
:visible="state.visible"
:users="state.users"
:selected-index="state.selectedIndex"
:query="state.query"
:anchor="textareaRef"
@select="selectUser"
/>
</div>
</template>MentionAutocomplete는 내부에서 후보의 mousedown 기본 동작을 막아 textarea가 먼저 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의 반환값을 반드시 확인하세요.- 후보 목록은
MentionAutocomplete의select이벤트를selectUser에 연결하세요. search는 실패해도 예외를 밖으로 던지지 않습니다. 오류 표시가 필요하면 주입 함수 안에서 처리하세요.