728x90
1) 문제 정의
사용자가 이메일을 입력한 뒤 Enter를 누르지 않고 포커스만 이동(blur)해도, 입력된 값을 즉시 확정(칩 추가/제출)하고 싶다. MUI Autocomplete는 onClose(event, reason) 콜백에서 reason === 'blur'로 분기 가능하지만, TextField는 onClose가 없으므로 onBlur에서 직접 처리해야 한다.
2) 핵심 요약
- Autocomplete:
onClose={(e, reason) => { if (reason === 'blur') ... }} - TextField:
onBlur={(e) => {...}}에서 네이티브KeyboardEvent('keydown', { key: 'Enter', bubbles: true })디스패치 - 권장 대안: 가능하다면 "가짜 키 이벤트" 대신 로직 함수(예:
addEmail())를 직접 호출
3) 공통 유틸 — 이메일 파싱·검증·중복 방지
// emailUtils.ts
export const parseEmails = (raw: string): string[] => {
// 콤마/세미콜론/공백 구분자 허용
return raw
.split(/[,;\s]+/)
.map((s) => s.trim())
.filter(Boolean);
};
export const isValidEmail = (email: string): boolean => {
// 실서비스에서는 더 엄격한 RFC5322 기반 검증기를 사용
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
};
export const uniq = (arr: string[]): string[] => Array.from(new Set(arr.map((s) => s.toLowerCase())));
4) 권장 패턴 — "Enter 시뮬레이션" 대신 로직 직접 호출
가능하면 이 방식이 더 단순하고 예측 가능
// 공통 로직 함수를 작성해 Autocomplete/TextField 양쪽에서 재사용
const addEmailsFromInput = (inputEl: HTMLInputElement | null, setEmails: (next: string[]) => void, current: string[]) => {
const value = inputEl?.value?.trim() ?? '';
if (!value) return;
const candidates = uniq(parseEmails(value)).filter(isValidEmail);
if (candidates.length === 0) return;
const merged = uniq([...current, ...candidates]);
setEmails(merged);
if (inputEl) inputEl.value = '';
};
5) Autocomplete — onClose(reason==='blur') 확장 예시
칩 UI(복수 이메일) + blur 시 자동 확정 + Enter 키/콤마/세미콜론 수용
import { Autocomplete, Chip, TextField } from '@mui/material';
import { useRef, useState } from 'react';
import { addEmailsFromInput } from './emailAddLogic';
import { parseEmails, isValidEmail, uniq } from './emailUtils';
export default function EmailChipsAutocomplete() {
const inputRef = useRef<HTMLInputElement | null>(null);
const [emails, setEmails] = useState<string[]>([]);
return (
<Autocomplete
multiple
freeSolo
options={[]}
value={emails}
onChange={(e, newValue) => {
// Autocomplete가 내부적으로 옵션/자유입력으로 값이 넘어올 때 동기화
const normalized = uniq(
newValue
.map((v) => (typeof v === 'string' ? v : String(v)).trim())
.flatMap((v) => parseEmails(v))
.filter(isValidEmail)
);
setEmails(normalized);
}}
onClose={(e, reason) => {
if (reason === 'blur') {
// blur 시 입력 박스에 남아있는 값을 칩으로 확정
addEmailsFromInput(inputRef.current, setEmails, emails);
}
}}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip variant="outlined" label={option} {...getTagProps({ index })} />
))
}
renderInput={(params) => (
<TextField
{...params}
inputRef={inputRef}
placeholder="이메일 입력 후 포커스 아웃 시 자동 추가"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',' || e.key === ';') {
e.preventDefault();
addEmailsFromInput(inputRef.current, setEmails, emails);
}
}}
/>
)}
/>
);
}
6) TextField — onBlur에서 직접 처리(Enter 시뮬레이션 버전 포함)
6-1. 로직 직접 호출(권장)
import { TextField, Stack, Chip, IconButton } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import { useRef, useState } from 'react';
import { addEmailsFromInput } from './emailAddLogic';
export default function EmailTextFieldDirect() {
const inputRef = useRef<HTMLInputElement | null>(null);
const [emails, setEmails] = useState<string[]>([]);
const commit = () => addEmailsFromInput(inputRef.current, setEmails, emails);
return (
<Stack spacing={1}>
<TextField
inputRef={inputRef}
placeholder="이메일 입력 후 포커스 아웃/Enter/버튼으로 추가"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',' || e.key === ';') {
e.preventDefault();
commit();
}
}}
onBlur={() => {
// blur 시 자동 확정
commit();
}}
fullWidth
/>
<Stack direction="row" spacing={1}>
<IconButton onMouseDown={(e) => e.preventDefault()} onClick={commit} aria-label="add-email">
<AddIcon />
</IconButton>
</Stack>
<Stack direction="row" spacing={1} flexWrap="wrap">
{emails.map((em) => (
<Chip key={em} label={em} variant="outlined" />
))}
</Stack>
</Stack>
);
}
6-2. Enter 키 이벤트 디스패치(기존 Enter 핫키 처리에 강하게 의존할 때)
import { TextField } from '@mui/material';
import { useRef } from 'react';
export default function EmailTextFieldEnterDispatch() {
const inputRef = useRef<HTMLInputElement | null>(null);
const dispatchEnter = () => {
const val = inputRef.current?.value.trim() ?? '';
if (!val) return;
// React는 루트에서 네이티브 이벤트를 위임하므로 bubbles가 중요
const ev = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
});
inputRef.current?.dispatchEvent(ev);
};
return (
<TextField
inputRef={inputRef}
placeholder="blur 시 Enter 이벤트 디스패치"
onBlur={dispatchEnter}
onKeyDown={(e) => {
if (e.key === 'Enter') {
// 여기서 실제 확정 로직이 동작하도록, 상위 핸들러/폼이 리슨하고 있어야 함
}
}}
fullWidth
/>
);
}
7) IME(조합)·메뉴 클릭 등 경계 상황 처리
// 조합 입력 도중에는 확정하면 안 되는 경우가 있음
const [composing, setComposing] = useState(false);
<TextField
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onBlur={() => {
if (composing) return; // 조합 종료 전에는 확정하지 않음
commit();
}}
InputProps={{
endAdornment: (
// 팝오버/버튼 클릭 시 blur 방지 필요할 경우
<IconButton
onMouseDown={(e) => e.preventDefault()} // preventDefault로 blur 방지
onClick={openMenu}
/>
),
}}
/>
8) 실제 화면 예: EmailInputDialog & AddEmailDialog
아래 두 컴포넌트는 공통 훅 useEnterOnBlur와 addEmailsFromInput을 사용해 구현 예시를 보입니다.
8-1. 공통 훅: useEnterOnBlur
// useEnterOnBlur.ts
import { useCallback } from 'react';
export const useEnterOnBlur = (inputEl: HTMLInputElement | null, guard?: () => boolean) => {
return useCallback(() => {
if (guard?.() === false) return;
const val = inputEl?.value?.trim() ?? '';
if (!val) return;
const ev = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
inputEl?.dispatchEvent(ev);
}, [inputEl, guard]);
};
8-2. EmailInputDialog (Autocomplete 버전)
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Autocomplete, Chip, TextField } from '@mui/material';
import { useRef, useState } from 'react';
import { addEmailsFromInput } from './emailAddLogic';
import { parseEmails, isValidEmail, uniq } from './emailUtils';
interface Props {
open: boolean;
onClose: () => void;
onSubmit: (emails: string[]) => void;
}
export function EmailInputDialog({ open, onClose, onSubmit }: Props) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [emails, setEmails] = useState<string[]>([]);
const commit = () => addEmailsFromInput(inputRef.current, setEmails, emails);
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>게스트 이메일 추가</DialogTitle>
<DialogContent>
<Autocomplete
freeSolo
multiple
options={[]}
value={emails}
onChange={(e, newValue) => {
const normalized = uniq(
newValue
.map((v) => (typeof v === 'string' ? v : String(v)).trim())
.flatMap((v) => parseEmails(v))
.filter(isValidEmail)
);
setEmails(normalized);
}}
onClose={(e, reason) => {
if (reason === 'blur') {
commit(); // blur 시 입력값 확정
}
}}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip variant="outlined" label={option} {...getTagProps({ index })} />
))
}
renderInput={(params) => (
<TextField
{...params}
inputRef={inputRef}
placeholder="이메일 입력 후 blur/Enter/콤마/세미콜론으로 추가"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',' || e.key === ';') {
e.preventDefault();
commit();
}
}}
/>
)}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>취소</Button>
<Button onClick={() => onSubmit(emails)} variant="contained">추가</Button>
</DialogActions>
</Dialog>
);
}
8-3. AddEmailDialog (TextField + Enter 디스패치 훅)
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Stack, Chip } from '@mui/material';
import { useRef, useState, useMemo } from 'react';
import { addEmailsFromInput } from './emailAddLogic';
import { useEnterOnBlur } from './useEnterOnBlur';
interface Props {
open: boolean;
onClose: () => void;
onSubmit: (emails: string[]) => void;
}
export function AddEmailDialog({ open, onClose, onSubmit }: Props) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [emails, setEmails] = useState<string[]>([]);
const guard = useMemo(() => () => true, []);
const enterOnBlur = useEnterOnBlur(inputRef.current, guard);
const commit = () => addEmailsFromInput(inputRef.current, setEmails, emails);
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>이메일로 공유</DialogTitle>
<DialogContent>
<TextField
inputRef={inputRef}
placeholder="이메일 입력 후 blur 시 Enter 디스패치"
fullWidth
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',' || e.key === ';') {
e.preventDefault();
commit();
}
}}
onBlur={() => {
// 기존 코드가 Enter 핫키에 묶여 있다면:
enterOnBlur();
// 또는 권장: 로직 직접 호출
// commit();
}}
/>
<Stack direction="row" spacing={1} mt={1} flexWrap="wrap">
{emails.map((em) => (
<Chip key={em} label={em} variant="outlined" />
))}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>취소</Button>
<Button onClick={() => onSubmit(emails)} variant="contained">공유</Button>
</DialogActions>
</Dialog>
);
}
9) 테스트 포인트 (React Testing Library 기준)
// 1) blur 시 값 확정되는가?
// 2) Enter/','/';' 입력 시 확정되는가?
// 3) 잘못된 이메일은 무시되는가?
// 4) 조합 입력 중에는 확정하지 않는가?
// 5) 중복 이메일은 제거되는가?
10) 사용 시점 가이드
써야 할 때
- 칩 기반 태깅/이메일 UI에서 사용자가 Enter를 누르지 않아도 포커스 이동만으로 항목을 확정해야 할 때
- 단일 입력 → 빠른 제출 플로우(게스트 초대, 이메일 공유 등)
피해야 할 때
- 복잡한 폼에서 포커스 이동=즉시 제출이 사용자의 예상을 깨는 경우
- 모바일/음성/IME 의존이 높아 오작동 가능성이 큰 경우
- 감사/법적 근거가 필요한 폼: 명시적 확인을 요구
11) 결론
- Autocomplete:
onClose의reason==='blur'분기 활용 - TextField:
onBlur에서 직접 처리(권장: 로직 호출 / 대안: Enter 디스패치) - 공통 유틸과 훅을 만들어 중복을 제거하고, IME/중복/잘못된 이메일에 대한 가드를 반드시 둔다
728x90
'React > React 실습' 카테고리의 다른 글
| window.opener 완전 정리 (0) | 2025.10.22 |
|---|---|
| [React] ResizeObserver로 이미지 주석 입력창의 위치 오류 해결하기 (0) | 2025.06.10 |
| [React] 이벤트 기반 아키텍처 vs HTTP 서비스 아키텍처 (0) | 2025.04.18 |
| System.Text.Json을 활용한 네이버 로그인 응답 처리 개선 (1) | 2025.04.10 |
| [SSO] 네이버 소셜 로그인 연동 및 보안 고려사항, (팝업 기반 소셜 로그인 처리 방식) (0) | 2025.04.10 |
댓글