이전에 프론트엔드 기능 개발 중 다음과 같은 기능 요구사항을 받았어요.
"postman
처럼 특정 문자를 삽입하면 자동완성 추천 overlay가 나오는 기능을 추가해주세요."
당시 급하게 개발한 건들이 많아 단순히 기능이 동작하는 컴포넌트를 만들었어요. 그러다 보니 특정 도메인에만 적용가능한 컴포넌트가 되었어요. 이를 보완하기 위해 처음부터 다시 요구사항을 정리하여 설계하고, 사용자 편의성을 위한 기능을 추가하기로 했어요.
구현할 요구 사항은 postman과 같은 변수 자동 완성 기능이었어요. 기능 개발을 하기 전에 어떤 기능이 필요한지 리스트를 정리했어요.
{
텍스트가 감지되면 popover가 열립니다.{
텍스트 다음에 입력한 키워드에 따라 popover 옵션이 달라집니다. (키워드에 해당하는 옵션이 없을 경우 popover를 닫습니다.){{변수명}}
형태로 입력됩니다.{
텍스트가 연속적으로 들어온 경우는 {
하나로 대체됩니다. (ex : {{{{{
→ {
)요구사항을 글로만 봤을 때는 감이 오지 않을 수 있어요. 아래에서 기능 하나하나 이해할 수 있도록 서술할게요.
참고 사항 및 현황
{
) 위치 기억하기기능 요구 사항
{
텍스트가 입력되면 popover가 열립니다.{
텍스트 다음에 입력한 키워드에 따라 popover 옵션이 달라집니다. (키워드에 해당하는 옵션이 없을 경우 popover를 닫습니다.)popover가 열리는 상황은 cursor의 위치가 {
+ 검색된 키워드
가 존재할 때예요. 그래서 입력된 text에서 {
+ 검색된 키워드
값을 구분해줘야 해요. handleVariableInput
이벤트 핸들러 함수를 만들어 구분해주는 로직을 구성했어요.
// 검색 키워드 ref
const keyword = ref("");
// 커서 위치
const variableStartPosition = ref(0);
// input 내부에 변수 입력 확인 처리
const handleVariableInput = (event: InputEvent | KeyboardEvent) => {
const target = event.target as HTMLInputElement;
const inputValue = target.value;
// 변수가 사용된 위치 찾기
const newCursorPosition = target.selectionStart as number;
if (inputValue[newCursorPosition - 1] === '{') {
variableStartPosition.value = newCursorPosition;
}
// ....후략
};
selectionStart는 해당 input 요소의 커서 시작점 index 값을 나타내요. input에서 현재 위치의 cursor 인덱스를 기억하다가 특정 문자열이 나오는 위치를 variableStartPosition
으로 저장해요.
const handleVariableInput = (event: InputEvent | KeyboardEvent) => {
// ... 생략
const currentText = inputValue.slice(0, newCursorPosition);
// '{'로 열린 구간이 있는지 확인
const isVariableMatch = currentText.match(/{+([^{}]*)$/);
const searchKeyword = inputValue.slice(
variableStartPosition.value,
newCursorPosition
);
keyword.value = searchKeyword;
// '{'로 열린 구간이 없거나 검색 결과가 없으면 드롭다운 닫기
if (!isVariableMatch || !searchVariables.value.length) {
onCloseHandler();
return;
}
// 변수가 사용된 경우 드롭다운 열기
onOpenHandler();
initializeIndex();
};
{
바로뒤에 입력된 키워드 값을 추출해야 해요.
키워드 값을 추출하기 위해 처음에는 indexOf
로 {
로 열린 부분을 파악했어요. 하지만 현재 위치가 아닌 다른 변수가 판단될수도 있어서 정규표현식으로 판단하기로 했어요.
ex) input에 new{variable1} old{variable2}
가 입력되어 있는 상태.
indexOf로 로직을 구성하면 variable2쪽에 커서 위치를 놓지만 판단은 variable1을 기준으로 계산을 해버려요.
중괄호가 열린 부분이 없거나 검색 키워드 값이 존재하지 않는다면 popover를 닫아줘요. 이렇게 만들어진 함수를 이제 input컴포넌트 이벤트에 등록해줘야 해요.
이벤트 핸들러는 다음 이벤트와 바인딩 시켜줘야해요.
<a-input
@input="handleVariableInput"
@click="handleVariableInput"
@keyup="onKeyupInput"
/>
<script setup>
// input 내부 이벤트(keyup)
const onKeyupInput = (event: KeyboardEvent) => {
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
handleVariableInput(event);
}
};
</script>
keyDown 이벤트와 연결되면 input index 값이 이전 값을 참조하여 계산해서 타이밍이 맞지 않을 수 있어요.
이를 mermaid로 흐름을 넓게 그려보면 다음과 같아요.
기능 요구 사항
{{변수명}}
형태로 입력됩니다.{
텍스트가 연속적으로 들어온 경우는 {
하나로 대체됩니다. (ex : {{{{{
→ {
)<a-dropdown v-model:open="isOpen" :trigger="['contextmenu']">
<a-input
ref="inputRef"
@input="onInputEvent"
@click="onClickInput"
@keyup="onKeyupInput"
/>
<template #overlay>
<div class="vista-msg" style="display: flex">
<a-menu class="vista-msg-left">
<template
v-for="(variable, index) of searchVariables"
:key="variable"
>
<a-menu-item>
<div ref="itemRefs">
<slot name="variable-item" :variable="variable">
{{ variable[variableKey] as string }}
</slot>
</div>
</a-menu-item>
</template>
</a-menu>
<slot name="right-menu"/>
</div>
</template>
</a-dropdown>
overlay 내부 템플릿에서 변수 목록을 보여주고 있어요. a-menu-item
에서는 각각의 옵션을 보여주고, right-menu
슬롯에는 해당 옵션을 hover했을 때 상세 정보를 보여줘야 해요.
첫 번째로 해당 메뉴 옵션을 클릭하면, 그대로 input에 입력되는 이벤트 동작을 만들어 볼게요.
// 변수 선택 시 처리하는 함수
const handleSelectMenu = (variable: T) => {
const inputValue = props.inputValue;
const cursorPos = variableStartPosition.value;
// 변수 시작 위치 찾기
let startPos = cursorPos;
while (startPos > 0 && inputValue[startPos - 1] === '{') {
startPos--;
}
};
위에서 만든 handleVariableInput
함수를 통해 현재 variable 시작 위치를 알고 있어요. 이것으로 간단하게 변수 시작 위치를 알 수는 있지만, 연속적으로 들어온 {
텍스트를 예외 처리해줘야 해요.
그래서 variableStartPosition
을 기준으로 이전 인덱스의 텍스트가 {
를 반복할 때마다 index를 하나씩 빼줬어요.
const handleSelectMenu = (variable: T) => {
//...생략
const prefix = inputValue.slice(0, startPos);
const afterCursor = inputValue.slice(cursorPos);
let suffix = afterCursor;
// 현재 키워드 제거
suffix = suffix.replace(keyword.value, "");
// 새로운 입력 템플릿 생성
const variableText = variable[props.variableKey] as string;
const newInputTemplate = `${prefix}${variableText}${suffix}`;
//emit을 통해 상위 컴포넌트로 새로운 템플릿 전송
emit("update:modelValue", newInputTemplate);
onCloseHandler();
}
다음과 같이 입력된 부분을 커서 이전
+ {{변수명}}
+ 커서 이후
로 템플릿을 만들어줬어요.
그런데, 값은 잘 만들어지지만 cursor의 위치가 맨 뒤로 이동했어요.
자연스러운 흐름이라면 입력된 {{변수명}}
바로 뒤에 cursor를 위치시키는 게 좋아 보여요.
const handleSelectMenu = (variable: T) => {
//...생략
const prefix = inputValue.slice(0, startPos);
const afterCursor = inputValue.slice(cursorPos);
let suffix = afterCursor;
// 현재 키워드 제거
suffix = suffix.replace(keyword.value, "");
// 새로운 입력 템플릿 생성
const variableText = variable[props.variableKey] as string;
const newInputTemplate = `${prefix}${variableText}${suffix}`;
//emit을 통해 상위 컴포넌트로 새로운 템플릿 전송
emit("update:modelValue", newInputTemplate);
onCloseHandler();
//커서위치 갱신!!
const newCursorPosition = prefix.length + variableText.length;
inputRef.value?.setSelectionRange(newCursorPosition, newCursorPosition);
}
setSelectionRange
를 통해 해당 커서 위치를 조정해줬어요. 그런데 여전히 동작하지 않았어요.
이유는 Vue의 반응성 시스템과 DOM 업데이트 타이밍이 비동기적으로 이뤄지고 있기 때문이에요.
emit("update:modelValue", newInputTemplate);
onCloseHandler();
const newCursorPosition = prefix.length + variableText.length;
inputRef.value?.setSelectionRange(newCursorPosition, newCursorPosition);
emit을 통해 값을 전달하고 이후에 이 값을 토대로 cursor의 위치를 계산해야 해요. 하지만 Vue에서도 React처럼 여러 상태 변경에 대해 배칭처리를 해주고 있어요.
배칭처리로 인해 비동기적으로 DOM 업데이트가 되고 있기 때문에 inputRef
는 이전 input value를 참조하고 있어요. 즉, setSelectionRange
를 호출하기 전에 DOM update가 완료된 후에 로직을 실행해야 해요.
Vue에서는 이 하나의 배칭처리를 하나의 틱
으로 보고 있어요.
emit("update:modelValue", newInputTemplate);
onCloseHandler();
await nextTick();
const newCursorPosition = prefix.length + variableText.length;
inputRef.value?.setSelectionRange(newCursorPosition, newCursorPosition);
DOM을 동기적으로 업데이트하기 위해 nextTick(상태 변경 직후 DOM업데이트 된 이후까지 기다림)
을 호출해서 마무리했어요.
드디어 기획 요구사항을 다 구현해냈어요. 그런나 사용성에 아쉬운 부분이 존재했어요.
select box의 경우, 마우스를 사용하지 않아도 키보드로 충분히 조작이 가능해요. 요구사항은 아니였지만, 일반적인 상황이라면 사용자에게 충분히 고려될 사항이라고 판단했어요.
const handleScrollView = (event: KeyboardEvent) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
// 하위 항목으로 이동 로직
break;
case "ArrowUp":
event.preventDefault();
// 이전 항목으로 이동 로직
break;
case "Enter":
event.preventDefault();
// 해당 항목 선택 로직
break;
case "Escape":
event.preventDefault();
// 팝오버 닫기 로직
break;
}
};
각 키보드에 대한 동작을 정의해줬어요. 그리고 로직을 설정하기 전에 각 키보드의 기본 동작을 막아줬어요.
ArrowDown
의 경우 input 기본 동작으로 커서가 맨 뒤로 이동해요.
Enter
의 경우 form 양식에서 button type이 submit이라면 form 제출 이벤트가 동작할 수 있어요.
이러한 상황들을 막기 위해 기본동작을 막았어요.
<template #overlay>
<div class="vista-msg" style="display: flex">
<a-menu class="vista-msg-left">
<template
v-for="(variable, index) of searchVariables"
:key="variable"
>
<a-menu-item
:style="hoveredStyle(index)"
@click="() => handleSelectMenu(variable)"
@mouseover="() => onMouseOverMenu(index)"
>
<div ref="itemRefs">
<slot name="variable-item" :variable="variable">
{{ variable[variableKey] as string }}
</slot>
</div>
</a-menu-item>
</template>
</a-menu>
<slot name="right-menu" :hovered-variable="hoveredVariable" />
</div>
</template>
<script setup lang="ts" generic="T extends Record<string, any>">
//...생략
const currentIndex = ref(0);
const initializeIndex = () => {
currentIndex.value = 0;
};
// menu option DOM 목록
const itemRefs = ref<HTMLElement[]>([]);
const navigateNextIndex = () => {
currentIndex.value = currentIndex.value + 1;
};
const navigatePreviousIndex = () => {
currentIndex.value = currentIndex.value - 1;
};
const onMouseOverMenu = (index: number) => {
currentIndex.value = index;
};
// 메뉴 선택 시 스타일 적용
const hoveredStyle = (index: number) => {
if (index !== currentIndex.value) {
return
}
return HOVERED_STYLED
};
const handleScrollView = (event: KeyboardEvent) => {
if (!itemRefs.value.length) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
navigateNextIndex();
break;
case "ArrowUp":
event.preventDefault();
navigatePreviousIndex();
break;
case "Enter":
event.preventDefault();
handleSelectMenu(searchVariables.value[currentIndex.value]);
break;
case "Escape":
event.preventDefault();
onCloseHandler();
break;
}
};
</script>
전체적인 흐름 코드를 작성했어요. menu option 목록 하나하나에 index를 부여했어요. select box처럼 동작하고 현재 focus된 menu에 스타일도 적용했어요.
다만 한 가지 문제가 있었어요. 옵션을 선택할 때 현재 뷰포트에 보이도록 스크롤이 되지 않았어요.
Vue는 React와는 다르게 다중 DOM 요소에 대한 바인딩(배열 형태)을 지원해요.
watch(currentIndex, (val) => {
if (val >= 0) {
const targetItem = itemRefs.value[val];
targetItem?.focus();
}
});
현재 옵션 인덱스를 observing하고 이를 focus하면 해당 요소가 자동적으로 뷰포트로 이동해요. 하지만 일반 div요소는 focus이벤트가 동작하지 않아요.
물론 tabIndex
를 부여하여 focus되도록 할수는 있긴하지만, tabIndex
의 경우 큰 개념 단위에서의 입력 필드에 부여하는 게 더 알맞아요. 즉, input 내부에 menu의 option마다 tabindex
를 부여하는 것은 오히려 접근성이 떨어지는 형태라고 생각했어요.
scrollIntoView
메서드는 scrollIntoView()가 호출된 요소가 사용자에게 표시되도록 요소의 상위 컨테이너를 스크롤해요.
watch(currentIndex, (val) => {
if (val >= 0) {
const targetItem = itemRefs.value[val];
targetItem?.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
해당 스크롤되는 요소는 가운데에 위치하여 사용자 눈에 띄도록 조정해줬어요. 여기까지 요구사항 + 사용성 개선
의 여정을 살펴봤어요.
select가 아닌 DOM 요소에 스크롤 기능을 부여하기 위해 useScrollView
로 추상화했어요.
/**
* scrollIntoView 컴포저블
* @param options - scrollIntoView 옵션
* @returns itemRefs, currentIndex, initializeIndex, handleScrollView
*/
export const useScrollView = (
options: ScrollIntoViewOptions = { behavior: "smooth", block: "center" }
) => {
const itemRefs = ref<HTMLElement[]>([]);
const currentIndex = ref(0);
watch(currentIndex, (val) => {
if (val >= 0) {
const targetItem = itemRefs.value[val];
targetItem?.scrollIntoView(options);
}
});
const initializeIndex = () => {
currentIndex.value = 0;
};
const navigateNextIndex = () => {
if (currentIndex.value === itemRefs.value.length - 1) {
initializeIndex();
return;
}
currentIndex.value = currentIndex.value + 1;
};
const navigatePreviousIndex = () => {
if (currentIndex.value <= 0) {
currentIndex.value = itemRefs.value.length - 1;
return;
}
currentIndex.value = currentIndex.value - 1;
};
const handleScrollView = (event: KeyboardEvent, callback: () => void) => {
if (!itemRefs.value.length) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
navigateNextIndex();
break;
case "ArrowUp":
event.preventDefault();
navigatePreviousIndex();
break;
case "Enter":
event.preventDefault();
callback();
break;
case "Escape":
event.preventDefault();
break;
}
};
return {
itemRefs,
currentIndex,
initializeIndex,
handleScrollView,
};
};
추상화를 진행하면서 사용성을 한 단계 더 개선했어요.
현재 index가 첫번째요소인데 ArrowUp 버튼을 클릭할 경우 마지막요소로 이동하고, index가 마지막요소인데 ArrowDown 버튼을 클릭할 경우 첫번째 요소로 이동하도록 기능을 추가했어요.
이번 자동완성 input 컴포넌트 개발을 통해 여러 중요한 개발 경험과 지식을 얻을 수 있었어요.
학습 포인트
하나의 독립적인 컴포넌트를 설계하고 만들었지만, 초기 단계라 아직 확장 및 개선 여지도 있었어요.
고려해볼 개선 사항들
{
대신 [
가 진입점인 경우의 확장성 고려이번 개발을 통해 단순한 기능 구현을 넘어 재사용 가능하고 사용자 친화적인 컴포넌트를 만드는 것의 중요성을 다시 한번 깨달았어요.
Reference
링크드인으로 이야기를 주고받고 싶으시다면 언제든지 편하게 연락주세요. 🙇♂️