feat: FoxEmail·FoxAddress 추가
Figma form-email(136:3092)과 form-address(5346:5823)를 옮긴다. 두 시안 모두 상자가 input·select 인스턴스이고 버튼도 btn 인스턴스라 FoxInput·FoxSelect· FoxButton을 그대로 조합했다 — 두 SCSS는 배치와 `@` 구분자만 갖는다. FoxEmail 값은 아이디@도메인이 합쳐진 문자열 하나다. 도메인을 목록에서 고르면 옆 칸이 그 값으로 덮이고 읽기 전용이 되며, "직접입력"을 고르면 잠금만 풀린다. 확인 버튼은 onVerify를 넘겼을 때만 렌더된다. 칸은 시안의 240px을 최소폭으로 두고 남는 폭을 나눠 갖는다 — 좁아지면 줄이 넘어간다(사용자 승인). 토큰 스케일에 240이 없어 이 한 줄만 리터럴이다. FoxAddress 우편번호와 주소는 검색 결과로만 채워지는 읽기 전용이고 상세주소만 입력한다. 어떤 우편번호 서비스를 쓰는지 @fox는 모른다 — 호출부가 onSearch에서 FoxAddressSearchResult(zipCode·address·선택 detail)로 바꿔 돌려주고, 취소는 null이다. detail을 안 넘기면 사용자가 적어 둔 상세주소를 건드리지 않는다. Co-Authored-By: Claude Opus 5
@84b98ea5d32d0f7a33839cdb7571c2ceae1284fa
--- @fox/README.md
+++ @fox/README.md
... | ... | @@ -149,6 +149,12 @@ |
| 149 | 149 |
비활성·읽기전용은 네이티브 속성, 오류는 `aria-invalid="true"`. 모양을 못박으려면 루트에 |
| 150 | 150 |
`data-state`를 줍니다 (`default` `focused` `completed` `error` `disabled` `view`). |
| 151 | 151 |
- 여러 줄 입력에는 크기 축이 없습니다. 높이는 `rows`가 정하고 시안은 5줄입니다. |
| 152 |
+- 이메일은 값이 `아이디@도메인`이 합쳐진 문자열 하나입니다. 도메인을 드롭다운에서 고르면 |
|
| 153 |
+ 옆 칸에 그 값이 채워지고 읽기 전용이 되며, `직접입력`을 고르면 풀립니다. 확인 버튼은 |
|
| 154 |
+ `onVerify`를 넘겼을 때만 생깁니다. |
|
| 155 |
+- 주소는 우편번호와 주소가 검색 결과로만 채워지는 읽기 전용이고 상세주소만 입력합니다. |
|
| 156 |
+ 어떤 우편번호 서비스를 쓸지는 `@fox`가 모릅니다 — 호출부가 `onSearch`에서 결과를 |
|
| 157 |
+ `FoxAddressSearchResult`(`zipCode`·`address`·선택 `detail`)로 돌려주고, 취소는 `null`입니다. |
|
| 152 | 158 |
- 휴대폰 인증은 보내기·확인을 호출부가 `Promise`로 넘깁니다 — resolve면 성공, reject면 |
| 153 | 159 |
실패이고 결과는 이벤트로 올라옵니다. 보내기가 성공해야 인증번호 칸과 확인 버튼이 열리고, |
| 154 | 160 |
인증에 성공하면 전부 잠깁니다. 남은 시간은 `remainingSeconds`를 주면 그 값을, 없으면 |
+++ @fox/core/components/fox-address/fox-address.tsx
... | ... | @@ -0,0 +1,198 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useId, useState, type ReactNode } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | +import { FoxButton } from "../fox-button"; | |
| 6 | +import { FoxInput } from "../fox-input"; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * 주소 검색 결과. 호출부가 어떤 우편번호 서비스를 쓰든 이 모양으로 바꿔서 돌려준다 — | |
| 10 | + * 그래야 `@fox`가 특정 API에 묶이지 않는다. | |
| 11 | + */ | |
| 12 | +export interface FoxAddressSearchResult { | |
| 13 | + /** 우편번호. 첫 칸에 그대로 들어간다. */ | |
| 14 | + zipCode: string; | |
| 15 | + /** 도로명 또는 지번 주소. 둘째 칸에 그대로 들어간다. 어느 쪽을 쓸지는 호출부가 정한다. */ | |
| 16 | + address: string; | |
| 17 | + /** | |
| 18 | + * 상세주소 칸에 미리 채울 값(건물명 같은 참고항목). | |
| 19 | + * 넘기지 않으면 사용자가 적어 둔 상세주소를 건드리지 않는다. | |
| 20 | + */ | |
| 21 | + detail?: string; | |
| 22 | +} | |
| 23 | + | |
| 24 | +/** 주소 한 벌. 화면의 세 칸을 그대로 담는다. */ | |
| 25 | +export interface FoxAddressValue { | |
| 26 | + zipCode: string; | |
| 27 | + address: string; | |
| 28 | + detail: string; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export const EMPTY_ADDRESS: FoxAddressValue = { zipCode: "", address: "", detail: "" }; | |
| 32 | + | |
| 33 | +export interface FoxAddressProps { | |
| 34 | + value?: FoxAddressValue; | |
| 35 | + defaultValue?: FoxAddressValue; | |
| 36 | + onChange?: (value: FoxAddressValue) => void; | |
| 37 | + | |
| 38 | + /** | |
| 39 | + * 주소 검색. 호출부가 우편번호 서비스를 띄우고 결과를 `FoxAddressSearchResult`로 | |
| 40 | + * 돌려준다. 사용자가 고르지 않고 닫으면 `null`을 돌려주면 되고, 그때는 값이 그대로 | |
| 41 | + * 남는다. throw·reject하면 `onSearchFail`을 부른다. | |
| 42 | + */ | |
| 43 | + onSearch: () => Promise<FoxAddressSearchResult | null>; | |
| 44 | + onSearchSuccess?: (result: FoxAddressSearchResult) => void; | |
| 45 | + onSearchFail?: (error: unknown) => void; | |
| 46 | + searchLabel?: string; | |
| 47 | + | |
| 48 | + label?: string; | |
| 49 | + /** 헬퍼 메시지. 오류일 때 색이 바뀐다. */ | |
| 50 | + message?: string; | |
| 51 | + messageIcon?: ReactNode; | |
| 52 | + zipCodePlaceholder?: string; | |
| 53 | + addressPlaceholder?: string; | |
| 54 | + detailPlaceholder?: string; | |
| 55 | + | |
| 56 | + invalid?: boolean; | |
| 57 | + disabled?: boolean; | |
| 58 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 59 | + hidden?: boolean; | |
| 60 | + /** 배치 조정용. */ | |
| 61 | + className?: string; | |
| 62 | +} | |
| 63 | + | |
| 64 | +/** | |
| 65 | + * @fox 주소 입력. 우편번호와 주소는 검색 결과로만 채워지고, 상세주소만 손으로 적는다. | |
| 66 | + * | |
| 67 | + * 어떤 우편번호 서비스를 쓸지는 `@fox`가 알 필요가 없다 — 호출부가 `onSearch`에서 그 | |
| 68 | + * 서비스를 띄우고 결과를 `FoxAddressSearchResult` 모양으로 바꿔서 돌려주면 된다. | |
| 69 | + * | |
| 70 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 71 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 72 | + */ | |
| 73 | +export function FoxAddress({ | |
| 74 | + value, | |
| 75 | + defaultValue, | |
| 76 | + onChange, | |
| 77 | + onSearch, | |
| 78 | + onSearchSuccess, | |
| 79 | + onSearchFail, | |
| 80 | + searchLabel = "주소 검색", | |
| 81 | + label, | |
| 82 | + message, | |
| 83 | + messageIcon, | |
| 84 | + zipCodePlaceholder = "우편번호", | |
| 85 | + addressPlaceholder = "주소", | |
| 86 | + detailPlaceholder = "상세주소", | |
| 87 | + invalid = false, | |
| 88 | + disabled = false, | |
| 89 | + hidden = false, | |
| 90 | + className, | |
| 91 | +}: FoxAddressProps) { | |
| 92 | + const autoId = useId(); | |
| 93 | + const [inner, setInner] = useState<FoxAddressValue>(defaultValue ?? EMPTY_ADDRESS); | |
| 94 | + const [searching, setSearching] = useState(false); | |
| 95 | + | |
| 96 | + if (hidden) { | |
| 97 | + return null; | |
| 98 | + } | |
| 99 | + | |
| 100 | + const current = value ?? inner; | |
| 101 | + const messageId = `${autoId}-message`; | |
| 102 | + | |
| 103 | + const commit = (next: FoxAddressValue) => { | |
| 104 | + if (value === undefined) { | |
| 105 | + setInner(next); | |
| 106 | + } | |
| 107 | + onChange?.(next); | |
| 108 | + }; | |
| 109 | + | |
| 110 | + const handleSearch = async () => { | |
| 111 | + if (searching) { | |
| 112 | + return; | |
| 113 | + } | |
| 114 | + setSearching(true); | |
| 115 | + try { | |
| 116 | + const result = await onSearch(); | |
| 117 | + // 취소하면 `null`이 온다 — 그때는 값이 그대로 남는다. | |
| 118 | + if (result) { | |
| 119 | + commit({ | |
| 120 | + zipCode: result.zipCode, | |
| 121 | + address: result.address, | |
| 122 | + detail: result.detail ?? current.detail, | |
| 123 | + }); | |
| 124 | + onSearchSuccess?.(result); | |
| 125 | + } | |
| 126 | + } catch (error) { | |
| 127 | + onSearchFail?.(error); | |
| 128 | + } finally { | |
| 129 | + setSearching(false); | |
| 130 | + } | |
| 131 | + }; | |
| 132 | + | |
| 133 | + return ( | |
| 134 | + <div className={cx("fox-address", className)}> | |
| 135 | + {label && ( | |
| 136 | + <span className="fox-address__label" id={`${autoId}-label`}> | |
| 137 | + {label} | |
| 138 | + </span> | |
| 139 | + )} | |
| 140 | + | |
| 141 | + <div className="fox-address__row"> | |
| 142 | + <div className="fox-address__zip"> | |
| 143 | + {/* 검색 결과로만 채워진다 — 손으로 고칠 수 없다. */} | |
| 144 | + <FoxInput | |
| 145 | + size="md" | |
| 146 | + aria-label={zipCodePlaceholder} | |
| 147 | + aria-describedby={message ? messageId : undefined} | |
| 148 | + value={current.zipCode} | |
| 149 | + placeholder={zipCodePlaceholder} | |
| 150 | + readOnly | |
| 151 | + invalid={invalid} | |
| 152 | + disabled={disabled} | |
| 153 | + /> | |
| 154 | + </div> | |
| 155 | + | |
| 156 | + <FoxButton | |
| 157 | + size="md" | |
| 158 | + type="secondary" | |
| 159 | + label={searchLabel} | |
| 160 | + loading={searching} | |
| 161 | + disabled={disabled} | |
| 162 | + onAction={handleSearch} | |
| 163 | + /> | |
| 164 | + </div> | |
| 165 | + | |
| 166 | + <FoxInput | |
| 167 | + size="md" | |
| 168 | + aria-label={addressPlaceholder} | |
| 169 | + value={current.address} | |
| 170 | + placeholder={addressPlaceholder} | |
| 171 | + readOnly | |
| 172 | + invalid={invalid} | |
| 173 | + disabled={disabled} | |
| 174 | + /> | |
| 175 | + | |
| 176 | + <FoxInput | |
| 177 | + size="md" | |
| 178 | + aria-label={detailPlaceholder} | |
| 179 | + value={current.detail} | |
| 180 | + onChange={(next) => commit({ ...current, detail: next })} | |
| 181 | + placeholder={detailPlaceholder} | |
| 182 | + invalid={invalid} | |
| 183 | + disabled={disabled} | |
| 184 | + /> | |
| 185 | + | |
| 186 | + {message && ( | |
| 187 | + <p className="fox-address__message" id={messageId}> | |
| 188 | + {messageIcon && ( | |
| 189 | + <span className="fox-address__message-icon" aria-hidden="true"> | |
| 190 | + {messageIcon} | |
| 191 | + </span> | |
| 192 | + )} | |
| 193 | + {message} | |
| 194 | + </p> | |
| 195 | + )} | |
| 196 | + </div> | |
| 197 | + ); | |
| 198 | +} |
+++ @fox/core/components/fox-address/index.ts
... | ... | @@ -0,0 +1,7 @@ |
| 1 | +export { | |
| 2 | + FoxAddress, | |
| 3 | + EMPTY_ADDRESS, | |
| 4 | + type FoxAddressProps, | |
| 5 | + type FoxAddressValue, | |
| 6 | + type FoxAddressSearchResult, | |
| 7 | +} from "./fox-address"; |
+++ @fox/core/components/fox-email/fox-email.tsx
... | ... | @@ -0,0 +1,234 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useId, useState, type ReactNode } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | +import { FoxButton } from "../fox-button"; | |
| 6 | +import { FoxInput } from "../fox-input"; | |
| 7 | +import { FoxSelect, type FoxSelectItem } from "../fox-select"; | |
| 8 | + | |
| 9 | +/** 드롭다운의 "직접입력" 항목이 쓰는 값. 실제 도메인과 겹치지 않게 잡았다. */ | |
| 10 | +const CUSTOM_DOMAIN = "__fox-email-custom__"; | |
| 11 | + | |
| 12 | +export interface FoxEmailProps { | |
| 13 | + /** `아이디@도메인` 전체 값. 둘 다 비어 있으면 빈 문자열이다. */ | |
| 14 | + value?: string; | |
| 15 | + defaultValue?: string; | |
| 16 | + onChange?: (value: string) => void; | |
| 17 | + /** 도메인 드롭다운 목록. "직접입력" 항목은 컴포넌트가 맨 아래에 더한다. */ | |
| 18 | + domainOptions?: string[]; | |
| 19 | + customDomainLabel?: string; | |
| 20 | + | |
| 21 | + /** | |
| 22 | + * 주면 확인 버튼이 생기고, 넘기지 않으면 버튼 자체가 렌더되지 않는다. | |
| 23 | + * resolve하면 성공, throw·reject하면 실패로 본다. | |
| 24 | + */ | |
| 25 | + onVerify?: (email: string) => Promise<unknown>; | |
| 26 | + onVerifySuccess?: () => void; | |
| 27 | + onVerifyFail?: (error: unknown) => void; | |
| 28 | + verifyLabel?: string; | |
| 29 | + | |
| 30 | + label?: string; | |
| 31 | + /** 헬퍼 메시지. 오류일 때 색이 바뀐다. */ | |
| 32 | + message?: string; | |
| 33 | + messageIcon?: ReactNode; | |
| 34 | + localPlaceholder?: string; | |
| 35 | + domainPlaceholder?: string; | |
| 36 | + customPlaceholder?: string; | |
| 37 | + | |
| 38 | + invalid?: boolean; | |
| 39 | + disabled?: boolean; | |
| 40 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 41 | + hidden?: boolean; | |
| 42 | + /** 배치 조정용. */ | |
| 43 | + className?: string; | |
| 44 | +} | |
| 45 | + | |
| 46 | +type Split = { local: string; domain: string; custom: boolean }; | |
| 47 | + | |
| 48 | +/** | |
| 49 | + * `아이디@도메인`을 칸으로 나눈다. `@`는 마지막 것을 기준으로 자른다. | |
| 50 | + * | |
| 51 | + * 나눈 도메인이 목록에 없으면 직접 적은 것으로 보고 드롭다운을 "직접입력"에 맞춘다. | |
| 52 | + */ | |
| 53 | +function splitEmail(value: string, domainOptions: string[]): Split { | |
| 54 | + const at = (value ?? "").lastIndexOf("@"); | |
| 55 | + if (at < 0) { | |
| 56 | + return { local: value ?? "", domain: "", custom: false }; | |
| 57 | + } | |
| 58 | + const domain = value.slice(at + 1); | |
| 59 | + return { | |
| 60 | + local: value.slice(0, at), | |
| 61 | + domain, | |
| 62 | + custom: domain !== "" && !domainOptions.includes(domain), | |
| 63 | + }; | |
| 64 | +} | |
| 65 | + | |
| 66 | +function joinEmail(local: string, domain: string): string { | |
| 67 | + return local || domain ? `${local}@${domain}` : ""; | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** | |
| 71 | + * @fox 이메일 입력. 아이디와 도메인을 나눠 받되 값은 합쳐진 문자열 하나다. | |
| 72 | + * | |
| 73 | + * 도메인은 드롭다운에서 고르면 옆 칸에 그 값이 채워지고 읽기 전용이 된다. "직접입력"을 | |
| 74 | + * 고르면 그 칸이 풀려 손으로 적을 수 있다. | |
| 75 | + * | |
| 76 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 77 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 78 | + */ | |
| 79 | +export function FoxEmail({ | |
| 80 | + value, | |
| 81 | + defaultValue, | |
| 82 | + onChange, | |
| 83 | + domainOptions = [], | |
| 84 | + customDomainLabel = "직접입력", | |
| 85 | + onVerify, | |
| 86 | + onVerifySuccess, | |
| 87 | + onVerifyFail, | |
| 88 | + verifyLabel = "확인", | |
| 89 | + label, | |
| 90 | + message, | |
| 91 | + messageIcon, | |
| 92 | + localPlaceholder = "아이디", | |
| 93 | + domainPlaceholder = "도메인 선택", | |
| 94 | + customPlaceholder = "직접입력", | |
| 95 | + invalid = false, | |
| 96 | + disabled = false, | |
| 97 | + hidden = false, | |
| 98 | + className, | |
| 99 | +}: FoxEmailProps) { | |
| 100 | + const autoId = useId(); | |
| 101 | + const [parts, setParts] = useState<Split>(() => | |
| 102 | + splitEmail(value ?? defaultValue ?? "", domainOptions) | |
| 103 | + ); | |
| 104 | + const [seenValue, setSeenValue] = useState(value); | |
| 105 | + const [verifying, setVerifying] = useState(false); | |
| 106 | + | |
| 107 | + // 밖에서 값을 바꿨을 때만 다시 나눈다. 우리가 방금 올려보낸 값이면 그대로 둔다. | |
| 108 | + if (value !== undefined && value !== seenValue) { | |
| 109 | + setSeenValue(value); | |
| 110 | + if (value !== joinEmail(parts.local, parts.domain)) { | |
| 111 | + setParts(splitEmail(value, domainOptions)); | |
| 112 | + } | |
| 113 | + } | |
| 114 | + | |
| 115 | + if (hidden) { | |
| 116 | + return null; | |
| 117 | + } | |
| 118 | + | |
| 119 | + const messageId = `${autoId}-message`; | |
| 120 | + const email = joinEmail(parts.local, parts.domain); | |
| 121 | + | |
| 122 | + const commit = (next: Split) => { | |
| 123 | + setParts(next); | |
| 124 | + const joined = joinEmail(next.local, next.domain); | |
| 125 | + setSeenValue(joined); | |
| 126 | + onChange?.(joined); | |
| 127 | + }; | |
| 128 | + | |
| 129 | + const domainItems: FoxSelectItem[] = [ | |
| 130 | + ...domainOptions.map((option) => ({ value: option, label: option })), | |
| 131 | + { value: CUSTOM_DOMAIN, label: customDomainLabel }, | |
| 132 | + ]; | |
| 133 | + | |
| 134 | + // 목록에서 고르면 옆 칸을 그 값으로 덮고 잠근다. "직접입력"이면 잠금만 푼다. | |
| 135 | + const handleDomainPick = (picked: string) => { | |
| 136 | + if (picked === CUSTOM_DOMAIN) { | |
| 137 | + commit({ ...parts, custom: true }); | |
| 138 | + return; | |
| 139 | + } | |
| 140 | + commit({ ...parts, domain: picked, custom: false }); | |
| 141 | + }; | |
| 142 | + | |
| 143 | + const handleVerify = async () => { | |
| 144 | + if (!onVerify || verifying) { | |
| 145 | + return; | |
| 146 | + } | |
| 147 | + setVerifying(true); | |
| 148 | + try { | |
| 149 | + await onVerify(email); | |
| 150 | + onVerifySuccess?.(); | |
| 151 | + } catch (error) { | |
| 152 | + onVerifyFail?.(error); | |
| 153 | + } finally { | |
| 154 | + setVerifying(false); | |
| 155 | + } | |
| 156 | + }; | |
| 157 | + | |
| 158 | + return ( | |
| 159 | + <div className={cx("fox-email", className)}> | |
| 160 | + {label && ( | |
| 161 | + <span className="fox-email__label" id={`${autoId}-label`}> | |
| 162 | + {label} | |
| 163 | + </span> | |
| 164 | + )} | |
| 165 | + | |
| 166 | + <div className="fox-email__row"> | |
| 167 | + <div className="fox-email__part"> | |
| 168 | + <FoxInput | |
| 169 | + size="md" | |
| 170 | + aria-label="이메일 아이디" | |
| 171 | + aria-describedby={message ? messageId : undefined} | |
| 172 | + value={parts.local} | |
| 173 | + onChange={(next) => commit({ ...parts, local: next })} | |
| 174 | + placeholder={localPlaceholder} | |
| 175 | + invalid={invalid} | |
| 176 | + disabled={disabled} | |
| 177 | + /> | |
| 178 | + </div> | |
| 179 | + | |
| 180 | + <span className="fox-email__at" aria-hidden="true"> | |
| 181 | + @ | |
| 182 | + </span> | |
| 183 | + | |
| 184 | + <div className="fox-email__part"> | |
| 185 | + <FoxSelect | |
| 186 | + size="md" | |
| 187 | + options={domainItems} | |
| 188 | + value={parts.custom ? CUSTOM_DOMAIN : parts.domain} | |
| 189 | + onValueChange={handleDomainPick} | |
| 190 | + placeholder={domainPlaceholder} | |
| 191 | + error={invalid} | |
| 192 | + disabled={disabled} | |
| 193 | + /> | |
| 194 | + </div> | |
| 195 | + | |
| 196 | + {/* 목록에서 고른 값이 여기에 채워지고 잠긴다. "직접입력"일 때만 풀린다. */} | |
| 197 | + <div className="fox-email__part"> | |
| 198 | + <FoxInput | |
| 199 | + size="md" | |
| 200 | + aria-label="이메일 도메인" | |
| 201 | + value={parts.domain} | |
| 202 | + onChange={(next) => commit({ ...parts, domain: next })} | |
| 203 | + placeholder={customPlaceholder} | |
| 204 | + readOnly={!parts.custom} | |
| 205 | + invalid={invalid} | |
| 206 | + disabled={disabled} | |
| 207 | + /> | |
| 208 | + </div> | |
| 209 | + | |
| 210 | + {onVerify && ( | |
| 211 | + <FoxButton | |
| 212 | + size="md" | |
| 213 | + type="primary" | |
| 214 | + label={verifyLabel} | |
| 215 | + loading={verifying} | |
| 216 | + disabled={disabled} | |
| 217 | + onAction={handleVerify} | |
| 218 | + /> | |
| 219 | + )} | |
| 220 | + </div> | |
| 221 | + | |
| 222 | + {message && ( | |
| 223 | + <p className="fox-email__message" id={messageId}> | |
| 224 | + {messageIcon && ( | |
| 225 | + <span className="fox-email__message-icon" aria-hidden="true"> | |
| 226 | + {messageIcon} | |
| 227 | + </span> | |
| 228 | + )} | |
| 229 | + {message} | |
| 230 | + </p> | |
| 231 | + )} | |
| 232 | + </div> | |
| 233 | + ); | |
| 234 | +} |
+++ @fox/core/components/fox-email/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxEmail, type FoxEmailProps } from "./fox-email"; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -1,7 +1,9 @@ |
| 1 |
+export * from "./fox-address"; |
|
| 1 | 2 |
export * from "./fox-button"; |
| 2 | 3 |
export * from "./fox-button-group"; |
| 3 | 4 |
export * from "./fox-button-panel"; |
| 4 | 5 |
export * from "./fox-certified"; |
| 6 |
+export * from "./fox-email"; |
|
| 5 | 7 |
export * from "./fox-icon-button"; |
| 6 | 8 |
export * from "./fox-input"; |
| 7 | 9 |
export * from "./fox-link-button"; |
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -7,7 +7,9 @@ |
| 7 | 7 |
import { FoxButtonPanel } from "../core/components/fox-button-panel";
|
| 8 | 8 |
import { FoxIconButton } from "../core/components/fox-icon-button";
|
| 9 | 9 |
import { FoxLinkButton } from "../core/components/fox-link-button";
|
| 10 |
+import { FoxAddress, type FoxAddressSearchResult, type FoxAddressValue } from "../core/components/fox-address";
|
|
| 10 | 11 |
import { FoxCertified } from "../core/components/fox-certified";
|
| 12 |
+import { FoxEmail } from "../core/components/fox-email";
|
|
| 11 | 13 |
import { FoxPhoneNumber, type FoxPhoneNumberState } from "../core/components/fox-phone-number";
|
| 12 | 14 |
import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
|
| 13 | 15 |
import { FoxTextButton } from "../core/components/fox-text-button";
|
... | ... | @@ -66,8 +68,8 @@ |
| 66 | 68 |
} |
| 67 | 69 |
|
| 68 | 70 |
// 입력 예제는 폭이 있어야 시안(360px)처럼 보인다. |
| 69 |
-function InputStage({ children }: { children: ReactNode }) {
|
|
| 70 |
- return <div className={styles.demoField}>{children}</div>;
|
|
| 71 |
+function InputStage({ children, wide = false }: { children: ReactNode; wide?: boolean }) {
|
|
| 72 |
+ return <div className={wide ? styles.demoFieldWide : styles.demoField}>{children}</div>;
|
|
| 71 | 73 |
} |
| 72 | 74 |
|
| 73 | 75 |
// 지우기 버튼과 자동 상태 판정을 실제로 눌러 보려면 예제가 값을 들고 있어야 한다. |
... | ... | @@ -157,6 +159,51 @@ |
| 157 | 159 |
message="앞자리 목록에 02가 있어 지역번호도 제자리에 갈립니다" |
| 158 | 160 |
/> |
| 159 | 161 |
</InputStage> |
| 162 |
+ </> |
|
| 163 |
+ ); |
|
| 164 |
+} |
|
| 165 |
+ |
|
| 166 |
+const EMAIL_DOMAINS = ["naver.com", "gmail.com", "daum.net", "kakao.com"]; |
|
| 167 |
+ |
|
| 168 |
+// 값이 합쳐진 문자열 하나라는 걸 보려면 예제가 그 값을 그대로 보여줘야 한다. |
|
| 169 |
+function EmailLiveDemo() {
|
|
| 170 |
+ const [email, setEmail] = useState("hong@naver.com");
|
|
| 171 |
+ return ( |
|
| 172 |
+ <InputStage wide> |
|
| 173 |
+ <FoxEmail |
|
| 174 |
+ label={`value="${email}"`}
|
|
| 175 |
+ domainOptions={EMAIL_DOMAINS}
|
|
| 176 |
+ value={email}
|
|
| 177 |
+ onChange={setEmail}
|
|
| 178 |
+ message="도메인을 고르면 옆 칸이 채워지고 잠깁니다. 직접입력을 고르면 풀립니다." |
|
| 179 |
+ /> |
|
| 180 |
+ </InputStage> |
|
| 181 |
+ ); |
|
| 182 |
+} |
|
| 183 |
+ |
|
| 184 |
+// 어떤 우편번호 서비스를 쓰든 이 모양으로 돌려주면 된다 — 여기서는 지연만 흉내낸다. |
|
| 185 |
+const FAKE_SEARCH_RESULT: FoxAddressSearchResult = {
|
|
| 186 |
+ zipCode: "06236", |
|
| 187 |
+ address: "서울 강남구 테헤란로 152", |
|
| 188 |
+ detail: "강남파이낸스센터", |
|
| 189 |
+}; |
|
| 190 |
+ |
|
| 191 |
+function AddressLiveDemo({ cancel = false }) {
|
|
| 192 |
+ const [value, setValue] = useState<FoxAddressValue>({ zipCode: "", address: "", detail: "" });
|
|
| 193 |
+ return ( |
|
| 194 |
+ <> |
|
| 195 |
+ <InputStage> |
|
| 196 |
+ <FoxAddress |
|
| 197 |
+ label={cancel ? "검색을 취소하면 값이 그대로 남습니다" : "주소 검색을 눌러보세요"}
|
|
| 198 |
+ value={value}
|
|
| 199 |
+ onChange={setValue}
|
|
| 200 |
+ onSearch={async () => {
|
|
| 201 |
+ await new Promise((resolve) => setTimeout(resolve, 400)); |
|
| 202 |
+ return cancel ? null : FAKE_SEARCH_RESULT; |
|
| 203 |
+ }} |
|
| 204 |
+ /> |
|
| 205 |
+ </InputStage> |
|
| 206 |
+ <pre className={styles.code}>{JSON.stringify(value, null, 2)}</pre>
|
|
| 160 | 207 |
</> |
| 161 | 208 |
); |
| 162 | 209 |
} |
... | ... | @@ -1125,4 +1172,100 @@ |
| 1125 | 1172 |
}, |
| 1126 | 1173 |
], |
| 1127 | 1174 |
}, |
| 1175 |
+ {
|
|
| 1176 |
+ id: "fox-email", |
|
| 1177 |
+ name: "FoxEmail", |
|
| 1178 |
+ description: |
|
| 1179 |
+ "Figma 시안(form-email 136:3092)을 옮겼습니다. 세 칸이 모두 input·select 인스턴스라 FoxInput·FoxSelect를 그대로 조합했습니다. 값은 아이디@도메인이 합쳐진 문자열 하나입니다. 도메인을 고르면 옆 칸에 그 값이 채워지고 읽기 전용이 되며, 직접입력을 고르면 풀립니다. 칸은 최소 240px을 지키며 남는 폭을 나눠 갖고, 좁아지면 줄이 넘어갑니다.", |
|
| 1180 |
+ variants: [ |
|
| 1181 |
+ {
|
|
| 1182 |
+ label: "직접 조작 — 도메인을 바꿔보세요", |
|
| 1183 |
+ node: <EmailLiveDemo />, |
|
| 1184 |
+ }, |
|
| 1185 |
+ {
|
|
| 1186 |
+ label: "확인 버튼 — onVerify를 넘겼을 때만 생깁니다", |
|
| 1187 |
+ node: ( |
|
| 1188 |
+ <> |
|
| 1189 |
+ <InputStage wide> |
|
| 1190 |
+ <FoxEmail |
|
| 1191 |
+ label="onVerify 있음" |
|
| 1192 |
+ domainOptions={EMAIL_DOMAINS}
|
|
| 1193 |
+ defaultValue="hong@naver.com" |
|
| 1194 |
+ onVerify={async () => new Promise((resolve) => setTimeout(resolve, 400))}
|
|
| 1195 |
+ /> |
|
| 1196 |
+ </InputStage> |
|
| 1197 |
+ <InputStage wide> |
|
| 1198 |
+ <FoxEmail label="onVerify 없음" domainOptions={EMAIL_DOMAINS} />
|
|
| 1199 |
+ </InputStage> |
|
| 1200 |
+ </> |
|
| 1201 |
+ ), |
|
| 1202 |
+ }, |
|
| 1203 |
+ {
|
|
| 1204 |
+ label: "좁은 곳에서는 줄이 넘어갑니다", |
|
| 1205 |
+ node: ( |
|
| 1206 |
+ <InputStage> |
|
| 1207 |
+ <FoxEmail domainOptions={EMAIL_DOMAINS} defaultValue="hong@naver.com" />
|
|
| 1208 |
+ </InputStage> |
|
| 1209 |
+ ), |
|
| 1210 |
+ }, |
|
| 1211 |
+ {
|
|
| 1212 |
+ label: "오류 · 비활성", |
|
| 1213 |
+ node: ( |
|
| 1214 |
+ <> |
|
| 1215 |
+ <InputStage wide> |
|
| 1216 |
+ <FoxEmail |
|
| 1217 |
+ label="invalid" |
|
| 1218 |
+ domainOptions={EMAIL_DOMAINS}
|
|
| 1219 |
+ defaultValue="hong@naver.com" |
|
| 1220 |
+ message="이미 쓰고 있는 주소입니다" |
|
| 1221 |
+ invalid |
|
| 1222 |
+ /> |
|
| 1223 |
+ </InputStage> |
|
| 1224 |
+ <InputStage wide> |
|
| 1225 |
+ <FoxEmail label="disabled" domainOptions={EMAIL_DOMAINS} defaultValue="hong@naver.com" disabled />
|
|
| 1226 |
+ </InputStage> |
|
| 1227 |
+ </> |
|
| 1228 |
+ ), |
|
| 1229 |
+ }, |
|
| 1230 |
+ ], |
|
| 1231 |
+ }, |
|
| 1232 |
+ {
|
|
| 1233 |
+ id: "fox-address", |
|
| 1234 |
+ name: "FoxAddress", |
|
| 1235 |
+ description: |
|
| 1236 |
+ "Figma 시안(form-address 5346:5823)을 옮겼습니다. 세 상자가 모두 input 인스턴스라 FoxInput을 그대로 조합했습니다. 우편번호와 주소는 검색 결과로만 채워지는 읽기 전용이고 상세주소만 손으로 적습니다. 어떤 우편번호 서비스를 쓸지는 호출부가 정합니다 — onSearch에서 결과를 FoxAddressSearchResult 모양으로 돌려주면 되고, 취소는 null입니다.", |
|
| 1237 |
+ variants: [ |
|
| 1238 |
+ {
|
|
| 1239 |
+ label: "직접 조작 — 검색하면 두 칸이 채워집니다", |
|
| 1240 |
+ node: <AddressLiveDemo />, |
|
| 1241 |
+ }, |
|
| 1242 |
+ {
|
|
| 1243 |
+ label: "검색을 취소하면(null) 값이 그대로 남습니다", |
|
| 1244 |
+ node: <AddressLiveDemo cancel />, |
|
| 1245 |
+ }, |
|
| 1246 |
+ {
|
|
| 1247 |
+ label: "오류 · 비활성", |
|
| 1248 |
+ node: ( |
|
| 1249 |
+ <> |
|
| 1250 |
+ <InputStage> |
|
| 1251 |
+ <FoxAddress |
|
| 1252 |
+ label="invalid" |
|
| 1253 |
+ message="주소를 입력해 주세요" |
|
| 1254 |
+ invalid |
|
| 1255 |
+ onSearch={async () => null}
|
|
| 1256 |
+ /> |
|
| 1257 |
+ </InputStage> |
|
| 1258 |
+ <InputStage> |
|
| 1259 |
+ <FoxAddress |
|
| 1260 |
+ label="disabled" |
|
| 1261 |
+ defaultValue={{ zipCode: "06236", address: "서울 강남구 테헤란로 152", detail: "10층" }}
|
|
| 1262 |
+ disabled |
|
| 1263 |
+ onSearch={async () => null}
|
|
| 1264 |
+ /> |
|
| 1265 |
+ </InputStage> |
|
| 1266 |
+ </> |
|
| 1267 |
+ ), |
|
| 1268 |
+ }, |
|
| 1269 |
+ ], |
|
| 1270 |
+ }, |
|
| 1128 | 1271 |
]; |
--- @fox/dev-test/dev-test.module.scss
+++ @fox/dev-test/dev-test.module.scss
... | ... | @@ -471,3 +471,9 @@ |
| 471 | 471 |
inline-size: 36rem; |
| 472 | 472 |
max-inline-size: 100%; |
| 473 | 473 |
} |
| 474 |
+ |
|
| 475 |
+// 이메일은 세 칸이라 360px에서는 계속 줄이 넘어간다. 넓게 볼 무대를 따로 둔다. |
|
| 476 |
+.demoFieldWide {
|
|
| 477 |
+ inline-size: 80rem; |
|
| 478 |
+ max-inline-size: 100%; |
|
| 479 |
+} |
+++ @fox/styles/_fox-address.scss
... | ... | @@ -0,0 +1,82 @@ |
| 1 | +// FoxAddress — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma form-address (5346:5823 · 섹션 5383:8863) | |
| 3 | +// | |
| 4 | +// [우편번호 + 주소 검색] / [주소] / [상세주소]. 시안에서 세 상자가 모두 input 인스턴스이고 | |
| 5 | +// 버튼도 btn 인스턴스라, 이 파일은 줄 배치만 갖는다 — 상자 모양과 상태는 FoxInput이 가진 | |
| 6 | +// 것을 쓴다. | |
| 7 | +// | |
| 8 | +// 마크업 계약 (React 밖 소비자용): | |
| 9 | +// <div class="fox-address"> | |
| 10 | +// <div class="fox-address__row"> | |
| 11 | +// <div class="fox-address__zip">…fox-input (읽기 전용)…</div> | |
| 12 | +// <button class="fox-button fox-button--secondary fox-button--md">주소 검색</button> | |
| 13 | +// </div> | |
| 14 | +// …fox-input (주소, 읽기 전용)… | |
| 15 | +// …fox-input (상세주소)… | |
| 16 | +// </div> | |
| 17 | +// | |
| 18 | +// 우편번호와 주소는 검색 결과로만 채워진다 — 손으로 고칠 수 없다. 상세주소만 입력한다. | |
| 19 | +// | |
| 20 | +// 토큰이 아닌 값: | |
| 21 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 22 | +// export하지 않는다. | |
| 23 | + | |
| 24 | +@use "abstracts" as fox; | |
| 25 | + | |
| 26 | +.fox-address { | |
| 27 | + display: flex; | |
| 28 | + flex-direction: column; | |
| 29 | + gap: fox.gap(3); | |
| 30 | + inline-size: 100%; | |
| 31 | + | |
| 32 | + &__label { | |
| 33 | + color: fox.color(font-neutral-default); | |
| 34 | + font-family: fox.font-family(body); | |
| 35 | + font-size: fox.font-size(label-md); | |
| 36 | + font-weight: fox.font-weight(medium); | |
| 37 | + line-height: 1; | |
| 38 | + letter-spacing: -0.025em; | |
| 39 | + } | |
| 40 | + | |
| 41 | + // 우편번호는 남는 폭을 먹고 버튼은 글자만큼만 차지한다. | |
| 42 | + &__row { | |
| 43 | + display: flex; | |
| 44 | + align-items: flex-start; | |
| 45 | + gap: fox.gap(3); | |
| 46 | + inline-size: 100%; | |
| 47 | + } | |
| 48 | + | |
| 49 | + &__zip { | |
| 50 | + flex: 1 0 0; | |
| 51 | + min-inline-size: 0; | |
| 52 | + } | |
| 53 | + | |
| 54 | + &__message { | |
| 55 | + display: flex; | |
| 56 | + gap: fox.number(3); | |
| 57 | + align-items: flex-start; | |
| 58 | + margin: 0; | |
| 59 | + color: fox.color(font-neutral-subtle); | |
| 60 | + font-family: fox.font-family(body); | |
| 61 | + font-size: fox.font-size(body-sm); | |
| 62 | + font-weight: fox.font-weight(regular); | |
| 63 | + line-height: 1.5; | |
| 64 | + letter-spacing: -0.025em; | |
| 65 | + } | |
| 66 | + | |
| 67 | + &__message-icon { | |
| 68 | + display: inline-flex; | |
| 69 | + flex-shrink: 0; | |
| 70 | + align-items: flex-start; | |
| 71 | + padding-block-start: fox.padding(1); | |
| 72 | + | |
| 73 | + > * { | |
| 74 | + inline-size: fox.icon(3); | |
| 75 | + block-size: fox.icon(3); | |
| 76 | + } | |
| 77 | + } | |
| 78 | + | |
| 79 | + &:has([aria-invalid="true"]) .fox-address__message { | |
| 80 | + color: fox.color(font-system-danger); | |
| 81 | + } | |
| 82 | +} |
+++ @fox/styles/_fox-email.scss
... | ... | @@ -0,0 +1,95 @@ |
| 1 | +// FoxEmail — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma form-email (136:3092 · 섹션 392:5820) | |
| 3 | +// | |
| 4 | +// [아이디] @ [도메인 드롭다운] [도메인 직접입력] + 선택적 확인 버튼. | |
| 5 | +// 시안에서 세 칸이 모두 input·select 인스턴스이고 버튼도 btn 인스턴스라, 이 파일은 줄 | |
| 6 | +// 배치와 `@` 구분자만 갖는다 — 상자 모양과 상태는 FoxInput·FoxSelect가 가진 것을 쓴다. | |
| 7 | +// | |
| 8 | +// 마크업 계약 (React 밖 소비자용): | |
| 9 | +// <div class="fox-email"> | |
| 10 | +// <div class="fox-email__row"> | |
| 11 | +// <div class="fox-email__part">…fox-input…</div> | |
| 12 | +// <span class="fox-email__at">@</span> | |
| 13 | +// <div class="fox-email__part">…fox-select…</div> | |
| 14 | +// <div class="fox-email__part">…fox-input…</div> | |
| 15 | +// <button class="fox-button fox-button--primary fox-button--md">확인</button> | |
| 16 | +// </div> | |
| 17 | +// </div> | |
| 18 | +// | |
| 19 | +// 토큰이 아닌 값(사용자 승인): | |
| 20 | +// - 칸의 `min-inline-size: 24rem`(240px): 시안이 세 칸을 240px로 고정했는데 토큰 | |
| 21 | +// 스케일에 240이 없다. 고정 대신 최소폭으로 두어 넓은 곳에서는 나눠 갖고 좁아지면 | |
| 22 | +// 줄이 넘어가게 했다. | |
| 23 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 24 | +// export하지 않는다. | |
| 25 | + | |
| 26 | +@use "abstracts" as fox; | |
| 27 | + | |
| 28 | +.fox-email { | |
| 29 | + display: flex; | |
| 30 | + flex-direction: column; | |
| 31 | + gap: fox.gap(3); | |
| 32 | + inline-size: 100%; | |
| 33 | + | |
| 34 | + &__label { | |
| 35 | + color: fox.color(font-neutral-default); | |
| 36 | + font-family: fox.font-family(body); | |
| 37 | + font-size: fox.font-size(label-md); | |
| 38 | + font-weight: fox.font-weight(medium); | |
| 39 | + line-height: 1; | |
| 40 | + letter-spacing: -0.025em; | |
| 41 | + } | |
| 42 | + | |
| 43 | + // 좁아지면 칸이 찌그러지는 대신 줄이 넘어간다. | |
| 44 | + &__row { | |
| 45 | + display: flex; | |
| 46 | + flex-wrap: wrap; | |
| 47 | + align-items: center; | |
| 48 | + gap: fox.gap(2); | |
| 49 | + inline-size: 100%; | |
| 50 | + } | |
| 51 | + | |
| 52 | + &__part { | |
| 53 | + flex: 1 1 0; | |
| 54 | + min-inline-size: 24rem; | |
| 55 | + } | |
| 56 | + | |
| 57 | + &__at { | |
| 58 | + flex-shrink: 0; | |
| 59 | + color: fox.color(font-neutral-default); | |
| 60 | + font-family: fox.font-family(body); | |
| 61 | + font-size: fox.font-size(body-md); | |
| 62 | + font-weight: fox.font-weight(regular); | |
| 63 | + line-height: 1.5; | |
| 64 | + letter-spacing: -0.025em; | |
| 65 | + } | |
| 66 | + | |
| 67 | + &__message { | |
| 68 | + display: flex; | |
| 69 | + gap: fox.number(3); | |
| 70 | + align-items: flex-start; | |
| 71 | + margin: 0; | |
| 72 | + color: fox.color(font-neutral-subtle); | |
| 73 | + font-family: fox.font-family(body); | |
| 74 | + font-size: fox.font-size(body-sm); | |
| 75 | + font-weight: fox.font-weight(regular); | |
| 76 | + line-height: 1.5; | |
| 77 | + letter-spacing: -0.025em; | |
| 78 | + } | |
| 79 | + | |
| 80 | + &__message-icon { | |
| 81 | + display: inline-flex; | |
| 82 | + flex-shrink: 0; | |
| 83 | + align-items: flex-start; | |
| 84 | + padding-block-start: fox.padding(1); | |
| 85 | + | |
| 86 | + > * { | |
| 87 | + inline-size: fox.icon(3); | |
| 88 | + block-size: fox.icon(3); | |
| 89 | + } | |
| 90 | + } | |
| 91 | + | |
| 92 | + &:has([aria-invalid="true"]) .fox-email__message { | |
| 93 | + color: fox.color(font-system-danger); | |
| 94 | + } | |
| 95 | +} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -7,7 +7,9 @@ |
| 7 | 7 |
@use "fox-icon-button"; |
| 8 | 8 |
@use "fox-button-group"; |
| 9 | 9 |
@use "fox-button-panel"; |
| 10 |
+@use "fox-address"; |
|
| 10 | 11 |
@use "fox-certified"; |
| 12 |
+@use "fox-email"; |
|
| 11 | 13 |
@use "fox-input"; |
| 12 | 14 |
@use "fox-phone-number"; |
| 13 | 15 |
@use "fox-select"; |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?