feat: FoxPhoneNumber 추가
Figma form-phonenumber(391:3156)의 두 모양을 옮긴다. unit은 상자 셋을 하이픈으로 잇고, combine은 상자 하나에 세 칸을 둔다. 값은 숫자만 담긴 문자열 하나다 — 하이픈은 화면에만 있고 값에는 없다. 화면의 세 칸이 진실 공급원이라 입력 도중 "0101"처럼 어디까지가 앞자리인지 알 수 없는 값이 생겨도 칸이 흔들리지 않고, 밖에서 value를 바꿨을 때만 다시 나눈다. 나눌 때 앞자리는 prefixOptions 중 가장 긴 일치를, 뒷자리는 항상 마지막 네 자리를 쓴다 — 010-123-4567과 010-1234-5678이 둘 다 맞게 갈린다. 목록이 없으면 maxLengths[0]만큼을 앞자리로 보므로 지역번호 02는 목록에 넣어야 한다. unit의 조각은 시안에서도 select·input 인스턴스라 FoxSelect·FoxInput을 그대로 조합한다. 상태·포커스가 저절로 따라오고 이 파일은 줄 배치와 하이픈만 갖는다. combine 상자만 자체 규칙을 둔다. 칸을 다 채우면 다음 칸으로 옮기고 빈 칸에서 지우면 앞칸으로 돌아간다 (autoAdvance로 끌 수 있다). Co-Authored-By: Claude Opus 5
@43030faabe9da217c0f5768156b768e42775d963
--- @fox/README.md
+++ @fox/README.md
... | ... | @@ -110,6 +110,17 @@ |
| 110 | 110 |
<p class="fox-input__message"><!-- 헬퍼 (선택) --></p> |
| 111 | 111 |
</div> |
| 112 | 112 |
|
| 113 |
+<div class="fox-phone-number" role="group" aria-labelledby="phone-label"> |
|
| 114 |
+ <span class="fox-phone-number__label" id="phone-label">전화번호</span> |
|
| 115 |
+ <div class="fox-phone-number__box"> <!-- combine --> |
|
| 116 |
+ <input class="fox-phone-number__segment" aria-label="전화번호 앞자리"> |
|
| 117 |
+ <span class="fox-phone-number__separator">-</span> |
|
| 118 |
+ <input class="fox-phone-number__segment" aria-label="전화번호 가운데자리"> |
|
| 119 |
+ <span class="fox-phone-number__separator">-</span> |
|
| 120 |
+ <input class="fox-phone-number__segment" aria-label="전화번호 뒷자리"> |
|
| 121 |
+ </div> |
|
| 122 |
+</div> |
|
| 123 |
+ |
|
| 113 | 124 |
<div class="fox-text-area"> |
| 114 | 125 |
<label class="fox-text-area__label" for="memo">레이블</label> |
| 115 | 126 |
<div class="fox-text-area__box"> |
... | ... | @@ -138,6 +149,9 @@ |
| 138 | 149 |
비활성·읽기전용은 네이티브 속성, 오류는 `aria-invalid="true"`. 모양을 못박으려면 루트에 |
| 139 | 150 |
`data-state`를 줍니다 (`default` `focused` `completed` `error` `disabled` `view`). |
| 140 | 151 |
- 여러 줄 입력에는 크기 축이 없습니다. 높이는 `rows`가 정하고 시안은 5줄입니다. |
| 152 |
+- 전화번호는 화면만 세 칸이고 값은 숫자만 담긴 문자열 하나입니다 — 하이픈은 값에 들어가지 |
|
| 153 |
+ 않습니다. `unit`은 앞자리가 드롭다운이라 조각을 select·input으로 조립하고, `combine`은 |
|
| 154 |
+ 상자 하나에 세 칸을 둡니다. |
|
| 141 | 155 |
- 아이콘 SVG는 `currentColor`로 그려야 계열별 색이 적용됩니다. 크기는 슬롯이 정합니다. |
| 142 | 156 |
|
| 143 | 157 |
`core/`의 React 컴포넌트는 이 클래스를 조립해주는 **얇은 래퍼**일 뿐입니다 — 쓰지 않아도 |
+++ @fox/core/components/fox-phone-number/fox-phone-number.tsx
... | ... | @@ -0,0 +1,285 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + Fragment, | |
| 5 | + useId, | |
| 6 | + useRef, | |
| 7 | + useState, | |
| 8 | + type ChangeEvent, | |
| 9 | + type KeyboardEvent, | |
| 10 | + type ReactNode, | |
| 11 | +} from "react"; | |
| 12 | +import { cx } from "../../utils"; | |
| 13 | +import { FoxInput, type FoxInputState } from "../fox-input"; | |
| 14 | +import { FoxSelect, type FoxSelectItem } from "../fox-select"; | |
| 15 | + | |
| 16 | +export type FoxPhoneNumberType = "unit" | "combine"; | |
| 17 | +export type FoxPhoneNumberState = FoxInputState; | |
| 18 | + | |
| 19 | +type Parts = [string, string, string]; | |
| 20 | +type Lengths = [number, number, number]; | |
| 21 | + | |
| 22 | +export interface FoxPhoneNumberProps { | |
| 23 | + /** `unit`은 상자 셋을 하이픈으로 잇고, `combine`은 상자 하나에 세 칸을 둔다. */ | |
| 24 | + type?: FoxPhoneNumberType; | |
| 25 | + /** | |
| 26 | + * 숫자만 담긴 값. 하이픈은 화면에만 있고 값에는 들어가지 않는다 — `"01012345678"`. | |
| 27 | + * 주면 제어 컴포넌트가 된다. | |
| 28 | + */ | |
| 29 | + value?: string; | |
| 30 | + defaultValue?: string; | |
| 31 | + onChange?: (value: string) => void; | |
| 32 | + /** | |
| 33 | + * 앞자리 목록. `unit`에서는 선택 목록이 되고, 두 모양 모두 **밖에서 받은 값을 나눌 때** | |
| 34 | + * 앞자리 길이를 알아내는 데 쓴다 — 없으면 `maxLengths[0]`만큼을 앞자리로 본다. | |
| 35 | + * 지역번호 `02`처럼 짧은 앞자리를 쓰면 목록에 넣거나 `maxLengths`를 함께 조정한다. | |
| 36 | + */ | |
| 37 | + prefixOptions?: string[]; | |
| 38 | + label?: string; | |
| 39 | + /** 헬퍼 메시지. 색은 상태를 따라간다. */ | |
| 40 | + message?: string; | |
| 41 | + /** 헬퍼 메시지 앞 아이콘. `currentColor`로 그려야 상태 색을 따라간다. */ | |
| 42 | + messageIcon?: ReactNode; | |
| 43 | + placeholders?: Parts; | |
| 44 | + /** 칸별 최대 자릿수. 자동 이동의 기준이기도 하다. */ | |
| 45 | + maxLengths?: Lengths; | |
| 46 | + /** 칸을 다 채우면 다음 칸으로 옮기고, 빈 칸에서 지우면 앞칸으로 돌아간다. */ | |
| 47 | + autoAdvance?: boolean; | |
| 48 | + /** 오류 표시. `aria-invalid`가 되고 테두리·헬퍼 색이 바뀐다. */ | |
| 49 | + invalid?: boolean; | |
| 50 | + disabled?: boolean; | |
| 51 | + readOnly?: boolean; | |
| 52 | + /** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */ | |
| 53 | + state?: FoxPhoneNumberState; | |
| 54 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 55 | + hidden?: boolean; | |
| 56 | + /** 배치 조정용. */ | |
| 57 | + className?: string; | |
| 58 | + name?: string; | |
| 59 | + id?: string; | |
| 60 | +} | |
| 61 | + | |
| 62 | +const DEFAULT_PLACEHOLDERS: Parts = ["010", "1234", "5678"]; | |
| 63 | +const DEFAULT_MAX_LENGTHS: Lengths = [3, 4, 4]; | |
| 64 | +const SEGMENT_LABELS: Parts = ["전화번호 앞자리", "전화번호 가운데자리", "전화번호 뒷자리"]; | |
| 65 | + | |
| 66 | +/** | |
| 67 | + * 숫자만 담긴 값을 화면의 세 칸으로 나눈다. | |
| 68 | + * | |
| 69 | + * 앞자리는 선택 목록 중 가장 긴 일치를 쓴다 — `02`와 `021`이 함께 있어도 흔들리지 않는다. | |
| 70 | + * 뒷자리는 항상 마지막 네 자리다(시안 기준). 그래서 `010-123-4567`과 `010-1234-5678`이 | |
| 71 | + * 둘 다 맞게 갈린다. 남은 글자가 그보다 짧으면 아직 입력 중이라 보고 가운데에 둔다. | |
| 72 | + */ | |
| 73 | +function splitValue(value: string, prefixOptions: string[], maxLengths: Lengths): Parts { | |
| 74 | + const digits = value ?? ""; | |
| 75 | + const matched = prefixOptions | |
| 76 | + .filter((option) => option && digits.startsWith(option)) | |
| 77 | + .sort((a, b) => b.length - a.length)[0]; | |
| 78 | + const head = matched ?? digits.slice(0, maxLengths[0]); | |
| 79 | + const rest = digits.slice(head.length); | |
| 80 | + | |
| 81 | + if (rest.length > maxLengths[2]) { | |
| 82 | + return [head, rest.slice(0, rest.length - maxLengths[2]), rest.slice(-maxLengths[2])]; | |
| 83 | + } | |
| 84 | + return [head, rest, ""]; | |
| 85 | +} | |
| 86 | + | |
| 87 | +/** | |
| 88 | + * @fox 전화번호 입력. 화면은 세 칸이지만 값은 숫자만 담긴 문자열 하나다. | |
| 89 | + * | |
| 90 | + * 화면의 세 칸이 값의 진실 공급원이다 — 입력 도중 `"0101"`처럼 어디까지가 앞자리인지 | |
| 91 | + * 알 수 없는 값이 생겨도 칸이 흔들리지 않는다. 밖에서 `value`를 바꿨을 때만 다시 나눈다. | |
| 92 | + * | |
| 93 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 94 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 95 | + */ | |
| 96 | +export function FoxPhoneNumber({ | |
| 97 | + type = "unit", | |
| 98 | + value, | |
| 99 | + defaultValue, | |
| 100 | + onChange, | |
| 101 | + prefixOptions = [], | |
| 102 | + label, | |
| 103 | + message, | |
| 104 | + messageIcon, | |
| 105 | + placeholders = DEFAULT_PLACEHOLDERS, | |
| 106 | + maxLengths = DEFAULT_MAX_LENGTHS, | |
| 107 | + autoAdvance = true, | |
| 108 | + invalid = false, | |
| 109 | + disabled = false, | |
| 110 | + readOnly = false, | |
| 111 | + state, | |
| 112 | + hidden = false, | |
| 113 | + className, | |
| 114 | + name, | |
| 115 | + id, | |
| 116 | +}: FoxPhoneNumberProps) { | |
| 117 | + const autoId = useId(); | |
| 118 | + const [parts, setParts] = useState<Parts>(() => | |
| 119 | + splitValue(value ?? defaultValue ?? "", prefixOptions, maxLengths) | |
| 120 | + ); | |
| 121 | + const [seenValue, setSeenValue] = useState(value); | |
| 122 | + const tailRefs = useRef<(HTMLInputElement | null)[]>([]); | |
| 123 | + | |
| 124 | + // 밖에서 값을 바꿨을 때만 다시 나눈다. 우리가 방금 올려보낸 값이면 그대로 둔다. | |
| 125 | + if (value !== undefined && value !== seenValue) { | |
| 126 | + setSeenValue(value); | |
| 127 | + if (value !== parts.join("")) { | |
| 128 | + setParts(splitValue(value, prefixOptions, maxLengths)); | |
| 129 | + } | |
| 130 | + } | |
| 131 | + | |
| 132 | + if (hidden) { | |
| 133 | + return null; | |
| 134 | + } | |
| 135 | + | |
| 136 | + const rootId = id ?? autoId; | |
| 137 | + const labelId = `${rootId}-label`; | |
| 138 | + const messageId = `${rootId}-message`; | |
| 139 | + const errored = invalid || state === "error"; | |
| 140 | + | |
| 141 | + const commit = (index: number, next: string) => { | |
| 142 | + const nextParts = [...parts] as Parts; | |
| 143 | + nextParts[index] = next; | |
| 144 | + setParts(nextParts); | |
| 145 | + const joined = nextParts.join(""); | |
| 146 | + setSeenValue(joined); | |
| 147 | + onChange?.(joined); | |
| 148 | + }; | |
| 149 | + | |
| 150 | + const focusSegment = (index: number) => { | |
| 151 | + tailRefs.current[index]?.focus(); | |
| 152 | + }; | |
| 153 | + | |
| 154 | + const handleSegmentChange = (index: number) => (next: string) => { | |
| 155 | + commit(index, next); | |
| 156 | + if (autoAdvance && index < 2 && next.length >= maxLengths[index]) { | |
| 157 | + focusSegment(index + 1); | |
| 158 | + } | |
| 159 | + }; | |
| 160 | + | |
| 161 | + // 빈 칸에서 백스페이스하면 앞칸으로 돌아간다. | |
| 162 | + const handleSegmentKeyDown = (index: number) => (event: KeyboardEvent<HTMLInputElement>) => { | |
| 163 | + if (autoAdvance && event.key === "Backspace" && index > 0 && parts[index] === "") { | |
| 164 | + event.preventDefault(); | |
| 165 | + focusSegment(index - 1); | |
| 166 | + } | |
| 167 | + }; | |
| 168 | + | |
| 169 | + const prefixItems: FoxSelectItem[] = prefixOptions.map((option) => ({ | |
| 170 | + value: option, | |
| 171 | + label: option, | |
| 172 | + })); | |
| 173 | + | |
| 174 | + const body = | |
| 175 | + type === "unit" ? ( | |
| 176 | + <div className="fox-phone-number__row"> | |
| 177 | + <div className="fox-phone-number__part"> | |
| 178 | + <FoxSelect | |
| 179 | + size="md" | |
| 180 | + options={prefixItems} | |
| 181 | + value={parts[0]} | |
| 182 | + onValueChange={(next) => { | |
| 183 | + commit(0, next); | |
| 184 | + if (autoAdvance) { | |
| 185 | + focusSegment(1); | |
| 186 | + } | |
| 187 | + }} | |
| 188 | + placeholder={placeholders[0]} | |
| 189 | + error={errored} | |
| 190 | + disabled={disabled || state === "disabled"} | |
| 191 | + readOnly={readOnly || state === "view"} | |
| 192 | + /> | |
| 193 | + </div> | |
| 194 | + | |
| 195 | + {[1, 2].map((index) => ( | |
| 196 | + <Fragment key={index}> | |
| 197 | + <span className="fox-phone-number__separator" aria-hidden="true"> | |
| 198 | + - | |
| 199 | + </span> | |
| 200 | + <div className="fox-phone-number__part"> | |
| 201 | + <FoxInput | |
| 202 | + size="md" | |
| 203 | + inputMode="numeric" | |
| 204 | + aria-label={SEGMENT_LABELS[index]} | |
| 205 | + value={parts[index]} | |
| 206 | + onChange={handleSegmentChange(index)} | |
| 207 | + onKeyDown={handleSegmentKeyDown(index)} | |
| 208 | + placeholder={placeholders[index]} | |
| 209 | + maxLength={maxLengths[index]} | |
| 210 | + invalid={invalid} | |
| 211 | + disabled={disabled} | |
| 212 | + readOnly={readOnly} | |
| 213 | + state={state} | |
| 214 | + ref={(node) => { | |
| 215 | + tailRefs.current[index] = node; | |
| 216 | + }} | |
| 217 | + /> | |
| 218 | + </div> | |
| 219 | + </Fragment> | |
| 220 | + ))} | |
| 221 | + </div> | |
| 222 | + ) : ( | |
| 223 | + <div className="fox-phone-number__box"> | |
| 224 | + {[0, 1, 2].map((index) => ( | |
| 225 | + <Fragment key={index}> | |
| 226 | + {index > 0 && ( | |
| 227 | + <span className="fox-phone-number__separator" aria-hidden="true"> | |
| 228 | + - | |
| 229 | + </span> | |
| 230 | + )} | |
| 231 | + <input | |
| 232 | + className="fox-phone-number__segment" | |
| 233 | + inputMode="numeric" | |
| 234 | + aria-label={SEGMENT_LABELS[index]} | |
| 235 | + aria-invalid={errored || undefined} | |
| 236 | + aria-describedby={message ? messageId : undefined} | |
| 237 | + value={parts[index]} | |
| 238 | + placeholder={placeholders[index]} | |
| 239 | + maxLength={maxLengths[index]} | |
| 240 | + disabled={disabled} | |
| 241 | + readOnly={readOnly} | |
| 242 | + onChange={(event: ChangeEvent<HTMLInputElement>) => | |
| 243 | + handleSegmentChange(index)(event.target.value) | |
| 244 | + } | |
| 245 | + onKeyDown={handleSegmentKeyDown(index)} | |
| 246 | + ref={(node) => { | |
| 247 | + tailRefs.current[index] = node; | |
| 248 | + }} | |
| 249 | + /> | |
| 250 | + </Fragment> | |
| 251 | + ))} | |
| 252 | + </div> | |
| 253 | + ); | |
| 254 | + | |
| 255 | + return ( | |
| 256 | + <div | |
| 257 | + className={cx("fox-phone-number", className)} | |
| 258 | + data-state={state} | |
| 259 | + role="group" | |
| 260 | + aria-labelledby={label ? labelId : undefined} | |
| 261 | + > | |
| 262 | + {label && ( | |
| 263 | + <span className="fox-phone-number__label" id={labelId}> | |
| 264 | + {label} | |
| 265 | + </span> | |
| 266 | + )} | |
| 267 | + | |
| 268 | + {body} | |
| 269 | + | |
| 270 | + {/* 폼 전송용. 화면의 세 칸은 이름을 갖지 않는다 — 값은 언제나 합쳐진 하나다. */} | |
| 271 | + {name && <input type="hidden" name={name} value={parts.join("")} />} | |
| 272 | + | |
| 273 | + {message && ( | |
| 274 | + <p className="fox-phone-number__message" id={messageId}> | |
| 275 | + {messageIcon && ( | |
| 276 | + <span className="fox-phone-number__message-icon" aria-hidden="true"> | |
| 277 | + {messageIcon} | |
| 278 | + </span> | |
| 279 | + )} | |
| 280 | + {message} | |
| 281 | + </p> | |
| 282 | + )} | |
| 283 | + </div> | |
| 284 | + ); | |
| 285 | +} |
+++ @fox/core/components/fox-phone-number/index.ts
... | ... | @@ -0,0 +1,6 @@ |
| 1 | +export { | |
| 2 | + FoxPhoneNumber, | |
| 3 | + type FoxPhoneNumberProps, | |
| 4 | + type FoxPhoneNumberType, | |
| 5 | + type FoxPhoneNumberState, | |
| 6 | +} from "./fox-phone-number"; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -4,6 +4,7 @@ |
| 4 | 4 |
export * from "./fox-icon-button"; |
| 5 | 5 |
export * from "./fox-input"; |
| 6 | 6 |
export * from "./fox-link-button"; |
| 7 |
+export * from "./fox-phone-number"; |
|
| 7 | 8 |
export * from "./fox-select"; |
| 8 | 9 |
export * from "./fox-select-option"; |
| 9 | 10 |
export * from "./fox-select-option-item"; |
--- @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 { FoxPhoneNumber, type FoxPhoneNumberState } from "../core/components/fox-phone-number";
|
|
| 10 | 11 |
import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
|
| 11 | 12 |
import { FoxTextButton } from "../core/components/fox-text-button";
|
| 12 | 13 |
import styles from "./dev-test.module.scss"; |
... | ... | @@ -122,6 +123,37 @@ |
| 122 | 123 |
maxLength={100}
|
| 123 | 124 |
counter |
| 124 | 125 |
invalid |
| 126 |
+ /> |
|
| 127 |
+ </InputStage> |
|
| 128 |
+ </> |
|
| 129 |
+ ); |
|
| 130 |
+} |
|
| 131 |
+ |
|
| 132 |
+const PHONE_PREFIXES = ["010", "011", "016", "017", "018", "019", "02", "031", "051"]; |
|
| 133 |
+ |
|
| 134 |
+// 값이 숫자만 담긴 문자열 하나라는 걸 보려면 예제가 그 값을 그대로 보여줘야 한다. |
|
| 135 |
+function PhoneNumberLiveDemo() {
|
|
| 136 |
+ const [unit, setUnit] = useState("01012345678");
|
|
| 137 |
+ const [combine, setCombine] = useState("0212345678");
|
|
| 138 |
+ return ( |
|
| 139 |
+ <> |
|
| 140 |
+ <InputStage> |
|
| 141 |
+ <FoxPhoneNumber |
|
| 142 |
+ label={`unit — value="${unit}"`}
|
|
| 143 |
+ prefixOptions={PHONE_PREFIXES}
|
|
| 144 |
+ value={unit}
|
|
| 145 |
+ onChange={setUnit}
|
|
| 146 |
+ message="칸을 다 채우면 다음 칸으로 넘어갑니다" |
|
| 147 |
+ /> |
|
| 148 |
+ </InputStage> |
|
| 149 |
+ <InputStage> |
|
| 150 |
+ <FoxPhoneNumber |
|
| 151 |
+ type="combine" |
|
| 152 |
+ label={`combine — value="${combine}"`}
|
|
| 153 |
+ prefixOptions={PHONE_PREFIXES}
|
|
| 154 |
+ value={combine}
|
|
| 155 |
+ onChange={setCombine}
|
|
| 156 |
+ message="앞자리 목록에 02가 있어 지역번호도 제자리에 갈립니다" |
|
| 125 | 157 |
/> |
| 126 | 158 |
</InputStage> |
| 127 | 159 |
</> |
... | ... | @@ -933,4 +965,85 @@ |
| 933 | 965 |
}, |
| 934 | 966 |
], |
| 935 | 967 |
}, |
| 968 |
+ {
|
|
| 969 |
+ id: "fox-phone-number", |
|
| 970 |
+ name: "FoxPhoneNumber", |
|
| 971 |
+ description: |
|
| 972 |
+ "Figma 시안(form-phonenumber 391:3156)을 옮겼습니다. unit은 상자 셋을 하이픈으로 잇고 앞자리가 선택이며, combine은 상자 하나에 세 칸을 둡니다. 화면은 세 칸이지만 값은 숫자만 담긴 문자열 하나입니다 — 하이픈은 값에 들어가지 않습니다. 칸을 다 채우면 다음 칸으로 넘어가고, 빈 칸에서 지우면 앞칸으로 돌아갑니다.", |
|
| 973 |
+ variants: [ |
|
| 974 |
+ {
|
|
| 975 |
+ label: "직접 조작 — 값이 그대로 라벨에 보입니다", |
|
| 976 |
+ node: <PhoneNumberLiveDemo />, |
|
| 977 |
+ }, |
|
| 978 |
+ {
|
|
| 979 |
+ label: "모양 (type)", |
|
| 980 |
+ node: ( |
|
| 981 |
+ <> |
|
| 982 |
+ <InputStage> |
|
| 983 |
+ <FoxPhoneNumber label="unit" prefixOptions={PHONE_PREFIXES} />
|
|
| 984 |
+ </InputStage> |
|
| 985 |
+ <InputStage> |
|
| 986 |
+ <FoxPhoneNumber type="combine" label="combine" prefixOptions={PHONE_PREFIXES} />
|
|
| 987 |
+ </InputStage> |
|
| 988 |
+ </> |
|
| 989 |
+ ), |
|
| 990 |
+ }, |
|
| 991 |
+ {
|
|
| 992 |
+ label: "상태 고정 (state) — combine 기준", |
|
| 993 |
+ node: ( |
|
| 994 |
+ <> |
|
| 995 |
+ {(
|
|
| 996 |
+ ["default", "focused", "completed", "error", "disabled", "view"] as FoxPhoneNumberState[] |
|
| 997 |
+ ).map((state) => ( |
|
| 998 |
+ <InputStage key={state}>
|
|
| 999 |
+ <FoxPhoneNumber |
|
| 1000 |
+ type="combine" |
|
| 1001 |
+ state={state}
|
|
| 1002 |
+ label={state}
|
|
| 1003 |
+ message="힌트 메시지" |
|
| 1004 |
+ /> |
|
| 1005 |
+ </InputStage> |
|
| 1006 |
+ ))} |
|
| 1007 |
+ </> |
|
| 1008 |
+ ), |
|
| 1009 |
+ }, |
|
| 1010 |
+ {
|
|
| 1011 |
+ label: "자동 이동 끄기 (autoAdvance)", |
|
| 1012 |
+ node: ( |
|
| 1013 |
+ <InputStage> |
|
| 1014 |
+ <FoxPhoneNumber |
|
| 1015 |
+ label="autoAdvance={false}"
|
|
| 1016 |
+ prefixOptions={PHONE_PREFIXES}
|
|
| 1017 |
+ autoAdvance={false}
|
|
| 1018 |
+ message="칸을 다 채워도 커서가 그대로 있습니다" |
|
| 1019 |
+ /> |
|
| 1020 |
+ </InputStage> |
|
| 1021 |
+ ), |
|
| 1022 |
+ }, |
|
| 1023 |
+ {
|
|
| 1024 |
+ label: "비활성 · 읽기 전용", |
|
| 1025 |
+ node: ( |
|
| 1026 |
+ <> |
|
| 1027 |
+ <InputStage> |
|
| 1028 |
+ <FoxPhoneNumber |
|
| 1029 |
+ label="disabled" |
|
| 1030 |
+ prefixOptions={PHONE_PREFIXES}
|
|
| 1031 |
+ defaultValue="01012345678" |
|
| 1032 |
+ disabled |
|
| 1033 |
+ /> |
|
| 1034 |
+ </InputStage> |
|
| 1035 |
+ <InputStage> |
|
| 1036 |
+ <FoxPhoneNumber |
|
| 1037 |
+ type="combine" |
|
| 1038 |
+ label="readOnly" |
|
| 1039 |
+ prefixOptions={PHONE_PREFIXES}
|
|
| 1040 |
+ defaultValue="01012345678" |
|
| 1041 |
+ readOnly |
|
| 1042 |
+ /> |
|
| 1043 |
+ </InputStage> |
|
| 1044 |
+ </> |
|
| 1045 |
+ ), |
|
| 1046 |
+ }, |
|
| 1047 |
+ ], |
|
| 1048 |
+ }, |
|
| 936 | 1049 |
]; |
+++ @fox/styles/_fox-phone-number.scss
... | ... | @@ -0,0 +1,234 @@ |
| 1 | +// FoxPhoneNumber — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma form-phonenumber (섹션 391:3156) | |
| 3 | +// | |
| 4 | +// 두 모양이 있다. | |
| 5 | +// - unit(136:2926): 상자 셋을 하이픈으로 잇는다. 앞자리는 선택, 뒤 둘은 입력이며 | |
| 6 | +// 시안에서도 select·input 컴포넌트의 인스턴스라 그대로 조합한다. 그래서 이 파일은 | |
| 7 | +// 줄 배치와 하이픈만 갖고, 상자 모양·상태는 FoxSelect·FoxInput이 가진 것을 쓴다. | |
| 8 | +// - combine(190:1772): 상자 하나 안에 세 칸을 두고 각 칸을 가운데 정렬한다. 이건 | |
| 9 | +// 시안이 별도 상자라 여기서 직접 그린다. | |
| 10 | +// | |
| 11 | +// 마크업 계약 (React 밖 소비자용): | |
| 12 | +// <div class="fox-phone-number" role="group" aria-labelledby="…"> | |
| 13 | +// <span class="fox-phone-number__label" id="…">레이블</span> | |
| 14 | +// <div class="fox-phone-number__row"> <!-- unit --> | |
| 15 | +// <div class="fox-phone-number__part">…select…</div> | |
| 16 | +// <span class="fox-phone-number__separator">-</span> | |
| 17 | +// <div class="fox-phone-number__part">…input…</div> | |
| 18 | +// <span class="fox-phone-number__separator">-</span> | |
| 19 | +// <div class="fox-phone-number__part">…input…</div> | |
| 20 | +// </div> | |
| 21 | +// <div class="fox-phone-number__box"> <!-- combine --> | |
| 22 | +// <input class="fox-phone-number__segment"> | |
| 23 | +// <span class="fox-phone-number__separator">-</span> | |
| 24 | +// … | |
| 25 | +// </div> | |
| 26 | +// <p class="fox-phone-number__message">…</p> | |
| 27 | +// </div> | |
| 28 | +// | |
| 29 | +// 상태는 FoxInput과 같은 두 갈래다(자동 / `data-state` 고정). combine 상자에만 규칙이 | |
| 30 | +// 필요하다 — unit은 조각이 알아서 따라온다. | |
| 31 | +// | |
| 32 | +// 시안에 크기 축이 없다. 상자는 md 하나뿐이다. | |
| 33 | +// | |
| 34 | +// 토큰이 아닌 값(FoxInput과 동일한 근거로 승인된 항목): | |
| 35 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 36 | +// export하지 않는다. | |
| 37 | +// - box-sizing / flex / text-align: 구조 규칙. | |
| 38 | + | |
| 39 | +@use "abstracts" as fox; | |
| 40 | + | |
| 41 | +.fox-phone-number { | |
| 42 | + display: flex; | |
| 43 | + flex-direction: column; | |
| 44 | + gap: fox.gap(3); | |
| 45 | + inline-size: 100%; | |
| 46 | + | |
| 47 | + &__label { | |
| 48 | + color: fox.color(font-neutral-default); | |
| 49 | + font-family: fox.font-family(body); | |
| 50 | + font-size: fox.font-size(label-md); | |
| 51 | + font-weight: fox.font-weight(medium); | |
| 52 | + line-height: 1; | |
| 53 | + letter-spacing: -0.025em; | |
| 54 | + } | |
| 55 | + | |
| 56 | + // ── unit ────────────────────────────────────────────────────────────────── | |
| 57 | + &__row { | |
| 58 | + display: flex; | |
| 59 | + align-items: center; | |
| 60 | + gap: fox.gap(2); | |
| 61 | + inline-size: 100%; | |
| 62 | + } | |
| 63 | + | |
| 64 | + // 세 조각이 남는 폭을 똑같이 나눈다. | |
| 65 | + &__part { | |
| 66 | + flex: 1 0 0; | |
| 67 | + min-inline-size: 0; | |
| 68 | + } | |
| 69 | + | |
| 70 | + &__separator { | |
| 71 | + flex-shrink: 0; | |
| 72 | + color: fox.color(font-neutral-default); | |
| 73 | + font-family: fox.font-family(body); | |
| 74 | + font-size: fox.font-size(body-md); | |
| 75 | + font-weight: fox.font-weight(regular); | |
| 76 | + line-height: 1.5; | |
| 77 | + letter-spacing: -0.025em; | |
| 78 | + } | |
| 79 | + | |
| 80 | + // ── combine ─────────────────────────────────────────────────────────────── | |
| 81 | + &__box { | |
| 82 | + box-sizing: border-box; | |
| 83 | + display: flex; | |
| 84 | + align-items: center; | |
| 85 | + gap: fox.gap(2); | |
| 86 | + inline-size: 100%; | |
| 87 | + block-size: fox.form(height-md); | |
| 88 | + padding-inline: fox.form(padding-md); | |
| 89 | + border: fox.border(1) solid fox.color(input-border); | |
| 90 | + border-radius: fox.form(radius-md); | |
| 91 | + background: fox.color(input-surface); | |
| 92 | + } | |
| 93 | + | |
| 94 | + &__segment { | |
| 95 | + flex: 1 0 0; | |
| 96 | + min-inline-size: 0; | |
| 97 | + margin: 0; | |
| 98 | + padding: 0; | |
| 99 | + border: none; | |
| 100 | + background: none; | |
| 101 | + color: fox.color(font-neutral-default); | |
| 102 | + font-family: fox.font-family(body); | |
| 103 | + font-size: fox.font-size(label-md); | |
| 104 | + font-weight: fox.font-weight(regular); | |
| 105 | + line-height: 1; | |
| 106 | + letter-spacing: -0.025em; | |
| 107 | + text-align: center; | |
| 108 | + | |
| 109 | + &::placeholder { | |
| 110 | + color: fox.color(font-neutral-subtler); | |
| 111 | + } | |
| 112 | + | |
| 113 | + // 포커스는 상자 테두리가 표시하므로 기본 외곽선을 지운다. | |
| 114 | + &:focus { | |
| 115 | + outline: none; | |
| 116 | + } | |
| 117 | + | |
| 118 | + &:disabled { | |
| 119 | + color: fox.color(font-neutral-disabled-strong); | |
| 120 | + cursor: default; | |
| 121 | + } | |
| 122 | + } | |
| 123 | + | |
| 124 | + &__message { | |
| 125 | + display: flex; | |
| 126 | + gap: fox.number(3); | |
| 127 | + align-items: flex-start; | |
| 128 | + margin: 0; | |
| 129 | + color: fox.color(font-neutral-subtle); | |
| 130 | + font-family: fox.font-family(body); | |
| 131 | + font-size: fox.font-size(body-sm); | |
| 132 | + font-weight: fox.font-weight(regular); | |
| 133 | + line-height: 1.5; | |
| 134 | + letter-spacing: -0.025em; | |
| 135 | + } | |
| 136 | + | |
| 137 | + &__message-icon { | |
| 138 | + display: inline-flex; | |
| 139 | + flex-shrink: 0; | |
| 140 | + align-items: flex-start; | |
| 141 | + padding-block-start: fox.padding(1); | |
| 142 | + | |
| 143 | + > * { | |
| 144 | + inline-size: fox.icon(3); | |
| 145 | + block-size: fox.icon(3); | |
| 146 | + } | |
| 147 | + } | |
| 148 | +} | |
| 149 | + | |
| 150 | +// ── 상태 · 자동 (combine 상자) ────────────────────────────────────────────── | |
| 151 | +// 뒤에 오는 규칙이 이긴다: 포커스 < 오류 < 읽기전용 < 비활성. | |
| 152 | +.fox-phone-number:not([data-state]) { | |
| 153 | + .fox-phone-number__box:focus-within { | |
| 154 | + border-width: fox.border(2); | |
| 155 | + border-color: fox.color(input-border-active); | |
| 156 | + } | |
| 157 | + | |
| 158 | + .fox-phone-number__box:has(.fox-phone-number__segment[aria-invalid="true"]) { | |
| 159 | + border-width: fox.border(2); | |
| 160 | + border-color: fox.color(input-border-error); | |
| 161 | + } | |
| 162 | + | |
| 163 | + // 비활성도 `:read-only`에 걸리므로 떼어낸다. | |
| 164 | + .fox-phone-number__box:has(.fox-phone-number__segment:read-only:not(:disabled)) { | |
| 165 | + border-width: fox.border(1); | |
| 166 | + border-color: fox.color(input-border-disabled); | |
| 167 | + background: fox.color(input-surface-disabled); | |
| 168 | + } | |
| 169 | + | |
| 170 | + .fox-phone-number__box:has(.fox-phone-number__segment:disabled) { | |
| 171 | + border-width: fox.border(1); | |
| 172 | + border-color: fox.color(input-border-disabled); | |
| 173 | + background: fox.color(input-surface-disabled); | |
| 174 | + } | |
| 175 | + | |
| 176 | + &:focus-within .fox-phone-number__message { | |
| 177 | + color: fox.color(font-system-information); | |
| 178 | + } | |
| 179 | + | |
| 180 | + &:has([aria-invalid="true"]) .fox-phone-number__message { | |
| 181 | + color: fox.color(font-system-danger); | |
| 182 | + } | |
| 183 | +} | |
| 184 | + | |
| 185 | +// ── 상태 · 고정 ───────────────────────────────────────────────────────────── | |
| 186 | +.fox-phone-number[data-state="default"] .fox-phone-number__box, | |
| 187 | +.fox-phone-number[data-state="completed"] .fox-phone-number__box { | |
| 188 | + border-width: fox.border(1); | |
| 189 | + border-color: fox.color(input-border); | |
| 190 | + background: fox.color(input-surface); | |
| 191 | +} | |
| 192 | + | |
| 193 | +.fox-phone-number[data-state="focused"] .fox-phone-number__box { | |
| 194 | + border-width: fox.border(2); | |
| 195 | + border-color: fox.color(input-border-active); | |
| 196 | + background: fox.color(input-surface); | |
| 197 | +} | |
| 198 | + | |
| 199 | +.fox-phone-number[data-state="error"] .fox-phone-number__box { | |
| 200 | + border-width: fox.border(2); | |
| 201 | + border-color: fox.color(input-border-error); | |
| 202 | + background: fox.color(input-surface); | |
| 203 | +} | |
| 204 | + | |
| 205 | +.fox-phone-number[data-state="view"] .fox-phone-number__box, | |
| 206 | +.fox-phone-number[data-state="disabled"] .fox-phone-number__box { | |
| 207 | + border-width: fox.border(1); | |
| 208 | + border-color: fox.color(input-border-disabled); | |
| 209 | + background: fox.color(input-surface-disabled); | |
| 210 | +} | |
| 211 | + | |
| 212 | +.fox-phone-number[data-state="completed"] .fox-phone-number__segment::placeholder, | |
| 213 | +.fox-phone-number[data-state="focused"] .fox-phone-number__segment::placeholder, | |
| 214 | +.fox-phone-number[data-state="error"] .fox-phone-number__segment::placeholder, | |
| 215 | +.fox-phone-number[data-state="view"] .fox-phone-number__segment::placeholder { | |
| 216 | + color: fox.color(font-neutral-default); | |
| 217 | +} | |
| 218 | + | |
| 219 | +.fox-phone-number[data-state="disabled"] .fox-phone-number__segment, | |
| 220 | +.fox-phone-number[data-state="disabled"] .fox-phone-number__segment::placeholder { | |
| 221 | + color: fox.color(font-neutral-disabled-strong); | |
| 222 | +} | |
| 223 | + | |
| 224 | +.fox-phone-number[data-state="focused"] .fox-phone-number__message { | |
| 225 | + color: fox.color(font-system-information); | |
| 226 | +} | |
| 227 | + | |
| 228 | +.fox-phone-number[data-state="completed"] .fox-phone-number__message { | |
| 229 | + color: fox.color(font-system-success); | |
| 230 | +} | |
| 231 | + | |
| 232 | +.fox-phone-number[data-state="error"] .fox-phone-number__message { | |
| 233 | + color: fox.color(font-system-danger); | |
| 234 | +} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -8,6 +8,7 @@ |
| 8 | 8 |
@use "fox-button-group"; |
| 9 | 9 |
@use "fox-button-panel"; |
| 10 | 10 |
@use "fox-input"; |
| 11 |
+@use "fox-phone-number"; |
|
| 11 | 12 |
@use "fox-select"; |
| 12 | 13 |
@use "fox-select-option"; |
| 13 | 14 |
@use "fox-select-option-item"; |
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?