feat: FoxTextArea 추가
Figma textarea(135:1655)의 6상태를 옮긴다. 시안에 크기 축이 없어 상자는 하나뿐이고 높이는 rows가 정한다(기본 5줄, 시안과 같음). 상태 판정은 FoxInput과 같은 두 갈래다 — 기본은 브라우저가 판단하고(포커스· 값·네이티브 속성·aria-invalid), data-state를 주면 그 모양으로 고정된다. 상태 매트릭스도 FoxInput과 동일해 규칙을 그대로 옮겼다. 하단은 왼쪽 헬퍼와 오른쪽 글자수 카운터로 나뉜다. 카운터는 현재 길이만 굵고 색이 다르며, 오류일 때 빨개진다(시안의 error 변형이 100/100을 빨갛게 둔다). maxLength가 없으면 개수만 적는다. 비제어 입력의 길이는 직접 따라간다. 리사이즈는 시안에 신호가 없어 폭이 흔들리지 않게 세로만 허용한다. Co-Authored-By: Claude Opus 5
@037705883a4caf1a31b54abb18e098334c8c6401
--- @fox/README.md
+++ @fox/README.md
... | ... | @@ -109,6 +109,19 @@ |
| 109 | 109 |
</div> |
| 110 | 110 |
<p class="fox-input__message"><!-- 헬퍼 (선택) --></p> |
| 111 | 111 |
</div> |
| 112 |
+ |
|
| 113 |
+<div class="fox-text-area"> |
|
| 114 |
+ <label class="fox-text-area__label" for="memo">레이블</label> |
|
| 115 |
+ <div class="fox-text-area__box"> |
|
| 116 |
+ <textarea class="fox-text-area__field" id="memo" rows="5" placeholder="내용을 입력하세요."></textarea> |
|
| 117 |
+ </div> |
|
| 118 |
+ <div class="fox-text-area__footer"> |
|
| 119 |
+ <p class="fox-text-area__message"><!-- 헬퍼 (선택) --></p> |
|
| 120 |
+ <p class="fox-text-area__counter"> |
|
| 121 |
+ <span class="fox-text-area__counter-current">0</span>/100 |
|
| 122 |
+ </p> |
|
| 123 |
+ </div> |
|
| 124 |
+</div> |
|
| 112 | 125 |
``` |
| 113 | 126 |
|
| 114 | 127 |
- 비활성은 네이티브 `disabled` 속성으로 표현합니다(별도 클래스 없음). 앵커에는 `disabled`가 |
... | ... | @@ -121,9 +134,10 @@ |
| 121 | 134 |
- 버튼 패널은 그룹이 여럿이면 양끝으로, 하나면 가운데로 둡니다 — 자식 수는 CSS `:only-child`가 |
| 122 | 135 |
판단하므로 따로 표시할 게 없습니다. 패널 안에서는 그룹 간격이 넓어지고 버튼에 최소 폭이 |
| 123 | 136 |
붙으며, 모바일에서는 패널·그룹이 모두 세로로 쌓입니다. |
| 124 |
-- 입력의 상태는 기본적으로 브라우저가 판단합니다 — 포커스는 `:focus-within`, 비활성·읽기전용은 |
|
| 125 |
- 네이티브 속성, 오류는 `aria-invalid="true"`. 모양을 못박으려면 루트에 `data-state`를 줍니다 |
|
| 126 |
- (`default` `focused` `completed` `error` `disabled` `view`). |
|
| 137 |
+- 입력과 여러 줄 입력의 상태는 기본적으로 브라우저가 판단합니다 — 포커스는 `:focus-within`, |
|
| 138 |
+ 비활성·읽기전용은 네이티브 속성, 오류는 `aria-invalid="true"`. 모양을 못박으려면 루트에 |
|
| 139 |
+ `data-state`를 줍니다 (`default` `focused` `completed` `error` `disabled` `view`). |
|
| 140 |
+- 여러 줄 입력에는 크기 축이 없습니다. 높이는 `rows`가 정하고 시안은 5줄입니다. |
|
| 127 | 141 |
- 아이콘 SVG는 `currentColor`로 그려야 계열별 색이 적용됩니다. 크기는 슬롯이 정합니다. |
| 128 | 142 |
|
| 129 | 143 |
`core/`의 React 컴포넌트는 이 클래스를 조립해주는 **얇은 래퍼**일 뿐입니다 — 쓰지 않아도 |
+++ @fox/core/components/fox-text-area/fox-text-area.tsx
... | ... | @@ -0,0 +1,142 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + useId, | |
| 5 | + useState, | |
| 6 | + type ChangeEvent, | |
| 7 | + type ComponentPropsWithRef, | |
| 8 | + type FormEvent, | |
| 9 | + type ReactNode, | |
| 10 | +} from "react"; | |
| 11 | +import { cx } from "../../utils"; | |
| 12 | + | |
| 13 | +export type FoxTextAreaState = "default" | "focused" | "completed" | "error" | "disabled" | "view"; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * 네이티브 `<textarea>`가 받는 것을 그대로 넘겨받는다 — rows·cols·maxLength·name· | |
| 17 | + * required·readOnly·disabled·autoComplete 등. 이름이 겹치거나 의미가 다른 것만 걷어낸다. | |
| 18 | + */ | |
| 19 | +type NativeTextAreaProps = Omit< | |
| 20 | + ComponentPropsWithRef<"textarea">, | |
| 21 | + "onChange" | "onInput" | "className" | "children" | "hidden" | |
| 22 | +>; | |
| 23 | + | |
| 24 | +export interface FoxTextAreaProps extends NativeTextAreaProps { | |
| 25 | + /** 값이 첫 인자다. */ | |
| 26 | + onChange?: (value: string, event: ChangeEvent<HTMLTextAreaElement>) => void; | |
| 27 | + onInput?: (value: string, event: FormEvent<HTMLTextAreaElement>) => void; | |
| 28 | + label?: string; | |
| 29 | + /** 헬퍼 메시지. 색은 상태를 따라간다. */ | |
| 30 | + message?: string; | |
| 31 | + /** 헬퍼 메시지 앞 아이콘. `currentColor`로 그려야 상태 색을 따라간다. */ | |
| 32 | + messageIcon?: ReactNode; | |
| 33 | + /** 글자 수를 오른쪽 아래에 보여준다. `maxLength`가 있으면 `현재/최대`로 적는다. */ | |
| 34 | + counter?: boolean; | |
| 35 | + /** 오류 표시. `aria-invalid`가 되고 테두리·헬퍼·카운터 색이 바뀐다. */ | |
| 36 | + invalid?: boolean; | |
| 37 | + /** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */ | |
| 38 | + state?: FoxTextAreaState; | |
| 39 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 40 | + hidden?: boolean; | |
| 41 | + /** 배치 조정용. */ | |
| 42 | + className?: string; | |
| 43 | +} | |
| 44 | + | |
| 45 | +/** | |
| 46 | + * @fox 여러 줄 입력. 시안에 크기 축이 없어 높이는 `rows`가 정한다(기본 5줄). | |
| 47 | + * | |
| 48 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 49 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 50 | + */ | |
| 51 | +export function FoxTextArea({ | |
| 52 | + onChange, | |
| 53 | + onInput, | |
| 54 | + label, | |
| 55 | + message, | |
| 56 | + messageIcon, | |
| 57 | + counter = false, | |
| 58 | + invalid = false, | |
| 59 | + state, | |
| 60 | + hidden = false, | |
| 61 | + className, | |
| 62 | + id, | |
| 63 | + rows = 5, | |
| 64 | + value, | |
| 65 | + defaultValue, | |
| 66 | + maxLength, | |
| 67 | + ...rest | |
| 68 | +}: FoxTextAreaProps) { | |
| 69 | + const autoId = useId(); | |
| 70 | + | |
| 71 | + // 카운터는 현재 길이를 알아야 한다. 비제어 입력은 DOM만 알고 있으므로 직접 따라간다. | |
| 72 | + const [typedLength, setTypedLength] = useState(() => String(defaultValue ?? "").length); | |
| 73 | + | |
| 74 | + if (hidden) { | |
| 75 | + return null; | |
| 76 | + } | |
| 77 | + | |
| 78 | + const fieldId = id ?? `${autoId}-field`; | |
| 79 | + const messageId = `${autoId}-message`; | |
| 80 | + const length = value !== undefined ? String(value).length : typedLength; | |
| 81 | + const errored = invalid || state === "error"; | |
| 82 | + const showFooter = Boolean(message) || counter; | |
| 83 | + | |
| 84 | + const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => { | |
| 85 | + if (value === undefined) { | |
| 86 | + setTypedLength(event.target.value.length); | |
| 87 | + } | |
| 88 | + onChange?.(event.target.value, event); | |
| 89 | + }; | |
| 90 | + | |
| 91 | + const handleInput = (event: FormEvent<HTMLTextAreaElement>) => { | |
| 92 | + onInput?.((event.target as HTMLTextAreaElement).value, event); | |
| 93 | + }; | |
| 94 | + | |
| 95 | + return ( | |
| 96 | + <div className={cx("fox-text-area", className)} data-state={state}> | |
| 97 | + {label && ( | |
| 98 | + <label className="fox-text-area__label" htmlFor={fieldId}> | |
| 99 | + {label} | |
| 100 | + </label> | |
| 101 | + )} | |
| 102 | + | |
| 103 | + <div className="fox-text-area__box"> | |
| 104 | + <textarea | |
| 105 | + {...rest} | |
| 106 | + id={fieldId} | |
| 107 | + className="fox-text-area__field" | |
| 108 | + rows={rows} | |
| 109 | + value={value} | |
| 110 | + defaultValue={defaultValue} | |
| 111 | + maxLength={maxLength} | |
| 112 | + aria-invalid={errored || undefined} | |
| 113 | + aria-describedby={message ? messageId : undefined} | |
| 114 | + onChange={handleChange} | |
| 115 | + onInput={handleInput} | |
| 116 | + /> | |
| 117 | + </div> | |
| 118 | + | |
| 119 | + {showFooter && ( | |
| 120 | + <div className="fox-text-area__footer"> | |
| 121 | + {message && ( | |
| 122 | + <p className="fox-text-area__message" id={messageId}> | |
| 123 | + {messageIcon && ( | |
| 124 | + <span className="fox-text-area__message-icon" aria-hidden="true"> | |
| 125 | + {messageIcon} | |
| 126 | + </span> | |
| 127 | + )} | |
| 128 | + {message} | |
| 129 | + </p> | |
| 130 | + )} | |
| 131 | + | |
| 132 | + {counter && ( | |
| 133 | + <p className="fox-text-area__counter"> | |
| 134 | + <span className="fox-text-area__counter-current">{length}</span> | |
| 135 | + {maxLength !== undefined && `/${maxLength}`} | |
| 136 | + </p> | |
| 137 | + )} | |
| 138 | + </div> | |
| 139 | + )} | |
| 140 | + </div> | |
| 141 | + ); | |
| 142 | +} |
+++ @fox/core/components/fox-text-area/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxTextArea, type FoxTextAreaProps, type FoxTextAreaState } from "./fox-text-area"; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -5,3 +5,4 @@ |
| 5 | 5 |
export * from "./fox-button-group"; |
| 6 | 6 |
export * from "./fox-button-panel"; |
| 7 | 7 |
export * from "./fox-input"; |
| 8 |
+export * from "./fox-text-area"; |
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -8,6 +8,7 @@ |
| 8 | 8 |
import { FoxButtonGroup } from "../core/components/fox-button-group";
|
| 9 | 9 |
import { FoxButtonPanel } from "../core/components/fox-button-panel";
|
| 10 | 10 |
import { FoxInput, type FoxInputState } from "../core/components/fox-input";
|
| 11 |
+import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
|
|
| 11 | 12 |
import styles from "./dev-test.module.scss"; |
| 12 | 13 |
|
| 13 | 14 |
export interface ComponentExample {
|
... | ... | @@ -81,6 +82,37 @@ |
| 81 | 82 |
label="오류 (invalid)" |
| 82 | 83 |
placeholder="내용을 입력하세요." |
| 83 | 84 |
message="필수 항목입니다" |
| 85 |
+ invalid |
|
| 86 |
+ /> |
|
| 87 |
+ </InputStage> |
|
| 88 |
+ </> |
|
| 89 |
+ ); |
|
| 90 |
+} |
|
| 91 |
+ |
|
| 92 |
+// 카운터가 실제로 올라가는 걸 보려면 예제가 값을 들고 있어야 한다. |
|
| 93 |
+function TextAreaLiveDemo() {
|
|
| 94 |
+ const [text, setText] = useState("입력하면 아래 숫자가 따라 올라갑니다.");
|
|
| 95 |
+ return ( |
|
| 96 |
+ <> |
|
| 97 |
+ <InputStage> |
|
| 98 |
+ <FoxTextArea |
|
| 99 |
+ label="자동 상태 · 카운터" |
|
| 100 |
+ placeholder="내용을 입력하세요." |
|
| 101 |
+ message="포커스하면 도움말이 파랗게 바뀝니다" |
|
| 102 |
+ value={text}
|
|
| 103 |
+ onChange={setText}
|
|
| 104 |
+ maxLength={100}
|
|
| 105 |
+ counter |
|
| 106 |
+ /> |
|
| 107 |
+ </InputStage> |
|
| 108 |
+ <InputStage> |
|
| 109 |
+ <FoxTextArea |
|
| 110 |
+ label="오류 (invalid)" |
|
| 111 |
+ placeholder="내용을 입력하세요." |
|
| 112 |
+ message="100자를 넘길 수 없습니다" |
|
| 113 |
+ defaultValue={"x".repeat(100)}
|
|
| 114 |
+ maxLength={100}
|
|
| 115 |
+ counter |
|
| 84 | 116 |
invalid |
| 85 | 117 |
/> |
| 86 | 118 |
</InputStage> |
... | ... | @@ -649,4 +681,82 @@ |
| 649 | 681 |
}, |
| 650 | 682 |
], |
| 651 | 683 |
}, |
| 684 |
+ {
|
|
| 685 |
+ id: "fox-text-area", |
|
| 686 |
+ name: "FoxTextArea", |
|
| 687 |
+ description: |
|
| 688 |
+ "Figma 시안(textarea 135:1655)을 옮겼습니다. 시안에 크기 축이 없어 상자는 하나뿐이고 높이는 rows가 정합니다(기본 5줄, 시안과 같음). 상태 판정은 FoxInput과 같은 방식입니다 — 기본은 브라우저가 판단하고 state를 주면 고정됩니다. 하단은 왼쪽 헬퍼와 오른쪽 글자수 카운터로 나뉘고, 오류일 때 현재 글자수가 빨개집니다.", |
|
| 689 |
+ variants: [ |
|
| 690 |
+ {
|
|
| 691 |
+ label: "직접 조작 — 입력·포커스·카운터", |
|
| 692 |
+ node: <TextAreaLiveDemo />, |
|
| 693 |
+ }, |
|
| 694 |
+ {
|
|
| 695 |
+ label: "상태 고정 (state) — 시안 6종을 그대로 비교", |
|
| 696 |
+ node: ( |
|
| 697 |
+ <> |
|
| 698 |
+ {(
|
|
| 699 |
+ ["default", "focused", "completed", "error", "disabled", "view"] as FoxTextAreaState[] |
|
| 700 |
+ ).map((state) => ( |
|
| 701 |
+ <InputStage key={state}>
|
|
| 702 |
+ <FoxTextArea |
|
| 703 |
+ state={state}
|
|
| 704 |
+ label={state}
|
|
| 705 |
+ placeholder="내용을 입력하세요." |
|
| 706 |
+ message="힌트 메시지" |
|
| 707 |
+ defaultValue={""}
|
|
| 708 |
+ maxLength={100}
|
|
| 709 |
+ counter |
|
| 710 |
+ /> |
|
| 711 |
+ </InputStage> |
|
| 712 |
+ ))} |
|
| 713 |
+ </> |
|
| 714 |
+ ), |
|
| 715 |
+ }, |
|
| 716 |
+ {
|
|
| 717 |
+ label: "줄 수 (rows)", |
|
| 718 |
+ node: ( |
|
| 719 |
+ <> |
|
| 720 |
+ {[3, 5, 8].map((rows) => (
|
|
| 721 |
+ <InputStage key={rows}>
|
|
| 722 |
+ <FoxTextArea rows={rows} label={`rows=${rows}`} placeholder="내용을 입력하세요." />
|
|
| 723 |
+ </InputStage> |
|
| 724 |
+ ))} |
|
| 725 |
+ </> |
|
| 726 |
+ ), |
|
| 727 |
+ }, |
|
| 728 |
+ {
|
|
| 729 |
+ label: "헬퍼·카운터는 따로 켠다", |
|
| 730 |
+ node: ( |
|
| 731 |
+ <> |
|
| 732 |
+ <InputStage> |
|
| 733 |
+ <FoxTextArea label="헬퍼만" placeholder="내용을 입력하세요." message="힌트 메시지" /> |
|
| 734 |
+ </InputStage> |
|
| 735 |
+ <InputStage> |
|
| 736 |
+ <FoxTextArea label="카운터만" placeholder="내용을 입력하세요." maxLength={100} counter />
|
|
| 737 |
+ </InputStage> |
|
| 738 |
+ <InputStage> |
|
| 739 |
+ <FoxTextArea label="최대 없이 개수만" placeholder="내용을 입력하세요." counter /> |
|
| 740 |
+ </InputStage> |
|
| 741 |
+ <InputStage> |
|
| 742 |
+ <FoxTextArea label="둘 다 없음" placeholder="내용을 입력하세요." /> |
|
| 743 |
+ </InputStage> |
|
| 744 |
+ </> |
|
| 745 |
+ ), |
|
| 746 |
+ }, |
|
| 747 |
+ {
|
|
| 748 |
+ label: "비활성 · 읽기 전용 — 네이티브 속성으로 자동 판정", |
|
| 749 |
+ node: ( |
|
| 750 |
+ <> |
|
| 751 |
+ <InputStage> |
|
| 752 |
+ <FoxTextArea label="disabled" defaultValue="수정할 수 없음" disabled /> |
|
| 753 |
+ </InputStage> |
|
| 754 |
+ <InputStage> |
|
| 755 |
+ <FoxTextArea label="readOnly" defaultValue="읽기 전용" readOnly /> |
|
| 756 |
+ </InputStage> |
|
| 757 |
+ </> |
|
| 758 |
+ ), |
|
| 759 |
+ }, |
|
| 760 |
+ ], |
|
| 761 |
+ }, |
|
| 652 | 762 |
]; |
+++ @fox/styles/_fox-text-area.scss
... | ... | @@ -0,0 +1,240 @@ |
| 1 | +// FoxTextArea — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma textarea (135:1655 · 섹션 391:3107) | |
| 3 | +// | |
| 4 | +// 마크업 계약 (React 밖 소비자용): | |
| 5 | +// <div class="fox-text-area"> | |
| 6 | +// <label class="fox-text-area__label" for="…">레이블</label> | |
| 7 | +// <div class="fox-text-area__box"> | |
| 8 | +// <textarea class="fox-text-area__field" id="…" rows="5" placeholder="…"></textarea> | |
| 9 | +// </div> | |
| 10 | +// <div class="fox-text-area__footer"> | |
| 11 | +// <p class="fox-text-area__message">…</p> <!-- 선택 --> | |
| 12 | +// <p class="fox-text-area__counter"> | |
| 13 | +// <span class="fox-text-area__counter-current">80</span>/100 | |
| 14 | +// </p> <!-- 선택 --> | |
| 15 | +// </div> | |
| 16 | +// </div> | |
| 17 | +// | |
| 18 | +// 상태는 FoxInput과 같은 두 갈래다. | |
| 19 | +// - 기본(자동): 포커스는 `:focus-within`, 비활성·읽기전용은 네이티브 속성, 오류는 | |
| 20 | +// `aria-invalid="true"`. 시안의 completed는 default와 글자색만 다른데 그건 | |
| 21 | +// 플레이스홀더와 입력값의 네이티브 차이라 규칙이 필요 없다. | |
| 22 | +// - 고정: 루트에 `data-state`를 주면 그 모양으로 못박힌다. | |
| 23 | +// | |
| 24 | +// 시안에 크기 축이 없다 — 상자 하나뿐이고 높이는 줄 수가 정한다(시안은 5줄). | |
| 25 | +// | |
| 26 | +// 토큰이 아닌 값(FoxInput과 동일한 근거로 승인된 항목): | |
| 27 | +// - line-height(본문·헬퍼 1.5, 라벨·카운터 1) / letter-spacing: 시안 텍스트 스타일의 | |
| 28 | +// 값이나 Figma가 변수로 export하지 않는다. | |
| 29 | +// - box-sizing / resize / flex 관련: 구조·상호작용 규칙. 시안은 리사이즈 핸들을 보여주지 | |
| 30 | +// 않지만 폭이 흔들리지 않도록 세로만 허용한다. | |
| 31 | + | |
| 32 | +@use "abstracts" as fox; | |
| 33 | + | |
| 34 | +.fox-text-area { | |
| 35 | + display: flex; | |
| 36 | + flex-direction: column; | |
| 37 | + gap: fox.gap(3); | |
| 38 | + inline-size: 100%; | |
| 39 | + | |
| 40 | + &__label { | |
| 41 | + color: fox.color(font-neutral-default); | |
| 42 | + font-family: fox.font-family(body); | |
| 43 | + font-size: fox.font-size(label-md); | |
| 44 | + font-weight: fox.font-weight(medium); | |
| 45 | + line-height: 1; | |
| 46 | + letter-spacing: -0.025em; | |
| 47 | + } | |
| 48 | + | |
| 49 | + &__box { | |
| 50 | + box-sizing: border-box; | |
| 51 | + display: flex; | |
| 52 | + gap: fox.gap(3); | |
| 53 | + inline-size: 100%; | |
| 54 | + padding-inline: fox.form(padding-md); | |
| 55 | + padding-block: fox.padding(5); | |
| 56 | + border: fox.border(1) solid fox.color(input-border); | |
| 57 | + border-radius: fox.form(radius-lg); | |
| 58 | + background: fox.color(input-surface); | |
| 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 | + resize: vertical; | |
| 69 | + color: fox.color(font-neutral-default); | |
| 70 | + font-family: fox.font-family(body); | |
| 71 | + font-size: fox.font-size(body-md); | |
| 72 | + font-weight: fox.font-weight(regular); | |
| 73 | + line-height: 1.5; | |
| 74 | + letter-spacing: -0.025em; | |
| 75 | + | |
| 76 | + &::placeholder { | |
| 77 | + color: fox.color(font-neutral-subtler); | |
| 78 | + } | |
| 79 | + | |
| 80 | + // 포커스는 상자 테두리가 표시하므로 기본 외곽선을 지운다. | |
| 81 | + &:focus { | |
| 82 | + outline: none; | |
| 83 | + } | |
| 84 | + | |
| 85 | + &:disabled { | |
| 86 | + color: fox.color(font-neutral-disabled-strong); | |
| 87 | + cursor: default; | |
| 88 | + } | |
| 89 | + } | |
| 90 | + | |
| 91 | + // 헬퍼는 왼쪽에서 남는 폭을 먹고 카운터는 오른쪽에 붙는다. | |
| 92 | + &__footer { | |
| 93 | + display: flex; | |
| 94 | + gap: fox.gap(5); | |
| 95 | + align-items: center; | |
| 96 | + justify-content: flex-end; | |
| 97 | + inline-size: 100%; | |
| 98 | + } | |
| 99 | + | |
| 100 | + &__message { | |
| 101 | + display: flex; | |
| 102 | + flex: 1 0 0; | |
| 103 | + gap: fox.number(3); | |
| 104 | + align-items: flex-start; | |
| 105 | + min-inline-size: 0; | |
| 106 | + margin: 0; | |
| 107 | + color: fox.color(font-neutral-subtle); | |
| 108 | + font-family: fox.font-family(body); | |
| 109 | + font-size: fox.font-size(body-sm); | |
| 110 | + font-weight: fox.font-weight(regular); | |
| 111 | + line-height: 1.5; | |
| 112 | + letter-spacing: -0.025em; | |
| 113 | + } | |
| 114 | + | |
| 115 | + &__message-icon { | |
| 116 | + display: inline-flex; | |
| 117 | + flex-shrink: 0; | |
| 118 | + align-items: flex-start; | |
| 119 | + padding-block-start: fox.padding(1); | |
| 120 | + | |
| 121 | + > * { | |
| 122 | + inline-size: fox.icon(3); | |
| 123 | + block-size: fox.icon(3); | |
| 124 | + } | |
| 125 | + } | |
| 126 | + | |
| 127 | + &__counter { | |
| 128 | + flex-shrink: 0; | |
| 129 | + margin: 0; | |
| 130 | + color: fox.color(font-neutral-default); | |
| 131 | + font-family: fox.font-family(body); | |
| 132 | + font-size: fox.font-size(label-sm); | |
| 133 | + font-weight: fox.font-weight(regular); | |
| 134 | + line-height: 1; | |
| 135 | + letter-spacing: -0.025em; | |
| 136 | + text-align: end; | |
| 137 | + } | |
| 138 | + | |
| 139 | + // 현재 글자 수만 굵고 색이 다르다. | |
| 140 | + &__counter-current { | |
| 141 | + color: fox.color(font-theme-primary); | |
| 142 | + font-weight: fox.font-weight(bold); | |
| 143 | + } | |
| 144 | +} | |
| 145 | + | |
| 146 | +// ── 상태 · 자동 ───────────────────────────────────────────────────────────── | |
| 147 | +// 뒤에 오는 규칙이 이긴다: 포커스 < 오류 < 읽기전용 < 비활성. | |
| 148 | +.fox-text-area:not([data-state]) { | |
| 149 | + .fox-text-area__box:focus-within { | |
| 150 | + border-width: fox.border(2); | |
| 151 | + border-color: fox.color(input-border-active); | |
| 152 | + } | |
| 153 | + | |
| 154 | + .fox-text-area__box:has(.fox-text-area__field[aria-invalid="true"]) { | |
| 155 | + border-width: fox.border(2); | |
| 156 | + border-color: fox.color(input-border-error); | |
| 157 | + } | |
| 158 | + | |
| 159 | + // 비활성도 `:read-only`에 걸리므로 떼어낸다. | |
| 160 | + .fox-text-area__box:has(.fox-text-area__field:read-only:not(:disabled)) { | |
| 161 | + border-width: fox.border(1); | |
| 162 | + border-color: fox.color(input-border-disabled); | |
| 163 | + background: fox.color(input-surface-disabled); | |
| 164 | + } | |
| 165 | + | |
| 166 | + .fox-text-area__box:has(.fox-text-area__field:disabled) { | |
| 167 | + border-width: fox.border(1); | |
| 168 | + border-color: fox.color(input-border-disabled); | |
| 169 | + background: fox.color(input-surface-disabled); | |
| 170 | + } | |
| 171 | + | |
| 172 | + &:focus-within .fox-text-area__message { | |
| 173 | + color: fox.color(font-system-information); | |
| 174 | + } | |
| 175 | + | |
| 176 | + &:has(.fox-text-area__field[aria-invalid="true"]) .fox-text-area__message { | |
| 177 | + color: fox.color(font-system-danger); | |
| 178 | + } | |
| 179 | + | |
| 180 | + // 시안의 오류 변형에서만 카운터 현재값이 빨갛다. | |
| 181 | + &:has(.fox-text-area__field[aria-invalid="true"]) .fox-text-area__counter-current { | |
| 182 | + color: fox.color(font-system-danger); | |
| 183 | + } | |
| 184 | +} | |
| 185 | + | |
| 186 | +// ── 상태 · 고정 ───────────────────────────────────────────────────────────── | |
| 187 | +// 시안의 focused·completed·error·view는 값이 들어찬 모습이라 플레이스홀더도 진하게 둔다. | |
| 188 | +.fox-text-area[data-state="default"] .fox-text-area__box, | |
| 189 | +.fox-text-area[data-state="completed"] .fox-text-area__box { | |
| 190 | + border-width: fox.border(1); | |
| 191 | + border-color: fox.color(input-border); | |
| 192 | + background: fox.color(input-surface); | |
| 193 | +} | |
| 194 | + | |
| 195 | +.fox-text-area[data-state="focused"] .fox-text-area__box { | |
| 196 | + border-width: fox.border(2); | |
| 197 | + border-color: fox.color(input-border-active); | |
| 198 | + background: fox.color(input-surface); | |
| 199 | +} | |
| 200 | + | |
| 201 | +.fox-text-area[data-state="error"] .fox-text-area__box { | |
| 202 | + border-width: fox.border(2); | |
| 203 | + border-color: fox.color(input-border-error); | |
| 204 | + background: fox.color(input-surface); | |
| 205 | +} | |
| 206 | + | |
| 207 | +.fox-text-area[data-state="view"] .fox-text-area__box, | |
| 208 | +.fox-text-area[data-state="disabled"] .fox-text-area__box { | |
| 209 | + border-width: fox.border(1); | |
| 210 | + border-color: fox.color(input-border-disabled); | |
| 211 | + background: fox.color(input-surface-disabled); | |
| 212 | +} | |
| 213 | + | |
| 214 | +.fox-text-area[data-state="completed"] .fox-text-area__field::placeholder, | |
| 215 | +.fox-text-area[data-state="focused"] .fox-text-area__field::placeholder, | |
| 216 | +.fox-text-area[data-state="error"] .fox-text-area__field::placeholder, | |
| 217 | +.fox-text-area[data-state="view"] .fox-text-area__field::placeholder { | |
| 218 | + color: fox.color(font-neutral-default); | |
| 219 | +} | |
| 220 | + | |
| 221 | +.fox-text-area[data-state="disabled"] .fox-text-area__field, | |
| 222 | +.fox-text-area[data-state="disabled"] .fox-text-area__field::placeholder { | |
| 223 | + color: fox.color(font-neutral-disabled-strong); | |
| 224 | +} | |
| 225 | + | |
| 226 | +.fox-text-area[data-state="focused"] .fox-text-area__message { | |
| 227 | + color: fox.color(font-system-information); | |
| 228 | +} | |
| 229 | + | |
| 230 | +.fox-text-area[data-state="completed"] .fox-text-area__message { | |
| 231 | + color: fox.color(font-system-success); | |
| 232 | +} | |
| 233 | + | |
| 234 | +.fox-text-area[data-state="error"] .fox-text-area__message { | |
| 235 | + color: fox.color(font-system-danger); | |
| 236 | +} | |
| 237 | + | |
| 238 | +.fox-text-area[data-state="error"] .fox-text-area__counter-current { | |
| 239 | + color: fox.color(font-system-danger); | |
| 240 | +} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -8,3 +8,4 @@ |
| 8 | 8 |
@use "fox-button-group"; |
| 9 | 9 |
@use "fox-button-panel"; |
| 10 | 10 |
@use "fox-input"; |
| 11 |
+@use "fox-text-area"; |
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?