feat: FoxInput 추가
Figma input(135:621)의 2타입 × 3크기 × 6상태를 옮긴다. 네이티브 을 감싸므로 시안에 없는 password·email·number 등 모든 타입이 그대로 통한다. 상태는 두 갈래다. 기본은 브라우저가 판단한다 — 포커스는 :focus-within, 비활성·읽기전용은 네이티브 속성, 오류는 aria-invalid. 시안의 completed는 default와 글자색만 다른데 그건 플레이스홀더와 입력값의 네이티브 차이라 규칙이 필요 없다. 모양을 못박아야 하면 루트에 data-state를 준다. date는 브라우저가 그리는 달력 아이콘과 피커를 그대로 쓴다(사용자 승인). 지우기 버튼은 포커스 중이고 값이 있을 때만 뜨고 누르면 값을 비운다 — 비제어 입력은 DOM을 직접 비우고 제어 입력은 onChange()로 알린다. 헬퍼 메시지 색은 상태를 따라간다(기본 회색 · 포커스 파랑 · 완료 초록 · 오류 빨강). 아이콘은 @fox에 아이콘 세트가 없어 슬롯으로 두었다. Co-Authored-By: Claude Opus 5
@f7f3037acb401297b7015cb0f1cd17bc111640fd
--- @fox/README.md
+++ @fox/README.md
... | ... | @@ -100,6 +100,15 @@ |
| 100 | 100 |
<div class="fox-button-group fox-button-group--horizontal fox-button-group--start">…</div> |
| 101 | 101 |
<div class="fox-button-group fox-button-group--horizontal fox-button-group--end">…</div> |
| 102 | 102 |
</div> |
| 103 |
+ |
|
| 104 |
+<div class="fox-input fox-input--md"> |
|
| 105 |
+ <label class="fox-input__label" for="name">레이블</label> |
|
| 106 |
+ <div class="fox-input__box"> |
|
| 107 |
+ <input class="fox-input__field" id="name" placeholder="내용을 입력하세요."> |
|
| 108 |
+ <span class="fox-input__icon"><!-- svg (선택) --></span> |
|
| 109 |
+ </div> |
|
| 110 |
+ <p class="fox-input__message"><!-- 헬퍼 (선택) --></p> |
|
| 111 |
+</div> |
|
| 103 | 112 |
``` |
| 104 | 113 |
|
| 105 | 114 |
- 비활성은 네이티브 `disabled` 속성으로 표현합니다(별도 클래스 없음). 앵커에는 `disabled`가 |
... | ... | @@ -112,6 +121,9 @@ |
| 112 | 121 |
- 버튼 패널은 그룹이 여럿이면 양끝으로, 하나면 가운데로 둡니다 — 자식 수는 CSS `:only-child`가 |
| 113 | 122 |
판단하므로 따로 표시할 게 없습니다. 패널 안에서는 그룹 간격이 넓어지고 버튼에 최소 폭이 |
| 114 | 123 |
붙으며, 모바일에서는 패널·그룹이 모두 세로로 쌓입니다. |
| 124 |
+- 입력의 상태는 기본적으로 브라우저가 판단합니다 — 포커스는 `:focus-within`, 비활성·읽기전용은 |
|
| 125 |
+ 네이티브 속성, 오류는 `aria-invalid="true"`. 모양을 못박으려면 루트에 `data-state`를 줍니다 |
|
| 126 |
+ (`default` `focused` `completed` `error` `disabled` `view`). |
|
| 115 | 127 |
- 아이콘 SVG는 `currentColor`로 그려야 계열별 색이 적용됩니다. 크기는 슬롯이 정합니다. |
| 116 | 128 |
|
| 117 | 129 |
`core/`의 React 컴포넌트는 이 클래스를 조립해주는 **얇은 래퍼**일 뿐입니다 — 쓰지 않아도 |
+++ @fox/core/components/fox-input/fox-input.tsx
... | ... | @@ -0,0 +1,193 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + useId, | |
| 5 | + useRef, | |
| 6 | + useState, | |
| 7 | + type ChangeEvent, | |
| 8 | + type ComponentPropsWithRef, | |
| 9 | + type FormEvent, | |
| 10 | + type ReactNode, | |
| 11 | +} from "react"; | |
| 12 | +import { cx } from "../../utils"; | |
| 13 | + | |
| 14 | +export type FoxInputSize = "lg" | "md" | "sm"; | |
| 15 | +export type FoxInputState = "default" | "focused" | "completed" | "error" | "disabled" | "view"; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * 네이티브 `<input>`이 받는 것을 그대로 넘겨받는다 — type·name·autoComplete·maxLength· | |
| 19 | + * min·max·step·pattern·inputMode·required·readOnly·disabled 등. 이름이 겹치거나 의미가 | |
| 20 | + * 다른 것만 걷어내고 아래에서 다시 정의한다. | |
| 21 | + */ | |
| 22 | +type NativeInputProps = Omit< | |
| 23 | + ComponentPropsWithRef<"input">, | |
| 24 | + "size" | "onChange" | "onInput" | "className" | "children" | "hidden" | |
| 25 | +>; | |
| 26 | + | |
| 27 | +export interface FoxInputProps extends NativeInputProps { | |
| 28 | + size?: FoxInputSize; | |
| 29 | + /** 값이 첫 인자다. 지우기 버튼은 이벤트 없이 빈 문자열로 호출한다. */ | |
| 30 | + onChange?: (value: string, event?: ChangeEvent<HTMLInputElement>) => void; | |
| 31 | + onInput?: (value: string, event: FormEvent<HTMLInputElement>) => void; | |
| 32 | + label?: string; | |
| 33 | + /** 헬퍼 메시지. 색은 상태를 따라간다. */ | |
| 34 | + message?: string; | |
| 35 | + /** 헬퍼 메시지 앞 아이콘. `currentColor`로 그려야 상태 색을 따라간다. */ | |
| 36 | + messageIcon?: ReactNode; | |
| 37 | + /** 입력 오른쪽 아이콘. `currentColor`로 그린 SVG여야 색이 적용된다. */ | |
| 38 | + icon?: ReactNode; | |
| 39 | + /** 포커스 중이고 값이 있으면 지우기 버튼을 보여준다. */ | |
| 40 | + clearable?: boolean; | |
| 41 | + clearLabel?: string; | |
| 42 | + /** 오류 표시. `aria-invalid`가 되고 테두리·헬퍼 색이 바뀐다. */ | |
| 43 | + invalid?: boolean; | |
| 44 | + /** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */ | |
| 45 | + state?: FoxInputState; | |
| 46 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 47 | + hidden?: boolean; | |
| 48 | + /** 배치 조정용. */ | |
| 49 | + className?: string; | |
| 50 | +} | |
| 51 | + | |
| 52 | +const SIZE_CLASS: Record<FoxInputSize, string> = { | |
| 53 | + lg: "fox-input--lg", | |
| 54 | + md: "fox-input--md", | |
| 55 | + sm: "fox-input--sm", | |
| 56 | +}; | |
| 57 | + | |
| 58 | +/** | |
| 59 | + * @fox 입력 필드. 네이티브 `<input>`을 감싸므로 date·password·email 등 모든 타입을 받는다. | |
| 60 | + * | |
| 61 | + * date 타입은 브라우저가 그리는 달력 아이콘과 피커를 그대로 쓴다 — 시안의 달력 아이콘으로 | |
| 62 | + * 바꾸지 않는다. | |
| 63 | + * | |
| 64 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 65 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 66 | + */ | |
| 67 | +export function FoxInput({ | |
| 68 | + size = "md", | |
| 69 | + onChange, | |
| 70 | + onInput, | |
| 71 | + label, | |
| 72 | + message, | |
| 73 | + messageIcon, | |
| 74 | + icon, | |
| 75 | + clearable = false, | |
| 76 | + clearLabel = "입력 지우기", | |
| 77 | + invalid = false, | |
| 78 | + state, | |
| 79 | + hidden = false, | |
| 80 | + className, | |
| 81 | + id, | |
| 82 | + value, | |
| 83 | + defaultValue, | |
| 84 | + ref, | |
| 85 | + ...rest | |
| 86 | +}: FoxInputProps) { | |
| 87 | + const autoId = useId(); | |
| 88 | + | |
| 89 | + // 지우기 버튼은 값이 있을 때만 뜬다. 비제어 입력은 DOM만 알고 있으므로 직접 따라간다. | |
| 90 | + const [typedIn, setTypedIn] = useState(() => String(defaultValue ?? "").length > 0); | |
| 91 | + const fieldRef = useRef<HTMLInputElement>(null); | |
| 92 | + | |
| 93 | + if (hidden) { | |
| 94 | + return null; | |
| 95 | + } | |
| 96 | + | |
| 97 | + const fieldId = id ?? `${autoId}-field`; | |
| 98 | + const messageId = `${autoId}-message`; | |
| 99 | + const hasValue = value !== undefined ? String(value).length > 0 : typedIn; | |
| 100 | + const errored = invalid || state === "error"; | |
| 101 | + | |
| 102 | + // 호출부 ref와 내부 ref를 함께 채운다 — 지우기가 비제어 입력의 DOM 값을 비워야 한다. | |
| 103 | + const attachField = (node: HTMLInputElement | null) => { | |
| 104 | + fieldRef.current = node; | |
| 105 | + if (typeof ref === "function") { | |
| 106 | + ref(node); | |
| 107 | + } else if (ref) { | |
| 108 | + (ref as { current: HTMLInputElement | null }).current = node; | |
| 109 | + } | |
| 110 | + }; | |
| 111 | + | |
| 112 | + const handleChange = (event: ChangeEvent<HTMLInputElement>) => { | |
| 113 | + if (value === undefined) { | |
| 114 | + setTypedIn(event.target.value.length > 0); | |
| 115 | + } | |
| 116 | + onChange?.(event.target.value, event); | |
| 117 | + }; | |
| 118 | + | |
| 119 | + const handleInput = (event: FormEvent<HTMLInputElement>) => { | |
| 120 | + onInput?.((event.target as HTMLInputElement).value, event); | |
| 121 | + }; | |
| 122 | + | |
| 123 | + const handleClear = () => { | |
| 124 | + const field = fieldRef.current; | |
| 125 | + if (field) { | |
| 126 | + // 비제어 입력은 DOM을 직접 비운다. 제어 입력이면 onChange를 받은 호출부가 비운다. | |
| 127 | + field.value = ""; | |
| 128 | + field.focus(); | |
| 129 | + } | |
| 130 | + setTypedIn(false); | |
| 131 | + onChange?.(""); | |
| 132 | + }; | |
| 133 | + | |
| 134 | + return ( | |
| 135 | + <div | |
| 136 | + className={cx("fox-input", SIZE_CLASS[size], className)} | |
| 137 | + data-state={state} | |
| 138 | + > | |
| 139 | + {label && ( | |
| 140 | + <label className="fox-input__label" htmlFor={fieldId}> | |
| 141 | + {label} | |
| 142 | + </label> | |
| 143 | + )} | |
| 144 | + | |
| 145 | + <div className="fox-input__box"> | |
| 146 | + <input | |
| 147 | + {...rest} | |
| 148 | + ref={attachField} | |
| 149 | + id={fieldId} | |
| 150 | + className="fox-input__field" | |
| 151 | + value={value} | |
| 152 | + defaultValue={defaultValue} | |
| 153 | + aria-invalid={errored || undefined} | |
| 154 | + aria-describedby={message ? messageId : undefined} | |
| 155 | + onChange={handleChange} | |
| 156 | + onInput={handleInput} | |
| 157 | + /> | |
| 158 | + | |
| 159 | + {clearable && hasValue && ( | |
| 160 | + <button | |
| 161 | + type="button" | |
| 162 | + className="fox-input__clear" | |
| 163 | + aria-label={clearLabel} | |
| 164 | + onClick={handleClear} | |
| 165 | + > | |
| 166 | + {/* 시안의 XCircle. @fox에 아이콘 세트가 없어 같은 모양을 직접 그린다. */} | |
| 167 | + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> | |
| 168 | + <circle cx="8" cy="8" r="6.25" /> | |
| 169 | + <path d="M6 6l4 4M10 6l-4 4" /> | |
| 170 | + </svg> | |
| 171 | + </button> | |
| 172 | + )} | |
| 173 | + | |
| 174 | + {icon && ( | |
| 175 | + <span className="fox-input__icon" aria-hidden="true"> | |
| 176 | + {icon} | |
| 177 | + </span> | |
| 178 | + )} | |
| 179 | + </div> | |
| 180 | + | |
| 181 | + {message && ( | |
| 182 | + <p className="fox-input__message" id={messageId}> | |
| 183 | + {messageIcon && ( | |
| 184 | + <span className="fox-input__message-icon" aria-hidden="true"> | |
| 185 | + {messageIcon} | |
| 186 | + </span> | |
| 187 | + )} | |
| 188 | + {message} | |
| 189 | + </p> | |
| 190 | + )} | |
| 191 | + </div> | |
| 192 | + ); | |
| 193 | +} |
+++ @fox/core/components/fox-input/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxInput, type FoxInputProps, type FoxInputSize, type FoxInputState } from "./fox-input"; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -4,3 +4,4 @@ |
| 4 | 4 |
export * from "./fox-icon-button"; |
| 5 | 5 |
export * from "./fox-button-group"; |
| 6 | 6 |
export * from "./fox-button-panel"; |
| 7 |
+export * from "./fox-input"; |
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -7,6 +7,7 @@ |
| 7 | 7 |
import { FoxIconButton } from "../core/components/fox-icon-button";
|
| 8 | 8 |
import { FoxButtonGroup } from "../core/components/fox-button-group";
|
| 9 | 9 |
import { FoxButtonPanel } from "../core/components/fox-button-panel";
|
| 10 |
+import { FoxInput, type FoxInputState } from "../core/components/fox-input";
|
|
| 10 | 11 |
import styles from "./dev-test.module.scss"; |
| 11 | 12 |
|
| 12 | 13 |
export interface ComponentExample {
|
... | ... | @@ -50,6 +51,40 @@ |
| 50 | 51 |
> |
| 51 | 52 |
{children}
|
| 52 | 53 |
</span> |
| 54 |
+ ); |
|
| 55 |
+} |
|
| 56 |
+ |
|
| 57 |
+// 입력 예제는 폭이 있어야 시안(360px)처럼 보인다. |
|
| 58 |
+function InputStage({ children }: { children: ReactNode }) {
|
|
| 59 |
+ return <div className={styles.demoField}>{children}</div>;
|
|
| 60 |
+} |
|
| 61 |
+ |
|
| 62 |
+// 지우기 버튼과 자동 상태 판정을 실제로 눌러 보려면 예제가 값을 들고 있어야 한다. |
|
| 63 |
+function InputLiveDemo() {
|
|
| 64 |
+ const [text, setText] = useState("지우기를 눌러보세요");
|
|
| 65 |
+ return ( |
|
| 66 |
+ <> |
|
| 67 |
+ <InputStage> |
|
| 68 |
+ <FoxInput |
|
| 69 |
+ size="md" |
|
| 70 |
+ label="자동 상태" |
|
| 71 |
+ placeholder="입력하면 글자가 진해집니다" |
|
| 72 |
+ message="포커스하면 도움말이 파랗게 바뀝니다" |
|
| 73 |
+ value={text}
|
|
| 74 |
+ onChange={setText}
|
|
| 75 |
+ clearable |
|
| 76 |
+ /> |
|
| 77 |
+ </InputStage> |
|
| 78 |
+ <InputStage> |
|
| 79 |
+ <FoxInput |
|
| 80 |
+ size="md" |
|
| 81 |
+ label="오류 (invalid)" |
|
| 82 |
+ placeholder="내용을 입력하세요." |
|
| 83 |
+ message="필수 항목입니다" |
|
| 84 |
+ invalid |
|
| 85 |
+ /> |
|
| 86 |
+ </InputStage> |
|
| 87 |
+ </> |
|
| 53 | 88 |
); |
| 54 | 89 |
} |
| 55 | 90 |
|
... | ... | @@ -513,4 +548,105 @@ |
| 513 | 548 |
}, |
| 514 | 549 |
], |
| 515 | 550 |
}, |
| 551 |
+ {
|
|
| 552 |
+ id: "fox-input", |
|
| 553 |
+ name: "FoxInput", |
|
| 554 |
+ description: |
|
| 555 |
+ "Figma 시안(input, 컴포넌트 세트 135:621)을 옮겼습니다. 네이티브 <input>을 감싸므로 시안에 없는 password·email·number 등 모든 타입을 그대로 받습니다. 상태는 기본적으로 브라우저가 판단하고(포커스·값·비활성·aria-invalid), state를 주면 그 모양으로 고정됩니다. 라벨은 입력 크기와 무관하게 항상 같은 크기입니다.", |
|
| 556 |
+ variants: [ |
|
| 557 |
+ {
|
|
| 558 |
+ label: "직접 조작 — 입력·포커스·지우기", |
|
| 559 |
+ node: <InputLiveDemo />, |
|
| 560 |
+ }, |
|
| 561 |
+ {
|
|
| 562 |
+ label: "크기 (size) — 상자·글자·아이콘이 함께 줄고 라벨만 고정", |
|
| 563 |
+ node: ( |
|
| 564 |
+ <> |
|
| 565 |
+ {(["lg", "md", "sm"] as const).map((size) => (
|
|
| 566 |
+ <InputStage key={size}>
|
|
| 567 |
+ <FoxInput size={size} label={`레이블 (${size})`} placeholder="내용을 입력하세요." />
|
|
| 568 |
+ </InputStage> |
|
| 569 |
+ ))} |
|
| 570 |
+ </> |
|
| 571 |
+ ), |
|
| 572 |
+ }, |
|
| 573 |
+ {
|
|
| 574 |
+ label: "상태 고정 (state) — 시안 6종을 그대로 비교", |
|
| 575 |
+ node: ( |
|
| 576 |
+ <> |
|
| 577 |
+ {(
|
|
| 578 |
+ ["default", "focused", "completed", "error", "disabled", "view"] as FoxInputState[] |
|
| 579 |
+ ).map((state) => ( |
|
| 580 |
+ <InputStage key={state}>
|
|
| 581 |
+ <FoxInput |
|
| 582 |
+ size="md" |
|
| 583 |
+ state={state}
|
|
| 584 |
+ label={state}
|
|
| 585 |
+ placeholder="내용을 입력하세요." |
|
| 586 |
+ message="힌트 메시지" |
|
| 587 |
+ /> |
|
| 588 |
+ </InputStage> |
|
| 589 |
+ ))} |
|
| 590 |
+ </> |
|
| 591 |
+ ), |
|
| 592 |
+ }, |
|
| 593 |
+ {
|
|
| 594 |
+ label: "타입 — 시안에 없는 것도 네이티브 그대로 동작한다", |
|
| 595 |
+ node: ( |
|
| 596 |
+ <> |
|
| 597 |
+ {(["text", "date", "password", "email", "number", "time"] as const).map((type) => (
|
|
| 598 |
+ <InputStage key={type}>
|
|
| 599 |
+ <FoxInput size="md" type={type} label={type} placeholder="내용을 입력하세요." />
|
|
| 600 |
+ </InputStage> |
|
| 601 |
+ ))} |
|
| 602 |
+ </> |
|
| 603 |
+ ), |
|
| 604 |
+ }, |
|
| 605 |
+ {
|
|
| 606 |
+ label: "아이콘 · 헬퍼 아이콘 슬롯", |
|
| 607 |
+ node: ( |
|
| 608 |
+ <> |
|
| 609 |
+ <InputStage> |
|
| 610 |
+ <FoxInput |
|
| 611 |
+ size="md" |
|
| 612 |
+ label="오른쪽 아이콘" |
|
| 613 |
+ placeholder="내용을 입력하세요." |
|
| 614 |
+ icon={<DemoIcon />}
|
|
| 615 |
+ /> |
|
| 616 |
+ </InputStage> |
|
| 617 |
+ <InputStage> |
|
| 618 |
+ <FoxInput |
|
| 619 |
+ size="md" |
|
| 620 |
+ label="헬퍼 아이콘" |
|
| 621 |
+ placeholder="내용을 입력하세요." |
|
| 622 |
+ message="아이콘은 호출부가 넣습니다" |
|
| 623 |
+ messageIcon={<DemoIcon />}
|
|
| 624 |
+ /> |
|
| 625 |
+ </InputStage> |
|
| 626 |
+ </> |
|
| 627 |
+ ), |
|
| 628 |
+ }, |
|
| 629 |
+ {
|
|
| 630 |
+ label: "비활성 · 읽기 전용 — 네이티브 속성으로 자동 판정", |
|
| 631 |
+ node: ( |
|
| 632 |
+ <> |
|
| 633 |
+ <InputStage> |
|
| 634 |
+ <FoxInput size="md" label="disabled" defaultValue="수정할 수 없음" disabled /> |
|
| 635 |
+ </InputStage> |
|
| 636 |
+ <InputStage> |
|
| 637 |
+ <FoxInput size="md" label="readOnly" defaultValue="읽기 전용" readOnly /> |
|
| 638 |
+ </InputStage> |
|
| 639 |
+ </> |
|
| 640 |
+ ), |
|
| 641 |
+ }, |
|
| 642 |
+ {
|
|
| 643 |
+ label: "라벨·헬퍼 없이도 쓸 수 있다", |
|
| 644 |
+ node: ( |
|
| 645 |
+ <InputStage> |
|
| 646 |
+ <FoxInput size="md" placeholder="내용을 입력하세요." /> |
|
| 647 |
+ </InputStage> |
|
| 648 |
+ ), |
|
| 649 |
+ }, |
|
| 650 |
+ ], |
|
| 651 |
+ }, |
|
| 516 | 652 |
]; |
--- @fox/dev-test/dev-test.module.scss
+++ @fox/dev-test/dev-test.module.scss
... | ... | @@ -439,3 +439,9 @@ |
| 439 | 439 |
inline-size: 20rem; |
| 440 | 440 |
max-inline-size: 100%; |
| 441 | 441 |
} |
| 442 |
+ |
|
| 443 |
+// 입력 예제용. 시안 폭(360px)에 맞춰야 실제 비율로 보인다. |
|
| 444 |
+.demoField {
|
|
| 445 |
+ inline-size: 36rem; |
|
| 446 |
+ max-inline-size: 100%; |
|
| 447 |
+} |
+++ @fox/styles/_fox-input.scss
... | ... | @@ -0,0 +1,285 @@ |
| 1 | +// FoxInput — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma input (컴포넌트 세트 135:621) | |
| 3 | +// | |
| 4 | +// 마크업 계약 (React 밖 소비자용): | |
| 5 | +// <div class="fox-input fox-input--md"> | |
| 6 | +// <label class="fox-input__label" for="…">레이블</label> | |
| 7 | +// <div class="fox-input__box"> | |
| 8 | +// <input class="fox-input__field" id="…" placeholder="…"> | |
| 9 | +// <button class="fox-input__clear" type="button">…svg…</button> <!-- 선택 --> | |
| 10 | +// <span class="fox-input__icon">…svg…</span> <!-- 선택 --> | |
| 11 | +// </div> | |
| 12 | +// <p class="fox-input__message">…</p> <!-- 선택 --> | |
| 13 | +// </div> | |
| 14 | +// | |
| 15 | +// 상태는 두 갈래다. | |
| 16 | +// - 기본(자동): 브라우저가 판단한다. 포커스는 `:focus-within`, 비활성·읽기전용은 네이티브 | |
| 17 | +// 속성, 오류는 `aria-invalid="true"`. 값이 들어찬 모습(시안의 completed)은 따로 규칙이 | |
| 18 | +// 필요 없다 — 시안에서 default와 completed의 차이가 글자색뿐인데 그건 플레이스홀더와 | |
| 19 | +// 입력값의 네이티브 차이다. | |
| 20 | +// - 고정: 루트에 `data-state`를 주면 그 모양으로 못박힌다. 자동 규칙은 전부 꺼진다. | |
| 21 | +// 카탈로그·스냅샷처럼 상태를 눈으로 비교해야 할 때 쓴다. | |
| 22 | +// | |
| 23 | +// 라벨은 입력 크기와 무관하게 항상 label/md다(시안의 form-label이 공용 컴포넌트라 크기를 | |
| 24 | +// 따라가지 않는다). | |
| 25 | +// | |
| 26 | +// 아이콘 크기 24/20/16은 시안이 변수를 걸지 않았지만 상자의 나머지 치수가 전부 `form/*` | |
| 27 | +// 반응형 토큰이라 아이콘도 `form-icon-*`(PC 24/20/16 · 모바일 20/16/12)에 맞춘다. | |
| 28 | +// | |
| 29 | +// 토큰이 아닌 값(FoxButton과 동일한 근거로 승인된 항목): | |
| 30 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 export하지 않는다. | |
| 31 | +// - 헬퍼 메시지의 line-height 1.5: 위와 같다. | |
| 32 | +// - box-sizing / cursor / flex / appearance 관련: 구조·상호작용 규칙. | |
| 33 | + | |
| 34 | +@use "abstracts" as fox; | |
| 35 | + | |
| 36 | +.fox-input { | |
| 37 | + display: flex; | |
| 38 | + flex-direction: column; | |
| 39 | + gap: fox.gap(3); | |
| 40 | + inline-size: 100%; | |
| 41 | + | |
| 42 | + &__label { | |
| 43 | + color: fox.color(font-neutral-default); | |
| 44 | + font-family: fox.font-family(body); | |
| 45 | + font-size: fox.font-size(label-md); | |
| 46 | + font-weight: fox.font-weight(medium); | |
| 47 | + line-height: 1; | |
| 48 | + letter-spacing: -0.025em; | |
| 49 | + } | |
| 50 | + | |
| 51 | + &__box { | |
| 52 | + box-sizing: border-box; | |
| 53 | + display: flex; | |
| 54 | + align-items: center; | |
| 55 | + gap: fox.gap(3); | |
| 56 | + inline-size: 100%; | |
| 57 | + background: fox.color(input-surface); | |
| 58 | + border: fox.border(1) solid fox.color(input-border); | |
| 59 | + } | |
| 60 | + | |
| 61 | + &__field { | |
| 62 | + flex: 1 0 0; | |
| 63 | + min-inline-size: 0; | |
| 64 | + margin: 0; | |
| 65 | + padding: 0; | |
| 66 | + border: none; | |
| 67 | + background: none; | |
| 68 | + color: fox.color(font-neutral-default); | |
| 69 | + font-family: fox.font-family(body); | |
| 70 | + font-weight: fox.font-weight(regular); | |
| 71 | + line-height: 1; | |
| 72 | + letter-spacing: -0.025em; | |
| 73 | + | |
| 74 | + &::placeholder { | |
| 75 | + color: fox.color(font-neutral-subtler); | |
| 76 | + } | |
| 77 | + | |
| 78 | + // 포커스는 상자 테두리가 표시하므로 기본 외곽선을 지운다. | |
| 79 | + &:focus { | |
| 80 | + outline: none; | |
| 81 | + } | |
| 82 | + | |
| 83 | + &:disabled { | |
| 84 | + color: fox.color(font-neutral-disabled-strong); | |
| 85 | + cursor: default; | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + &__icon, | |
| 90 | + &__clear { | |
| 91 | + display: inline-flex; | |
| 92 | + flex-shrink: 0; | |
| 93 | + align-items: center; | |
| 94 | + justify-content: center; | |
| 95 | + color: fox.color(icon-neutral-default); | |
| 96 | + } | |
| 97 | + | |
| 98 | + &__icon > *, | |
| 99 | + &__clear > * { | |
| 100 | + inline-size: 100%; | |
| 101 | + block-size: 100%; | |
| 102 | + } | |
| 103 | + | |
| 104 | + &__clear { | |
| 105 | + margin: 0; | |
| 106 | + padding: 0; | |
| 107 | + border: none; | |
| 108 | + background: none; | |
| 109 | + cursor: pointer; | |
| 110 | + } | |
| 111 | + | |
| 112 | + &__message { | |
| 113 | + display: flex; | |
| 114 | + gap: fox.number(3); | |
| 115 | + align-items: flex-start; | |
| 116 | + margin: 0; | |
| 117 | + color: fox.color(font-neutral-subtle); | |
| 118 | + font-family: fox.font-family(body); | |
| 119 | + font-size: fox.font-size(body-sm); | |
| 120 | + font-weight: fox.font-weight(regular); | |
| 121 | + line-height: 1.5; | |
| 122 | + letter-spacing: -0.025em; | |
| 123 | + } | |
| 124 | + | |
| 125 | + &__message-icon { | |
| 126 | + display: inline-flex; | |
| 127 | + flex-shrink: 0; | |
| 128 | + align-items: flex-start; | |
| 129 | + padding-block-start: fox.padding(1); | |
| 130 | + | |
| 131 | + > * { | |
| 132 | + inline-size: fox.icon(3); | |
| 133 | + block-size: fox.icon(3); | |
| 134 | + } | |
| 135 | + } | |
| 136 | + | |
| 137 | + // ── 크기 ────────────────────────────────────────────────────────────────── | |
| 138 | + &--lg &__box { | |
| 139 | + block-size: fox.form(height-lg); | |
| 140 | + padding-inline: fox.form(padding-lg); | |
| 141 | + border-radius: fox.form(radius-lg); | |
| 142 | + } | |
| 143 | + | |
| 144 | + &--lg &__field { | |
| 145 | + font-size: fox.font-size(label-lg); | |
| 146 | + } | |
| 147 | + | |
| 148 | + &--lg &__icon, | |
| 149 | + &--lg &__clear { | |
| 150 | + inline-size: fox.form(icon-lg); | |
| 151 | + block-size: fox.form(icon-lg); | |
| 152 | + } | |
| 153 | + | |
| 154 | + &--md &__box { | |
| 155 | + block-size: fox.form(height-md); | |
| 156 | + padding-inline: fox.form(padding-md); | |
| 157 | + border-radius: fox.form(radius-md); | |
| 158 | + } | |
| 159 | + | |
| 160 | + &--md &__field { | |
| 161 | + font-size: fox.font-size(label-md); | |
| 162 | + } | |
| 163 | + | |
| 164 | + &--md &__icon, | |
| 165 | + &--md &__clear { | |
| 166 | + inline-size: fox.form(icon-md); | |
| 167 | + block-size: fox.form(icon-md); | |
| 168 | + } | |
| 169 | + | |
| 170 | + &--sm &__box { | |
| 171 | + block-size: fox.form(height-sm); | |
| 172 | + padding-inline: fox.form(padding-sm); | |
| 173 | + border-radius: fox.form(radius-sm); | |
| 174 | + } | |
| 175 | + | |
| 176 | + &--sm &__field { | |
| 177 | + font-size: fox.font-size(label-sm); | |
| 178 | + } | |
| 179 | + | |
| 180 | + &--sm &__icon, | |
| 181 | + &--sm &__clear { | |
| 182 | + inline-size: fox.form(icon-sm); | |
| 183 | + block-size: fox.form(icon-sm); | |
| 184 | + } | |
| 185 | + | |
| 186 | + // 지우기 버튼은 포커스 중에만 보인다(시안에서 focused 변형에만 있다). | |
| 187 | + &__clear { | |
| 188 | + display: none; | |
| 189 | + } | |
| 190 | + | |
| 191 | + &__box:focus-within &__clear { | |
| 192 | + display: inline-flex; | |
| 193 | + } | |
| 194 | +} | |
| 195 | + | |
| 196 | +// ── 상태 · 자동 ───────────────────────────────────────────────────────────── | |
| 197 | +// 뒤에 오는 규칙이 이긴다: 포커스 < 오류 < 읽기전용 < 비활성. | |
| 198 | +.fox-input:not([data-state]) { | |
| 199 | + .fox-input__box:focus-within { | |
| 200 | + border-width: fox.border(2); | |
| 201 | + border-color: fox.color(input-border-active); | |
| 202 | + } | |
| 203 | + | |
| 204 | + .fox-input__box:has(.fox-input__field[aria-invalid="true"]) { | |
| 205 | + border-width: fox.border(2); | |
| 206 | + border-color: fox.color(input-border-error); | |
| 207 | + } | |
| 208 | + | |
| 209 | + // 비활성도 `:read-only`에 걸리므로 떼어낸다. | |
| 210 | + .fox-input__box:has(.fox-input__field:read-only:not(:disabled)) { | |
| 211 | + border-width: fox.border(1); | |
| 212 | + border-color: fox.color(input-border-disabled); | |
| 213 | + background: fox.color(input-surface-disabled); | |
| 214 | + } | |
| 215 | + | |
| 216 | + .fox-input__box:has(.fox-input__field:disabled) { | |
| 217 | + border-width: fox.border(1); | |
| 218 | + border-color: fox.color(input-border-disabled); | |
| 219 | + background: fox.color(input-surface-disabled); | |
| 220 | + } | |
| 221 | + | |
| 222 | + &:focus-within .fox-input__message { | |
| 223 | + color: fox.color(font-system-information); | |
| 224 | + } | |
| 225 | + | |
| 226 | + &:has(.fox-input__field[aria-invalid="true"]) .fox-input__message { | |
| 227 | + color: fox.color(font-system-danger); | |
| 228 | + } | |
| 229 | +} | |
| 230 | + | |
| 231 | +// ── 상태 · 고정 ───────────────────────────────────────────────────────────── | |
| 232 | +// 시안의 focused·completed·error·view는 값이 들어찬 모습이라 플레이스홀더도 진하게 둔다. | |
| 233 | +.fox-input[data-state="default"] .fox-input__box, | |
| 234 | +.fox-input[data-state="completed"] .fox-input__box { | |
| 235 | + border-width: fox.border(1); | |
| 236 | + border-color: fox.color(input-border); | |
| 237 | + background: fox.color(input-surface); | |
| 238 | +} | |
| 239 | + | |
| 240 | +.fox-input[data-state="focused"] .fox-input__box { | |
| 241 | + border-width: fox.border(2); | |
| 242 | + border-color: fox.color(input-border-active); | |
| 243 | + background: fox.color(input-surface); | |
| 244 | +} | |
| 245 | + | |
| 246 | +.fox-input[data-state="error"] .fox-input__box { | |
| 247 | + border-width: fox.border(2); | |
| 248 | + border-color: fox.color(input-border-error); | |
| 249 | + background: fox.color(input-surface); | |
| 250 | +} | |
| 251 | + | |
| 252 | +.fox-input[data-state="view"] .fox-input__box, | |
| 253 | +.fox-input[data-state="disabled"] .fox-input__box { | |
| 254 | + border-width: fox.border(1); | |
| 255 | + border-color: fox.color(input-border-disabled); | |
| 256 | + background: fox.color(input-surface-disabled); | |
| 257 | +} | |
| 258 | + | |
| 259 | +.fox-input[data-state="completed"] .fox-input__field::placeholder, | |
| 260 | +.fox-input[data-state="focused"] .fox-input__field::placeholder, | |
| 261 | +.fox-input[data-state="error"] .fox-input__field::placeholder, | |
| 262 | +.fox-input[data-state="view"] .fox-input__field::placeholder { | |
| 263 | + color: fox.color(font-neutral-default); | |
| 264 | +} | |
| 265 | + | |
| 266 | +.fox-input[data-state="disabled"] .fox-input__field, | |
| 267 | +.fox-input[data-state="disabled"] .fox-input__field::placeholder { | |
| 268 | + color: fox.color(font-neutral-disabled-strong); | |
| 269 | +} | |
| 270 | + | |
| 271 | +.fox-input[data-state="focused"] .fox-input__message { | |
| 272 | + color: fox.color(font-system-information); | |
| 273 | +} | |
| 274 | + | |
| 275 | +.fox-input[data-state="completed"] .fox-input__message { | |
| 276 | + color: fox.color(font-system-success); | |
| 277 | +} | |
| 278 | + | |
| 279 | +.fox-input[data-state="error"] .fox-input__message { | |
| 280 | + color: fox.color(font-system-danger); | |
| 281 | +} | |
| 282 | + | |
| 283 | +.fox-input[data-state="focused"] .fox-input__clear { | |
| 284 | + display: inline-flex; | |
| 285 | +} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -7,3 +7,4 @@ |
| 7 | 7 |
@use "fox-icon-button"; |
| 8 | 8 |
@use "fox-button-group"; |
| 9 | 9 |
@use "fox-button-panel"; |
| 10 |
+@use "fox-input"; |
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?