feat: 디자인시스템 FoxCheckIcon, FoxCheckbox, FoxCheckboxGroup 추가 — FoxConditionalOption에 체크박스 지원
@a5f293d8d95f48a7d7d7e82e49d1278ccd6af978
+++ @fox/core/components/fox-check-icon/fox-check-icon.tsx
... | ... | @@ -0,0 +1,93 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { Ref } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | + | |
| 6 | +export type FoxCheckIconSize = "lg" | "md"; | |
| 7 | + | |
| 8 | +export interface FoxCheckIconProps { | |
| 9 | + size?: FoxCheckIconSize; | |
| 10 | + /** | |
| 11 | + * 켜짐 상태를 클래스로 못박는다. `FoxCheckbox` 안에서는 넘기지 않는다 — 네이티브 | |
| 12 | + * `<input type="checkbox">`의 `:checked`가 스타일 훅을 겸하므로 비제어에서도 맞는다. | |
| 13 | + * 상태를 나란히 보여주는 예제처럼 단독으로 쓸 때만 쓴다. | |
| 14 | + */ | |
| 15 | + checked?: boolean; | |
| 16 | + /** 부분선택 상태를 클래스로 못박는다. `checked`와 같은 이유로 단독 사용 전용이다. */ | |
| 17 | + indeterminate?: boolean; | |
| 18 | + /** 비활성 상태를 클래스로 못박는다. `checked`와 같은 이유로 단독 사용 전용이다. */ | |
| 19 | + disabled?: boolean; | |
| 20 | + /** 배치 조정용. */ | |
| 21 | + className?: string; | |
| 22 | + ref?: Ref<HTMLSpanElement>; | |
| 23 | +} | |
| 24 | + | |
| 25 | +const SIZE_CLASS: Record<FoxCheckIconSize, string> = { | |
| 26 | + lg: "fox-check-icon--lg", | |
| 27 | + md: "fox-check-icon--md", | |
| 28 | +}; | |
| 29 | + | |
| 30 | +/** | |
| 31 | + * @fox 체크박스의 네모 표시. 상태를 갖지 않는다. | |
| 32 | + * | |
| 33 | + * 자체로는 입력이 아니다 — 실제 켜짐은 `FoxCheckbox`가 감싸는 네이티브 | |
| 34 | + * `<input type="checkbox">`가 담당하고, 이 요소는 그 상태를 CSS로 비춘다. | |
| 35 | + * | |
| 36 | + * 체크와 부분선택 표시를 둘 다 그려 두고 CSS가 골라 보여 준다. 켜졌을 때만 넣으면 비제어 | |
| 37 | + * 입력에서 React가 상태를 몰라 어긋난다. | |
| 38 | + * | |
| 39 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 40 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 41 | + */ | |
| 42 | +export function FoxCheckIcon({ | |
| 43 | + size = "lg", | |
| 44 | + checked = false, | |
| 45 | + indeterminate = false, | |
| 46 | + disabled = false, | |
| 47 | + className, | |
| 48 | + ref, | |
| 49 | +}: FoxCheckIconProps) { | |
| 50 | + return ( | |
| 51 | + <span | |
| 52 | + ref={ref} | |
| 53 | + aria-hidden="true" | |
| 54 | + className={cx( | |
| 55 | + "fox-check-icon", | |
| 56 | + SIZE_CLASS[size], | |
| 57 | + checked && "fox-check-icon--checked", | |
| 58 | + indeterminate && "fox-check-icon--indeterminate", | |
| 59 | + disabled && "fox-check-icon--disabled", | |
| 60 | + className | |
| 61 | + )} | |
| 62 | + > | |
| 63 | + <CheckMark /> | |
| 64 | + <DashMark /> | |
| 65 | + </span> | |
| 66 | + ); | |
| 67 | +} | |
| 68 | + | |
| 69 | +// 시안 아이콘. `fill`을 시안의 흰색 리터럴 대신 `currentColor`로 두면 상자 규칙이 정한 색을 | |
| 70 | +// 그대로 물려받는다 — 채워진 상자 위에서는 반전색, 비활성일 때는 흐린 색이 된다. | |
| 71 | +function CheckMark() { | |
| 72 | + return ( | |
| 73 | + <svg className="fox-check-icon__check" viewBox="0 0 20 20" fill="none" aria-hidden="true"> | |
| 74 | + <path | |
| 75 | + d="M18.1633 6.28827L8.16328 16.2883C8.07618 16.3757 7.97269 16.445 7.85874 16.4923C7.74478 16.5397 7.62261 16.564 7.49922 16.564C7.37583 16.564 7.25366 16.5397 7.1397 16.4923C7.02575 16.445 6.92225 16.3757 6.83516 16.2883L2.46016 11.9133C2.37295 11.8261 2.30378 11.7225 2.25658 11.6086C2.20938 11.4947 2.18509 11.3725 2.18509 11.2492C2.18509 11.1259 2.20938 11.0038 2.25658 10.8898C2.30378 10.7759 2.37295 10.6724 2.46016 10.5851C2.54736 10.4979 2.65089 10.4288 2.76483 10.3816C2.87877 10.3344 3.00089 10.3101 3.12422 10.3101C3.24755 10.3101 3.36967 10.3344 3.48361 10.3816C3.59755 10.4288 3.70108 10.4979 3.78828 10.5851L7.5 14.2969L16.8367 4.96171C17.0128 4.78559 17.2517 4.68665 17.5008 4.68665C17.7499 4.68665 17.9887 4.78559 18.1648 4.96171C18.341 5.13783 18.4399 5.3767 18.4399 5.62577C18.4399 5.87484 18.341 6.11371 18.1648 6.28983L18.1633 6.28827Z" | |
| 76 | + fill="currentColor" | |
| 77 | + /> | |
| 78 | + </svg> | |
| 79 | + ); | |
| 80 | +} | |
| 81 | + | |
| 82 | +// 부분선택 표시. 가로 막대라 CSS로도 그릴 수 있지만, 시안 글리프를 쓰면 끝 모서리와 두께가 | |
| 83 | +// 체크와 같은 손에서 나온 값이 된다. | |
| 84 | +function DashMark() { | |
| 85 | + return ( | |
| 86 | + <svg className="fox-check-icon__dash" viewBox="0 0 20 20" fill="none" aria-hidden="true"> | |
| 87 | + <path | |
| 88 | + d="M17.8125 10C17.8125 10.2486 17.7137 10.4871 17.5379 10.6629C17.3621 10.8387 17.1236 10.9375 16.875 10.9375H3.125C2.87636 10.9375 2.6379 10.8387 2.46209 10.6629C2.28627 10.4871 2.1875 10.2486 2.1875 10C2.1875 9.75136 2.28627 9.5129 2.46209 9.33709C2.6379 9.16127 2.87636 9.0625 3.125 9.0625H16.875C17.1236 9.0625 17.3621 9.16127 17.5379 9.33709C17.7137 9.5129 17.8125 9.75136 17.8125 10Z" | |
| 89 | + fill="currentColor" | |
| 90 | + /> | |
| 91 | + </svg> | |
| 92 | + ); | |
| 93 | +} |
+++ @fox/core/components/fox-check-icon/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxCheckIcon, type FoxCheckIconProps, type FoxCheckIconSize } from "./fox-check-icon"; |
+++ @fox/core/components/fox-checkbox-group/fox-checkbox-group.tsx
... | ... | @@ -0,0 +1,109 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + Children, | |
| 5 | + Fragment, | |
| 6 | + cloneElement, | |
| 7 | + isValidElement, | |
| 8 | + type ReactElement, | |
| 9 | + type ReactNode, | |
| 10 | + type Ref, | |
| 11 | +} from "react"; | |
| 12 | +import { cx } from "../../utils"; | |
| 13 | +import type { FoxCheckboxSize } from "../fox-checkbox"; | |
| 14 | + | |
| 15 | +export type FoxCheckboxGroupOrientation = "horizontal" | "vertical"; | |
| 16 | + | |
| 17 | +export interface FoxCheckboxGroupProps { | |
| 18 | + orientation?: FoxCheckboxGroupOrientation; | |
| 19 | + /** | |
| 20 | + * 자식 체크박스의 `name`을 전부 이 값으로 덮어쓴다. 라디오와 달리 `name`이 같아도 서로를 | |
| 21 | + * 끄지 않는다 — 폼에서 같은 이름의 값 여러 개로 함께 전송될 뿐이다. | |
| 22 | + */ | |
| 23 | + name?: string; | |
| 24 | + /** 자식 체크박스의 `size`를 전부 이 값으로 덮어쓴다. 넘기지 않으면 자식이 각자 정한다. */ | |
| 25 | + size?: FoxCheckboxSize; | |
| 26 | + /** 그룹을 읽어 줄 이름. 화면에 보이는 제목이 이미 있으면 `labelledBy`를 쓴다. */ | |
| 27 | + label?: string; | |
| 28 | + /** 그룹 이름 역할을 하는 요소의 id. `label`보다 우선한다 — 화면의 글자와 어긋나지 않는다. */ | |
| 29 | + labelledBy?: string; | |
| 30 | + /** `FoxCheckbox`들. */ | |
| 31 | + children?: ReactNode; | |
| 32 | + /** 배치 조정용. */ | |
| 33 | + className?: string; | |
| 34 | + ref?: Ref<HTMLDivElement>; | |
| 35 | +} | |
| 36 | + | |
| 37 | +const ORIENTATION_CLASS: Record<FoxCheckboxGroupOrientation, string> = { | |
| 38 | + horizontal: "fox-checkbox-group--horizontal", | |
| 39 | + vertical: "fox-checkbox-group--vertical", | |
| 40 | +}; | |
| 41 | + | |
| 42 | +type Overrides = { name?: string; size?: FoxCheckboxSize }; | |
| 43 | + | |
| 44 | +/** | |
| 45 | + * 자식 체크박스에 그룹 값을 강제로 덮어쓴다. | |
| 46 | + * | |
| 47 | + * - 호스트 요소(`div` 등)는 건드리지 않는다 — DOM에 없는 속성이 넘어가면 React가 경고한다. | |
| 48 | + * - `Children.map`은 프래그먼트 안으로 들어가지 않아 직접 내려간다. 그러지 않으면 | |
| 49 | + * `<>…</>`로 감싼 체크박스들이 덮어쓰기를 피해 가고 Fragment에 `name`이 붙어 경고가 난다. | |
| 50 | + */ | |
| 51 | +function overrideChildren(children: ReactNode, overrides: Overrides): ReactNode { | |
| 52 | + return Children.map(children, (child) => { | |
| 53 | + if (!isValidElement(child)) { | |
| 54 | + return child; | |
| 55 | + } | |
| 56 | + | |
| 57 | + if (child.type === Fragment) { | |
| 58 | + const fragment = child as ReactElement<{ children?: ReactNode }>; | |
| 59 | + return overrideChildren(fragment.props.children, overrides); | |
| 60 | + } | |
| 61 | + | |
| 62 | + if (typeof child.type === "string") { | |
| 63 | + return child; | |
| 64 | + } | |
| 65 | + | |
| 66 | + return cloneElement(child as ReactElement<Record<string, unknown>>, overrides); | |
| 67 | + }); | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** | |
| 71 | + * @fox 체크박스 묶음. 배치와 `name`·`size` 통일만 책임지고 상태를 갖지 않는다. | |
| 72 | + * | |
| 73 | + * `role="radiogroup"`이 아니라 `role="group"`이다 — 체크박스는 서로를 끄지 않아 "하나만 | |
| 74 | + * 고르는 묶음"이 아니고, 화살표 이동으로 옮겨 다니지도 않는다. 각 칸이 독립적으로 Tab을 받는다. | |
| 75 | + * | |
| 76 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 77 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 78 | + */ | |
| 79 | +export function FoxCheckboxGroup({ | |
| 80 | + orientation = "vertical", | |
| 81 | + name, | |
| 82 | + size, | |
| 83 | + label, | |
| 84 | + labelledBy, | |
| 85 | + children, | |
| 86 | + className, | |
| 87 | + ref, | |
| 88 | +}: FoxCheckboxGroupProps) { | |
| 89 | + // 값을 준 것만 넣는다 — `undefined`를 그대로 넘기면 자식이 스스로 정한 값을 지운다. | |
| 90 | + const overrides: Overrides = {}; | |
| 91 | + if (name !== undefined) { | |
| 92 | + overrides.name = name; | |
| 93 | + } | |
| 94 | + if (size !== undefined) { | |
| 95 | + overrides.size = size; | |
| 96 | + } | |
| 97 | + | |
| 98 | + return ( | |
| 99 | + <div | |
| 100 | + ref={ref} | |
| 101 | + role="group" | |
| 102 | + aria-label={labelledBy ? undefined : label} | |
| 103 | + aria-labelledby={labelledBy} | |
| 104 | + className={cx("fox-checkbox-group", ORIENTATION_CLASS[orientation], className)} | |
| 105 | + > | |
| 106 | + {overrideChildren(children, overrides)} | |
| 107 | + </div> | |
| 108 | + ); | |
| 109 | +} |
+++ @fox/core/components/fox-checkbox-group/index.ts
... | ... | @@ -0,0 +1,5 @@ |
| 1 | +export { | |
| 2 | + FoxCheckboxGroup, | |
| 3 | + type FoxCheckboxGroupProps, | |
| 4 | + type FoxCheckboxGroupOrientation, | |
| 5 | +} from "./fox-checkbox-group"; |
+++ @fox/core/components/fox-checkbox/fox-checkbox.tsx
... | ... | @@ -0,0 +1,124 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + useEffect, | |
| 5 | + useRef, | |
| 6 | + type ChangeEvent, | |
| 7 | + type ComponentPropsWithRef, | |
| 8 | + type ReactNode, | |
| 9 | +} from "react"; | |
| 10 | +import { cx } from "../../utils"; | |
| 11 | +import { FoxCheckIcon } from "../fox-check-icon"; | |
| 12 | + | |
| 13 | +export type FoxCheckboxSize = "lg" | "md"; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * 네이티브 `<input type="checkbox">`가 받는 것을 그대로 넘겨받는다 — name·value·checked· | |
| 17 | + * defaultChecked·disabled·required·form 등. 의미가 겹치는 것만 걷어내고 아래에서 다시 정의한다. | |
| 18 | + */ | |
| 19 | +type NativeCheckboxProps = Omit< | |
| 20 | + ComponentPropsWithRef<"input">, | |
| 21 | + "type" | "size" | "className" | "children" | "hidden" | "onChange" | |
| 22 | +>; | |
| 23 | + | |
| 24 | +export interface FoxCheckboxProps extends NativeCheckboxProps { | |
| 25 | + size?: FoxCheckboxSize; | |
| 26 | + label: ReactNode; | |
| 27 | + /** 라벨 아래 보조 설명. 넘기지 않으면 영역을 렌더링하지 않는다. */ | |
| 28 | + description?: ReactNode; | |
| 29 | + /** | |
| 30 | + * 부분선택. "전체 동의"처럼 자식 일부만 켜진 상태를 나타낸다. | |
| 31 | + * | |
| 32 | + * HTML 속성이 아니라 DOM 프로퍼티라 마크업만으로는 표현할 수 없다 — 이 컴포넌트가 대신 | |
| 33 | + * 넣어 준다. 켜짐 여부와는 별개라, DOM에서 `checked`와 함께 참일 수 있고 그때는 부분선택 | |
| 34 | + * 모양이 이긴다. | |
| 35 | + */ | |
| 36 | + indeterminate?: boolean; | |
| 37 | + /** | |
| 38 | + * 켜지거나 꺼질 때 부른다. 켜짐 여부가 첫 인자다 — 네이티브 `onChange`를 `Omit`으로 | |
| 39 | + * 걷어내고 그 자리를 대신한다(`FoxInput`과 같은 방식). | |
| 40 | + */ | |
| 41 | + onChange?: (checked: boolean, event: ChangeEvent<HTMLInputElement>) => void; | |
| 42 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 43 | + hidden?: boolean; | |
| 44 | + /** 배치 조정용. */ | |
| 45 | + className?: string; | |
| 46 | +} | |
| 47 | + | |
| 48 | +const SIZE_CLASS: Record<FoxCheckboxSize, string> = { | |
| 49 | + lg: "fox-checkbox--lg", | |
| 50 | + md: "fox-checkbox--md", | |
| 51 | +}; | |
| 52 | + | |
| 53 | +/** | |
| 54 | + * @fox 체크박스 한 칸. 켜짐 여부는 네이티브 `<input type="checkbox">`가 소유한다. | |
| 55 | + * | |
| 56 | + * 라디오와 달리 `name`이 같아도 서로를 끄지 않는다. 여러 개를 동시에 고를 수 있고, 폼 전송 | |
| 57 | + * 시 고른 것들이 함께 나간다. | |
| 58 | + * | |
| 59 | + * 루트가 `<label>`이라 라벨이나 설명을 눌러도 켜진다. 네모 표시는 CSS가 input의 | |
| 60 | + * `:checked`·`:indeterminate`·`:disabled`를 보고 그리므로, 비제어로 써도 모양이 어긋나지 않는다. | |
| 61 | + * | |
| 62 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 63 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 64 | + */ | |
| 65 | +export function FoxCheckbox({ | |
| 66 | + size = "lg", | |
| 67 | + label, | |
| 68 | + description, | |
| 69 | + indeterminate = false, | |
| 70 | + onChange, | |
| 71 | + hidden = false, | |
| 72 | + className, | |
| 73 | + ref, | |
| 74 | + ...rest | |
| 75 | +}: FoxCheckboxProps) { | |
| 76 | + const inputRef = useRef<HTMLInputElement>(null); | |
| 77 | + | |
| 78 | + // `indeterminate`는 마크업으로 못 준다 — 렌더 후 DOM에 직접 넣어야 한다. | |
| 79 | + useEffect(() => { | |
| 80 | + if (inputRef.current) { | |
| 81 | + inputRef.current.indeterminate = indeterminate; | |
| 82 | + } | |
| 83 | + }, [indeterminate]); | |
| 84 | + | |
| 85 | + // 훅을 전부 부른 뒤에 빠져나간다 — 위쪽에서 반환하면 훅 호출 순서가 깨진다. | |
| 86 | + if (hidden) { | |
| 87 | + return null; | |
| 88 | + } | |
| 89 | + | |
| 90 | + // 호출부 ref와 내부 ref를 함께 채운다 — 부분선택을 넣으려면 안에서도 요소를 쥐어야 한다. | |
| 91 | + const attachInput = (node: HTMLInputElement | null) => { | |
| 92 | + inputRef.current = node; | |
| 93 | + if (typeof ref === "function") { | |
| 94 | + ref(node); | |
| 95 | + } else if (ref) { | |
| 96 | + (ref as { current: HTMLInputElement | null }).current = node; | |
| 97 | + } | |
| 98 | + }; | |
| 99 | + | |
| 100 | + // 라디오와 달리 끌 때도 change가 뛴다. 그래서 켜짐 여부를 그대로 넘긴다. | |
| 101 | + const handleChange = (event: ChangeEvent<HTMLInputElement>) => { | |
| 102 | + onChange?.(event.target.checked, event); | |
| 103 | + }; | |
| 104 | + | |
| 105 | + return ( | |
| 106 | + <label className={cx("fox-checkbox", SIZE_CLASS[size], className)}> | |
| 107 | + {/* 형제 선택자(`~`)가 뒤따르는 표시와 본문에 닿아야 하므로 맨 앞에 둔다. */} | |
| 108 | + <input | |
| 109 | + {...rest} | |
| 110 | + ref={attachInput} | |
| 111 | + type="checkbox" | |
| 112 | + className="fox-checkbox__input" | |
| 113 | + onChange={handleChange} | |
| 114 | + /> | |
| 115 | + <FoxCheckIcon size={size} /> | |
| 116 | + <span className="fox-checkbox__body"> | |
| 117 | + <span className="fox-checkbox__label">{label}</span> | |
| 118 | + {description ? ( | |
| 119 | + <span className="fox-checkbox__description">{description}</span> | |
| 120 | + ) : null} | |
| 121 | + </span> | |
| 122 | + </label> | |
| 123 | + ); | |
| 124 | +} |
+++ @fox/core/components/fox-checkbox/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxCheckbox, type FoxCheckboxProps, type FoxCheckboxSize } from "./fox-checkbox"; |
--- @fox/core/components/fox-conditional-option/fox-conditional-option.tsx
+++ @fox/core/components/fox-conditional-option/fox-conditional-option.tsx
... | ... | @@ -2,13 +2,31 @@ |
| 2 | 2 |
|
| 3 | 3 |
import { useRef, type ReactNode } from "react";
|
| 4 | 4 |
import { cx } from "../../utils";
|
| 5 |
-import { FoxRadio, type FoxRadioSize } from "../fox-radio";
|
|
| 5 |
+import { FoxCheckbox } from "../fox-checkbox";
|
|
| 6 |
+import { FoxRadio } from "../fox-radio";
|
|
| 6 | 7 |
import { FoxSelect, type FoxSelectItem } from "../fox-select";
|
| 7 | 8 |
|
| 8 | 9 |
export type FoxConditionalOptionOrientation = "horizontal" | "vertical"; |
| 9 | 10 |
|
| 11 |
+/** |
|
| 12 |
+ * 앞에 놓이는 컨트롤. 라디오는 묶음에서 하나만 골리고, 체크박스는 여러 개를 동시에 골릴 수 |
|
| 13 |
+ * 있다 — 나머지는 전부 같다. |
|
| 14 |
+ */ |
|
| 15 |
+export type FoxConditionalOptionControl = "radio" | "checkbox"; |
|
| 16 |
+ |
|
| 17 |
+/** 라디오·체크박스가 같은 두 단계를 쓴다. */ |
|
| 18 |
+export type FoxConditionalOptionSize = "lg" | "md"; |
|
| 19 |
+ |
|
| 10 | 20 |
export interface FoxConditionalOptionProps {
|
| 11 |
- /** 라디오 값. 같은 `name` 안에서 이 칸을 가리킨다. */ |
|
| 21 |
+ /** |
|
| 22 |
+ * 앞에 놓이는 컨트롤. 기본은 라디오다. |
|
| 23 |
+ * |
|
| 24 |
+ * 시안에서는 `trigger`라고 부르지만 이름을 바꿨다 — 이 패키지에서 `trigger`는 이미 |
|
| 25 |
+ * `fox-select__trigger`(목록을 여는 버튼)라는 다른 뜻으로 쓰인다. 여기 라디오·체크박스는 |
|
| 26 |
+ * 무엇을 여는 게 아니라 답을 담는 컨트롤이다. |
|
| 27 |
+ */ |
|
| 28 |
+ control?: FoxConditionalOptionControl; |
|
| 29 |
+ /** 컨트롤 값. 같은 `name` 안에서 이 칸을 가리킨다. */ |
|
| 12 | 30 |
value: string; |
| 13 | 31 |
label: ReactNode; |
| 14 | 32 |
/** 라벨 아래 보조 설명. 넘기지 않으면 영역을 렌더링하지 않는다. */ |
... | ... | @@ -21,18 +39,23 @@ |
| 21 | 39 |
defaultDetail?: string; |
| 22 | 40 |
/** 딸린 select 값이 바뀔 때 부른다. 값이 첫 인자다. */ |
| 23 | 41 |
onChange?: (detail: string) => void; |
| 24 |
- /** 이 칸이 선택될 때 부른다 — select를 골라 자동으로 켜진 경우도 포함이다. */ |
|
| 25 |
- onCheck?: (value: string) => void; |
|
| 42 |
+ /** |
|
| 43 |
+ * 켜짐 여부가 바뀔 때 부른다 — select를 골라 자동으로 켜진 경우도 포함이다. |
|
| 44 |
+ * |
|
| 45 |
+ * 라디오는 켜질 때만 뛰므로 항상 `true`로 온다. 체크박스는 끌 때도 뛰어서 `false`가 올 수 |
|
| 46 |
+ * 있다. 값은 호출부가 이미 아는 것이라 되돌려주지 않는다. |
|
| 47 |
+ */ |
|
| 48 |
+ onCheckedChange?: (checked: boolean) => void; |
|
| 26 | 49 |
/** 아무것도 고르지 않았을 때 select에 보여줄 문구. */ |
| 27 | 50 |
placeholder?: ReactNode; |
| 28 | 51 |
orientation?: FoxConditionalOptionOrientation; |
| 29 |
- /** 라디오 묶음 이름. `FoxRadioGroup` 안에 넣으면 그룹이 넣어 주므로 직접 적지 않아도 된다. */ |
|
| 52 |
+ /** 묶음 이름. 그룹 안에 넣으면 그룹이 넣어 주므로 직접 적지 않아도 된다. */ |
|
| 30 | 53 |
name?: string; |
| 31 |
- /** `FoxRadioGroup`이 넣어 준다. 직접 넣으면 그 값이 라디오 크기가 된다. */ |
|
| 32 |
- size?: FoxRadioSize; |
|
| 33 |
- /** 라디오를 처음부터 선택된 채로 둔다(비제어). */ |
|
| 54 |
+ /** `FoxRadioGroup`·`FoxCheckboxGroup`이 넣어 준다. 직접 넣으면 그 값이 컨트롤 크기가 된다. */ |
|
| 55 |
+ size?: FoxConditionalOptionSize; |
|
| 56 |
+ /** 처음부터 켜진 채로 둔다(비제어). */ |
|
| 34 | 57 |
defaultChecked?: boolean; |
| 35 |
- /** 주면 선택 여부를 호출부가 소유한다. */ |
|
| 58 |
+ /** 주면 켜짐 여부를 호출부가 소유한다. */ |
|
| 36 | 59 |
checked?: boolean; |
| 37 | 60 |
disabled?: boolean; |
| 38 | 61 |
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ |
... | ... | @@ -47,22 +70,25 @@ |
| 47 | 70 |
}; |
| 48 | 71 |
|
| 49 | 72 |
/** |
| 50 |
- * @fox 후속 질문이 딸린 선택지. 라디오 한 칸과 select를 묶어 하나의 답으로 다룬다. |
|
| 73 |
+ * @fox 후속 질문이 딸린 선택지. 컨트롤 한 칸과 select를 묶어 하나의 답으로 다룬다. |
|
| 51 | 74 |
* |
| 52 |
- * select는 이 칸이 골라지지 않아도 **조작할 수 있다.** 값을 고르면 라디오가 자동으로 켜진다 — |
|
| 53 |
- * 값을 고르고도 라디오를 안 눌러 답이 비는 일이 흔해서다. 반대로 select를 잠가 두면 그 자동 |
|
| 75 |
+ * `control`로 라디오와 체크박스를 고른다. 두 컨트롤이 `name`·`value`·`size`·`checked`를 같은 |
|
| 76 |
+ * 이름으로 받고 크기 단계도 같아서, 나머지 배선은 그대로 쓴다. 그래서 `FoxRadioGroup`이든 |
|
| 77 |
+ * `FoxCheckboxGroup`이든 그대로 넣을 수 있다 — 그룹이 `name`·`size`를 넣어 준다. |
|
| 78 |
+ * |
|
| 79 |
+ * select는 이 칸이 켜지지 않아도 **조작할 수 있다.** 값을 고르면 컨트롤이 자동으로 켜진다 — |
|
| 80 |
+ * 값을 고르고도 컨트롤을 안 눌러 답이 비는 일이 흔해서다. 반대로 select를 잠가 두면 그 자동 |
|
| 54 | 81 |
* 선택 자체가 불가능해진다(`disabled`는 클릭을 먹지 않는다). |
| 55 | 82 |
* |
| 56 |
- * 켜는 방법은 숨은 input을 실제로 클릭하는 것이다. 그래야 같은 `name`의 다른 칸이 꺼지는 |
|
| 57 |
- * 것과 change 이벤트가 브라우저 규칙 그대로 일어난다 — `checked`를 직접 대입하면 이벤트가 |
|
| 58 |
- * 뛰지 않아 호출부가 모른다. |
|
| 59 |
- * |
|
| 60 |
- * `FoxRadioGroup` 안에 그대로 넣을 수 있다 — 그룹이 `name`·`size`를 넣어 준다. |
|
| 83 |
+ * 켜는 방법은 숨은 input을 실제로 클릭하는 것이다. 그래야 라디오에서 같은 `name`의 다른 칸이 |
|
| 84 |
+ * 꺼지는 것과 change 이벤트가 브라우저 규칙 그대로 일어난다 — `checked`를 직접 대입하면 |
|
| 85 |
+ * 이벤트가 뛰지 않아 호출부가 모른다. 이미 켜져 있으면 누르지 않는다(체크박스가 꺼져 버린다). |
|
| 61 | 86 |
* |
| 62 | 87 |
* ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` |
| 63 | 88 |
* (또는 개별 파티셜)로 한 번 불러와야 한다. |
| 64 | 89 |
*/ |
| 65 | 90 |
export function FoxConditionalOption({
|
| 91 |
+ control = "radio", |
|
| 66 | 92 |
value, |
| 67 | 93 |
label, |
| 68 | 94 |
description, |
... | ... | @@ -70,7 +96,7 @@ |
| 70 | 96 |
detail, |
| 71 | 97 |
defaultDetail, |
| 72 | 98 |
onChange, |
| 73 |
- onCheck, |
|
| 99 |
+ onCheckedChange, |
|
| 74 | 100 |
placeholder, |
| 75 | 101 |
orientation = "vertical", |
| 76 | 102 |
name, |
... | ... | @@ -81,7 +107,7 @@ |
| 81 | 107 |
hidden = false, |
| 82 | 108 |
className, |
| 83 | 109 |
}: FoxConditionalOptionProps) {
|
| 84 |
- const radioRef = useRef<HTMLInputElement>(null); |
|
| 110 |
+ const inputRef = useRef<HTMLInputElement>(null); |
|
| 85 | 111 |
|
| 86 | 112 |
if (hidden) {
|
| 87 | 113 |
return null; |
... | ... | @@ -91,27 +117,35 @@ |
| 91 | 117 |
const handleDetail = (next: string) => {
|
| 92 | 118 |
onChange?.(next); |
| 93 | 119 |
|
| 94 |
- // 선택 여부는 DOM에서 읽는다 — 형제 칸을 고르면 이 칸이 조용히 꺼지므로 따로 들고 있으면 |
|
| 95 |
- // 어긋난다. 이미 켜져 있으면 다시 클릭해 change를 중복으로 뛰우지 않는다. |
|
| 96 |
- if (radioRef.current && !radioRef.current.checked) {
|
|
| 97 |
- radioRef.current.click(); |
|
| 120 |
+ // 켜짐 여부는 DOM에서 읽는다 — 라디오에서 형제 칸을 고르면 이 칸이 조용히 꺼지므로 따로 |
|
| 121 |
+ // 들고 있으면 어긋난다. 이미 켜져 있으면 다시 눌러 끄지 않는다. |
|
| 122 |
+ if (inputRef.current && !inputRef.current.checked) {
|
|
| 123 |
+ inputRef.current.click(); |
|
| 98 | 124 |
} |
| 125 |
+ }; |
|
| 126 |
+ |
|
| 127 |
+ // 두 컨트롤이 같은 props를 받는다. 다른 것은 change가 무엇을 넘기는지뿐이다. |
|
| 128 |
+ const shared = {
|
|
| 129 |
+ ref: inputRef, |
|
| 130 |
+ name, |
|
| 131 |
+ value, |
|
| 132 |
+ label, |
|
| 133 |
+ description, |
|
| 134 |
+ size, |
|
| 135 |
+ checked, |
|
| 136 |
+ defaultChecked, |
|
| 137 |
+ disabled, |
|
| 99 | 138 |
}; |
| 100 | 139 |
|
| 101 | 140 |
return ( |
| 102 | 141 |
<div className={cx("fox-conditional-option", ORIENTATION_CLASS[orientation], className)}>
|
| 103 |
- <FoxRadio |
|
| 104 |
- ref={radioRef}
|
|
| 105 |
- name={name}
|
|
| 106 |
- value={value}
|
|
| 107 |
- label={label}
|
|
| 108 |
- description={description}
|
|
| 109 |
- size={size}
|
|
| 110 |
- checked={checked}
|
|
| 111 |
- defaultChecked={defaultChecked}
|
|
| 112 |
- disabled={disabled}
|
|
| 113 |
- onChange={onCheck}
|
|
| 114 |
- /> |
|
| 142 |
+ {control === "checkbox" ? (
|
|
| 143 |
+ // 체크박스는 끌 때도 뛰므로 켜짐 여부를 그대로 넘긴다. |
|
| 144 |
+ <FoxCheckbox {...shared} onChange={(next) => onCheckedChange?.(next)} />
|
|
| 145 |
+ ) : ( |
|
| 146 |
+ // 라디오는 켜질 때만 뛴다 — 그래서 `true`로 고정해 넘긴다. |
|
| 147 |
+ <FoxRadio {...shared} onChange={() => onCheckedChange?.(true)} />
|
|
| 148 |
+ )} |
|
| 115 | 149 |
|
| 116 | 150 |
<div className="fox-conditional-option__detail"> |
| 117 | 151 |
<FoxSelect |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -3,6 +3,9 @@ |
| 3 | 3 |
export * from "./fox-button-group"; |
| 4 | 4 |
export * from "./fox-button-panel"; |
| 5 | 5 |
export * from "./fox-certified"; |
| 6 |
+export * from "./fox-check-icon"; |
|
| 7 |
+export * from "./fox-checkbox"; |
|
| 8 |
+export * from "./fox-checkbox-group"; |
|
| 6 | 9 |
export * from "./fox-conditional-option"; |
| 7 | 10 |
export * from "./fox-email"; |
| 8 | 11 |
export * from "./fox-file-upload"; |
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -9,6 +9,9 @@ |
| 9 | 9 |
import { FoxLinkButton } from "../core/components/fox-link-button";
|
| 10 | 10 |
import { FoxAddress, type FoxAddressSearchResult, type FoxAddressValue } from "../core/components/fox-address";
|
| 11 | 11 |
import { FoxCertified } from "../core/components/fox-certified";
|
| 12 |
+import { FoxCheckIcon } from "../core/components/fox-check-icon";
|
|
| 13 |
+import { FoxCheckbox } from "../core/components/fox-checkbox";
|
|
| 14 |
+import { FoxCheckboxGroup } from "../core/components/fox-checkbox-group";
|
|
| 12 | 15 |
import { FoxConditionalOption } from "../core/components/fox-conditional-option";
|
| 13 | 16 |
import { FoxEmail } from "../core/components/fox-email";
|
| 14 | 17 |
import { FoxFormLabel } from "../core/components/fox-form-label";
|
... | ... | @@ -1175,7 +1178,7 @@ |
| 1175 | 1178 |
id: "fox-conditional-option", |
| 1176 | 1179 |
name: "FoxConditionalOption", |
| 1177 | 1180 |
description: |
| 1178 |
- "Figma 시안(conditional-option 190:2163) 라디오 한 칸과 select가 합쳐 하나의 답을 이룹니다. select는 이 칸이 골라지지 않아도 조작할 수 있고, 값을 고르면 라디오가 자동으로 켜집니다. 잠가 두면 그 자동 선택 자체가 불가능해지기 때문입니다(disabled는 클릭을 먹지 않습니다). 켜는 방법은 숨은 input을 실제로 클릭하는 것이라 형제 칸이 꺼지는 것과 change 이벤트가 브라우저 규칙 그대로 일어납니다. ⚠️ 시안의 변형 이름이 실제 동작과 반대라서(FoxRadioGroup과 같은 문제) 동작 기준으로 다시 이름 붙였습니다 — 시안 row가 vertical, 시안 col이 horizontal입니다.", |
|
| 1181 |
+ "Figma 시안(conditional-option 190:2163) 컨트롤 한 칸과 select가 합쳐 하나의 답을 이룹니다. control로 라디오·체크박스를 고릅니다(시안의 trigger — 이 패키지에서 trigger는 fox-select__trigger라는 다른 뜻으로 이미 쓰여 이름을 바꿨습니다). select는 이 칸이 골라지지 않아도 조작할 수 있고, 값을 고르면 라디오가 자동으로 켜집니다. 잠가 두면 그 자동 선택 자체가 불가능해지기 때문입니다(disabled는 클릭을 먹지 않습니다). 켜는 방법은 숨은 input을 실제로 클릭하는 것이라 형제 칸이 꺼지는 것과 change 이벤트가 브라우저 규칙 그대로 일어납니다. ⚠️ 시안의 변형 이름이 실제 동작과 반대라서(FoxRadioGroup과 같은 문제) 동작 기준으로 다시 이름 붙였습니다 — 시안 row가 vertical, 시안 col이 horizontal입니다.", |
|
| 1179 | 1182 |
variants: [ |
| 1180 | 1183 |
{
|
| 1181 | 1184 |
label: "vertical (기본) — select가 아래로 내려가고 왼쪽 끝에 붙는다", |
... | ... | @@ -1233,7 +1236,7 @@ |
| 1233 | 1236 |
), |
| 1234 | 1237 |
}, |
| 1235 | 1238 |
{
|
| 1236 |
- label: "disabled — 라디오와 select가 함께 잠긴다", |
|
| 1239 |
+ label: "disabled — 컨트롤과 select가 함께 잠긴다", |
|
| 1237 | 1240 |
node: ( |
| 1238 | 1241 |
<FoxConditionalOption |
| 1239 | 1242 |
name="co-off" |
... | ... | @@ -1244,6 +1247,46 @@ |
| 1244 | 1247 |
placeholder="주소를 고르세요" |
| 1245 | 1248 |
disabled |
| 1246 | 1249 |
/> |
| 1250 |
+ ), |
|
| 1251 |
+ }, |
|
| 1252 |
+ {
|
|
| 1253 |
+ label: "control=\"checkbox\" — 여러 개를 동시에 켤 수 있다", |
|
| 1254 |
+ node: ( |
|
| 1255 |
+ <FoxCheckboxGroup name="co-cb" label="추가 옵션"> |
|
| 1256 |
+ <FoxCheckbox value="gift" label="선물 포장" /> |
|
| 1257 |
+ <FoxConditionalOption |
|
| 1258 |
+ control="checkbox" |
|
| 1259 |
+ value="card" |
|
| 1260 |
+ label="카드 동봉" |
|
| 1261 |
+ description="문구를 고르면 이 항목이 자동으로 켜집니다." |
|
| 1262 |
+ options={SELECT_ITEMS}
|
|
| 1263 |
+ placeholder="문구를 고르세요" |
|
| 1264 |
+ /> |
|
| 1265 |
+ </FoxCheckboxGroup> |
|
| 1266 |
+ ), |
|
| 1267 |
+ }, |
|
| 1268 |
+ {
|
|
| 1269 |
+ label: "control 비교 — 라디오는 하나만, 체크박스는 여럿", |
|
| 1270 |
+ node: ( |
|
| 1271 |
+ <div className={styles.demoStack}>
|
|
| 1272 |
+ <FoxConditionalOption |
|
| 1273 |
+ orientation="horizontal" |
|
| 1274 |
+ name="co-cmp-r" |
|
| 1275 |
+ value="a" |
|
| 1276 |
+ label="radio (기본)" |
|
| 1277 |
+ options={SELECT_ITEMS}
|
|
| 1278 |
+ placeholder="고르세요" |
|
| 1279 |
+ /> |
|
| 1280 |
+ <FoxConditionalOption |
|
| 1281 |
+ orientation="horizontal" |
|
| 1282 |
+ control="checkbox" |
|
| 1283 |
+ name="co-cmp-c" |
|
| 1284 |
+ value="a" |
|
| 1285 |
+ label="checkbox" |
|
| 1286 |
+ options={SELECT_ITEMS}
|
|
| 1287 |
+ placeholder="고르세요" |
|
| 1288 |
+ /> |
|
| 1289 |
+ </div> |
|
| 1247 | 1290 |
), |
| 1248 | 1291 |
}, |
| 1249 | 1292 |
], |
... | ... | @@ -1634,5 +1677,155 @@ |
| 1634 | 1677 |
), |
| 1635 | 1678 |
}, |
| 1636 | 1679 |
], |
| 1680 |
+ }, {
|
|
| 1681 |
+ id: "fox-check-icon", |
|
| 1682 |
+ name: "FoxCheckIcon", |
|
| 1683 |
+ description: |
|
| 1684 |
+ "체크박스의 네모 표시만 담당합니다. 상자는 CSS로 그리고 안쪽 표시만 SVG입니다 — 상자는 테두리·배경·모서리로 끝나지만 체크는 임의의 path라 CSS로 그릴 수 없습니다(FoxRadioIcon은 안쪽이 원이라 전부 CSS였습니다). 선택과 부분선택은 상자 모양이 같고 안쪽 표시만 다릅니다. FoxCheckbox 안에서는 네이티브 input의 :checked·:indeterminate·:disabled가 스타일 훅을 겸해서 클래스가 필요 없고, 아래처럼 단독으로 상태를 비교할 때만 모디파이어로 못박습니다.", |
|
| 1685 |
+ variants: [ |
|
| 1686 |
+ {
|
|
| 1687 |
+ label: "lg — 기본 / 선택 / 부분선택 / 비활성 / 선택+비활성 / 부분선택+비활성", |
|
| 1688 |
+ node: ( |
|
| 1689 |
+ <> |
|
| 1690 |
+ <FoxCheckIcon size="lg" /> |
|
| 1691 |
+ <FoxCheckIcon size="lg" checked /> |
|
| 1692 |
+ <FoxCheckIcon size="lg" indeterminate /> |
|
| 1693 |
+ <FoxCheckIcon size="lg" disabled /> |
|
| 1694 |
+ <FoxCheckIcon size="lg" checked disabled /> |
|
| 1695 |
+ <FoxCheckIcon size="lg" indeterminate disabled /> |
|
| 1696 |
+ </> |
|
| 1697 |
+ ), |
|
| 1698 |
+ }, |
|
| 1699 |
+ {
|
|
| 1700 |
+ label: "md — 기본 / 선택 / 부분선택 / 비활성 / 선택+비활성 / 부분선택+비활성", |
|
| 1701 |
+ node: ( |
|
| 1702 |
+ <> |
|
| 1703 |
+ <FoxCheckIcon size="md" /> |
|
| 1704 |
+ <FoxCheckIcon size="md" checked /> |
|
| 1705 |
+ <FoxCheckIcon size="md" indeterminate /> |
|
| 1706 |
+ <FoxCheckIcon size="md" disabled /> |
|
| 1707 |
+ <FoxCheckIcon size="md" checked disabled /> |
|
| 1708 |
+ <FoxCheckIcon size="md" indeterminate disabled /> |
|
| 1709 |
+ </> |
|
| 1710 |
+ ), |
|
| 1711 |
+ }, |
|
| 1712 |
+ ], |
|
| 1713 |
+ }, |
|
| 1714 |
+ {
|
|
| 1715 |
+ id: "fox-checkbox", |
|
| 1716 |
+ name: "FoxCheckbox", |
|
| 1717 |
+ description: |
|
| 1718 |
+ "네모 표시 + 라벨 + 부가설명을 묶은 체크박스 한 칸입니다. 루트가 <label>이라 라벨이나 설명을 눌러도 켜집니다. 라디오와 달리 name이 같아도 서로를 끄지 않아 여러 개를 동시에 고를 수 있고, 폼 전송 시 고른 것들이 함께 나갑니다. ⚠️ 부분선택은 HTML 속성이 아니라 DOM 프로퍼티라 마크업만으로는 켤 수 없습니다 — 컴포넌트가 대신 넣어 줍니다.", |
|
| 1719 |
+ variants: [ |
|
| 1720 |
+ {
|
|
| 1721 |
+ label: "여러 개를 동시에 고를 수 있다 (같은 name이어도 서로 끄지 않는다)", |
|
| 1722 |
+ node: ( |
|
| 1723 |
+ <div className={styles.demoStack}>
|
|
| 1724 |
+ <FoxCheckbox name="cb-terms" value="service" label="서비스 이용약관" defaultChecked /> |
|
| 1725 |
+ <FoxCheckbox name="cb-terms" value="privacy" label="개인정보 처리방침" defaultChecked /> |
|
| 1726 |
+ <FoxCheckbox name="cb-terms" value="marketing" label="마케팅 정보 수신" /> |
|
| 1727 |
+ </div> |
|
| 1728 |
+ ), |
|
| 1729 |
+ }, |
|
| 1730 |
+ {
|
|
| 1731 |
+ label: "부분선택 — 전체 동의처럼 자식 일부만 켜진 상태", |
|
| 1732 |
+ node: ( |
|
| 1733 |
+ <div className={styles.demoStack}>
|
|
| 1734 |
+ <FoxCheckbox name="cb-ind" value="all" label="전체 동의" indeterminate /> |
|
| 1735 |
+ <FoxCheckbox name="cb-ind" value="a" label="필수 약관" defaultChecked /> |
|
| 1736 |
+ <FoxCheckbox name="cb-ind" value="b" label="선택 약관" /> |
|
| 1737 |
+ </div> |
|
| 1738 |
+ ), |
|
| 1739 |
+ }, |
|
| 1740 |
+ {
|
|
| 1741 |
+ label: "부가설명 — 여러 줄이어도 네모 표시는 첫 줄에 붙는다", |
|
| 1742 |
+ node: ( |
|
| 1743 |
+ <div className={styles.demoStack}>
|
|
| 1744 |
+ <FoxCheckbox |
|
| 1745 |
+ name="cb-desc" |
|
| 1746 |
+ value="a" |
|
| 1747 |
+ label="마케팅 정보 수신" |
|
| 1748 |
+ description="이벤트·혜택 소식을 받아봅니다." |
|
| 1749 |
+ defaultChecked |
|
| 1750 |
+ /> |
|
| 1751 |
+ <FoxCheckbox |
|
| 1752 |
+ name="cb-desc" |
|
| 1753 |
+ value="b" |
|
| 1754 |
+ label="야간 알림" |
|
| 1755 |
+ description="밤 9시 이후에도 알림을 받습니다. 설명이 길어지면 이렇게 줄이 넘어가고, 네모 표시는 첫 줄에 붙어 있어야 정상입니다." |
|
| 1756 |
+ /> |
|
| 1757 |
+ </div> |
|
| 1758 |
+ ), |
|
| 1759 |
+ }, |
|
| 1760 |
+ {
|
|
| 1761 |
+ label: "크기 (size)", |
|
| 1762 |
+ node: ( |
|
| 1763 |
+ <div className={styles.demoStack}>
|
|
| 1764 |
+ <FoxCheckbox name="cb-lg" value="a" label="lg" description="부가설명" defaultChecked /> |
|
| 1765 |
+ <FoxCheckbox name="cb-md" value="a" label="md" description="부가설명" size="md" defaultChecked /> |
|
| 1766 |
+ </div> |
|
| 1767 |
+ ), |
|
| 1768 |
+ }, |
|
| 1769 |
+ {
|
|
| 1770 |
+ label: "비활성 — 글자색도 흐려지고 클릭이 막힌다", |
|
| 1771 |
+ node: ( |
|
| 1772 |
+ <div className={styles.demoStack}>
|
|
| 1773 |
+ <FoxCheckbox name="cb-off" value="a" label="비활성" description="선택할 수 없습니다." disabled /> |
|
| 1774 |
+ <FoxCheckbox name="cb-off2" value="a" label="비활성 + 선택됨" description="체크가 남고 색만 흐려집니다." disabled defaultChecked /> |
|
| 1775 |
+ <FoxCheckbox name="cb-off3" value="a" label="비활성 + 부분선택" description="대시가 남고 색만 흐려집니다." disabled indeterminate /> |
|
| 1776 |
+ </div> |
|
| 1777 |
+ ), |
|
| 1778 |
+ }, |
|
| 1779 |
+ ], |
|
| 1780 |
+ }, |
|
| 1781 |
+ {
|
|
| 1782 |
+ id: "fox-checkbox-group", |
|
| 1783 |
+ name: "FoxCheckboxGroup", |
|
| 1784 |
+ description: |
|
| 1785 |
+ "체크박스 여러 칸의 배치와 name·size 통일을 책임집니다. 켜짐 여부는 각 FoxCheckbox의 네이티브 input이 소유하므로 그룹은 상태를 갖지 않습니다. role이 radiogroup이 아니라 group입니다 — 체크박스는 서로를 끄지 않아 하나만 고르는 묶음이 아니고, 화살표 이동으로 옮겨 다니지도 않아 각 칸이 독립적으로 Tab을 받습니다. 배치 값은 FoxRadioGroup과 같습니다.", |
|
| 1786 |
+ variants: [ |
|
| 1787 |
+ {
|
|
| 1788 |
+ label: "vertical (기본) — 간격 gap-7, 왼쪽 끝 정렬", |
|
| 1789 |
+ node: ( |
|
| 1790 |
+ <FoxCheckboxGroup name="cbg-v" label="약관 동의"> |
|
| 1791 |
+ <FoxCheckbox value="service" label="서비스 이용약관" defaultChecked /> |
|
| 1792 |
+ <FoxCheckbox value="privacy" label="개인정보 처리방침" /> |
|
| 1793 |
+ <FoxCheckbox value="marketing" label="마케팅 정보 수신" /> |
|
| 1794 |
+ </FoxCheckboxGroup> |
|
| 1795 |
+ ), |
|
| 1796 |
+ }, |
|
| 1797 |
+ {
|
|
| 1798 |
+ label: "horizontal — 행 gap-7 / 열 gap-9, 폭이 모자라면 줄바꿈", |
|
| 1799 |
+ node: ( |
|
| 1800 |
+ <FoxCheckboxGroup orientation="horizontal" name="cbg-h" label="관심 분야"> |
|
| 1801 |
+ <FoxCheckbox value="dev" label="개발" defaultChecked /> |
|
| 1802 |
+ <FoxCheckbox value="design" label="디자인" /> |
|
| 1803 |
+ <FoxCheckbox value="pm" label="기획" /> |
|
| 1804 |
+ </FoxCheckboxGroup> |
|
| 1805 |
+ ), |
|
| 1806 |
+ }, |
|
| 1807 |
+ {
|
|
| 1808 |
+ label: "horizontal + 줄바꿈 — 좁은 무대에서 다음 줄로 넘어간다", |
|
| 1809 |
+ node: ( |
|
| 1810 |
+ <div className={styles.demoNarrow}>
|
|
| 1811 |
+ <FoxCheckboxGroup orientation="horizontal" name="cbg-wrap" label="줄바꿈 확인"> |
|
| 1812 |
+ {["첫째", "둘째", "셋째", "넷째", "다섯째"].map((label, i) => (
|
|
| 1813 |
+ <FoxCheckbox key={label} value={label} label={label} defaultChecked={i === 0} />
|
|
| 1814 |
+ ))} |
|
| 1815 |
+ </FoxCheckboxGroup> |
|
| 1816 |
+ </div> |
|
| 1817 |
+ ), |
|
| 1818 |
+ }, |
|
| 1819 |
+ {
|
|
| 1820 |
+ label: "size — 자식이 준 값은 무시되고 그룹 값으로 통일된다", |
|
| 1821 |
+ node: ( |
|
| 1822 |
+ <FoxCheckboxGroup orientation="horizontal" name="cbg-md" size="md" label="md 크기"> |
|
| 1823 |
+ {/* 일부러 어긋난 size를 준다 — 둘 다 md로 나와야 정상 */}
|
|
| 1824 |
+ <FoxCheckbox value="a" label="첫째" size="lg" defaultChecked /> |
|
| 1825 |
+ <FoxCheckbox value="b" label="둘째" /> |
|
| 1826 |
+ </FoxCheckboxGroup> |
|
| 1827 |
+ ), |
|
| 1828 |
+ }, |
|
| 1829 |
+ ], |
|
| 1637 | 1830 |
}, |
| 1638 | 1831 |
]; |
+++ @fox/styles/_fox-check-icon.scss
... | ... | @@ -0,0 +1,136 @@ |
| 1 | +// FoxCheckIcon — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma check-icon | |
| 3 | +// | |
| 4 | +// 체크박스의 네모 표시만 담당한다. 라벨·부가설명까지 묶은 것은 `fox-checkbox`다. | |
| 5 | +// | |
| 6 | +// 상자는 CSS로 그리고 안쪽 표시만 SVG다 — 상자는 테두리·배경·모서리로 끝나지만 체크는 임의의 | |
| 7 | +// path라 CSS로 그릴 수 없다. 실무에서도 이 조합이 표준이다(`fox-radio-icon`은 안쪽이 원이라 | |
| 8 | +// 전부 CSS였다). | |
| 9 | +// | |
| 10 | +// 마크업 계약 (React 밖 소비자용): | |
| 11 | +// <span class="fox-check-icon fox-check-icon--lg"> | |
| 12 | +// <svg class="fox-check-icon__check">…svg…</svg> | |
| 13 | +// <svg class="fox-check-icon__dash">…svg…</svg> | |
| 14 | +// </span> | |
| 15 | +// | |
| 16 | +// 두 표시를 **항상 마크업에 둔다.** 켜졌을 때만 넣는 게 아니라 CSS가 보이기/숨기기를 정한다 — | |
| 17 | +// 그래야 비제어 입력에서도(React가 켜짐 여부를 모르는 상태에서도) 모양이 맞는다. | |
| 18 | +// | |
| 19 | +// 상태를 거는 방법이 둘이다. | |
| 20 | +// - `fox-checkbox` 안에서는 네이티브 `<input type="checkbox">`가 형제로 앞에 있고, 그 | |
| 21 | +// 요소의 `:checked`·`:indeterminate`·`:disabled`가 그대로 스타일 훅이 된다(규약 3). | |
| 22 | +// - 단독으로 쓸 때(예: 상태 비교 예제)는 `--checked`·`--indeterminate`·`--disabled` | |
| 23 | +// 모디파이어로 고정한다. | |
| 24 | +// | |
| 25 | +// 선택과 부분선택은 상자 모양이 같다 — 시안이 두 상태에 같은 값을 준다. 안쪽 표시만 다르다. | |
| 26 | +// 선택된 상자에는 시안이 테두리를 주지 않지만, 테두리를 없애는 대신 배경과 같은 색으로 둔다 — | |
| 27 | +// 두께가 그대로라 상태가 바뀌어도 안쪽 표시 자리가 흔들리지 않는다. | |
| 28 | +// | |
| 29 | +// ⚠️ 포커스 표시는 시안에 없다. 네이티브 input을 시각적으로 숨기므로 브라우저 기본 링도 | |
| 30 | +// 사라져서, 키보드 사용자를 위해 이 파일에서 만들어 준다(`fox-radio-icon`과 같은 처리). | |
| 31 | +// | |
| 32 | +// ⚠️ md의 안쪽 표시 크기는 시안에서 확인하지 못했다. lg의 비율(상자 `form-icon-lg` : 표시 | |
| 33 | +// `icon-4`)을 그대로 적용해 `icon-3`으로 두었다. | |
| 34 | +// | |
| 35 | +// ⚠️ 비활성이면서 켜졌을 때 안쪽 표시의 색도 시안 미확인이다. `fox-radio-icon`이 같은 자리에 | |
| 36 | +// 쓰는 `element-neutral-disabled-strong`으로 맞췄다. | |
| 37 | +// | |
| 38 | +// 토큰이 아닌 값(사용자 승인): | |
| 39 | +// - box-sizing / display / flex: 구조 규칙. | |
| 40 | + | |
| 41 | +@use "@fox/styles/abstracts" as fox; | |
| 42 | + | |
| 43 | +.fox-check-icon { | |
| 44 | + // 테두리가 바깥 크기를 늘리지 않게 한다 — 크기는 `form-icon-*`이 정한 값이어야 한다. | |
| 45 | + box-sizing: border-box; | |
| 46 | + display: flex; | |
| 47 | + flex-shrink: 0; | |
| 48 | + align-items: center; | |
| 49 | + justify-content: center; | |
| 50 | + border: fox.border(1) solid fox.color(element-neutral-strong); | |
| 51 | + border-radius: fox.radius(2); | |
| 52 | + background: fox.color(element-neutral-inverse); | |
| 53 | + | |
| 54 | + // 안쪽 표시 둘. 크기는 크기 모디파이어가, 보이기는 상태가 정한다. `currentColor`라 색은 | |
| 55 | + // 상자 규칙이 정한 값을 그대로 물려받는다. | |
| 56 | + &__check, | |
| 57 | + &__dash { | |
| 58 | + display: none; | |
| 59 | + flex-shrink: 0; | |
| 60 | + } | |
| 61 | + | |
| 62 | + // ── 크기 ──────────────────────────────────────────────────────────────────── | |
| 63 | + // 시안이 바깥 크기에 `form-icon-*`을 건다 — 반응형 토큰이라 모바일에서 한 단계 작아진다. | |
| 64 | + &--lg { | |
| 65 | + inline-size: fox.form(icon-lg); | |
| 66 | + block-size: fox.form(icon-lg); | |
| 67 | + } | |
| 68 | + | |
| 69 | + &--lg &__check, | |
| 70 | + &--lg &__dash { | |
| 71 | + inline-size: fox.icon(4); | |
| 72 | + block-size: fox.icon(4); | |
| 73 | + } | |
| 74 | + | |
| 75 | + &--md { | |
| 76 | + inline-size: fox.form(icon-md); | |
| 77 | + block-size: fox.form(icon-md); | |
| 78 | + } | |
| 79 | + | |
| 80 | + &--md &__check, | |
| 81 | + &--md &__dash { | |
| 82 | + inline-size: fox.icon(3); | |
| 83 | + block-size: fox.icon(3); | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 87 | +// ── 선택 · 부분선택 ─────────────────────────────────────────────────────────── | |
| 88 | +// 상자는 둘이 같다. 두 선택자를 함께 쓴다 — `fox-checkbox` 안에서는 네이티브 상태가, | |
| 89 | +// 단독으로 쓸 때는 모디파이어가 같은 모양을 만든다. | |
| 90 | +.fox-check-icon--checked, | |
| 91 | +.fox-check-icon--indeterminate, | |
| 92 | +.fox-checkbox__input:checked ~ .fox-check-icon, | |
| 93 | +.fox-checkbox__input:indeterminate ~ .fox-check-icon { | |
| 94 | + border-color: fox.color(element-theme-primary); | |
| 95 | + background: fox.color(element-theme-primary); | |
| 96 | + // 채워진 상자 위에 얹히므로 안쪽 표시는 반전색이다. | |
| 97 | + color: fox.color(element-neutral-inverse); | |
| 98 | +} | |
| 99 | + | |
| 100 | +.fox-check-icon--checked .fox-check-icon__check, | |
| 101 | +.fox-checkbox__input:checked ~ .fox-check-icon .fox-check-icon__check { | |
| 102 | + display: block; | |
| 103 | +} | |
| 104 | + | |
| 105 | +// 부분선택이 선택보다 뒤에 와야 이긴다 — DOM에서는 둘 다 켜져 있을 수 있는데, 그때 보여야 | |
| 106 | +// 하는 건 부분선택이다. | |
| 107 | +.fox-check-icon--indeterminate .fox-check-icon__check, | |
| 108 | +.fox-checkbox__input:indeterminate ~ .fox-check-icon .fox-check-icon__check { | |
| 109 | + display: none; | |
| 110 | +} | |
| 111 | + | |
| 112 | +.fox-check-icon--indeterminate .fox-check-icon__dash, | |
| 113 | +.fox-checkbox__input:indeterminate ~ .fox-check-icon .fox-check-icon__dash { | |
| 114 | + display: block; | |
| 115 | +} | |
| 116 | + | |
| 117 | +// ── 비활성 ──────────────────────────────────────────────────────────────────── | |
| 118 | +// 선택 규칙보다 뒤에 와야 이긴다 — 비활성이면 켜짐 여부와 무관하게 상자 색이 같다. | |
| 119 | +.fox-check-icon--disabled, | |
| 120 | +.fox-checkbox__input:disabled ~ .fox-check-icon { | |
| 121 | + border-color: fox.color(border-neutral-disabled); | |
| 122 | + background: fox.color(element-neutral-disabled); | |
| 123 | +} | |
| 124 | + | |
| 125 | +// 안쪽 표시는 숨기지 않는다 — 켜진 채 비활성이어도 켜짐 여부가 보여야 한다. 색만 흐려진다. | |
| 126 | +.fox-check-icon--disabled, | |
| 127 | +.fox-checkbox__input:disabled ~ .fox-check-icon { | |
| 128 | + color: fox.color(element-neutral-disabled-strong); | |
| 129 | +} | |
| 130 | + | |
| 131 | +// ── 포커스 ──────────────────────────────────────────────────────────────────── | |
| 132 | +// input이 시각적으로 숨겨져 브라우저 기본 링이 보이지 않으므로 여기서 대신 그린다. | |
| 133 | +.fox-checkbox__input:focus-visible ~ .fox-check-icon { | |
| 134 | + outline: fox.border(2) solid fox.color(border-theme-primary); | |
| 135 | + outline-offset: fox.border(1); | |
| 136 | +} |
+++ @fox/styles/_fox-checkbox-group.scss
... | ... | @@ -0,0 +1,42 @@ |
| 1 | +// FoxCheckboxGroup — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma checkbox-group | |
| 3 | +// | |
| 4 | +// 체크박스 여러 칸의 배치만 책임진다. 켜짐 여부는 각 `fox-checkbox`의 네이티브 input이 | |
| 5 | +// 소유한다 — 그룹이 상태를 갖지 않는다. | |
| 6 | +// | |
| 7 | +// 마크업 계약 (React 밖 소비자용): | |
| 8 | +// <div class="fox-checkbox-group fox-checkbox-group--vertical" role="group" aria-label="약관"> | |
| 9 | +// <label class="fox-checkbox fox-checkbox--lg">…</label> | |
| 10 | +// <label class="fox-checkbox fox-checkbox--lg">…</label> | |
| 11 | +// </div> | |
| 12 | +// | |
| 13 | +// 라디오 묶음과 달리 `role="radiogroup"`이 아니라 `role="group"`이다 — 체크박스는 서로를 | |
| 14 | +// 끄지 않아 "하나만 고르는 묶음"이 아니기 때문이다. | |
| 15 | +// | |
| 16 | +// 배치 규칙은 `fox-radio-group`과 같은 값이다. 시안에서 두 묶음이 같은 간격을 쓴다. | |
| 17 | +// | |
| 18 | +// 토큰이 아닌 값(사용자 승인): | |
| 19 | +// - display / flex-direction / flex-wrap / align-*: 배치 규칙. | |
| 20 | + | |
| 21 | +@use "@fox/styles/abstracts" as fox; | |
| 22 | + | |
| 23 | +.fox-checkbox-group { | |
| 24 | + display: flex; | |
| 25 | + | |
| 26 | + // 세로 — 한 칸씩 아래로 쌓인다. 라벨 길이가 달라도 왼쪽 끝이 맞아야 하므로 flex-start다. | |
| 27 | + &--vertical { | |
| 28 | + flex-direction: column; | |
| 29 | + align-items: flex-start; | |
| 30 | + gap: fox.gap(7); | |
| 31 | + } | |
| 32 | + | |
| 33 | + // 가로 — 폭이 모자라면 다음 줄로 넘어간다. `align-content`가 있어야 여러 줄이 됐을 때 | |
| 34 | + // 줄 묶음 전체가 가운데 정렬된다. | |
| 35 | + &--horizontal { | |
| 36 | + flex-direction: row; | |
| 37 | + flex-wrap: wrap; | |
| 38 | + align-items: center; | |
| 39 | + align-content: center; | |
| 40 | + gap: fox.gap(7) fox.gap(9); | |
| 41 | + } | |
| 42 | +} |
+++ @fox/styles/_fox-checkbox.scss
... | ... | @@ -0,0 +1,123 @@ |
| 1 | +// FoxCheckbox — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma checkbox | |
| 3 | +// | |
| 4 | +// 체크박스 한 칸이다 — 네모 표시(`fox-check-icon`) + 라벨 + 부가설명. | |
| 5 | +// | |
| 6 | +// 마크업 계약 (React 밖 소비자용): | |
| 7 | +// <label class="fox-checkbox fox-checkbox--lg"> | |
| 8 | +// <input type="checkbox" class="fox-checkbox__input" name="terms" value="privacy"> | |
| 9 | +// <span class="fox-check-icon fox-check-icon--lg">…svg…</span> | |
| 10 | +// <span class="fox-checkbox__body"> | |
| 11 | +// <span class="fox-checkbox__label">라벨</span> | |
| 12 | +// <span class="fox-checkbox__description">부가 설명</span> <!-- 선택 --> | |
| 13 | +// </span> | |
| 14 | +// </label> | |
| 15 | +// | |
| 16 | +// 루트가 `<label>`이라 어디를 눌러도 브라우저가 선택을 처리한다. 상태는 전부 네이티브 | |
| 17 | +// `<input type="checkbox">`가 소유한다(규약 3). | |
| 18 | +// | |
| 19 | +// 라디오와 다른 점은 **여러 개를 동시에 고를 수 있다**는 것뿐이다. `name`이 같아도 서로를 | |
| 20 | +// 끄지 않으므로 그룹이 단일 선택을 강제하지 않고, 폼 전송 시 고른 것들이 함께 나간다. | |
| 21 | +// | |
| 22 | +// ⚠️ 부분선택(`:indeterminate`)은 HTML 속성이 아니라 DOM 프로퍼티다. 마크업만으로는 켤 수 | |
| 23 | +// 없고 `el.indeterminate = true` 한 줄이 필요하다 — React 밖 소비자가 알아야 할 지점이다. | |
| 24 | +// | |
| 25 | +// input은 순서상 **맨 앞**에 와야 한다. 네모 표시와 본문이 `~` 형제 선택자로 그 상태를 | |
| 26 | +// 따라가기 때문이다. 시각적으로만 숨기고 포커스는 살려 둔다 — `display: none`으로 지우면 | |
| 27 | +// 키보드로 도달할 수 없다. | |
| 28 | +// | |
| 29 | +// 라벨 서체는 시안 확인 완료(body/lg·body/md · font-neutral-strong · 150% · -0.025em). | |
| 30 | +// ⚠️ 부가설명의 서체는 시안 미확인이라 `fox-radio`와 같은 값으로 맞췄다. | |
| 31 | +// | |
| 32 | +// 토큰이 아닌 값(FoxButton과 동일한 근거로 승인된 항목): | |
| 33 | +// - line-height / letter-spacing: 라벨·부가설명의 시안 텍스트 스타일 값이나 Figma가 변수로 | |
| 34 | +// export하지 않는다. | |
| 35 | +// | |
| 36 | +// 토큰이 아닌 값(사용자 승인): | |
| 37 | +// - 아이콘의 위쪽 여백 `0.1rem`(1px): 시안 icon-area의 `padding-top` 값이고 변수로 바인딩 | |
| 38 | +// 되어 있지 않다. 크기·브레이크포인트가 바뀌어도 필요한 보정이 0.75~1.25px 범위라 하나로 | |
| 39 | +// 쓴다(1rem = 10px). | |
| 40 | +// - cursor / display / flex / position: 구조·상호작용 규칙. | |
| 41 | +// - 숨긴 input의 `inline-size: 0`: 크기가 아니라 "보이지 않게" 하는 구조 값이다. | |
| 42 | + | |
| 43 | +@use "@fox/styles/abstracts" as fox; | |
| 44 | + | |
| 45 | +.fox-checkbox { | |
| 46 | + // 숨긴 input의 위치 기준. 안 주면 input이 조상까지 올라가 엉뚱한 곳에 붙는다. | |
| 47 | + position: relative; | |
| 48 | + display: flex; | |
| 49 | + // 부가설명이 여러 줄이어도 네모 표시는 첫 줄에 붙어 있어야 한다. | |
| 50 | + align-items: flex-start; | |
| 51 | + gap: fox.gap(3); | |
| 52 | + cursor: pointer; | |
| 53 | + | |
| 54 | + // 시각적으로만 숨긴다. `opacity: 0`은 포커스를 유지하므로 키보드로 도달할 수 있고, | |
| 55 | + // 클릭은 루트 `<label>`이 받는다. | |
| 56 | + &__input { | |
| 57 | + position: absolute; | |
| 58 | + inline-size: 0; | |
| 59 | + block-size: 0; | |
| 60 | + margin: 0; | |
| 61 | + opacity: 0; | |
| 62 | + pointer-events: none; | |
| 63 | + } | |
| 64 | + | |
| 65 | + // 시안이 아이콘을 감싼 icon-area에 위쪽 여백 1px을 준다. 라벨의 line box(글자 크기 × 1.5)가 | |
| 66 | + // 아이콘보다 조금 높아서, 그만큼 내려야 글자 첫 줄과 시각 중심이 맞는다. | |
| 67 | + // | |
| 68 | + // 감싸는 요소를 따로 두지 않고 여백으로 처리한다 — 1px 내리는 것 말고는 하는 일이 없어 | |
| 69 | + // React 밖 소비자에게 마크업을 하나 더 요구할 이유가 없다. | |
| 70 | + .fox-check-icon { | |
| 71 | + margin-block-start: 0.1rem; | |
| 72 | + } | |
| 73 | + | |
| 74 | + &__body { | |
| 75 | + display: flex; | |
| 76 | + flex-direction: column; | |
| 77 | + gap: fox.gap(1); | |
| 78 | + align-items: flex-start; | |
| 79 | + } | |
| 80 | + | |
| 81 | + // 자간은 px이 아니라 em이다 — 라벨은 크기 수식어(lg·md)마다, 부가설명은 브레이크포인트마다 | |
| 82 | + // 글자 크기가 달라지는데, px으로 박으면 그 자리에서 비율이 어긋난다. | |
| 83 | + &__label { | |
| 84 | + // 시안이 라벨에 "가로 채우기"를 준다. 본문이 `align-items: flex-start`라 그대로 두면 | |
| 85 | + // 글자 너비만큼만 차지하므로, 라벨만 늘려 준다. | |
| 86 | + align-self: stretch; | |
| 87 | + color: fox.color(font-neutral-strong); | |
| 88 | + font-family: fox.font-family(body); | |
| 89 | + font-weight: fox.font-weight(regular); | |
| 90 | + line-height: 1.5; | |
| 91 | + letter-spacing: -0.025em; | |
| 92 | + } | |
| 93 | + | |
| 94 | + &__description { | |
| 95 | + color: fox.color(font-neutral-subtle); | |
| 96 | + font-family: fox.font-family(body); | |
| 97 | + font-weight: fox.font-weight(regular); | |
| 98 | + font-size: fox.font-size(body-sm); | |
| 99 | + line-height: 1.5; | |
| 100 | + letter-spacing: -0.025em; | |
| 101 | + } | |
| 102 | + | |
| 103 | + // ── 크기 ──────────────────────────────────────────────────────────────────── | |
| 104 | + &--lg &__label { | |
| 105 | + font-size: fox.font-size(body-lg); | |
| 106 | + } | |
| 107 | + | |
| 108 | + &--md &__label { | |
| 109 | + font-size: fox.font-size(body-md); | |
| 110 | + } | |
| 111 | +} | |
| 112 | + | |
| 113 | +// ── 비활성 ──────────────────────────────────────────────────────────────────── | |
| 114 | +// 글자색도 함께 흐려진다. 네모 표시는 `_fox-check-icon.scss`가 같은 `:disabled`를 보고 | |
| 115 | +// 처리하므로 여기서 건드리지 않는다. | |
| 116 | +.fox-checkbox:has(.fox-checkbox__input:disabled) { | |
| 117 | + cursor: default; | |
| 118 | +} | |
| 119 | + | |
| 120 | +.fox-checkbox__input:disabled ~ .fox-checkbox__body .fox-checkbox__label, | |
| 121 | +.fox-checkbox__input:disabled ~ .fox-checkbox__body .fox-checkbox__description { | |
| 122 | + color: fox.color(font-neutral-disabled); | |
| 123 | +} |
--- @fox/styles/_fox-conditional-option.scss
+++ @fox/styles/_fox-conditional-option.scss
... | ... | @@ -1,20 +1,23 @@ |
| 1 | 1 |
// FoxConditionalOption — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) |
| 2 | 2 |
// Figma conditional-option |
| 3 | 3 |
// |
| 4 |
-// 라디오 한 칸 + 그 칸을 골랐을 때만 의미가 생기는 select를 한 덩어리로 묶는다. 둘이 합쳐 |
|
| 5 |
-// 하나의 답이다 — "다른 주소로 배송(라디오) → 어느 주소인지(select)"처럼 후속 질문이 딸린 |
|
| 4 |
+// 컨트롤 한 칸 + 그 칸을 켰을 때만 의미가 생기는 select를 한 덩어리로 묶는다. 둘이 합쳐 |
|
| 5 |
+// 하나의 답이다 — "다른 주소로 배송(컨트롤) → 어느 주소인지(select)"처럼 후속 질문이 딸린 |
|
| 6 | 6 |
// 선택지에 쓴다. |
| 7 |
+// |
|
| 8 |
+// 앞의 컨트롤은 라디오와 체크박스 둘 다 될 수 있다(시안의 trigger). 하나만 골라야 하면 |
|
| 9 |
+// 라디오, 여러 개를 켤 수 있어야 하면 체크박스다. |
|
| 7 | 10 |
// |
| 8 | 11 |
// 마크업 계약 (React 밖 소비자용): |
| 9 | 12 |
// <div class="fox-conditional-option fox-conditional-option--vertical"> |
| 10 |
-// <label class="fox-radio fox-radio--lg">…</label> |
|
| 13 |
+// <label class="fox-radio fox-radio--lg">…</label> <!-- 또는 fox-checkbox --> |
|
| 11 | 14 |
// <div class="fox-conditional-option__detail"> |
| 12 | 15 |
// <div class="fox-select fox-select--md">…</div> |
| 13 | 16 |
// </div> |
| 14 | 17 |
// </div> |
| 15 | 18 |
// |
| 16 |
-// select는 라디오가 안 골라져 있어도 조작할 수 있다 — 값을 고르면 라디오가 자동으로 켜지는데, |
|
| 17 |
-// 잠가 두면 그 조작 자체가 불가능해진다. 선택 여부는 라디오의 네이티브 input이 소유하므로 |
|
| 19 |
+// select는 컨트롤이 안 켜져 있어도 조작할 수 있다 — 값을 고르면 컨트롤이 자동으로 켜지는데, |
|
| 20 |
+// 잠가 두면 그 조작 자체가 불가능해진다. 켜짐 여부는 컨트롤의 네이티브 input이 소유하므로 |
|
| 18 | 21 |
// 이 파일은 배치만 맡는다. |
| 19 | 22 |
// |
| 20 | 23 |
// ⚠️ 시안의 변형 이름이 실제 동작과 반대다(`fox-radio-group`과 같은 문제). 동작 기준으로 |
... | ... | @@ -32,19 +35,28 @@ |
| 32 | 35 |
|
| 33 | 36 |
.fox-conditional-option {
|
| 34 | 37 |
display: flex; |
| 38 |
+ // 간격은 방향과 무관하게 `gap-3` 하나다 — 옆에 서든 아래로 내려가든 시안이 같은 값을 쓴다. |
|
| 39 |
+ gap: fox.gap(3); |
|
| 35 | 40 |
|
| 36 |
- // select가 라디오 옆에 선다. 라벨 한 줄과 높이를 맞춘다. |
|
| 41 |
+ // select가 컨트롤 옆에 선다. 라벨 한 줄과 높이를 맞춘다. |
|
| 37 | 42 |
&--horizontal {
|
| 38 | 43 |
flex-direction: row; |
| 39 | 44 |
align-items: center; |
| 40 |
- gap: fox.gap(5); |
|
| 41 | 45 |
} |
| 42 | 46 |
|
| 43 |
- // select가 라디오 아래로 내려간다. |
|
| 47 |
+ // select가 컨트롤 아래로 내려간다. |
|
| 44 | 48 |
&--vertical {
|
| 45 | 49 |
flex-direction: column; |
| 46 | 50 |
align-items: flex-start; |
| 47 |
- gap: fox.gap(4); |
|
| 51 |
+ } |
|
| 52 |
+ |
|
| 53 |
+ // select가 행 폭을 채운다. `FoxSelect`는 폭을 스스로 정하지 않으므로(부모가 정한다) 여기서 |
|
| 54 |
+ // 늘려 주지 않으면 트리거 안 글자 폭으로 줄어들고, 값을 고를 때마다 상자가 넓어진다. |
|
| 55 |
+ // |
|
| 56 |
+ // ⚠️ 세로에서만 듣는다. 가로에서는 교차축이 세로라 `stretch`가 높이를 늘릴 뿐이고 폭은 |
|
| 57 |
+ // 그대로 내용 폭이다 — 가로에서도 채워야 하면 `flex: 1 0 0`을 따로 걸어야 한다. |
|
| 58 |
+ &--vertical &__detail {
|
|
| 59 |
+ align-self: stretch; |
|
| 48 | 60 |
} |
| 49 | 61 |
} |
| 50 | 62 |
|
--- @fox/styles/_fox-radio.scss
+++ @fox/styles/_fox-radio.scss
... | ... | @@ -29,6 +29,9 @@ |
| 29 | 29 |
// export하지 않는다. |
| 30 | 30 |
// |
| 31 | 31 |
// 토큰이 아닌 값(사용자 승인): |
| 32 |
+// - 아이콘의 위쪽 여백 `0.1rem`(1px): 시안 icon-area의 `padding-top` 값이고 변수로 바인딩 |
|
| 33 |
+// 되어 있지 않다. 크기·브레이크포인트가 바뀌어도 필요한 보정이 0.75~1.25px 범위라 하나로 |
|
| 34 |
+// 쓴다(1rem = 10px). |
|
| 32 | 35 |
// - cursor / display / flex / position: 구조·상호작용 규칙. |
| 33 | 36 |
// - 숨긴 input의 `inline-size: 0`: 크기가 아니라 "보이지 않게" 하는 구조 값이다. |
| 34 | 37 |
|
... | ... | @@ -54,6 +57,15 @@ |
| 54 | 57 |
pointer-events: none; |
| 55 | 58 |
} |
| 56 | 59 |
|
| 60 |
+ // 시안이 아이콘을 감싼 icon-area에 위쪽 여백 1px을 준다. 라벨의 line box(글자 크기 × 1.5)가 |
|
| 61 |
+ // 아이콘보다 조금 높아서, 그만큼 내려야 글자 첫 줄과 시각 중심이 맞는다. |
|
| 62 |
+ // |
|
| 63 |
+ // 감싸는 요소를 따로 두지 않고 여백으로 처리한다 — 1px 내리는 것 말고는 하는 일이 없어 |
|
| 64 |
+ // React 밖 소비자에게 마크업을 하나 더 요구할 이유가 없다. |
|
| 65 |
+ .fox-radio-icon {
|
|
| 66 |
+ margin-block-start: 0.1rem; |
|
| 67 |
+ } |
|
| 68 |
+ |
|
| 57 | 69 |
&__body {
|
| 58 | 70 |
display: flex; |
| 59 | 71 |
flex-direction: column; |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -9,6 +9,9 @@ |
| 9 | 9 |
@use "fox-button-panel"; |
| 10 | 10 |
@use "fox-address"; |
| 11 | 11 |
@use "fox-certified"; |
| 12 |
+@use "fox-check-icon"; |
|
| 13 |
+@use "fox-checkbox"; |
|
| 14 |
+@use "fox-checkbox-group"; |
|
| 12 | 15 |
@use "fox-conditional-option"; |
| 13 | 16 |
@use "fox-email"; |
| 14 | 17 |
@use "fox-file-upload"; |
... | ... | @@ -21,4 +24,4 @@ |
| 21 | 24 |
@use "fox-select"; |
| 22 | 25 |
@use "fox-select-option"; |
| 23 | 26 |
@use "fox-select-option-item"; |
| 24 |
-@use "fox-text-area"; |
|
| 27 |
+@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?