feat: FoxCertified 추가
Figma form-certified(190:2017)의 세 줄을 옮긴다. 전화번호는 FoxPhoneNumber (combine), 두 버튼은 FoxButton(secondary·primary 전체폭)을 그대로 쓴다 — 시안도 그 인스턴스다. 인증번호 상자만 자체로 그리는데, 오른쪽 남은 시간이 FoxInput의 아이콘 슬롯(정사각 20px)보다 넓기 때문이다. 보내기·확인은 호출부가 Promise로 넘긴다. resolve면 성공, throw·reject면 실패로 보고 이벤트를 올린다 — onSendSuccess/onSendFail/onVerifySuccess/ onVerifyFail/onExpire. 화면은 최소한만 반응한다. 보내기가 성공해야 인증번호 칸과 확인 버튼이 열리고, 인증에 성공하면 전화번호·인증번호·두 버튼이 전부 잠긴다. 실패 문구는 호출부가 보여준다. 남은 시간은 remainingSeconds를 주면 그 값을 그리고, 없으면 expiresIn부터 컴포넌트가 센다. 0에 닿는 순간 onExpire를 한 번만 올린다. Co-Authored-By: Claude Opus 5
@4ab0b1b085c6d4afe44b74fb2dc8320d39b81275
--- @fox/README.md
+++ @fox/README.md
... | ... | @@ -149,6 +149,10 @@ |
| 149 | 149 |
비활성·읽기전용은 네이티브 속성, 오류는 `aria-invalid="true"`. 모양을 못박으려면 루트에 |
| 150 | 150 |
`data-state`를 줍니다 (`default` `focused` `completed` `error` `disabled` `view`). |
| 151 | 151 |
- 여러 줄 입력에는 크기 축이 없습니다. 높이는 `rows`가 정하고 시안은 5줄입니다. |
| 152 |
+- 휴대폰 인증은 보내기·확인을 호출부가 `Promise`로 넘깁니다 — resolve면 성공, reject면 |
|
| 153 |
+ 실패이고 결과는 이벤트로 올라옵니다. 보내기가 성공해야 인증번호 칸과 확인 버튼이 열리고, |
|
| 154 |
+ 인증에 성공하면 전부 잠깁니다. 남은 시간은 `remainingSeconds`를 주면 그 값을, 없으면 |
|
| 155 |
+ `expiresIn`부터 컴포넌트가 셉니다. |
|
| 152 | 156 |
- 전화번호는 화면만 세 칸이고 값은 숫자만 담긴 문자열 하나입니다 — 하이픈은 값에 들어가지 |
| 153 | 157 |
않습니다. `unit`은 앞자리가 드롭다운이라 조각을 select·input으로 조립하고, `combine`은 |
| 154 | 158 |
상자 하나에 세 칸을 둡니다. |
+++ @fox/core/components/fox-certified/fox-certified.tsx
... | ... | @@ -0,0 +1,261 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useEffect, useId, useRef, useState, type ChangeEvent } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | +import { FoxButton } from "../fox-button"; | |
| 6 | +import { FoxPhoneNumber } from "../fox-phone-number"; | |
| 7 | + | |
| 8 | +export interface FoxCertifiedProps { | |
| 9 | + /** 전화번호. 숫자만 담긴 문자열이다 — `FoxPhoneNumber`와 같다. */ | |
| 10 | + phone?: string; | |
| 11 | + defaultPhone?: string; | |
| 12 | + onPhoneChange?: (phone: string) => void; | |
| 13 | + /** 전화번호 앞자리 목록. 밖에서 받은 값을 칸으로 나눌 때도 쓴다. */ | |
| 14 | + prefixOptions?: string[]; | |
| 15 | + | |
| 16 | + /** | |
| 17 | + * 인증번호 받기. resolve하면 성공으로 보고 인증번호 칸과 확인 버튼을 연다. | |
| 18 | + * throw하거나 reject하면 실패로 보고 `onSendFail`을 부른다. | |
| 19 | + */ | |
| 20 | + onSend: (phone: string) => Promise<unknown>; | |
| 21 | + /** 인증번호 확인. resolve하면 성공, throw·reject하면 실패다. */ | |
| 22 | + onVerify: (phone: string, code: string) => Promise<unknown>; | |
| 23 | + | |
| 24 | + onSendSuccess?: () => void; | |
| 25 | + onSendFail?: (error: unknown) => void; | |
| 26 | + onVerifySuccess?: () => void; | |
| 27 | + onVerifyFail?: (error: unknown) => void; | |
| 28 | + /** 남은 시간이 0이 됐을 때 한 번 부른다. */ | |
| 29 | + onExpire?: () => void; | |
| 30 | + | |
| 31 | + /** | |
| 32 | + * 제한 시간(초). 주면 인증번호를 보낸 시점부터 컴포넌트가 직접 센다. | |
| 33 | + * `remainingSeconds`를 함께 주면 그쪽이 이긴다. | |
| 34 | + */ | |
| 35 | + expiresIn?: number; | |
| 36 | + /** 남은 시간(초)을 직접 셀 때. 주면 컴포넌트는 세지 않고 이 값만 그린다. */ | |
| 37 | + remainingSeconds?: number; | |
| 38 | + | |
| 39 | + label?: string; | |
| 40 | + sendLabel?: string; | |
| 41 | + verifyLabel?: string; | |
| 42 | + codePlaceholder?: string; | |
| 43 | + codeMaxLength?: number; | |
| 44 | + /** 전체 잠금. 인증에 성공하면 이 값과 무관하게 잠긴다. */ | |
| 45 | + disabled?: boolean; | |
| 46 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 47 | + hidden?: boolean; | |
| 48 | + /** 배치 조정용. */ | |
| 49 | + className?: string; | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** 시안의 `24:16` 표기. 분이 두 자리를 넘어가면 그대로 늘어난다. */ | |
| 53 | +function formatTimer(seconds: number): string { | |
| 54 | + const safe = Math.max(0, Math.floor(seconds)); | |
| 55 | + const minutes = Math.floor(safe / 60); | |
| 56 | + const rest = safe % 60; | |
| 57 | + return `${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`; | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** 시안의 ico/ClockCountdown 자리. @fox에 아이콘 세트가 없어 같은 모양을 직접 그린다. */ | |
| 61 | +function TimerIcon() { | |
| 62 | + return ( | |
| 63 | + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"> | |
| 64 | + <circle cx="8" cy="8" r="6.3" /> | |
| 65 | + <path d="M8 4.4V8l2.4 1.6" strokeLinecap="round" /> | |
| 66 | + </svg> | |
| 67 | + ); | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** | |
| 71 | + * @fox 휴대폰 인증. 전화번호를 받고, 인증번호를 보내고, 확인한다. | |
| 72 | + * | |
| 73 | + * 보내기·확인은 호출부가 `Promise`로 넘긴다 — resolve면 성공, throw·reject면 실패다. | |
| 74 | + * 성공·실패는 모두 이벤트로 올라오고, 화면은 최소한만 반응한다(보내기 성공이면 인증번호 | |
| 75 | + * 칸과 확인 버튼이 열리고, 인증 성공이면 전부 잠긴다). 실패 문구는 호출부가 보여준다. | |
| 76 | + * | |
| 77 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 78 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 79 | + */ | |
| 80 | +export function FoxCertified({ | |
| 81 | + phone, | |
| 82 | + defaultPhone, | |
| 83 | + onPhoneChange, | |
| 84 | + prefixOptions = [], | |
| 85 | + onSend, | |
| 86 | + onVerify, | |
| 87 | + onSendSuccess, | |
| 88 | + onSendFail, | |
| 89 | + onVerifySuccess, | |
| 90 | + onVerifyFail, | |
| 91 | + onExpire, | |
| 92 | + expiresIn, | |
| 93 | + remainingSeconds, | |
| 94 | + label, | |
| 95 | + sendLabel = "인증번호 받기", | |
| 96 | + verifyLabel = "인증번호 확인", | |
| 97 | + codePlaceholder = "인증번호 입력", | |
| 98 | + codeMaxLength, | |
| 99 | + disabled = false, | |
| 100 | + hidden = false, | |
| 101 | + className, | |
| 102 | +}: FoxCertifiedProps) { | |
| 103 | + const autoId = useId(); | |
| 104 | + const [innerPhone, setInnerPhone] = useState(defaultPhone ?? ""); | |
| 105 | + const [code, setCode] = useState(""); | |
| 106 | + const [sent, setSent] = useState(false); | |
| 107 | + const [verified, setVerified] = useState(false); | |
| 108 | + const [sending, setSending] = useState(false); | |
| 109 | + const [verifying, setVerifying] = useState(false); | |
| 110 | + const [ticking, setTicking] = useState<number | null>(null); | |
| 111 | + const expiredRef = useRef(false); | |
| 112 | + | |
| 113 | + const selfTimed = remainingSeconds === undefined && expiresIn !== undefined; | |
| 114 | + const left = remainingSeconds ?? ticking; | |
| 115 | + | |
| 116 | + // 스스로 셀 때만 1초마다 줄인다. 호출부가 값을 주면 그리기만 한다. | |
| 117 | + useEffect(() => { | |
| 118 | + if (!selfTimed || !sent) { | |
| 119 | + return; | |
| 120 | + } | |
| 121 | + const id = setInterval(() => { | |
| 122 | + setTicking((prev) => (prev === null || prev <= 0 ? prev : prev - 1)); | |
| 123 | + }, 1000); | |
| 124 | + return () => clearInterval(id); | |
| 125 | + }, [selfTimed, sent]); | |
| 126 | + | |
| 127 | + // 0에 닿는 순간 한 번만 알린다. 다시 보내 시간이 살아나면 잠금을 푼다. | |
| 128 | + useEffect(() => { | |
| 129 | + if (left === null || left === undefined) { | |
| 130 | + return; | |
| 131 | + } | |
| 132 | + if (left <= 0 && !expiredRef.current) { | |
| 133 | + expiredRef.current = true; | |
| 134 | + onExpire?.(); | |
| 135 | + } | |
| 136 | + if (left > 0) { | |
| 137 | + expiredRef.current = false; | |
| 138 | + } | |
| 139 | + }, [left, onExpire]); | |
| 140 | + | |
| 141 | + if (hidden) { | |
| 142 | + return null; | |
| 143 | + } | |
| 144 | + | |
| 145 | + const phoneValue = phone ?? innerPhone; | |
| 146 | + const locked = disabled || verified; | |
| 147 | + const codeId = `${autoId}-code`; | |
| 148 | + | |
| 149 | + const handlePhoneChange = (next: string) => { | |
| 150 | + if (phone === undefined) { | |
| 151 | + setInnerPhone(next); | |
| 152 | + } | |
| 153 | + onPhoneChange?.(next); | |
| 154 | + }; | |
| 155 | + | |
| 156 | + const handleSend = async () => { | |
| 157 | + if (sending) { | |
| 158 | + return; | |
| 159 | + } | |
| 160 | + setSending(true); | |
| 161 | + try { | |
| 162 | + await onSend(phoneValue); | |
| 163 | + setSent(true); | |
| 164 | + expiredRef.current = false; | |
| 165 | + if (selfTimed) { | |
| 166 | + setTicking(expiresIn ?? null); | |
| 167 | + } | |
| 168 | + onSendSuccess?.(); | |
| 169 | + } catch (error) { | |
| 170 | + onSendFail?.(error); | |
| 171 | + } finally { | |
| 172 | + setSending(false); | |
| 173 | + } | |
| 174 | + }; | |
| 175 | + | |
| 176 | + const handleVerify = async () => { | |
| 177 | + if (verifying) { | |
| 178 | + return; | |
| 179 | + } | |
| 180 | + setVerifying(true); | |
| 181 | + try { | |
| 182 | + await onVerify(phoneValue, code); | |
| 183 | + setVerified(true); | |
| 184 | + onVerifySuccess?.(); | |
| 185 | + } catch (error) { | |
| 186 | + onVerifyFail?.(error); | |
| 187 | + } finally { | |
| 188 | + setVerifying(false); | |
| 189 | + } | |
| 190 | + }; | |
| 191 | + | |
| 192 | + return ( | |
| 193 | + <div className={cx("fox-certified", className)}> | |
| 194 | + {label && ( | |
| 195 | + <span className="fox-certified__label" id={`${autoId}-label`}> | |
| 196 | + {label} | |
| 197 | + </span> | |
| 198 | + )} | |
| 199 | + | |
| 200 | + <div className="fox-certified__row"> | |
| 201 | + <div className="fox-certified__phone"> | |
| 202 | + <FoxPhoneNumber | |
| 203 | + type="combine" | |
| 204 | + prefixOptions={prefixOptions} | |
| 205 | + value={phoneValue} | |
| 206 | + onChange={handlePhoneChange} | |
| 207 | + disabled={locked} | |
| 208 | + /> | |
| 209 | + </div> | |
| 210 | + | |
| 211 | + <FoxButton | |
| 212 | + size="md" | |
| 213 | + type="secondary" | |
| 214 | + label={sendLabel} | |
| 215 | + loading={sending} | |
| 216 | + disabled={locked} | |
| 217 | + onAction={handleSend} | |
| 218 | + /> | |
| 219 | + </div> | |
| 220 | + | |
| 221 | + {/* 보내기가 성공해야 열린다. */} | |
| 222 | + {sent && ( | |
| 223 | + <> | |
| 224 | + <div className="fox-certified__box"> | |
| 225 | + <input | |
| 226 | + id={codeId} | |
| 227 | + className="fox-certified__field" | |
| 228 | + inputMode="numeric" | |
| 229 | + autoComplete="one-time-code" | |
| 230 | + aria-label={codePlaceholder} | |
| 231 | + placeholder={codePlaceholder} | |
| 232 | + value={code} | |
| 233 | + maxLength={codeMaxLength} | |
| 234 | + disabled={locked} | |
| 235 | + onChange={(event: ChangeEvent<HTMLInputElement>) => setCode(event.target.value)} | |
| 236 | + /> | |
| 237 | + | |
| 238 | + {left !== null && left !== undefined && ( | |
| 239 | + <span className="fox-certified__timer"> | |
| 240 | + <span className="fox-certified__timer-icon" aria-hidden="true"> | |
| 241 | + <TimerIcon /> | |
| 242 | + </span> | |
| 243 | + <span className="fox-certified__timer-text">{formatTimer(left)}</span> | |
| 244 | + </span> | |
| 245 | + )} | |
| 246 | + </div> | |
| 247 | + | |
| 248 | + <FoxButton | |
| 249 | + size="md" | |
| 250 | + type="primary" | |
| 251 | + label={verifyLabel} | |
| 252 | + fullWidth | |
| 253 | + loading={verifying} | |
| 254 | + disabled={locked} | |
| 255 | + onAction={handleVerify} | |
| 256 | + /> | |
| 257 | + </> | |
| 258 | + )} | |
| 259 | + </div> | |
| 260 | + ); | |
| 261 | +} |
+++ @fox/core/components/fox-certified/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxCertified, type FoxCertifiedProps } from "./fox-certified"; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 |
export * from "./fox-button"; |
| 2 | 2 |
export * from "./fox-button-group"; |
| 3 | 3 |
export * from "./fox-button-panel"; |
| 4 |
+export * from "./fox-certified"; |
|
| 4 | 5 |
export * from "./fox-icon-button"; |
| 5 | 6 |
export * from "./fox-input"; |
| 6 | 7 |
export * from "./fox-link-button"; |
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -7,6 +7,7 @@ |
| 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 { FoxCertified } from "../core/components/fox-certified";
|
|
| 10 | 11 |
import { FoxPhoneNumber, type FoxPhoneNumberState } from "../core/components/fox-phone-number";
|
| 11 | 12 |
import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
|
| 12 | 13 |
import { FoxTextButton } from "../core/components/fox-text-button";
|
... | ... | @@ -156,6 +157,40 @@ |
| 156 | 157 |
message="앞자리 목록에 02가 있어 지역번호도 제자리에 갈립니다" |
| 157 | 158 |
/> |
| 158 | 159 |
</InputStage> |
| 160 |
+ </> |
|
| 161 |
+ ); |
|
| 162 |
+} |
|
| 163 |
+ |
|
| 164 |
+// 이벤트가 실제로 도는 걸 보려면 예제가 결과를 적어 둬야 한다. 서버 대신 지연만 흉내낸다. |
|
| 165 |
+function CertifiedLiveDemo({ failSend = false, failVerify = false }) {
|
|
| 166 |
+ const [log, setLog] = useState<string[]>([]); |
|
| 167 |
+ const write = (line: string) => setLog((prev) => [line, ...prev].slice(0, 5)); |
|
| 168 |
+ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); |
|
| 169 |
+ |
|
| 170 |
+ return ( |
|
| 171 |
+ <> |
|
| 172 |
+ <InputStage> |
|
| 173 |
+ <FoxCertified |
|
| 174 |
+ prefixOptions={PHONE_PREFIXES}
|
|
| 175 |
+ defaultPhone="01012345678" |
|
| 176 |
+ expiresIn={180}
|
|
| 177 |
+ onSend={async (phone) => {
|
|
| 178 |
+ await wait(400); |
|
| 179 |
+ if (failSend) throw new Error("발송 한도를 넘었습니다");
|
|
| 180 |
+ write(`보내기 요청 → ${phone}`);
|
|
| 181 |
+ }} |
|
| 182 |
+ onVerify={async (_phone, code) => {
|
|
| 183 |
+ await wait(400); |
|
| 184 |
+ if (failVerify || code !== "123456") throw new Error("번호가 맞지 않습니다");
|
|
| 185 |
+ }} |
|
| 186 |
+ onSendSuccess={() => write("onSendSuccess")}
|
|
| 187 |
+ onSendFail={(error) => write(`onSendFail — ${(error as Error).message}`)}
|
|
| 188 |
+ onVerifySuccess={() => write("onVerifySuccess — 이제 전부 잠깁니다")}
|
|
| 189 |
+ onVerifyFail={(error) => write(`onVerifyFail — ${(error as Error).message}`)}
|
|
| 190 |
+ onExpire={() => write("onExpire")}
|
|
| 191 |
+ /> |
|
| 192 |
+ </InputStage> |
|
| 193 |
+ <pre className={styles.code}>{log.length ? log.join("\n") : "이벤트가 여기에 쌓입니다 (인증번호는 123456)"}</pre>
|
|
| 159 | 194 |
</> |
| 160 | 195 |
); |
| 161 | 196 |
} |
... | ... | @@ -1046,4 +1081,48 @@ |
| 1046 | 1081 |
}, |
| 1047 | 1082 |
], |
| 1048 | 1083 |
}, |
| 1084 |
+ {
|
|
| 1085 |
+ id: "fox-certified", |
|
| 1086 |
+ name: "FoxCertified", |
|
| 1087 |
+ description: |
|
| 1088 |
+ "Figma 시안(form-certified 190:2017)을 옮겼습니다. 전화번호는 FoxPhoneNumber(combine), 두 버튼은 FoxButton(secondary·primary)을 그대로 씁니다. 보내기·확인은 호출부가 Promise로 넘기고, resolve면 성공 reject면 실패입니다. 보내기가 성공해야 인증번호 칸과 확인 버튼이 열리고, 인증에 성공하면 전부 잠깁니다.", |
|
| 1089 |
+ variants: [ |
|
| 1090 |
+ {
|
|
| 1091 |
+ label: "직접 조작 — 인증번호 123456이면 성공, 아니면 실패", |
|
| 1092 |
+ node: <CertifiedLiveDemo />, |
|
| 1093 |
+ }, |
|
| 1094 |
+ {
|
|
| 1095 |
+ label: "보내기가 실패하는 경우 — 칸이 열리지 않고 이벤트만 옵니다", |
|
| 1096 |
+ node: <CertifiedLiveDemo failSend />, |
|
| 1097 |
+ }, |
|
| 1098 |
+ {
|
|
| 1099 |
+ label: "타이머를 호출부가 넘길 때 (remainingSeconds)", |
|
| 1100 |
+ node: ( |
|
| 1101 |
+ <InputStage> |
|
| 1102 |
+ <FoxCertified |
|
| 1103 |
+ prefixOptions={PHONE_PREFIXES}
|
|
| 1104 |
+ defaultPhone="01012345678" |
|
| 1105 |
+ remainingSeconds={1456}
|
|
| 1106 |
+ onSend={async () => undefined}
|
|
| 1107 |
+ onVerify={async () => undefined}
|
|
| 1108 |
+ /> |
|
| 1109 |
+ </InputStage> |
|
| 1110 |
+ ), |
|
| 1111 |
+ }, |
|
| 1112 |
+ {
|
|
| 1113 |
+ label: "전체 잠금 (disabled)", |
|
| 1114 |
+ node: ( |
|
| 1115 |
+ <InputStage> |
|
| 1116 |
+ <FoxCertified |
|
| 1117 |
+ prefixOptions={PHONE_PREFIXES}
|
|
| 1118 |
+ defaultPhone="01012345678" |
|
| 1119 |
+ disabled |
|
| 1120 |
+ onSend={async () => undefined}
|
|
| 1121 |
+ onVerify={async () => undefined}
|
|
| 1122 |
+ /> |
|
| 1123 |
+ </InputStage> |
|
| 1124 |
+ ), |
|
| 1125 |
+ }, |
|
| 1126 |
+ ], |
|
| 1127 |
+ }, |
|
| 1049 | 1128 |
]; |
+++ @fox/styles/_fox-certified.scss
... | ... | @@ -0,0 +1,147 @@ |
| 1 | +// FoxCertified — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma form-certified (190:2017 · 섹션 392:5850) | |
| 3 | +// | |
| 4 | +// 세 줄이다. [전화번호 + 인증번호 받기] / [인증번호 입력 + 남은 시간] / [인증번호 확인]. | |
| 5 | +// 전화번호는 combine 모양 그대로이고 두 버튼도 시안에서 btn 인스턴스라, 이 파일은 줄 배치와 | |
| 6 | +// 인증번호 상자만 갖는다. 인증번호 상자를 FoxInput으로 쓰지 못한 이유는 오른쪽 남은 시간이 | |
| 7 | +// 아이콘 슬롯(정사각 20px)보다 넓기 때문이다. | |
| 8 | +// | |
| 9 | +// 마크업 계약 (React 밖 소비자용): | |
| 10 | +// <div class="fox-certified"> | |
| 11 | +// <div class="fox-certified__row"> | |
| 12 | +// <div class="fox-certified__phone">…fox-phone-number…</div> | |
| 13 | +// <button class="fox-button fox-button--secondary fox-button--md">인증번호 받기</button> | |
| 14 | +// </div> | |
| 15 | +// <div class="fox-certified__box"> | |
| 16 | +// <input class="fox-certified__field" placeholder="인증번호 입력"> | |
| 17 | +// <span class="fox-certified__timer"> | |
| 18 | +// <span class="fox-certified__timer-icon">…svg…</span> | |
| 19 | +// <span class="fox-certified__timer-text">24:16</span> | |
| 20 | +// </span> | |
| 21 | +// </div> | |
| 22 | +// <button class="fox-button fox-button--primary fox-button--md fox-button--full-width"> | |
| 23 | +// 인증번호 확인 | |
| 24 | +// </button> | |
| 25 | +// </div> | |
| 26 | +// | |
| 27 | +// 인증번호 상자의 상태는 FoxInput과 같은 방식으로 브라우저가 판단한다 — 포커스는 | |
| 28 | +// `:focus-within`, 비활성은 네이티브 속성. 시안에 상태 변형이 없어 고정 표기는 두지 않는다. | |
| 29 | +// | |
| 30 | +// 토큰이 아닌 값(FoxInput과 동일한 근거로 승인된 항목): | |
| 31 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 32 | +// export하지 않는다. | |
| 33 | +// - box-sizing / flex: 구조 규칙. | |
| 34 | + | |
| 35 | +@use "abstracts" as fox; | |
| 36 | + | |
| 37 | +.fox-certified { | |
| 38 | + display: flex; | |
| 39 | + flex-direction: column; | |
| 40 | + gap: fox.gap(3); | |
| 41 | + inline-size: 100%; | |
| 42 | + | |
| 43 | + &__label { | |
| 44 | + color: fox.color(font-neutral-default); | |
| 45 | + font-family: fox.font-family(body); | |
| 46 | + font-size: fox.font-size(label-md); | |
| 47 | + font-weight: fox.font-weight(medium); | |
| 48 | + line-height: 1; | |
| 49 | + letter-spacing: -0.025em; | |
| 50 | + } | |
| 51 | + | |
| 52 | + // 전화번호는 남는 폭을 먹고 버튼은 글자만큼만 차지한다. | |
| 53 | + &__row { | |
| 54 | + display: flex; | |
| 55 | + align-items: flex-start; | |
| 56 | + gap: fox.gap(3); | |
| 57 | + inline-size: 100%; | |
| 58 | + } | |
| 59 | + | |
| 60 | + &__phone { | |
| 61 | + flex: 1 0 0; | |
| 62 | + min-inline-size: 0; | |
| 63 | + } | |
| 64 | + | |
| 65 | + &__box { | |
| 66 | + box-sizing: border-box; | |
| 67 | + display: flex; | |
| 68 | + align-items: center; | |
| 69 | + gap: fox.gap(3); | |
| 70 | + inline-size: 100%; | |
| 71 | + block-size: fox.form(height-md); | |
| 72 | + padding-inline: fox.form(padding-md); | |
| 73 | + border: fox.border(1) solid fox.color(input-border); | |
| 74 | + border-radius: fox.form(radius-md); | |
| 75 | + background: fox.color(input-surface); | |
| 76 | + } | |
| 77 | + | |
| 78 | + &__field { | |
| 79 | + flex: 1 0 0; | |
| 80 | + min-inline-size: 0; | |
| 81 | + margin: 0; | |
| 82 | + padding: 0; | |
| 83 | + border: none; | |
| 84 | + background: none; | |
| 85 | + color: fox.color(font-neutral-default); | |
| 86 | + font-family: fox.font-family(body); | |
| 87 | + font-size: fox.font-size(label-md); | |
| 88 | + font-weight: fox.font-weight(regular); | |
| 89 | + line-height: 1; | |
| 90 | + letter-spacing: -0.025em; | |
| 91 | + | |
| 92 | + &::placeholder { | |
| 93 | + color: fox.color(font-neutral-subtler); | |
| 94 | + } | |
| 95 | + | |
| 96 | + // 포커스는 상자 테두리가 표시하므로 기본 외곽선을 지운다. | |
| 97 | + &:focus { | |
| 98 | + outline: none; | |
| 99 | + } | |
| 100 | + | |
| 101 | + &:disabled { | |
| 102 | + color: fox.color(font-neutral-disabled-strong); | |
| 103 | + cursor: default; | |
| 104 | + } | |
| 105 | + } | |
| 106 | + | |
| 107 | + &__timer { | |
| 108 | + display: inline-flex; | |
| 109 | + flex-shrink: 0; | |
| 110 | + align-items: center; | |
| 111 | + gap: fox.gap(2); | |
| 112 | + color: fox.color(icon-system-warning-strong); | |
| 113 | + } | |
| 114 | + | |
| 115 | + &__timer-icon { | |
| 116 | + display: inline-flex; | |
| 117 | + flex-shrink: 0; | |
| 118 | + | |
| 119 | + > * { | |
| 120 | + inline-size: fox.icon(3); | |
| 121 | + block-size: fox.icon(3); | |
| 122 | + } | |
| 123 | + } | |
| 124 | + | |
| 125 | + &__timer-text { | |
| 126 | + color: fox.color(font-system-warning); | |
| 127 | + font-family: fox.font-family(body); | |
| 128 | + font-size: fox.font-size(label-sm); | |
| 129 | + font-weight: fox.font-weight(regular); | |
| 130 | + line-height: 1; | |
| 131 | + letter-spacing: -0.025em; | |
| 132 | + text-align: end; | |
| 133 | + } | |
| 134 | +} | |
| 135 | + | |
| 136 | +// ── 인증번호 상자 상태 ────────────────────────────────────────────────────── | |
| 137 | +// 뒤에 오는 규칙이 이긴다: 포커스 < 비활성. | |
| 138 | +.fox-certified__box:focus-within { | |
| 139 | + border-width: fox.border(2); | |
| 140 | + border-color: fox.color(input-border-active); | |
| 141 | +} | |
| 142 | + | |
| 143 | +.fox-certified__box:has(.fox-certified__field:disabled) { | |
| 144 | + border-width: fox.border(1); | |
| 145 | + border-color: fox.color(input-border-disabled); | |
| 146 | + background: fox.color(input-surface-disabled); | |
| 147 | +} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -7,6 +7,7 @@ |
| 7 | 7 |
@use "fox-icon-button"; |
| 8 | 8 |
@use "fox-button-group"; |
| 9 | 9 |
@use "fox-button-panel"; |
| 10 |
+@use "fox-certified"; |
|
| 10 | 11 |
@use "fox-input"; |
| 11 | 12 |
@use "fox-phone-number"; |
| 12 | 13 |
@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?