임동욱 임동욱 08-19
merge: @fox 전화번호·이메일 시안 구조
@5d76601e4aa4ca2f3bbf7ab946bfb30db6f52e6a
@fox/core/components/fox-email/fox-email.tsx
--- @fox/core/components/fox-email/fox-email.tsx
+++ @fox/core/components/fox-email/fox-email.tsx
@@ -1,7 +1,7 @@
 "use client";
 
 import { useId, useState, type ReactNode } from "react";
-import { FoxFormLabel } from "../fox-form-label";
+import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label";
 import { cx } from "../../utils";
 import { FoxButton } from "../fox-button";
 import { FoxInput } from "../fox-input";
@@ -29,6 +29,8 @@
   verifyLabel?: string;
 
   label?: string;
+  /** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다(`FoxInput`·`FoxSelect`와 같은 규약). */
+  requirement?: FoxFormLabelRequirement;
   /** 헬퍼 메시지. 오류일 때 색이 바뀐다. */
   message?: string;
   messageIcon?: ReactNode;
@@ -88,6 +90,7 @@
   onVerifyFail,
   verifyLabel = "확인",
   label,
+  requirement,
   message,
   messageIcon,
   localPlaceholder = "아이디",
@@ -163,12 +166,16 @@
       aria-labelledby={label ? `${autoId}-label` : undefined}
     >
       {label && (
-        <FoxFormLabel as="span" id={`${autoId}-label`}>
+        <FoxFormLabel as="span" id={`${autoId}-label`} requirement={requirement}>
           {label}
         </FoxFormLabel>
       )}
 
-      <div className="fox-email__row">
+      {/* 시안의 form-email 알맹이 — form-group / select / btn이 4px 간격으로 쌓인다.
+          라벨·헬퍼와의 간격은 바깥(`field.root`)이 갖는다. */}
+      <div className="fox-email__body">
+      {/* 시안의 form-group — 아이디·@·도메인이 한 줄이다. 도메인 목록은 아래 줄에 따로 선다. */}
+      <div className="fox-email__group">
         <div className="fox-email__part">
           <FoxInput
             size="md"
@@ -186,18 +193,6 @@
           @
         </span>
 
-        <div className="fox-email__part">
-          <FoxSelect
-            size="md"
-            options={domainItems}
-            value={parts.custom ? CUSTOM_DOMAIN : parts.domain}
-            onValueChange={handleDomainPick}
-            placeholder={domainPlaceholder}
-            error={invalid}
-            disabled={disabled}
-          />
-        </div>
-
         {/* 목록에서 고른 값이 여기에 채워지고 잠긴다. "직접입력"일 때만 풀린다. */}
         <div className="fox-email__part">
           <FoxInput
@@ -211,17 +206,28 @@
             disabled={disabled}
           />
         </div>
+      </div>
 
