Skip to content

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타입설명
visibleboolean후보 목록 표시 여부
usersreadonly MentionCandidate[]표시할 후보. id, name 필수, dept, jobTitle 선택
selectedIndexnumber키보드로 선택 중인 후보의 인덱스
anchorHTMLTextAreaElement | null팝업 위치를 잡을 textarea
querystring일치하는 이름·ID 강조에 사용. 기본 ''
labelstring후보 목록의 접근성 이름
emptyLabelstring검색 결과가 없을 때 표시할 문구
avatarColor(id: string) => string후보 아바타 배경색을 정하는 함수
inlineboolean목록을 현재 위치에 렌더링. 기본 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);
항목타입설명
stateRef<MentionAutocompleteState>팝업 상태
handleInput() => voidtextarea 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는 실패해도 예외를 밖으로 던지지 않습니다. 오류 표시가 필요하면 주입 함수 안에서 처리하세요.