feat: 디자인시스템 FoxRadio, FoxRadioIcon, FoxRadioGruop, FoxConditionalOption 추가
@ca1df31197564578ec7b9dcaedd27168b29a60b3
+++ @fox/core/components/fox-conditional-option/fox-conditional-option.tsx
... | ... | @@ -0,0 +1,129 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useRef, type ReactNode } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | +import { FoxRadio, type FoxRadioSize } from "../fox-radio"; | |
| 6 | +import { FoxSelect, type FoxSelectItem } from "../fox-select"; | |
| 7 | + | |
| 8 | +export type FoxConditionalOptionOrientation = "horizontal" | "vertical"; | |
| 9 | + | |
| 10 | +export interface FoxConditionalOptionProps { | |
| 11 | + /** 라디오 값. 같은 `name` 안에서 이 칸을 가리킨다. */ | |
| 12 | + value: string; | |
| 13 | + label: ReactNode; | |
| 14 | + /** 라벨 아래 보조 설명. 넘기지 않으면 영역을 렌더링하지 않는다. */ | |
| 15 | + description?: ReactNode; | |
| 16 | + /** 딸린 select의 목록. */ | |
| 17 | + options: FoxSelectItem[]; | |
| 18 | + /** 딸린 select의 값(제어). 넘기지 않으면 안에서 들고 있는다. */ | |
| 19 | + detail?: string; | |
| 20 | + /** 비제어로 쓸 때 딸린 select의 초기 값. */ | |
| 21 | + defaultDetail?: string; | |
| 22 | + /** 딸린 select 값이 바뀔 때 부른다. 값이 첫 인자다. */ | |
| 23 | + onChange?: (detail: string) => void; | |
| 24 | + /** 이 칸이 선택될 때 부른다 — select를 골라 자동으로 켜진 경우도 포함이다. */ | |
| 25 | + onCheck?: (value: string) => void; | |
| 26 | + /** 아무것도 고르지 않았을 때 select에 보여줄 문구. */ | |
| 27 | + placeholder?: ReactNode; | |
| 28 | + orientation?: FoxConditionalOptionOrientation; | |
| 29 | + /** 라디오 묶음 이름. `FoxRadioGroup` 안에 넣으면 그룹이 넣어 주므로 직접 적지 않아도 된다. */ | |
| 30 | + name?: string; | |
| 31 | + /** `FoxRadioGroup`이 넣어 준다. 직접 넣으면 그 값이 라디오 크기가 된다. */ | |
| 32 | + size?: FoxRadioSize; | |
| 33 | + /** 라디오를 처음부터 선택된 채로 둔다(비제어). */ | |
| 34 | + defaultChecked?: boolean; | |
| 35 | + /** 주면 선택 여부를 호출부가 소유한다. */ | |
| 36 | + checked?: boolean; | |
| 37 | + disabled?: boolean; | |
| 38 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 39 | + hidden?: boolean; | |
| 40 | + /** 배치 조정용. */ | |
| 41 | + className?: string; | |
| 42 | +} | |
| 43 | + | |
| 44 | +const ORIENTATION_CLASS: Record<FoxConditionalOptionOrientation, string> = { | |
| 45 | + horizontal: "fox-conditional-option--horizontal", | |
| 46 | + vertical: "fox-conditional-option--vertical", | |
| 47 | +}; | |
| 48 | + | |
| 49 | +/** | |
| 50 | + * @fox 후속 질문이 딸린 선택지. 라디오 한 칸과 select를 묶어 하나의 답으로 다룬다. | |
| 51 | + * | |
| 52 | + * select는 이 칸이 골라지지 않아도 **조작할 수 있다.** 값을 고르면 라디오가 자동으로 켜진다 — | |
| 53 | + * 값을 고르고도 라디오를 안 눌러 답이 비는 일이 흔해서다. 반대로 select를 잠가 두면 그 자동 | |
| 54 | + * 선택 자체가 불가능해진다(`disabled`는 클릭을 먹지 않는다). | |
| 55 | + * | |
| 56 | + * 켜는 방법은 숨은 input을 실제로 클릭하는 것이다. 그래야 같은 `name`의 다른 칸이 꺼지는 | |
| 57 | + * 것과 change 이벤트가 브라우저 규칙 그대로 일어난다 — `checked`를 직접 대입하면 이벤트가 | |
| 58 | + * 뛰지 않아 호출부가 모른다. | |
| 59 | + * | |
| 60 | + * `FoxRadioGroup` 안에 그대로 넣을 수 있다 — 그룹이 `name`·`size`를 넣어 준다. | |
| 61 | + * | |
| 62 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 63 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 64 | + */ | |
| 65 | +export function FoxConditionalOption({ | |
| 66 | + value, | |
| 67 | + label, | |
| 68 | + description, | |
| 69 | + options, | |
| 70 | + detail, | |
| 71 | + defaultDetail, | |
| 72 | + onChange, | |
| 73 | + onCheck, | |
| 74 | + placeholder, | |
| 75 | + orientation = "vertical", | |
| 76 | + name, | |
| 77 | + size = "lg", | |
| 78 | + defaultChecked, | |
| 79 | + checked, | |
| 80 | + disabled = false, | |
| 81 | + hidden = false, | |
| 82 | + className, | |
| 83 | +}: FoxConditionalOptionProps) { | |
| 84 | + const radioRef = useRef<HTMLInputElement>(null); | |
| 85 | + | |
| 86 | + if (hidden) { | |
| 87 | + return null; | |
| 88 | + } | |
| 89 | + | |
| 90 | + // 비제어일 때의 값은 `FoxSelect`가 이미 들고 있다 — 여기서 또 들면 두 벌이 어긋난다. | |
| 91 | + const handleDetail = (next: string) => { | |
| 92 | + onChange?.(next); | |
| 93 | + | |
| 94 | + // 선택 여부는 DOM에서 읽는다 — 형제 칸을 고르면 이 칸이 조용히 꺼지므로 따로 들고 있으면 | |
| 95 | + // 어긋난다. 이미 켜져 있으면 다시 클릭해 change를 중복으로 뛰우지 않는다. | |
| 96 | + if (radioRef.current && !radioRef.current.checked) { | |
| 97 | + radioRef.current.click(); | |
| 98 | + } | |
| 99 | + }; | |
| 100 | + | |
| 101 | + return ( | |
| 102 | + <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 | + /> | |
| 115 | + | |
| 116 | + <div className="fox-conditional-option__detail"> | |
| 117 | + <FoxSelect | |
| 118 | + size="md" | |
| 119 | + options={options} | |
| 120 | + value={detail} | |
| 121 | + defaultValue={defaultDetail} | |
| 122 | + placeholder={placeholder} | |
| 123 | + onValueChange={handleDetail} | |
| 124 | + disabled={disabled} | |
| 125 | + /> | |
| 126 | + </div> | |
| 127 | + </div> | |
| 128 | + ); | |
| 129 | +} |
+++ @fox/core/components/fox-conditional-option/index.ts
... | ... | @@ -0,0 +1,5 @@ |
| 1 | +export { | |
| 2 | + FoxConditionalOption, | |
| 3 | + type FoxConditionalOptionProps, | |
| 4 | + type FoxConditionalOptionOrientation, | |
| 5 | +} from "./fox-conditional-option"; |
+++ @fox/core/components/fox-radio-group/fox-radio-group.tsx
... | ... | @@ -0,0 +1,112 @@ |
| 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 { FoxRadioSize } from "../fox-radio"; | |
| 14 | + | |
| 15 | +export type FoxRadioGroupOrientation = "horizontal" | "vertical"; | |
| 16 | + | |
| 17 | +export interface FoxRadioGroupProps { | |
| 18 | + orientation?: FoxRadioGroupOrientation; | |
| 19 | + /** | |
| 20 | + * 자식 라디오의 `name`을 전부 이 값으로 덮어쓴다. 그룹 안에서 `name`이 어긋나면 브라우저가 | |
| 21 | + * 다른 묶음으로 보고 여러 개가 동시에 선택되므로, 그룹이 단일 진실 공급원이다. | |
| 22 | + */ | |
| 23 | + name?: string; | |
| 24 | + /** 자식 라디오의 `size`를 전부 이 값으로 덮어쓴다. 넘기지 않으면 자식이 각자 정한다. */ | |
| 25 | + size?: FoxRadioSize; | |
| 26 | + /** 그룹을 읽어 줄 이름. 화면에 보이는 제목이 이미 있으면 `labelledBy`를 쓴다. */ | |
| 27 | + label?: string; | |
| 28 | + /** 그룹 이름 역할을 하는 요소의 id. `label`보다 우선한다 — 화면의 글자와 어긋나지 않는다. */ | |
| 29 | + labelledBy?: string; | |
| 30 | + /** `FoxRadio`들. */ | |
| 31 | + children?: ReactNode; | |
| 32 | + /** 배치 조정용. */ | |
| 33 | + className?: string; | |
| 34 | + ref?: Ref<HTMLDivElement>; | |
| 35 | +} | |
| 36 | + | |
| 37 | +const ORIENTATION_CLASS: Record<FoxRadioGroupOrientation, string> = { | |
| 38 | + horizontal: "fox-radio-group--horizontal", | |
| 39 | + vertical: "fox-radio-group--vertical", | |
| 40 | +}; | |
| 41 | + | |
| 42 | +type Overrides = { name?: string; size?: FoxRadioSize }; | |
| 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 | + * 어느 칸이 선택됐는지는 각 `FoxRadio`의 네이티브 input이 소유한다 — 같은 `name`을 쓰면 | |
| 74 | + * 브라우저가 하나만 선택되게 묶고 화살표 이동까지 처리한다. | |
| 75 | + * | |
| 76 | + * `name`은 **그룹이 단일 진실 공급원**이다. 자식마다 적지 않아도 되고, 자식이 다른 값을 줘도 | |
| 77 | + * 그룹 값으로 통일되므로 "한 칸만 name을 빼먹어 두 개가 선택되는" 실수가 생기지 않는다. | |
| 78 | + * | |
| 79 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 80 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 81 | + */ | |
| 82 | +export function FoxRadioGroup({ | |
| 83 | + orientation = "vertical", | |
| 84 | + name, | |
| 85 | + size, | |
| 86 | + label, | |
| 87 | + labelledBy, | |
| 88 | + children, | |
| 89 | + className, | |
| 90 | + ref, | |
| 91 | +}: FoxRadioGroupProps) { | |
| 92 | + // 값을 준 것만 넣는다 — `undefined`를 그대로 넘기면 자식이 스스로 정한 값을 지운다. | |
| 93 | + const overrides: Overrides = {}; | |
| 94 | + if (name !== undefined) { | |
| 95 | + overrides.name = name; | |
| 96 | + } | |
| 97 | + if (size !== undefined) { | |
| 98 | + overrides.size = size; | |
| 99 | + } | |
| 100 | + | |
| 101 | + return ( | |
| 102 | + <div | |
| 103 | + ref={ref} | |
| 104 | + role="radiogroup" | |
| 105 | + aria-label={labelledBy ? undefined : label} | |
| 106 | + aria-labelledby={labelledBy} | |
| 107 | + className={cx("fox-radio-group", ORIENTATION_CLASS[orientation], className)} | |
| 108 | + > | |
| 109 | + {overrideChildren(children, overrides)} | |
| 110 | + </div> | |
| 111 | + ); | |
| 112 | +} |
+++ @fox/core/components/fox-radio-group/index.ts
... | ... | @@ -0,0 +1,5 @@ |
| 1 | +export { | |
| 2 | + FoxRadioGroup, | |
| 3 | + type FoxRadioGroupProps, | |
| 4 | + type FoxRadioGroupOrientation, | |
| 5 | +} from "./fox-radio-group"; |
+++ @fox/core/components/fox-radio-icon/fox-radio-icon.tsx
... | ... | @@ -0,0 +1,61 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { Ref } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | + | |
| 6 | +export type FoxRadioIconSize = "lg" | "md"; | |
| 7 | + | |
| 8 | +export interface FoxRadioIconProps { | |
| 9 | + size?: FoxRadioIconSize; | |
| 10 | + /** | |
| 11 | + * 선택 상태를 클래스로 못박는다. `FoxRadio` 안에서는 넘기지 않는다 — 네이티브 | |
| 12 | + * `<input type="radio">`의 `:checked`가 스타일 훅을 겸하므로 비제어에서도 맞는다. | |
| 13 | + * 상태를 나란히 보여주는 예제처럼 단독으로 쓸 때만 쓴다. | |
| 14 | + */ | |
| 15 | + checked?: boolean; | |
| 16 | + /** 비활성 상태를 클래스로 못박는다. `checked`와 같은 이유로 단독 사용 전용이다. */ | |
| 17 | + disabled?: boolean; | |
| 18 | + /** 배치 조정용. */ | |
| 19 | + className?: string; | |
| 20 | + ref?: Ref<HTMLSpanElement>; | |
| 21 | +} | |
| 22 | + | |
| 23 | +const SIZE_CLASS: Record<FoxRadioIconSize, string> = { | |
| 24 | + lg: "fox-radio-icon--lg", | |
| 25 | + md: "fox-radio-icon--md", | |
| 26 | +}; | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * @fox 라디오의 동그란 표시. 상태를 갖지 않는다. | |
| 30 | + * | |
| 31 | + * 자체로는 입력이 아니다 — 실제 선택은 `FoxRadio`가 감싸는 네이티브 | |
| 32 | + * `<input type="radio">`가 담당하고, 이 요소는 그 상태를 CSS로 비춘다. 그래서 `FoxRadio` | |
| 33 | + * 안에서는 `checked`·`disabled`를 넘길 필요가 없다. | |
| 34 | + * | |
| 35 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 36 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 37 | + */ | |
| 38 | +export function FoxRadioIcon({ | |
| 39 | + size = "lg", | |
| 40 | + checked = false, | |
| 41 | + disabled = false, | |
| 42 | + className, | |
| 43 | + ref, | |
| 44 | +}: FoxRadioIconProps) { | |
| 45 | + return ( | |
| 46 | + <span | |
| 47 | + ref={ref} | |
| 48 | + aria-hidden="true" | |
| 49 | + className={cx( | |
| 50 | + "fox-radio-icon", | |
| 51 | + SIZE_CLASS[size], | |
| 52 | + checked && "fox-radio-icon--checked", | |
| 53 | + disabled && "fox-radio-icon--disabled", | |
| 54 | + className | |
| 55 | + )} | |
| 56 | + > | |
| 57 | + {/* 선택됐을 때만 넣지 않는다 — 보이기/숨기기는 CSS가 정해야 비제어에서도 맞는다. */} | |
| 58 | + <span className="fox-radio-icon__dot" /> | |
| 59 | + </span> | |
| 60 | + ); | |
| 61 | +} |
+++ @fox/core/components/fox-radio-icon/index.ts
... | ... | @@ -0,0 +1,5 @@ |
| 1 | +export { | |
| 2 | + FoxRadioIcon, | |
| 3 | + type FoxRadioIconProps, | |
| 4 | + type FoxRadioIconSize, | |
| 5 | +} from "./fox-radio-icon"; |
+++ @fox/core/components/fox-radio/fox-radio.tsx
... | ... | @@ -0,0 +1,86 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { type ChangeEvent, type ComponentPropsWithRef, type ReactNode } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | +import { FoxRadioIcon } from "../fox-radio-icon"; | |
| 6 | + | |
| 7 | +export type FoxRadioSize = "lg" | "md"; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * 네이티브 `<input type="radio">`가 받는 것을 그대로 넘겨받는다 — name·value·checked· | |
| 11 | + * defaultChecked·disabled·required·form 등. 의미가 겹치는 것만 걷어내고 아래에서 다시 정의한다. | |
| 12 | + */ | |
| 13 | +type NativeRadioProps = Omit< | |
| 14 | + ComponentPropsWithRef<"input">, | |
| 15 | + "type" | "size" | "className" | "children" | "hidden" | "onChange" | |
| 16 | +>; | |
| 17 | + | |
| 18 | +export interface FoxRadioProps extends NativeRadioProps { | |
| 19 | + size?: FoxRadioSize; | |
| 20 | + label: ReactNode; | |
| 21 | + /** 라벨 아래 보조 설명. 넘기지 않으면 영역을 렌더링하지 않는다. */ | |
| 22 | + description?: ReactNode; | |
| 23 | + /** | |
| 24 | + * 이 칸이 선택될 때 부른다. 값이 첫 인자다 — 네이티브 `onChange`를 `Omit`으로 걷어내고 | |
| 25 | + * 그 자리를 대신한다(`FoxInput`과 같은 방식). | |
| 26 | + */ | |
| 27 | + onChange?: (value: string, event: ChangeEvent<HTMLInputElement>) => void; | |
| 28 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 29 | + hidden?: boolean; | |
| 30 | + /** 배치 조정용. */ | |
| 31 | + className?: string; | |
| 32 | +} | |
| 33 | + | |
| 34 | +const SIZE_CLASS: Record<FoxRadioSize, string> = { | |
| 35 | + lg: "fox-radio--lg", | |
| 36 | + md: "fox-radio--md", | |
| 37 | +}; | |
| 38 | + | |
| 39 | +/** | |
| 40 | + * @fox 라디오 버튼 한 칸. 상태를 갖지 않는다 — 선택 여부는 네이티브 | |
| 41 | + * `<input type="radio">`가 소유한다. | |
| 42 | + * | |
| 43 | + * `name`이 같은 라디오끼리는 브라우저가 그룹으로 묶어 화살표 이동·단일 선택·폼 전송을 | |
| 44 | + * 처리한다. 그래서 별도 group 컴포넌트를 두지 않는다. | |
| 45 | + * | |
| 46 | + * 루트가 `<label>`이라 라벨이나 설명을 눌러도 선택된다. 동그란 표시는 CSS가 input의 | |
| 47 | + * `:checked`·`:disabled`를 보고 그리므로, 비제어로 써도 모양이 어긋나지 않는다. | |
| 48 | + * | |
| 49 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 50 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 51 | + */ | |
| 52 | +export function FoxRadio({ | |
| 53 | + size = "lg", | |
| 54 | + label, | |
| 55 | + description, | |
| 56 | + onChange, | |
| 57 | + hidden = false, | |
| 58 | + className, | |
| 59 | + ...rest | |
| 60 | +}: FoxRadioProps) { | |
| 61 | + if (hidden) { | |
| 62 | + return null; | |
| 63 | + } | |
| 64 | + | |
| 65 | + // 라디오는 켜질 때만 change가 뛴다(꺼질 때는 안 뛴다). 그래도 명시적으로 걸러 두면 | |
| 66 | + // 브라우저가 달라져도 "선택될 때"라는 계약이 흔들리지 않는다. | |
| 67 | + const handleChange = (event: ChangeEvent<HTMLInputElement>) => { | |
| 68 | + if (event.target.checked) { | |
| 69 | + onChange?.(event.target.value, event); | |
| 70 | + } | |
| 71 | + }; | |
| 72 | + | |
| 73 | + return ( | |
| 74 | + <label className={cx("fox-radio", SIZE_CLASS[size], className)}> | |
| 75 | + {/* 형제 선택자(`~`)가 뒤따르는 표시와 본문에 닿아야 하므로 맨 앞에 둔다. */} | |
| 76 | + <input {...rest} type="radio" className="fox-radio__input" onChange={handleChange} /> | |
| 77 | + <FoxRadioIcon size={size} /> | |
| 78 | + <span className="fox-radio__body"> | |
| 79 | + <span className="fox-radio__label">{label}</span> | |
| 80 | + {description ? ( | |
| 81 | + <span className="fox-radio__description">{description}</span> | |
| 82 | + ) : null} | |
| 83 | + </span> | |
| 84 | + </label> | |
| 85 | + ); | |
| 86 | +} |
+++ @fox/core/components/fox-radio/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxRadio, type FoxRadioProps, type FoxRadioSize } from "./fox-radio"; |
--- @fox/core/components/fox-select/fox-select.tsx
+++ @fox/core/components/fox-select/fox-select.tsx
... | ... | @@ -37,7 +37,16 @@ |
| 37 | 37 |
* 호출부마다 `useState`를 반복하는 비용이 크기 때문이다(사용자 승인). |
| 38 | 38 |
*/ |
| 39 | 39 |
defaultValue?: string; |
| 40 |
- /** 값이 바뀔 때 호출된다. 이름이 `onChange`가 아닌 이유는 DOM 이벤트가 아니기 때문이다. */ |
|
| 40 |
+ /** |
|
| 41 |
+ * 값이 바뀔 때 호출된다. |
|
| 42 |
+ * |
|
| 43 |
+ * ⚠️ 이름을 `onChange`로 바꾸지 않는다. 이 패키지의 기준은 "네이티브 요소를 감싸면 |
|
| 44 |
+ * `onChange`(그 네이티브 것을 `Omit`으로 걷어내고 대신한다), 감싸는 요소가 없는 커스텀 |
|
| 45 |
+ * 위젯이면 `onValueChange`"다. FoxInput·FoxTextArea·FoxRadio는 앞쪽이고, 이 컴포넌트는 |
|
| 46 |
+ * 네이티브 `<select>`가 아니라 버튼 + 리스트박스라 뒤쪽이다. |
|
| 47 |
+ * |
|
| 48 |
+ * 게다가 `FoxEmail`·`FoxPhoneNumber`가 이 이름으로 호출하고 있어 바꾸면 함께 깨진다. |
|
| 49 |
+ */ |
|
| 41 | 50 |
onValueChange?: (value: string) => void; |
| 42 | 51 |
/** 아무 옵션도 선택되지 않았을 때 트리거에 보여줄 문구. */ |
| 43 | 52 |
placeholder?: ReactNode; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -3,12 +3,16 @@ |
| 3 | 3 |
export * from "./fox-button-group"; |
| 4 | 4 |
export * from "./fox-button-panel"; |
| 5 | 5 |
export * from "./fox-certified"; |
| 6 |
+export * from "./fox-conditional-option"; |
|
| 6 | 7 |
export * from "./fox-email"; |
| 7 | 8 |
export * from "./fox-file-upload"; |
| 8 | 9 |
export * from "./fox-icon-button"; |
| 9 | 10 |
export * from "./fox-input"; |
| 10 | 11 |
export * from "./fox-link-button"; |
| 11 | 12 |
export * from "./fox-phone-number"; |
| 13 |
+export * from "./fox-radio"; |
|
| 14 |
+export * from "./fox-radio-group"; |
|
| 15 |
+export * from "./fox-radio-icon"; |
|
| 12 | 16 |
export * from "./fox-select"; |
| 13 | 17 |
export * from "./fox-select-option"; |
| 14 | 18 |
export * from "./fox-select-option-item"; |
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -9,11 +9,15 @@ |
| 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 { FoxConditionalOption } from "../core/components/fox-conditional-option";
|
|
| 12 | 13 |
import { FoxEmail } from "../core/components/fox-email";
|
| 13 | 14 |
import { FoxFileUpload, type FoxFileItem } from "../core/components/fox-file-upload";
|
| 14 | 15 |
import { FoxPhoneNumber, type FoxPhoneNumberState } from "../core/components/fox-phone-number";
|
| 15 | 16 |
import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
|
| 16 | 17 |
import { FoxTextButton } from "../core/components/fox-text-button";
|
| 18 |
+import { FoxRadio } from "../core/components/fox-radio";
|
|
| 19 |
+import { FoxRadioGroup } from "../core/components/fox-radio-group";
|
|
| 20 |
+import { FoxRadioIcon } from "../core/components/fox-radio-icon";
|
|
| 17 | 21 |
import styles from "./dev-test.module.scss"; |
| 18 | 22 |
|
| 19 | 23 |
export interface ComponentExample {
|
... | ... | @@ -1006,6 +1010,192 @@ |
| 1006 | 1010 |
], |
| 1007 | 1011 |
}, |
| 1008 | 1012 |
{
|
| 1013 |
+ id: "fox-radio-icon", |
|
| 1014 |
+ name: "FoxRadioIcon", |
|
| 1015 |
+ description: |
|
| 1016 |
+ "Figma 시안(radio-icon 107:551) 라디오의 동그란 표시만 담당합니다. SVG가 아니라 CSS로 그립니다 — 시안이 바깥 원과 안쪽 점을 별개 레이어로 두었고, 네 상태가 모양이 아니라 색만 다르기 때문입니다(테두리 두께는 전부 border-1). FoxRadio 안에서는 네이티브 input의 :checked·:disabled가 스타일 훅을 겸해서 클래스가 필요 없고, 아래처럼 단독으로 상태를 비교할 때만 모디파이어로 못박습니다.", |
|
| 1017 |
+ variants: [ |
|
| 1018 |
+ {
|
|
| 1019 |
+ label: "lg — 기본 / 선택 / 비활성 / 선택+비활성", |
|
| 1020 |
+ node: ( |
|
| 1021 |
+ <> |
|
| 1022 |
+ <FoxRadioIcon size="lg" /> |
|
| 1023 |
+ <FoxRadioIcon size="lg" checked /> |
|
| 1024 |
+ <FoxRadioIcon size="lg" disabled /> |
|
| 1025 |
+ <FoxRadioIcon size="lg" checked disabled /> |
|
| 1026 |
+ </> |
|
| 1027 |
+ ), |
|
| 1028 |
+ }, |
|
| 1029 |
+ {
|
|
| 1030 |
+ label: "md — 기본 / 선택 / 비활성 / 선택+비활성", |
|
| 1031 |
+ node: ( |
|
| 1032 |
+ <> |
|
| 1033 |
+ <FoxRadioIcon size="md" /> |
|
| 1034 |
+ <FoxRadioIcon size="md" checked /> |
|
| 1035 |
+ <FoxRadioIcon size="md" disabled /> |
|
| 1036 |
+ <FoxRadioIcon size="md" checked disabled /> |
|
| 1037 |
+ </> |
|
| 1038 |
+ ), |
|
| 1039 |
+ }, |
|
| 1040 |
+ ], |
|
| 1041 |
+ }, |
|
| 1042 |
+ {
|
|
| 1043 |
+ id: "fox-radio", |
|
| 1044 |
+ name: "FoxRadio", |
|
| 1045 |
+ description: |
|
| 1046 |
+ "Figma 시안(radio 180:278) 동그란 표시 + 라벨 + 부가설명을 묶은 라디오 한 칸입니다.", |
|
| 1047 |
+ variants: [ |
|
| 1048 |
+ {
|
|
| 1049 |
+ label: "size-lg", |
|
| 1050 |
+ node: ( |
|
| 1051 |
+ <div className={styles.demoStack}>
|
|
| 1052 |
+ <FoxRadio name="demo-plan" value="basic" label="라디오버튼" size="lg" description="부가적인 설명이 들어갑니다." /> |
|
| 1053 |
+ <FoxRadio name="demo-plan" value="pro" label="라디오버튼" size="lg" description="부가적인 설명이 들어갑니다." defaultChecked/> |
|
| 1054 |
+ <FoxRadio name="demo-plan" value="team" label="라디오버튼" size="lg" description="부가적인 설명이 들어갑니다." disabled /> |
|
| 1055 |
+ <FoxRadio name="demo-plan" value="team" label="라디오버튼" size="lg" description="부가적인 설명이 들어갑니다." disabled defaultChecked /> |
|
| 1056 |
+ </div> |
|
| 1057 |
+ ), |
|
| 1058 |
+ }, |
|
| 1059 |
+ {
|
|
| 1060 |
+ label: "size-md", |
|
| 1061 |
+ node: ( |
|
| 1062 |
+ <div className={styles.demoStack}>
|
|
| 1063 |
+ <FoxRadio name="demo-plan" value="basic" label="라디오버튼" size="md" description="부가적인 설명이 들어갑니다." /> |
|
| 1064 |
+ <FoxRadio name="demo-plan" value="pro" label="라디오버튼" size="md" description="부가적인 설명이 들어갑니다." defaultChecked/> |
|
| 1065 |
+ <FoxRadio name="demo-plan" value="team" label="라디오버튼" size="md" description="부가적인 설명이 들어갑니다." disabled /> |
|
| 1066 |
+ <FoxRadio name="demo-plan" value="team" label="라디오버튼" size="md" description="부가적인 설명이 들어갑니다." disabled defaultChecked /> |
|
| 1067 |
+ </div> |
|
| 1068 |
+ ), |
|
| 1069 |
+ }, |
|
| 1070 |
+ ], |
|
| 1071 |
+ }, |
|
| 1072 |
+ {
|
|
| 1073 |
+ id: "fox-radio-group", |
|
| 1074 |
+ name: "FoxRadioGroup", |
|
| 1075 |
+ description: |
|
| 1076 |
+ "Figma 시안(radio-group 194:4327)라디오 여러 칸의 배치와 name·size 통일을 책임집니다. 어느 칸이 선택됐는지는 각 FoxRadio의 네이티브 input이 소유하므로 그룹은 상태를 갖지 않습니다. name은 그룹이 단일 진실 공급원이라 자식마다 적지 않아도 되고, 한 칸만 빼먹어 두 개가 선택되는 실수가 막힙니다. ⚠️ 시안의 변형 이름이 실제 동작과 반대라서(direction=row가 세로로 쌓습니다) 동작 기준으로 다시 이름 붙였습니다 — 시안 row가 vertical, 시안 col이 horizontal입니다.", |
|
| 1077 |
+ variants: [ |
|
| 1078 |
+ {
|
|
| 1079 |
+ label: "vertical (기본) — 간격 gap-7, 왼쪽 끝 정렬", |
|
| 1080 |
+ node: ( |
|
| 1081 |
+ <FoxRadioGroup name="vertical" label="vertical"> |
|
| 1082 |
+ <FoxRadio label="라디오버튼" defaultChecked /> |
|
| 1083 |
+ <FoxRadio label="라디오버튼" /> |
|
| 1084 |
+ </FoxRadioGroup> |
|
| 1085 |
+ ), |
|
| 1086 |
+ }, |
|
| 1087 |
+ {
|
|
| 1088 |
+ label: "horizontal — 행 gap-7 / 열 gap-9, 폭이 모자라면 줄바꿈", |
|
| 1089 |
+ node: ( |
|
| 1090 |
+ <FoxRadioGroup orientation="horizontal" name="horizontal" label="horizontal"> |
|
| 1091 |
+ <FoxRadio label="라디오버튼" defaultChecked /> |
|
| 1092 |
+ <FoxRadio label="라디오버튼" /> |
|
| 1093 |
+ <FoxRadio label="라디오버튼" /> |
|
| 1094 |
+ </FoxRadioGroup> |
|
| 1095 |
+ ), |
|
| 1096 |
+ }, |
|
| 1097 |
+ {
|
|
| 1098 |
+ label: "horizontal + 줄바꿈 — 다음 줄로 넘어간다", |
|
| 1099 |
+ node: ( |
|
| 1100 |
+ <div className={styles.demoNarrow}>
|
|
| 1101 |
+ <FoxRadioGroup orientation="horizontal" name="rg-wrap" label="줄바꿈 확인"> |
|
| 1102 |
+ {["첫째", "둘째", "셋째", "넷째", "다섯째"].map((label, i) => (
|
|
| 1103 |
+ <FoxRadio key={label} value={label} label={label} defaultChecked={i === 0} />
|
|
| 1104 |
+ ))} |
|
| 1105 |
+ </FoxRadioGroup> |
|
| 1106 |
+ </div> |
|
| 1107 |
+ ), |
|
| 1108 |
+ }, |
|
| 1109 |
+ {
|
|
| 1110 |
+ label: "size — 자식이 준 값은 무시되고 그룹 값으로 통일된다", |
|
| 1111 |
+ node: ( |
|
| 1112 |
+ <FoxRadioGroup orientation="horizontal" name="rg-md" size="md" label="md 크기"> |
|
| 1113 |
+ {/* 일부러 어긋난 size를 준다 — 둘 다 md로 나와야 정상 */}
|
|
| 1114 |
+ <FoxRadio value="a" label="첫째" size="lg" defaultChecked /> |
|
| 1115 |
+ <FoxRadio value="b" label="둘째" /> |
|
| 1116 |
+ </FoxRadioGroup> |
|
| 1117 |
+ ), |
|
| 1118 |
+ }, |
|
| 1119 |
+ ], |
|
| 1120 |
+ }, |
|
| 1121 |
+ {
|
|
| 1122 |
+ id: "fox-conditional-option", |
|
| 1123 |
+ name: "FoxConditionalOption", |
|
| 1124 |
+ description: |
|
| 1125 |
+ "Figma 시안(conditional-option 190:2163) 라디오 한 칸과 select가 합쳐 하나의 답을 이룹니다. select는 이 칸이 골라지지 않아도 조작할 수 있고, 값을 고르면 라디오가 자동으로 켜집니다. 잠가 두면 그 자동 선택 자체가 불가능해지기 때문입니다(disabled는 클릭을 먹지 않습니다). 켜는 방법은 숨은 input을 실제로 클릭하는 것이라 형제 칸이 꺼지는 것과 change 이벤트가 브라우저 규칙 그대로 일어납니다. ⚠️ 시안의 변형 이름이 실제 동작과 반대라서(FoxRadioGroup과 같은 문제) 동작 기준으로 다시 이름 붙였습니다 — 시안 row가 vertical, 시안 col이 horizontal입니다.", |
|
| 1126 |
+ variants: [ |
|
| 1127 |
+ {
|
|
| 1128 |
+ label: "vertical (기본) — select가 아래로 내려가고 라벨 왼쪽 끝에 맞는다", |
|
| 1129 |
+ node: ( |
|
| 1130 |
+ <FoxConditionalOption |
|
| 1131 |
+ name="co-v" |
|
| 1132 |
+ value="other" |
|
| 1133 |
+ label="다른 주소로 배송" |
|
| 1134 |
+ description="주소를 고르면 이 항목이 자동으로 선택됩니다." |
|
| 1135 |
+ options={SELECT_ITEMS}
|
|
| 1136 |
+ placeholder="주소를 고르세요" |
|
| 1137 |
+ /> |
|
| 1138 |
+ ), |
|
| 1139 |
+ }, |
|
| 1140 |
+ {
|
|
| 1141 |
+ label: "horizontal — select가 라디오 옆에 선다", |
|
| 1142 |
+ node: ( |
|
| 1143 |
+ <FoxConditionalOption |
|
| 1144 |
+ orientation="horizontal" |
|
| 1145 |
+ name="co-h" |
|
| 1146 |
+ value="other" |
|
| 1147 |
+ label="다른 주소로 배송" |
|
| 1148 |
+ options={SELECT_ITEMS}
|
|
| 1149 |
+ placeholder="주소를 고르세요" |
|
| 1150 |
+ /> |
|
| 1151 |
+ ), |
|
| 1152 |
+ }, |
|
| 1153 |
+ {
|
|
| 1154 |
+ label: "", |
|
| 1155 |
+ node: ( |
|
| 1156 |
+ <FoxRadioGroup name="co-auto" label="배송지"> |
|
| 1157 |
+ <FoxRadio value="default" label="기본 배송지" defaultChecked /> |
|
| 1158 |
+ <FoxConditionalOption |
|
| 1159 |
+ value="other" |
|
| 1160 |
+ label="다른 주소로 배송" |
|
| 1161 |
+ options={SELECT_ITEMS}
|
|
| 1162 |
+ placeholder="주소를 고르세요" |
|
| 1163 |
+ /> |
|
| 1164 |
+ </FoxRadioGroup> |
|
| 1165 |
+ ), |
|
| 1166 |
+ }, |
|
| 1167 |
+ {
|
|
| 1168 |
+ label: "FoxRadioGroup 안 — name·size를 그룹이 넣어 준다(md로 통일된다)", |
|
| 1169 |
+ node: ( |
|
| 1170 |
+ <FoxRadioGroup name="co-group" size="md" label="결제 수단"> |
|
| 1171 |
+ <FoxRadio value="card" label="카드" defaultChecked /> |
|
| 1172 |
+ <FoxConditionalOption |
|
| 1173 |
+ value="bank" |
|
| 1174 |
+ label="계좌 이체" |
|
| 1175 |
+ description="은행을 고르면 이 항목이 선택됩니다." |
|
| 1176 |
+ options={SELECT_ITEMS}
|
|
| 1177 |
+ placeholder="은행을 고르세요" |
|
| 1178 |
+ /> |
|
| 1179 |
+ </FoxRadioGroup> |
|
| 1180 |
+ ), |
|
| 1181 |
+ }, |
|
| 1182 |
+ {
|
|
| 1183 |
+ label: "disabled — 라디오와 select가 함께 잠긴다", |
|
| 1184 |
+ node: ( |
|
| 1185 |
+ <FoxConditionalOption |
|
| 1186 |
+ name="co-off" |
|
| 1187 |
+ value="other" |
|
| 1188 |
+ label="다른 주소로 배송" |
|
| 1189 |
+ description="선택할 수 없습니다." |
|
| 1190 |
+ options={SELECT_ITEMS}
|
|
| 1191 |
+ placeholder="주소를 고르세요" |
|
| 1192 |
+ disabled |
|
| 1193 |
+ /> |
|
| 1194 |
+ ), |
|
| 1195 |
+ }, |
|
| 1196 |
+ ], |
|
| 1197 |
+ }, |
|
| 1198 |
+ {
|
|
| 1009 | 1199 |
id: "fox-text-area", |
| 1010 | 1200 |
name: "FoxTextArea", |
| 1011 | 1201 |
description: |
+++ @fox/styles/_fox-conditional-option.scss
... | ... | @@ -0,0 +1,61 @@ |
| 1 | +// FoxConditionalOption — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma conditional-option | |
| 3 | +// | |
| 4 | +// 라디오 한 칸 + 그 칸을 골랐을 때만 의미가 생기는 select를 한 덩어리로 묶는다. 둘이 합쳐 | |
| 5 | +// 하나의 답이다 — "다른 주소로 배송(라디오) → 어느 주소인지(select)"처럼 후속 질문이 딸린 | |
| 6 | +// 선택지에 쓴다. | |
| 7 | +// | |
| 8 | +// 마크업 계약 (React 밖 소비자용): | |
| 9 | +// <div class="fox-conditional-option fox-conditional-option--vertical"> | |
| 10 | +// <label class="fox-radio fox-radio--lg">…</label> | |
| 11 | +// <div class="fox-conditional-option__detail"> | |
| 12 | +// <div class="fox-select fox-select--md">…</div> | |
| 13 | +// </div> | |
| 14 | +// </div> | |
| 15 | +// | |
| 16 | +// select는 라디오가 안 골라져 있어도 조작할 수 있다 — 값을 고르면 라디오가 자동으로 켜지는데, | |
| 17 | +// 잠가 두면 그 조작 자체가 불가능해진다. 선택 여부는 라디오의 네이티브 input이 소유하므로 | |
| 18 | +// 이 파일은 배치만 맡는다. | |
| 19 | +// | |
| 20 | +// ⚠️ 시안의 변형 이름이 실제 동작과 반대다(`fox-radio-group`과 같은 문제). 동작 기준으로 | |
| 21 | +// 다시 이름 붙였다. | |
| 22 | +// | |
| 23 | +// 시안 direction=row → `--vertical` (select가 라디오 아래로) | |
| 24 | +// 시안 direction=col → `--horizontal` (select가 라디오 옆에) | |
| 25 | +// | |
| 26 | +// 세로일 때 select는 **라벨 왼쪽 끝**에 맞춘다 — 동그란 표시 폭 + 간격만큼 들여쓴다. 그래야 | |
| 27 | +// 라벨·부가설명과 한 덩어리로 읽힌다. | |
| 28 | +// | |
| 29 | +// 토큰이 아닌 값(사용자 승인): | |
| 30 | +// - display / flex / align-*: 배치 규칙. | |
| 31 | + | |
| 32 | +@use "@fox/styles/abstracts" as fox; | |
| 33 | + | |
| 34 | +.fox-conditional-option { | |
| 35 | + display: flex; | |
| 36 | + | |
| 37 | + // select가 라디오 옆에 선다. 라벨 한 줄과 높이를 맞춘다. | |
| 38 | + &--horizontal { | |
| 39 | + flex-direction: row; | |
| 40 | + align-items: center; | |
| 41 | + gap: fox.gap(5); | |
| 42 | + } | |
| 43 | + | |
| 44 | + // select가 라디오 아래로 내려간다. | |
| 45 | + &--vertical { | |
| 46 | + flex-direction: column; | |
| 47 | + align-items: flex-start; | |
| 48 | + gap: fox.gap(4); | |
| 49 | + } | |
| 50 | +} | |
| 51 | + | |
| 52 | +// 들여쓰기 폭은 `fox-radio`의 동그란 표시 폭과 그 간격을 그대로 따른다 — 두 값이 바뀌면 | |
| 53 | +// 여기도 같이 바뀌어야 라벨 왼쪽 끝에 계속 맞는다. 가로일 때는 옆에 서므로 들여쓰지 않는다. | |
| 54 | +.fox-conditional-option--vertical .fox-conditional-option__detail { | |
| 55 | + margin-inline-start: calc(#{fox.form(icon-lg)} + #{fox.gap(3)}); | |
| 56 | +} | |
| 57 | + | |
| 58 | +// 라디오가 md면 동그란 표시도 한 단계 작아지므로 들여쓰기도 따라 줄인다. | |
| 59 | +.fox-conditional-option--vertical:has(.fox-radio--md) .fox-conditional-option__detail { | |
| 60 | + margin-inline-start: calc(#{fox.form(icon-md)} + #{fox.gap(3)}); | |
| 61 | +} |
+++ @fox/styles/_fox-radio-group.scss
... | ... | @@ -0,0 +1,57 @@ |
| 1 | +// FoxRadioGroup — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma radio-group | |
| 3 | +// | |
| 4 | +// 라디오 여러 칸의 배치만 책임진다. 선택 상태는 각 `fox-radio`의 네이티브 input이 소유하고, | |
| 5 | +// 같은 `name`을 쓰면 브라우저가 하나만 선택되게 묶는다 — 그룹이 상태를 갖지 않는다. | |
| 6 | +// | |
| 7 | +// 자식들의 `name`을 맞추는 건 작성자 몫이다(React 래퍼는 `name` prop으로 자동화한다). 한 칸만 | |
| 8 | +// 어긋나도 브라우저가 다른 묶음으로 보고 두 개가 동시에 선택된다. | |
| 9 | +// | |
| 10 | +// 마크업 계약 (React 밖 소비자용): | |
| 11 | +// <div class="fox-radio-group fox-radio-group--vertical" role="radiogroup" aria-label="요금제"> | |
| 12 | +// <label class="fox-radio fox-radio--lg">…</label> | |
| 13 | +// <label class="fox-radio fox-radio--lg">…</label> | |
| 14 | +// </div> | |
| 15 | +// | |
| 16 | +// ⚠️ 시안의 변형 이름이 실제 동작과 반대다. `direction=row`가 세로로 쌓고 | |
| 17 | +// (`flex-direction: column`), `direction=col`이 가로로 나열한다(사용자 확인). 이름을 그대로 | |
| 18 | +// 옮기면 코드가 거짓말을 하므로, **동작 기준으로 다시 이름 붙였다.** | |
| 19 | +// | |
| 20 | +// 시안 direction=row → `--vertical` (세로로 쌓임) | |
| 21 | +// 시안 direction=col → `--horizontal` (가로 나열, 줄바꿈) | |
| 22 | +// | |
| 23 | +// 단어는 `fox-button-group`의 orientation 규약을 따른다 — 같은 개념을 형제 컴포넌트가 다르게 | |
| 24 | +// 부르지 않게 한다. 시안과 대조할 때는 위 표를 보면 된다. | |
| 25 | +// | |
| 26 | +// 가로는 줄바꿈되며 행 간격(`gap-7`)보다 열 간격(`gap-9`)이 넓다. 세로는 한 축뿐이라 | |
| 27 | +// `gap-7` 하나다. | |
| 28 | +// | |
| 29 | +// 시안 dev 출력의 `align-self: stretch`(세로)와 `flex: 1 0 0`(가로)은 옮기지 않았다 — 그룹이 | |
| 30 | +// **부모 안에서** 어떻게 늘어나는지를 정하는 값이라, 규약 7("className은 배치용")에 따라 | |
| 31 | +// 호출부가 정할 몫이다. 남는 폭을 채워야 하는 자리에서는 `className`으로 준다. | |
| 32 | +// | |
| 33 | +// 토큰이 아닌 값(사용자 승인): | |
| 34 | +// - display / flex-direction / flex-wrap / align-*: 배치 규칙. | |
| 35 | + | |
| 36 | +@use "@fox/styles/abstracts" as fox; | |
| 37 | + | |
| 38 | +.fox-radio-group { | |
| 39 | + display: flex; | |
| 40 | + | |
| 41 | + // 세로 — 한 칸씩 아래로 쌓인다. 라벨 길이가 달라도 왼쪽 끝이 맞아야 하므로 flex-start다. | |
| 42 | + &--vertical { | |
| 43 | + flex-direction: column; | |
| 44 | + align-items: flex-start; | |
| 45 | + gap: fox.gap(7); | |
| 46 | + } | |
| 47 | + | |
| 48 | + // 가로 — 폭이 모자라면 다음 줄로 넘어간다. `align-content`가 있어야 여러 줄이 됐을 때 | |
| 49 | + // 줄 묶음 전체가 가운데 정렬된다. | |
| 50 | + &--horizontal { | |
| 51 | + flex-direction: row; | |
| 52 | + flex-wrap: wrap; | |
| 53 | + align-items: center; | |
| 54 | + align-content: center; | |
| 55 | + gap: fox.gap(7) fox.gap(9); | |
| 56 | + } | |
| 57 | +} |
+++ @fox/styles/_fox-radio-icon.scss
... | ... | @@ -0,0 +1,119 @@ |
| 1 | +// FoxRadioIcon — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma radio-icon | |
| 3 | +// | |
| 4 | +// 라디오의 동그란 표시만 담당한다. 라벨·부가설명까지 묶은 것은 `fox-radio`다. | |
| 5 | +// | |
| 6 | +// SVG를 쓰지 않고 CSS로 그린다. 시안이 바깥 원과 안쪽 점을 별개 레이어로 두었고 네 상태가 | |
| 7 | +// 모양이 아니라 색만 다르므로, `border` + `border-radius`로 전부 표현된다. SVG로 가면 | |
| 8 | +// 배경·테두리 두 색을 `currentColor` 하나로 몰 수 없고 원시 hex가 박힌다. | |
| 9 | +// | |
| 10 | +// 마크업 계약 (React 밖 소비자용): | |
| 11 | +// <span class="fox-radio-icon fox-radio-icon--lg"> | |
| 12 | +// <span class="fox-radio-icon__dot"></span> | |
| 13 | +// </span> | |
| 14 | +// | |
| 15 | +// 안쪽 점은 **항상 마크업에 둔다.** 선택됐을 때만 넣는 게 아니라 CSS가 보이기/숨기기를 | |
| 16 | +// 정한다 — 그래야 비제어 입력에서도(React가 선택 여부를 모르는 상태에서도) 모양이 맞는다. | |
| 17 | +// 바깥 원이 flex 컨테이너라 점은 저절로 가운데 놓인다. | |
| 18 | +// | |
| 19 | +// 상태를 거는 방법이 둘이다. | |
| 20 | +// - `fox-radio` 안에서는 네이티브 `<input type="radio">`가 형제로 앞에 있고, 그 요소의 | |
| 21 | +// `:checked`·`:disabled`가 그대로 스타일 훅이 된다(규약 3). 클래스를 붙일 필요가 없다. | |
| 22 | +// - 단독으로 쓸 때(예: 상태 비교 예제)는 `--checked`·`--disabled` 모디파이어로 고정한다. | |
| 23 | +// | |
| 24 | +// 네 상태 모두 테두리 두께는 `border-1`이다 — 선택은 테두리 색만, 비활성은 테두리·배경 | |
| 25 | +// 색만 바꾼다. 비활성도 기본·선택 두 가지가 있고, 선택된 채 비활성이면 점이 남되 색이 | |
| 26 | +// 흐려진다. | |
| 27 | +// | |
| 28 | +// ⚠️ 포커스 표시는 시안에 없다. 네이티브 input을 시각적으로 숨기므로 브라우저 기본 링도 | |
| 29 | +// 사라져서, 키보드 사용자를 위해 이 파일에서 만들어 준다. | |
| 30 | +// | |
| 31 | +// 토큰이 아닌 값(사용자 승인): | |
| 32 | +// - md의 안쪽 점 `1rem`(10px): `form-icon-md` 지름의 절반이라 lg(`form-icon-lg` 지름에 | |
| 33 | +// `icon-2` 점)과 같은 비율인데, icon 스케일이 `icon-1`에서 `icon-2`로 건너뛰어 그 값에 | |
| 34 | +// 맞는 토큰이 없다. | |
| 35 | +// - box-sizing / display / flex: 구조 규칙. | |
| 36 | + | |
| 37 | +@use "@fox/styles/abstracts" as fox; | |
| 38 | + | |
| 39 | +.fox-radio-icon { | |
| 40 | + // 테두리가 바깥 지름을 늘리지 않게 한다 — 지름은 `form-icon-*`이 정한 값이어야 한다. | |
| 41 | + box-sizing: border-box; | |
| 42 | + display: flex; | |
| 43 | + flex-shrink: 0; | |
| 44 | + align-items: center; | |
| 45 | + justify-content: center; | |
| 46 | + border: fox.border(1) solid fox.color(element-neutral-strong); | |
| 47 | + border-radius: fox.radius(max); | |
| 48 | + background: fox.color(element-neutral-inverse); | |
| 49 | + | |
| 50 | + // 안쪽 점. 크기는 크기 모디파이어가, 보이기는 선택 상태가 정한다. | |
| 51 | + &__dot { | |
| 52 | + display: none; | |
| 53 | + flex-shrink: 0; | |
| 54 | + border-radius: fox.radius(max); | |
| 55 | + background: fox.color(element-theme-primary); | |
| 56 | + } | |
| 57 | + | |
| 58 | + // ── 크기 ──────────────────────────────────────────────────────────────────── | |
| 59 | + // 시안이 바깥 지름에 `form-icon-*`을 건다 — 반응형 토큰이라 모바일에서 한 단계 작아진다. | |
| 60 | + &--lg { | |
| 61 | + inline-size: fox.form(icon-lg); | |
| 62 | + block-size: fox.form(icon-lg); | |
| 63 | + } | |
| 64 | + | |
| 65 | + &--lg &__dot { | |
| 66 | + inline-size: fox.icon(2); | |
| 67 | + block-size: fox.icon(2); | |
| 68 | + } | |
| 69 | + | |
| 70 | + &--md { | |
| 71 | + inline-size: fox.form(icon-md); | |
| 72 | + block-size: fox.form(icon-md); | |
| 73 | + } | |
| 74 | + | |
| 75 | + // 시안 10px — 지름의 1/2로 lg과 같은 비율인데, icon 스케일이 `icon-1`에서 `icon-2`로 | |
| 76 | + // 건너뛰어 맞는 토큰이 없다(위 승인 목록 참고). | |
| 77 | + &--md &__dot { | |
| 78 | + inline-size: 1rem; | |
| 79 | + block-size: 1rem; | |
| 80 | + } | |
| 81 | +} | |
| 82 | + | |
| 83 | +// ── 선택 ────────────────────────────────────────────────────────────────────── | |
| 84 | +// 두 선택자를 함께 쓴다 — `fox-radio` 안에서는 네이티브 `:checked`가, 단독으로 쓸 때는 | |
| 85 | +// 모디파이어가 같은 모양을 만든다. 다른 블록(`fox-radio__input`)을 지목하는 것은 | |
| 86 | +// `fox-button-panel`이 안쪽 `.fox-button`을 지목하는 것과 같은 방식이다. | |
| 87 | +.fox-radio-icon--checked, | |
| 88 | +.fox-radio__input:checked ~ .fox-radio-icon { | |
| 89 | + border: fox.border(2) solid fox.color(element-theme-primary); | |
| 90 | +} | |
| 91 | + | |
| 92 | +.fox-radio-icon--checked .fox-radio-icon__dot, | |
| 93 | +.fox-radio__input:checked ~ .fox-radio-icon .fox-radio-icon__dot { | |
| 94 | + display: block; | |
| 95 | +} | |
| 96 | + | |
| 97 | +// ── 비활성 ──────────────────────────────────────────────────────────────────── | |
| 98 | +// 선택 규칙보다 뒤에 와야 이긴다 — 비활성이면 선택 여부와 무관하게 바깥 원 색이 같다. | |
| 99 | +.fox-radio-icon--disabled, | |
| 100 | +.fox-radio__input:disabled ~ .fox-radio-icon { | |
| 101 | + border: fox.border(2) solid fox.color(border-neutral-disabled); | |
| 102 | + background: fox.color(element-neutral-disabled); | |
| 103 | +} | |
| 104 | + | |
| 105 | +// 점은 숨기지 않는다 — 선택된 채 비활성이어도 선택 여부가 보여야 한다. 색만 흐려진다. | |
| 106 | +// 보이기/숨기기는 위 선택 규칙이 정하므로 여기서는 색만 건드린다. | |
| 107 | +.fox-radio-icon--disabled .fox-radio-icon__dot, | |
| 108 | +.fox-radio__input:disabled ~ .fox-radio-icon .fox-radio-icon__dot { | |
| 109 | + background: fox.color(element-neutral-disabled-strong); | |
| 110 | +} | |
| 111 | + | |
| 112 | +// ── 포커스 ──────────────────────────────────────────────────────────────────── | |
| 113 | +// input이 시각적으로 숨겨져 브라우저 기본 링이 보이지 않으므로 여기서 대신 그린다. | |
| 114 | +// 투명이 아니라 실제 링인 이유: 선택 시 테두리도 같은 primary 색이라, 테두리 변화만으로는 | |
| 115 | +// 포커스와 선택을 구분할 수 없다. | |
| 116 | +.fox-radio__input:focus-visible ~ .fox-radio-icon { | |
| 117 | + outline: fox.border(2) solid fox.color(border-theme-primary); | |
| 118 | + outline-offset: fox.border(1); | |
| 119 | +} |
+++ @fox/styles/_fox-radio.scss
... | ... | @@ -0,0 +1,103 @@ |
| 1 | +// FoxRadio — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma radio | |
| 3 | +// | |
| 4 | +// 라디오 버튼 한 칸이다 — 동그란 표시(`fox-radio-icon`) + 라벨 + 부가설명. | |
| 5 | +// | |
| 6 | +// 마크업 계약 (React 밖 소비자용): | |
| 7 | +// <label class="fox-radio fox-radio--lg"> | |
| 8 | +// <input type="radio" class="fox-radio__input" name="plan" value="basic"> | |
| 9 | +// <span class="fox-radio-icon fox-radio-icon--lg"></span> | |
| 10 | +// <span class="fox-radio__body"> | |
| 11 | +// <span class="fox-radio__label">라벨</span> | |
| 12 | +// <span class="fox-radio__description">부가 설명</span> <!-- 선택 --> | |
| 13 | +// </span> | |
| 14 | +// </label> | |
| 15 | +// | |
| 16 | +// 루트가 `<label>`이라 어디를 눌러도 브라우저가 선택을 처리한다. 상태는 전부 네이티브 | |
| 17 | +// `<input type="radio">`가 소유한다 — `name`이 같으면 그룹·화살표 이동·폼 전송까지 브라우저가 | |
| 18 | +// 알아서 하므로 별도 group 컴포넌트가 없다(규약 3). | |
| 19 | +// | |
| 20 | +// input은 순서상 **맨 앞**에 와야 한다. 동그란 표시와 본문이 `~` 형제 선택자로 그 상태를 | |
| 21 | +// 따라가기 때문이다. 시각적으로만 숨기고 포커스는 살려 둔다 — `display: none`으로 지우면 | |
| 22 | +// 키보드로 도달할 수 없다. | |
| 23 | +// | |
| 24 | +// 크기 모디파이어는 안쪽 `fox-radio-icon`의 크기까지 맞춰 준다. 자식에 직접 크기 클래스를 | |
| 25 | +// 붙이는 것은 React 래퍼가 자동화하지만, 순수 SCSS 소비자는 둘을 같은 단계로 적어야 한다. | |
| 26 | +// | |
| 27 | +// 토큰이 아닌 값(FoxButton과 동일한 근거로 승인된 항목): | |
| 28 | +// - line-height / letter-spacing: 라벨·부가설명의 시안 텍스트 스타일 값이나 Figma가 변수로 | |
| 29 | +// export하지 않는다. | |
| 30 | +// | |
| 31 | +// 토큰이 아닌 값(사용자 승인): | |
| 32 | +// - cursor / display / flex / position: 구조·상호작용 규칙. | |
| 33 | +// - 숨긴 input의 `inline-size: 0`: 크기가 아니라 "보이지 않게" 하는 구조 값이다. | |
| 34 | + | |
| 35 | +@use "@fox/styles/abstracts" as fox; | |
| 36 | + | |
| 37 | +.fox-radio { | |
| 38 | + // 숨긴 input의 위치 기준. 안 주면 input이 조상까지 올라가 엉뚱한 곳에 붙는다. | |
| 39 | + position: relative; | |
| 40 | + display: flex; | |
| 41 | + // 부가설명이 여러 줄이어도 동그란 표시는 첫 줄에 붙어 있어야 한다. | |
| 42 | + align-items: flex-start; | |
| 43 | + gap: fox.gap(3); | |
| 44 | + cursor: pointer; | |
| 45 | + | |
| 46 | + // 시각적으로만 숨긴다. `opacity: 0`은 포커스를 유지하므로 키보드로 도달할 수 있고, | |
| 47 | + // 클릭은 루트 `<label>`이 받는다. | |
| 48 | + &__input { | |
| 49 | + position: absolute; | |
| 50 | + inline-size: 0; | |
| 51 | + block-size: 0; | |
| 52 | + margin: 0; | |
| 53 | + opacity: 0; | |
| 54 | + pointer-events: none; | |
| 55 | + } | |
| 56 | + | |
| 57 | + &__body { | |
| 58 | + display: flex; | |
| 59 | + flex-direction: column; | |
| 60 | + gap: fox.gap(1); | |
| 61 | + align-items: flex-start; | |
| 62 | + } | |
| 63 | + | |
| 64 | + // 자간은 px이 아니라 em이다 — 라벨은 크기 수식어(lg·md)마다, 부가설명은 브레이크포인트마다 | |
| 65 | + // 글자 크기가 달라지는데, px으로 박으면 그 자리에서 비율이 어긋난다. | |
| 66 | + &__label { | |
| 67 | + color: fox.color(font-neutral-strong); | |
| 68 | + font-family: fox.font-family(body); | |
| 69 | + font-weight: fox.font-weight(regular); | |
| 70 | + line-height: 1.5; | |
| 71 | + letter-spacing: -0.025em; | |
| 72 | + } | |
| 73 | + | |
| 74 | + &__description { | |
| 75 | + color: fox.color(font-neutral-subtle); | |
| 76 | + font-family: fox.font-family(body); | |
| 77 | + font-weight: fox.font-weight(regular); | |
| 78 | + font-size: fox.font-size(body-sm); | |
| 79 | + line-height: 1.5; | |
| 80 | + letter-spacing: -0.025em; | |
| 81 | + } | |
| 82 | + | |
| 83 | + // ── 크기 ──────────────────────────────────────────────────────────────────── | |
| 84 | + &--lg &__label { | |
| 85 | + font-size: fox.font-size(body-lg); | |
| 86 | + } | |
| 87 | + | |
| 88 | + &--md &__label { | |
| 89 | + font-size: fox.font-size(body-md); | |
| 90 | + } | |
| 91 | +} | |
| 92 | + | |
| 93 | +// ── 비활성 ──────────────────────────────────────────────────────────────────── | |
| 94 | +// 글자색도 함께 흐려진다. 동그란 표시는 `_fox-radio-icon.scss`가 같은 `:disabled`를 보고 | |
| 95 | +// 처리하므로 여기서 건드리지 않는다. | |
| 96 | +.fox-radio:has(.fox-radio__input:disabled) { | |
| 97 | + cursor: default; | |
| 98 | +} | |
| 99 | + | |
| 100 | +.fox-radio__input:disabled ~ .fox-radio__body .fox-radio__label, | |
| 101 | +.fox-radio__input:disabled ~ .fox-radio__body .fox-radio__description { | |
| 102 | + color: fox.color(font-neutral-disabled); | |
| 103 | +} |
--- @fox/styles/_fox-select-option-item.scss
+++ @fox/styles/_fox-select-option-item.scss
... | ... | @@ -43,10 +43,11 @@ |
| 43 | 43 |
font-family: fox.font-family(body); |
| 44 | 44 |
font-weight: fox.font-weight(regular); |
| 45 | 45 |
font-size: fox.font-size(body-sm); |
| 46 |
- // line-height 150% / letter-spacing -0.375px — Figma 텍스트 스타일 값이며 변수로 |
|
| 47 |
- // export되지 않아 토큰이 없다(fox-button의 label/*-strong 사례와 같은 예외, 사용자 승인). |
|
| 46 |
+ // line-height 150% / letter-spacing — 시안 텍스트 스타일 값이나 Figma가 변수로 |
|
| 47 |
+ // export하지 않는다(사용자 승인). 자간은 px이 아니라 em이라 크기·브레이크포인트가 |
|
| 48 |
+ // 바뀌어도 비율이 유지된다. |
|
| 48 | 49 |
line-height: 1.5; |
| 49 |
- letter-spacing: -0.375px; |
|
| 50 |
+ letter-spacing: -0.025em; |
|
| 50 | 51 |
cursor: pointer; |
| 51 | 52 |
|
| 52 | 53 |
// 키보드 화살표로 이동한 항목도 마우스 호버와 같은 모양이어야 하므로, 실제 `:hover` |
--- @fox/styles/_fox-select.scss
+++ @fox/styles/_fox-select.scss
... | ... | @@ -132,10 +132,11 @@ |
| 132 | 132 |
font-family: fox.font-family(body); |
| 133 | 133 |
font-weight: fox.font-weight(regular); |
| 134 | 134 |
font-size: fox.font-size(body-sm); |
| 135 |
- // line-height 150% / letter-spacing -0.325px — Figma 텍스트 스타일 값이며 변수로 |
|
| 136 |
- // export되지 않아 토큰이 없다(fox-button의 label/*-strong 사례와 같은 예외, 사용자 승인). |
|
| 135 |
+ // line-height 150% / letter-spacing — 시안 텍스트 스타일 값이나 Figma가 변수로 |
|
| 136 |
+ // export하지 않는다(사용자 승인). 자간은 px이 아니라 em이라 크기·브레이크포인트가 |
|
| 137 |
+ // 바뀌어도 비율이 유지된다. |
|
| 137 | 138 |
line-height: 1.5; |
| 138 |
- letter-spacing: -0.325px; |
|
| 139 |
+ letter-spacing: -0.025em; |
|
| 139 | 140 |
} |
| 140 | 141 |
|
| 141 | 142 |
// 힌트 색은 트리거의 `aria-invalid`를 따라간다 — 오류 표시를 트리거 한 곳에만 걸면 |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -9,10 +9,14 @@ |
| 9 | 9 |
@use "fox-button-panel"; |
| 10 | 10 |
@use "fox-address"; |
| 11 | 11 |
@use "fox-certified"; |
| 12 |
+@use "fox-conditional-option"; |
|
| 12 | 13 |
@use "fox-email"; |
| 13 | 14 |
@use "fox-file-upload"; |
| 14 | 15 |
@use "fox-input"; |
| 15 | 16 |
@use "fox-phone-number"; |
| 17 |
+@use "fox-radio"; |
|
| 18 |
+@use "fox-radio-group"; |
|
| 19 |
+@use "fox-radio-icon"; |
|
| 16 | 20 |
@use "fox-select"; |
| 17 | 21 |
@use "fox-select-option"; |
| 18 | 22 |
@use "fox-select-option-item"; |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?