-        {onVerify && (
-          <FoxButton
-            size="md"
-            type="primary"
-            label={verifyLabel}
-            loading={verifying}
-            disabled={disabled}
-            onAction={handleVerify}
-          />
-        )}
+      <FoxSelect
+        size="md"
+        options={domainItems}
+        value={parts.custom ? CUSTOM_DOMAIN : parts.domain}
+        onValueChange={handleDomainPick}
+        placeholder={domainPlaceholder}
+        error={invalid}
+        disabled={disabled}
+      />
+
+      {onVerify && (
+        <FoxButton
+          size="md"
+          type="primary"
+          label={verifyLabel}
+          loading={verifying}
+          disabled={disabled}
+          onAction={handleVerify}
+        />
+      )}
       </div>
 
       {message && (
@fox/core/components/fox-phone-number/fox-phone-number.tsx
--- @fox/core/components/fox-phone-number/fox-phone-number.tsx
+++ @fox/core/components/fox-phone-number/fox-phone-number.tsx
@@ -184,47 +184,52 @@
   const body =
     type === "unit" ? (
       <div className="fox-phone-number__row">
-        <div className="fox-phone-number__part">
-          <FoxSelect
-            size="md"
-            options={prefixItems}
-            value={parts[0]}
-            onValueChange={(next) => {
-              commit(0, next);
-              if (autoAdvance) {
-                focusSegment(1);
-              }
-            }}
-            placeholder={placeholders[0]}
-            error={errored}
-            disabled={disabled || state === "disabled"}
-            readOnly={readOnly || state === "view"}
-          />
-        </div>
-
-        {[1, 2].map((index) => (
+        {[0, 1, 2].map((index) => (
           <Fragment key={index}>
-            <span className="fox-phone-number__separator" aria-hidden="true">
-              -
-            </span>
+            {index > 0 && (
+              <span className="fox-phone-number__separator" aria-hidden="true">
+                -
+              </span>
+            )}
             <div className="fox-phone-number__part">
-              <FoxInput
-                size="md"
-                inputMode="numeric"
-                aria-label={SEGMENT_LABELS[index]}
-                value={parts[index]}
-                onChange={handleSegmentChange(index)}
-                onKeyDown={handleSegmentKeyDown(index)}
-                placeholder={placeholders[index]}
-                maxLength={maxLengths[index]}
-                invalid={invalid}
-                disabled={disabled}
-                readOnly={readOnly}
-                state={state}
-                ref={(node) => {
-                  tailRefs.current[index] = node;
-                }}
-              />
+              {/* 앞자리는 고를 목록이 있을 때만 셀렉트다. 목록이 비었는데 셀렉트를 그리면
+                  placeholder("010")가 이미 고른 값처럼 보이는데 실제 값은 비어 있어, 뒷자리만
+                  채운 번호가 조용히 만들어진다 — 그때는 나머지 칸과 같은 입력 칸이 된다. */}
+              {index === 0 && prefixItems.length > 0 ? (
+                <FoxSelect
+                  size="md"
+                  options={prefixItems}
+                  value={parts[0]}
+                  onValueChange={(next) => {
+                    commit(0, next);
+                    if (autoAdvance) {
+                      focusSegment(1);
+                    }
+                  }}
+                  placeholder={placeholders[0]}
+                  error={errored}
+                  disabled={disabled || state === "disabled"}
+                  readOnly={readOnly || state === "view"}
+                />
+              ) : (
+                <FoxInput
+                  size="md"
+                  inputMode="numeric"
+                  aria-label={SEGMENT_LABELS[index]}
+                  value={parts[index]}
+                  onChange={handleSegmentChange(index)}
+                  onKeyDown={handleSegmentKeyDown(index)}
+                  placeholder={placeholders[index]}
+                  maxLength={maxLengths[index]}
+                  invalid={invalid}
+                  disabled={disabled}
+                  readOnly={readOnly}
+                  state={state}
+                  ref={(node) => {
+                    tailRefs.current[index] = node;
+                  }}
+                />
+              )}
             </div>
           </Fragment>
         ))}
@fox/styles/_fox-email.scss
--- @fox/styles/_fox-email.scss
+++ @fox/styles/_fox-email.scss
@@ -1,25 +1,27 @@
 // FoxEmail — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F)
 //                   Figma form-email (136:3092 · 섹션 392:5820)
 //
-// [아이디] @ [도메인 드롭다운] [도메인 직접입력] + 선택적 확인 버튼.
-// 시안에서 세 칸이 모두 input·select 인스턴스이고 버튼도 btn 인스턴스라, 이 파일은 줄
-// 배치와 `@` 구분자만 갖는다 — 상자 모양과 상태는 FoxInput·FoxSelect가 가진 것을 쓴다.
+// 시안 구조는 **줄이 나뉜다** — [아이디] @ [도메인] 한 줄, 그 아래 도메인 드롭다운, 그 아래
+// 선택적 확인 버튼(form-email 5543:9940: form-group 320×40 · select 320×40 · btn).
+// 종전에는 넷을 한 줄에 두고 넘치면 접히게 했는데, 그러면 좁은 곳에서 드롭다운이 도메인 칸보다
+// 먼저 접혀 시안과 순서가 어긋났다.
+//
+// 세 칸이 모두 input·select 인스턴스이고 버튼도 btn 인스턴스라, 이 파일은 줄 배치와 `@`
+// 구분자만 갖는다 — 상자 모양과 상태는 FoxInput·FoxSelect가 가진 것을 쓴다.
 //
 // 마크업 계약 (React 밖 소비자용):
 //   <div class="fox-email">
-//     <div class="fox-email__row">
-//       <div class="fox-email__part">…fox-input…</div>
+//     <div class="fox-email__body">
+//     <div class="fox-email__group">
+//       <div class="fox-email__part">…fox-input(아이디)…</div>
 //       <span class="fox-email__at">@</span>
-//       <div class="fox-email__part">…fox-select…</div>
-//       <div class="fox-email__part">…fox-input…</div>
-//       <button class="fox-button fox-button--primary fox-button--md">확인</button>
+//       <div class="fox-email__part">…fox-input(도메인)…</div>
 //     </div>
+//     …fox-select(도메인 목록)…
+//     <button class="fox-button …">확인</button>            <!-- 선택 -->
 //   </div>
 //
 // 토큰이 아닌 값(사용자 승인):
-//   - 칸의 `min-inline-size: 24rem`(240px): 시안이 세 칸을 240px로 고정했는데 토큰
-//     스케일에 240이 없다. 고정 대신 최소폭으로 두어 넓은 곳에서는 나눠 갖고 좁아지면
-//     줄이 넘어가게 했다.
 //   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
 //     export하지 않는다.
 
@@ -30,18 +32,27 @@
   @include field.root;
 
 
-  // 좁아지면 칸이 찌그러지는 대신 줄이 넘어간다.
-  &__row {
+  // 세 줄이 4px 간격으로 쌓인다(시안 form-group 0-40 · select 44-84).
+  &__body {
     display: flex;
-    flex-wrap: wrap;
+    inline-size: 100%;
+    flex-direction: column;
+    gap: fox.gap(2);
+  }
+
+  // 아이디·@·도메인이 한 줄을 나눠 갖는다(시안 149 + @ + 149).
+  &__group {
+    display: flex;
     align-items: center;
     gap: fox.gap(2);
     inline-size: 100%;
   }
 
+  // 두 칸이 남는 폭을 똑같이 나눈다. `min-inline-size: 0`이 없으면 입력의 기본 폭 때문에
+  // 좁은 곳에서 넘친다.
   &__part {
     flex: 1 1 0;
-    min-inline-size: 24rem;
+    min-inline-size: 0;
   }
 
   &__at {
app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
@@ -9,9 +9,8 @@
 } from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
-import { FoxFormLabel } from '@fox/core/components/fox-form-label';
+import { FoxEmail } from '@fox/core/components/fox-email';
 import { FoxInput } from '@fox/core/components/fox-input';
-import { FoxSelect } from '@fox/core/components/fox-select';
 import { FoxModal } from '@fox/core/components/fox-modal';
 import {
   foxPasswordValidator,
@@ -46,10 +45,7 @@
   onClose: () => void;
 }
 
-/** 목록에 없는 주소를 위해 도메인 칸을 입력으로 바꾸는 값. 실제 도메인과 겹치지 않게 잡았다. */
-const CUSTOM_EMAIL_DOMAIN = '__custom__';
-
-/** 시안의 도메인 목록. 맨 아래 "직접입력"은 같은 칸을 입력으로 바꾼다(상자를 늘리지 않는다). */
+/** 시안의 도메인 목록. 맨 아래 "직접입력" 항목은 `FoxEmail`이 스스로 더한다. */
 const EMAIL_DOMAIN_OPTIONS = [
   'naver.com',
   'gmail.com',
@@ -94,9 +90,9 @@
 
   // 앞자리는 고르지 않아도 되도록 기본값으로 시작한다(상수 주석 참조).
   const [phoneNumber, setPhoneNumber] = useState(DEFAULT_MOBILE_PHONE_PREFIX);
-  const [emailLocal, setEmailLocal] = useState('');
-  const [emailDomain, setEmailDomain] = useState(EMAIL_DOMAIN_OPTIONS[0]);
-  const [isCustomDomain, setIsCustomDomain] = useState(false);
+  // `FoxEmail`이 아이디·도메인을 스스로 나눠 들고 합친 값 하나를 내보낸다.
+  // 도메인은 시안처럼 첫 항목이 골라진 채로 시작한다(아이디만 적으면 되게).
+  const [emailValue, setEmailValue] = useState(`@${EMAIL_DOMAIN_OPTIONS[0]}`);
   // 이메일은 칸이 둘로 나뉘어 있어 검증 대상이 **합친 값**이다 — FoxInput 한 칸이 스스로
   // 판정할 수 없어 여기서 돌리고, `touched`도 직접 든다(FoxInput의 blur 규칙과 같은 시점).
   const [isEmailTouched, setIsEmailTouched] = useState(false);
@@ -117,7 +113,9 @@
 
   // 둘 중 하나라도 비면 **빈 이메일**로 본다 — 도메인만 고른 상태(`@naver.com`)를 값으로 보내면
   // "형식이 올바르지 않습니다"가 뜨지만, 사용자가 한 일은 아무것도 입력하지 않은 것이다.
-  const email = emailLocal && emailDomain ? `${emailLocal}@${emailDomain}` : '';
+  // (`FoxEmail`은 한쪽만 채워도 `@`를 붙여 내보내므로 여기서 다시 가른다.)
+  const [emailLocal = '', emailDomain = ''] = emailValue.split('@');
+  const email = emailLocal && emailDomain ? emailValue : '';
 
   const emailMessage =
     errors.email ??
@@ -240,65 +238,19 @@
           errors={errors}
           emailField={
             <>
-              <div className={styles.field}>
-                <FoxFormLabel as="span" requirement="required">
-                  이메일
-                </FoxFormLabel>
-                <div className={styles.emailRow}>
-                  <span className={styles.emailPart}>
-                    <FoxInput
-                      size="md"
-                      aria-label="이메일 아이디"
-                      placeholder="1234-5678"
-                      value={emailLocal}
-                      onChange={setEmailLocal}
-                      onBlur={() => setIsEmailTouched(true)}
-                      invalid={Boolean(emailMessage)}
-                    />
-                  </span>
-                  <span aria-hidden="true">@</span>
-                  <span className={styles.emailPart}>
-                    {isCustomDomain ? (
-                      <FoxInput
-                        size="md"
-                        aria-label="이메일 도메인"
-                        placeholder="직접입력"
-                        value={emailDomain}
-                        onChange={setEmailDomain}
-                        onBlur={() => setIsEmailTouched(true)}
-                        invalid={Boolean(emailMessage)}
-                      />
-                    ) : (
-                      <FoxSelect
-                        size="md"
-                        options={[
-                          ...EMAIL_DOMAIN_OPTIONS.map((domain) => ({
-                            value: domain,
-                            label: domain,
-                          })),
-                          { value: CUSTOM_EMAIL_DOMAIN, label: '직접입력' },
-                        ]}
-                        value={emailDomain}
-                        onValueChange={(next) => {
-                          setIsEmailTouched(true);
-                          if (next === CUSTOM_EMAIL_DOMAIN) {
-                            setIsCustomDomain(true);
-                            setEmailDomain('');
-                            return;
-                          }
-                          setEmailDomain(next);
-                        }}
-                        error={Boolean(emailMessage)}
-                      />
-                    )}
-                  </span>
-                </div>
-                {emailMessage && (
-                  <p className={styles.fieldError} role="alert">
-                    {emailMessage}
-                  </p>
-                )}
-              </div>
+              <FoxEmail
+                label="이메일"
+                requirement="required"
+                domainOptions={[...EMAIL_DOMAIN_OPTIONS]}
+                localPlaceholder="ID"
+                value={emailValue}
+                onChange={(next) => {
+                  setEmailValue(next);
+                  setIsEmailTouched(true);
+                }}
+                message={emailMessage}
+                invalid={Boolean(emailMessage)}
+              />
               <input type="hidden" name="email" value={email} />
             </>
           }
Add a comment
List