+++ @fox/core/components/fox-form-label/fox-form-label.tsx
... | ... | @@ -0,0 +1,63 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { ComponentPropsWithRef } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * 필수·선택 표시. 둘은 배타적이라 boolean 두 개가 아니라 하나의 값으로 받는다 — 그래야 | |
| 8 | + * "필수이면서 선택"이라는 있을 수 없는 조합이 타입에서 막힌다. | |
| 9 | + */ | |
| 10 | +export type FoxFormLabelRequirement = "required" | "optional"; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * 네이티브 `<label>`이 받는 것을 그대로 넘겨받는다 — htmlFor·id·title 등. 이름이 겹치는 | |
| 14 | + * 것만 걷어내고 아래에서 다시 정의한다. | |
| 15 | + */ | |
| 16 | +type NativeLabelProps = Omit<ComponentPropsWithRef<"label">, "className" | "hidden">; | |
| 17 | + | |
| 18 | +export interface FoxFormLabelProps extends NativeLabelProps { | |
| 19 | + requirement?: FoxFormLabelRequirement; | |
| 20 | + /** "(선택)" 대신 쓸 문구. */ | |
| 21 | + optionalText?: string; | |
| 22 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 23 | + hidden?: boolean; | |
| 24 | + /** 배치 조정용. */ | |
| 25 | + className?: string; | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * @fox 입력 칸 위에 붙는 라벨. 상태를 갖지 않는다. | |
| 30 | + * | |
| 31 | + * 필수는 글자 뒤에 `*`, 선택은 "(선택)"을 붙인다. `*`는 `aria-hidden`이라 스크린리더가 읽지 | |
| 32 | + * 않는다 — 필수 여부는 입력 요소의 `required`가 알리는 것이고 별표는 그 눈에 보이는 짝이라, | |
| 33 | + * 둘 다 읽히면 같은 말이 두 번 나온다. 반대로 "(선택)"은 대신 알려 줄 속성이 없어 읽히게 둔다. | |
| 34 | + * | |
| 35 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 36 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 37 | + */ | |
| 38 | +export function FoxFormLabel({ | |
| 39 | + requirement, | |
| 40 | + optionalText = "(선택)", | |
| 41 | + hidden = false, | |
| 42 | + className, | |
| 43 | + children, | |
| 44 | + ...rest | |
| 45 | +}: FoxFormLabelProps) { | |
| 46 | + if (hidden) { | |
| 47 | + return null; | |
| 48 | + } | |
| 49 | + | |
| 50 | + return ( | |
| 51 | + <label {...rest} className={cx("fox-form-label", className)}> | |
| 52 | + {children} | |
| 53 | + {requirement === "required" ? ( | |
| 54 | + <span className="fox-form-label__required" aria-hidden="true"> | |
| 55 | + * | |
| 56 | + </span> | |
| 57 | + ) : null} | |
| 58 | + {requirement === "optional" ? ( | |
| 59 | + <span className="fox-form-label__optional">{optionalText}</span> | |
| 60 | + ) : null} | |
| 61 | + </label> | |
| 62 | + ); | |
| 63 | +} |
+++ @fox/core/components/fox-form-label/index.ts
... | ... | @@ -0,0 +1,5 @@ |
| 1 | +export { | |
| 2 | + FoxFormLabel, | |
| 3 | + type FoxFormLabelProps, | |
| 4 | + type FoxFormLabelRequirement, | |
| 5 | +} from "./fox-form-label"; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -6,6 +6,7 @@ |
| 6 | 6 |
export * from "./fox-conditional-option"; |
| 7 | 7 |
export * from "./fox-email"; |
| 8 | 8 |
export * from "./fox-file-upload"; |
| 9 |
+export * from "./fox-form-label"; |
|
| 9 | 10 |
export * from "./fox-icon-button"; |
| 10 | 11 |
export * from "./fox-input"; |
| 11 | 12 |
export * from "./fox-link-button"; |
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -11,6 +11,7 @@ |
| 11 | 11 |
import { FoxCertified } from "../core/components/fox-certified";
|
| 12 | 12 |
import { FoxConditionalOption } from "../core/components/fox-conditional-option";
|
| 13 | 13 |
import { FoxEmail } from "../core/components/fox-email";
|
| 14 |
+import { FoxFormLabel } from "../core/components/fox-form-label";
|
|
| 14 | 15 |
import { FoxFileUpload, type FoxFileItem } from "../core/components/fox-file-upload";
|
| 15 | 16 |
import { FoxPhoneNumber, type FoxPhoneNumberState } from "../core/components/fox-phone-number";
|
| 16 | 17 |
import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
|
... | ... | @@ -756,6 +757,48 @@ |
| 756 | 757 |
}, |
| 757 | 758 |
], |
| 758 | 759 |
}, |
| 760 |
+ {
|
|
| 761 |
+ id: "fox-form-label", |
|
| 762 |
+ name: "FoxFormLabel", |
|
| 763 |
+ description: |
|
| 764 |
+ "Figma 시안(form-label 180:609) 입력 칸 위에 붙는 라벨입니다. 글자 자체는 form-field의 label 믹스인이 갖습니다 — 시안에서 이 컴포넌트와 입력 계열의 라벨이 같은 공용 조각이라 값이 갈라질 이유가 없습니다. 필수는 글자 뒤에 *, 선택은 (선택)을 붙이고 둘은 배타적이라 boolean 두 개가 아니라 값 하나로 받습니다. *는 aria-hidden이라 스크린리더가 읽지 않습니다 — 필수 여부는 입력 요소의 required가 알리는 것이고 별표는 그 눈에 보이는 짝이라, 둘 다 읽히면 같은 말이 두 번 나옵니다. 반대로 (선택)은 대신 알려 줄 속성이 없어 읽히게 둡니다.", |
|
| 765 |
+ variants: [ |
|
| 766 |
+ {
|
|
| 767 |
+ label: "기본 / 필수 / 선택", |
|
| 768 |
+ node: ( |
|
| 769 |
+ <div className={styles.demoStack}>
|
|
| 770 |
+ <FoxFormLabel>레이블</FoxFormLabel> |
|
| 771 |
+ <FoxFormLabel requirement="required">레이블</FoxFormLabel> |
|
| 772 |
+ <FoxFormLabel requirement="optional">레이블</FoxFormLabel> |
|
| 773 |
+ </div> |
|
| 774 |
+ ), |
|
| 775 |
+ }, |
|
| 776 |
+ {
|
|
| 777 |
+ label: "optionalText — 문구를 바꿀 수 있다", |
|
| 778 |
+ node: ( |
|
| 779 |
+ <div className={styles.demoStack}>
|
|
| 780 |
+ <FoxFormLabel requirement="optional">기본 문구</FoxFormLabel> |
|
| 781 |
+ <FoxFormLabel requirement="optional" optionalText="(입력하지 않아도 됩니다)"> |
|
| 782 |
+ 직접 지정 |
|
| 783 |
+ </FoxFormLabel> |
|
| 784 |
+ </div> |
|
| 785 |
+ ), |
|
| 786 |
+ }, |
|
| 787 |
+ {
|
|
| 788 |
+ label: "htmlFor — 눌러 보면 아래 입력칸에 커서가 간다", |
|
| 789 |
+ node: ( |
|
| 790 |
+ <InputStage> |
|
| 791 |
+ <div className={styles.demoStack}>
|
|
| 792 |
+ <FoxFormLabel htmlFor="demo-form-label" requirement="required"> |
|
| 793 |
+ 라벨을 눌러 보세요 |
|
| 794 |
+ </FoxFormLabel> |
|
| 795 |
+ <FoxInput size="md" id="demo-form-label" placeholder="여기로 커서가 옵니다" /> |
|
| 796 |
+ </div> |
|
| 797 |
+ </InputStage> |
|
| 798 |
+ ), |
|
| 799 |
+ }, |
|
| 800 |
+ ], |
|
| 801 |
+ }, |
|
| 759 | 802 |
{
|
| 760 | 803 |
id: "fox-input", |
| 761 | 804 |
name: "FoxInput", |
... | ... | @@ -1125,7 +1168,7 @@ |
| 1125 | 1168 |
"Figma 시안(conditional-option 190:2163) 라디오 한 칸과 select가 합쳐 하나의 답을 이룹니다. select는 이 칸이 골라지지 않아도 조작할 수 있고, 값을 고르면 라디오가 자동으로 켜집니다. 잠가 두면 그 자동 선택 자체가 불가능해지기 때문입니다(disabled는 클릭을 먹지 않습니다). 켜는 방법은 숨은 input을 실제로 클릭하는 것이라 형제 칸이 꺼지는 것과 change 이벤트가 브라우저 규칙 그대로 일어납니다. ⚠️ 시안의 변형 이름이 실제 동작과 반대라서(FoxRadioGroup과 같은 문제) 동작 기준으로 다시 이름 붙였습니다 — 시안 row가 vertical, 시안 col이 horizontal입니다.", |
| 1126 | 1169 |
variants: [ |
| 1127 | 1170 |
{
|
| 1128 |
- label: "vertical (기본) — select가 아래로 내려가고 라벨 왼쪽 끝에 맞는다", |
|
| 1171 |
+ label: "vertical (기본) — select가 아래로 내려가고 왼쪽 끝에 붙는다", |
|
| 1129 | 1172 |
node: ( |
| 1130 | 1173 |
<FoxConditionalOption |
| 1131 | 1174 |
name="co-v" |
+++ @fox/styles/_fox-form-label.scss
... | ... | @@ -0,0 +1,49 @@ |
| 1 | +// FoxFormLabel — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma form-label | |
| 3 | +// | |
| 4 | +// 입력 칸 위에 붙는 라벨. 필수·선택 표시를 뒤에 달 수 있다. | |
| 5 | +// | |
| 6 | +// 마크업 계약 (React 밖 소비자용): | |
| 7 | +// <label class="fox-form-label" for="…"> | |
| 8 | +// 이름 | |
| 9 | +// <span class="fox-form-label__required" aria-hidden="true">*</span> <!-- 필수일 때 --> | |
| 10 | +// <span class="fox-form-label__optional">(선택)</span> <!-- 선택일 때 --> | |
| 11 | +// </label> | |
| 12 | +// | |
| 13 | +// 글자 자체는 `form-field`의 `label` 믹스인이 갖는다 — 시안에서 이 컴포넌트와 입력 계열의 | |
| 14 | +// 라벨이 같은 공용 조각이라 값이 갈라질 이유가 없다. 이 파일에는 표시 두 개만 남는다. | |
| 15 | +// | |
| 16 | +// 필수의 `*`는 `aria-hidden`이다. 필수 여부는 입력 요소의 `required`(또는 `aria-required`)가 | |
| 17 | +// 알리는 것이고, 별표는 그 눈에 보이는 짝일 뿐이라 두 번 읽히면 안 된다. 반대로 "(선택)"은 | |
| 18 | +// 대신 알려 줄 속성이 없어 그대로 읽히게 둔다. | |
| 19 | +// | |
| 20 | +// 표시는 서로 배타적이다 — 필수이면서 선택일 수는 없어 React 래퍼가 하나만 받는다. | |
| 21 | +// | |
| 22 | +// 토큰이 아닌 값(FoxButton과 동일한 근거로 승인된 항목): | |
| 23 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 export하지 | |
| 24 | +// 않는다. 자간은 px이 아니라 em이라 브레이크포인트가 바뀌어도 비율이 유지된다. | |
| 25 | +// - display / align-items: 구조 규칙. | |
| 26 | + | |
| 27 | +@use "@fox/styles/abstracts" as fox; | |
| 28 | +@use "@fox/styles/form-field" as field; | |
| 29 | + | |
| 30 | +.fox-form-label { | |
| 31 | + @include field.label; | |
| 32 | + | |
| 33 | + display: inline-flex; | |
| 34 | + align-items: center; | |
| 35 | + | |
| 36 | + // 시안 label/md-em — 기본 라벨보다 굵고 강조 색이다. | |
| 37 | + &__required { | |
| 38 | + color: fox.color(font-theme-accent); | |
| 39 | + font-weight: fox.font-weight(bold); | |
| 40 | + } | |
| 41 | + | |
| 42 | + // 시안 label/sm — 기본 라벨보다 한 단계 작고 흐리다. | |
| 43 | + &__optional { | |
| 44 | + color: fox.color(font-neutral-subtle); | |
| 45 | + font-size: fox.font-size(label-sm); | |
| 46 | + font-weight: fox.font-weight(regular); | |
| 47 | + letter-spacing: -0.025em; | |
| 48 | + } | |
| 49 | +} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -12,6 +12,7 @@ |
| 12 | 12 |
@use "fox-conditional-option"; |
| 13 | 13 |
@use "fox-email"; |
| 14 | 14 |
@use "fox-file-upload"; |
| 15 |
+@use "fox-form-label"; |
|
| 15 | 16 |
@use "fox-input"; |
| 16 | 17 |
@use "fox-phone-number"; |
| 17 | 18 |
@use "fox-radio"; |
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?