nnuer
강희태@gmlxo9917

이메일 칩을 모아 보내는 멤버 초대 모달

기타 블록

어두운 배경막 위에 이메일 입력·추가, 이메일 칩 목록, 역할 선택, 취소/초대 보내기 버튼이 있는 흰 모달입니다. 이메일 형식·중복 검사, Esc 닫기, 다시 열기 버튼을 지원합니다.

#modal#dialog#invite#email-chips#team2026. 10. 16. 업데이트

블록 신고

운영자가 확인한 뒤 필요하면 블록을 숨깁니다.

사유
Claude Code · 모름 · React (TSX)
결과 코드펼치기
import { useEffect, useRef, useState } from "react";

const FONT_URL =
  "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Instrument+Sans:wght@600;700&display=swap";

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export default function InviteMemberModal() {
  const [open, setOpen] = useState(true);
  const [emails, setEmails] = useState(["mina@example.com", "joon@example.com", "hana@example.com"]);
  const [draft, setDraft] = useState("");
  const [error, setError] = useState("");
  const [role, setRole] = useState("Member");
  const [sent, setSent] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    if (!open) return;
    inputRef.current?.focus();
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") close();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open]);

  const close = () => {
    setOpen(false);
    requestAnimationFrame(() => triggerRef.current?.focus());
  };

  const add = () => {
    const value = draft.trim().toLowerCase();
    if (!value) return;
    if (!EMAIL_RE.test(value)) {
      setError("올바른 이메일 주소를 입력해 주세요.");
      return;
    }
    if (emails.includes(value)) {
      setError("이미 추가한 이메일입니다.");
      return;
    }
    setEmails([...emails, value]);
    setDraft("");
    setError("");
    setSent(false);
  };

  return (
    <section className="relative flex min-h-[640px] w-full items-center justify-center overflow-hidden bg-[#f8fafc] px-4 py-16 font-['Inter',sans-serif]">
      <link rel="stylesheet" href={FONT_URL} precedence="default" />
      <div className="flex flex-col items-center gap-3 text-center">
        <p className="text-[14px] text-[#475569]">{sent ? `${emails.length}명에게 초대장을 보냈습니다.` : "팀원을 워크스페이스에 초대하세요."}</p>
        <button
          ref={triggerRef}
          type="button"
          onClick={() => setOpen(true)}
          className="rounded-lg bg-[#4f46e5] px-4 py-2.5 text-[14px] font-semibold text-white transition-colors hover:bg-[#4338ca] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5]"
        >
          Invite Member
        </button>
      </div>

      {open && (
        <div className="absolute inset-0 flex items-center justify-center p-4">
          <div className="absolute inset-0 bg-black/40" onClick={close} aria-hidden="true" />
          <div
            role="dialog"
            aria-modal="true"
            aria-labelledby="invite-title"
            aria-describedby="invite-desc"
            className="relative flex w-full max-w-[520px] flex-col gap-6 rounded-[20px] bg-white p-6 drop-shadow-[0px_20px_20px_rgba(0,0,0,0.2)] sm:p-8"
          >
            <div className="flex items-center justify-between">
              <h2 id="invite-title" className="font-['Instrument_Sans',sans-serif] text-[22px] font-bold leading-normal text-[#0f172a]">
                Invite Member
              </h2>
              <button
                type="button"
                onClick={close}
                aria-label="닫기"
                className="flex size-8 items-center justify-center rounded-2xl bg-[#f1f5f9] text-[#475569] transition-colors hover:bg-[#e2e8f0] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5]"
              >
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
                  <circle cx="12" cy="12" r="10" />
                  <path d="m15 9-6 6M9 9l6 6" />
                </svg>
              </button>
            </div>

            <div className="flex flex-col gap-5">
              <p id="invite-desc" className="text-[14px] leading-[1.5] text-[#475569]">
                Invite team members to your workspace by entering their email addresses. You can invite multiple people at once.
              </p>

              <div className="flex flex-col gap-3">
                <div className="flex items-end gap-2">
                  <div className="flex min-w-0 flex-1 flex-col gap-2">
                    <label htmlFor="invite-email" className="text-[14px] font-semibold leading-normal text-[#0f172a]">
                      Email Address
                    </label>
                    <input
                      ref={inputRef}
                      id="invite-email"
                      type="email"
                      value={draft}
                      onChange={(e) => {
                        setDraft(e.target.value);
                        setError("");
                      }}
                      onKeyDown={(e) => {
                        if (e.key === "Enter" && !e.nativeEvent.isComposing) {
                          e.preventDefault();
                          add();
                        }
                      }}
                      placeholder="Enter email address"
                      aria-invalid={error ? true : undefined}
                      aria-describedby={error ? "invite-error" : undefined}
                      className={`h-11 w-full rounded-lg border bg-white px-3 text-[14px] text-[#0f172a] outline-none transition-colors placeholder:text-[#94a3b8] focus:ring-2 ${
                        error ? "border-[#ef4444] focus:ring-[#ef4444]/15" : "border-[#e2e8f0] focus:border-[#4f46e5] focus:ring-[#4f46e5]/15"
                      }`}
                    />
                  </div>
                  <button
                    type="button"
                    onClick={add}
                    className="h-11 shrink-0 rounded-lg border border-[#e2e8f0] bg-white px-4 text-[14px] font-semibold text-[#0f172a] transition-colors hover:bg-[#f8fafc] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5]"
                  >
                    Add
                  </button>
                </div>
                {error && (
                  <p id="invite-error" role="alert" className="text-[12px] text-[#ef4444]">
                    {error}
                  </p>
                )}
                {emails.length > 0 && (
                  <ul className="flex flex-wrap gap-2" aria-label="초대할 이메일">
                    {emails.map((email) => (
                      <li key={email} className="flex items-center gap-1.5 rounded-full border border-[#c7d2fe] bg-[#eef2ff] py-1.5 pl-2.5 pr-1.5">
                        <span className="text-[13px] font-medium leading-normal text-[#4f46e5]">{email}</span>
                        <button
                          type="button"
                          onClick={() => setEmails(emails.filter((x) => x !== email))}
                          aria-label={`${email} 삭제`}
                          className="flex size-4 items-center justify-center rounded-lg text-[#4f46e5] hover:opacity-70 focus-visible:outline-2 focus-visible:outline-[#4f46e5]"
                        >
                          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden="true">
                            <circle cx="12" cy="12" r="10" />
                            <path d="m15 9-6 6M9 9l6 6" />
                          </svg>
                        </button>
                      </li>
                    ))}
                  </ul>
                )}
              </div>

              <div className="flex flex-col gap-2">
                <label htmlFor="invite-role" className="text-[14px] font-semibold leading-normal text-[#0f172a]">
                  Assigned Role
                </label>
                <div className="relative">
                  <select
                    id="invite-role"
                    value={role}
                    onChange={(e) => setRole(e.target.value)}
                    className="h-11 w-full cursor-pointer appearance-none rounded-lg border border-[#e2e8f0] bg-white px-3 pr-9 text-[14px] text-[#0f172a] outline-none focus:border-[#4f46e5] focus:ring-2 focus:ring-[#4f46e5]/15"
                  >
                    <option>Member</option>
                    <option>Editor</option>
                    <option>Admin</option>
                  </select>
                  <svg className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-[#0f172a]" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                    <path d="m6 9 6 6 6-6" />
                  </svg>
                </div>
              </div>
            </div>

            <div className="flex justify-end gap-3 pt-2">
              <button
                type="button"
                onClick={close}
                className="rounded-lg border border-[#e2e8f0] bg-white px-4 py-2.5 text-[14px] font-semibold text-[#0f172a] transition-colors hover:bg-[#f8fafc] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5]"
              >
                Cancel
              </button>
              <button
                type="button"
                disabled={emails.length === 0}
                onClick={() => {
                  setSent(true);
                  close();
                }}
                className="rounded-lg bg-[#4f46e5] px-4 py-2.5 text-[14px] font-semibold text-white transition-colors hover:bg-[#4338ca] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5] disabled:cursor-not-allowed disabled:opacity-50"
              >
                Send Invitations
              </button>
            </div>
          </div>
        </div>
      )}
    </section>
  );
}

적용 사이트 0

이 블록이 실제로 쓰인 사이트입니다.

로그인하고 내 사이트 추가

아직 등록된 사이트가 없습니다. 이 블록을 쓴 사이트가 있다면 알려 주세요.

로그인하고 재현 결과 올리기

재현 후기

v1 · 0개

아직 이 버전의 프롬프트로 직접 만들어 본 사람이 없습니다. 첫 번째로 재현해 보세요.