본문 바로가기
React/React 실습

Blur 시 Enter 키 이벤트 디스패치: MUI Autocomplete vs TextField — 실전 패턴 & 예제

by haheehee 2025. 11. 7.
728x90

1) 문제 정의

사용자가 이메일을 입력한 뒤 Enter를 누르지 않고 포커스만 이동(blur)해도, 입력된 값을 즉시 확정(칩 추가/제출)하고 싶다. MUI AutocompleteonClose(event, reason) 콜백에서 reason === 'blur'로 분기 가능하지만, TextFieldonClose가 없으므로 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

아래 두 컴포넌트는 공통 훅 useEnterOnBluraddEmailsFromInput을 사용해 구현 예시를 보입니다.

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: onClosereason==='blur' 분기 활용
  • TextField: onBlur에서 직접 처리(권장: 로직 호출 / 대안: Enter 디스패치)
  • 공통 유틸과 훅을 만들어 중복을 제거하고, IME/중복/잘못된 이메일에 대한 가드를 반드시 둔다
728x90

댓글