merge: develop의 폼 컴포넌트 7커밋을 hub로 통합
FoxFormLabel · FoxRadio(+Icon·Group) · FoxConditionalOption · FoxCheckbox(+Icon·Group) · FoxTag(+List) · FoxToggleSwitch가 들어왔다. 충돌 2건은 양쪽을 모두 살리는 union으로 해소했다 — 둘 다 "컴포넌트를 만들 때마다 항목 하나를 추가하는" 등록 파일이라 서로 지울 이유가 없다. - @fox/styles/components.scss: 새 파티셜 10개 + Navigation 파티셜 5개 - @fox/dev-test/component-registry.tsx: 카탈로그 항목 32개 검증: sass 컴파일 450규칙 · tsc --noEmit · eslint 모두 통과. Co-Authored-By: Claude Opus 5
@9dd8a1b85c89fa6b7d0b1425b93eb7a3f52283cc
+++ @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
... | ... | @@ -0,0 +1,163 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useRef, type ReactNode } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | +import { FoxCheckbox } from "../fox-checkbox"; | |
| 6 | +import { FoxRadio } from "../fox-radio"; | |
| 7 | +import { FoxSelect, type FoxSelectItem } from "../fox-select"; | |
| 8 | + | |
| 9 | +export type FoxConditionalOptionOrientation = "horizontal" | "vertical"; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * 앞에 놓이는 컨트롤. 라디오는 묶음에서 하나만 골리고, 체크박스는 여러 개를 동시에 골릴 수 | |
| 13 | + * 있다 — 나머지는 전부 같다. | |
| 14 | + */ | |
| 15 | +export type FoxConditionalOptionControl = "radio" | "checkbox"; | |
| 16 | + | |
| 17 | +/** 라디오·체크박스가 같은 두 단계를 쓴다. */ | |
| 18 | +export type FoxConditionalOptionSize = "lg" | "md"; | |
| 19 | + | |
| 20 | +export interface FoxConditionalOptionProps { | |
| 21 | + /** | |
| 22 | + * 앞에 놓이는 컨트롤. 기본은 라디오다. | |
| 23 | + * | |
| 24 | + * 시안에서는 `trigger`라고 부르지만 이름을 바꿨다 — 이 패키지에서 `trigger`는 이미 | |
| 25 | + * `fox-select__trigger`(목록을 여는 버튼)라는 다른 뜻으로 쓰인다. 여기 라디오·체크박스는 | |
| 26 | + * 무엇을 여는 게 아니라 답을 담는 컨트롤이다. | |
| 27 | + */ | |
| 28 | + control?: FoxConditionalOptionControl; | |
| 29 | + /** 컨트롤 값. 같은 `name` 안에서 이 칸을 가리킨다. */ | |
| 30 | + value: string; | |
| 31 | + label: ReactNode; | |
| 32 | + /** 라벨 아래 보조 설명. 넘기지 않으면 영역을 렌더링하지 않는다. */ | |
| 33 | + description?: ReactNode; | |
| 34 | + /** 딸린 select의 목록. */ | |
| 35 | + options: FoxSelectItem[]; | |
| 36 | + /** 딸린 select의 값(제어). 넘기지 않으면 안에서 들고 있는다. */ | |
| 37 | + detail?: string; | |
| 38 | + /** 비제어로 쓸 때 딸린 select의 초기 값. */ | |
| 39 | + defaultDetail?: string; | |
| 40 | + /** 딸린 select 값이 바뀔 때 부른다. 값이 첫 인자다. */ | |
| 41 | + onChange?: (detail: string) => void; | |
| 42 | + /** | |
| 43 | + * 켜짐 여부가 바뀔 때 부른다 — select를 골라 자동으로 켜진 경우도 포함이다. | |
| 44 | + * | |
| 45 | + * 라디오는 켜질 때만 뛰므로 항상 `true`로 온다. 체크박스는 끌 때도 뛰어서 `false`가 올 수 | |
| 46 | + * 있다. 값은 호출부가 이미 아는 것이라 되돌려주지 않는다. | |
| 47 | + */ | |
| 48 | + onCheckedChange?: (checked: boolean) => void; | |
| 49 | + /** 아무것도 고르지 않았을 때 select에 보여줄 문구. */ | |
| 50 | + placeholder?: ReactNode; | |
| 51 | + orientation?: FoxConditionalOptionOrientation; | |
| 52 | + /** 묶음 이름. 그룹 안에 넣으면 그룹이 넣어 주므로 직접 적지 않아도 된다. */ | |
| 53 | + name?: string; | |
| 54 | + /** `FoxRadioGroup`·`FoxCheckboxGroup`이 넣어 준다. 직접 넣으면 그 값이 컨트롤 크기가 된다. */ | |
| 55 | + size?: FoxConditionalOptionSize; | |
| 56 | + /** 처음부터 켜진 채로 둔다(비제어). */ | |
| 57 | + defaultChecked?: boolean; | |
| 58 | + /** 주면 켜짐 여부를 호출부가 소유한다. */ | |
| 59 | + checked?: boolean; | |
| 60 | + disabled?: boolean; | |
| 61 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 62 | + hidden?: boolean; | |
| 63 | + /** 배치 조정용. */ | |
| 64 | + className?: string; | |
| 65 | +} | |
| 66 | + | |
| 67 | +const ORIENTATION_CLASS: Record<FoxConditionalOptionOrientation, string> = { | |
| 68 | + horizontal: "fox-conditional-option--horizontal", | |
| 69 | + vertical: "fox-conditional-option--vertical", | |
| 70 | +}; | |
| 71 | + | |
| 72 | +/** | |
| 73 | + * @fox 후속 질문이 딸린 선택지. 컨트롤 한 칸과 select를 묶어 하나의 답으로 다룬다. | |
| 74 | + * | |
| 75 | + * `control`로 라디오와 체크박스를 고른다. 두 컨트롤이 `name`·`value`·`size`·`checked`를 같은 | |
| 76 | + * 이름으로 받고 크기 단계도 같아서, 나머지 배선은 그대로 쓴다. 그래서 `FoxRadioGroup`이든 | |
| 77 | + * `FoxCheckboxGroup`이든 그대로 넣을 수 있다 — 그룹이 `name`·`size`를 넣어 준다. | |
| 78 | + * | |
| 79 | + * select는 이 칸이 켜지지 않아도 **조작할 수 있다.** 값을 고르면 컨트롤이 자동으로 켜진다 — | |
| 80 | + * 값을 고르고도 컨트롤을 안 눌러 답이 비는 일이 흔해서다. 반대로 select를 잠가 두면 그 자동 | |
| 81 | + * 선택 자체가 불가능해진다(`disabled`는 클릭을 먹지 않는다). | |
| 82 | + * | |
| 83 | + * 켜는 방법은 숨은 input을 실제로 클릭하는 것이다. 그래야 라디오에서 같은 `name`의 다른 칸이 | |
| 84 | + * 꺼지는 것과 change 이벤트가 브라우저 규칙 그대로 일어난다 — `checked`를 직접 대입하면 | |
| 85 | + * 이벤트가 뛰지 않아 호출부가 모른다. 이미 켜져 있으면 누르지 않는다(체크박스가 꺼져 버린다). | |
| 86 | + * | |
| 87 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 88 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 89 | + */ | |
| 90 | +export function FoxConditionalOption({ | |
| 91 | + control = "radio", | |
| 92 | + value, | |
| 93 | + label, | |
| 94 | + description, | |
| 95 | + options, | |
| 96 | + detail, | |
| 97 | + defaultDetail, | |
| 98 | + onChange, | |
| 99 | + onCheckedChange, | |
| 100 | + placeholder, | |
| 101 | + orientation = "vertical", | |
| 102 | + name, | |
| 103 | + size = "lg", | |
| 104 | + defaultChecked, | |
| 105 | + checked, | |
| 106 | + disabled = false, | |
| 107 | + hidden = false, | |
| 108 | + className, | |
| 109 | +}: FoxConditionalOptionProps) { | |
| 110 | + const inputRef = useRef<HTMLInputElement>(null); | |
| 111 | + | |
| 112 | + if (hidden) { | |
| 113 | + return null; | |
| 114 | + } | |
| 115 | + | |
| 116 | + // 비제어일 때의 값은 `FoxSelect`가 이미 들고 있다 — 여기서 또 들면 두 벌이 어긋난다. | |
| 117 | + const handleDetail = (next: string) => { | |
| 118 | + onChange?.(next); | |
| 119 | + | |
| 120 | + // 켜짐 여부는 DOM에서 읽는다 — 라디오에서 형제 칸을 고르면 이 칸이 조용히 꺼지므로 따로 | |
| 121 | + // 들고 있으면 어긋난다. 이미 켜져 있으면 다시 눌러 끄지 않는다. | |
| 122 | + if (inputRef.current && !inputRef.current.checked) { | |
| 123 | + inputRef.current.click(); | |
| 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, | |
| 138 | + }; | |
| 139 | + | |
| 140 | + return ( | |
| 141 | + <div className={cx("fox-conditional-option", ORIENTATION_CLASS[orientation], className)}> | |
| 142 | + {control === "checkbox" ? ( | |
| 143 | + // 체크박스는 끌 때도 뛰므로 켜짐 여부를 그대로 넘긴다. | |
| 144 | + <FoxCheckbox {...shared} onChange={(next) => onCheckedChange?.(next)} /> | |
| 145 | + ) : ( | |
| 146 | + // 라디오는 켜질 때만 뛴다 — 그래서 `true`로 고정해 넘긴다. | |
| 147 | + <FoxRadio {...shared} onChange={() => onCheckedChange?.(true)} /> | |
| 148 | + )} | |
| 149 | + | |
| 150 | + <div className="fox-conditional-option__detail"> | |
| 151 | + <FoxSelect | |
| 152 | + size="md" | |
| 153 | + options={options} | |
| 154 | + value={detail} | |
| 155 | + defaultValue={defaultDetail} | |
| 156 | + placeholder={placeholder} | |
| 157 | + onValueChange={handleDetail} | |
| 158 | + disabled={disabled} | |
| 159 | + /> | |
| 160 | + </div> | |
| 161 | + </div> | |
| 162 | + ); | |
| 163 | +} |
+++ @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-form-label/fox-form-label.tsx
... | ... | @@ -0,0 +1,63 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { ComponentPropsWithRef } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * 필수·선택 표시. 둘은 배타적이라 boolean 두 개가 아니라 하나의 값으로 받는다 — 그래야 | |
| 8 | + * "필수이면서 선택"이라는 있을 수 없는 조합이 타입에서 막힌다. | |
| 9 | + */ | |
| 10 | +export type FoxFormLabelRequirement = "required" | "optional"; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * 네이티브 `<label>`이 받는 것을 그대로 넘겨받는다 — htmlFor·id·title 등. 이름이 겹치는 | |
| 14 | + * 것만 걷어내고 아래에서 다시 정의한다. | |
| 15 | + */ | |
| 16 | +type NativeLabelProps = Omit<ComponentPropsWithRef<"label">, "className" | "hidden">; | |
| 17 | + | |
| 18 | +export interface FoxFormLabelProps extends NativeLabelProps { | |
| 19 | + requirement?: FoxFormLabelRequirement; | |
| 20 | + /** "(선택)" 대신 쓸 문구. */ | |
| 21 | + optionalText?: string; | |
| 22 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 23 | + hidden?: boolean; | |
| 24 | + /** 배치 조정용. */ | |
| 25 | + className?: string; | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * @fox 입력 칸 위에 붙는 라벨. 상태를 갖지 않는다. | |
| 30 | + * | |
| 31 | + * 필수는 글자 뒤에 `*`, 선택은 "(선택)"을 붙인다. `*`는 `aria-hidden`이라 스크린리더가 읽지 | |
| 32 | + * 않는다 — 필수 여부는 입력 요소의 `required`가 알리는 것이고 별표는 그 눈에 보이는 짝이라, | |
| 33 | + * 둘 다 읽히면 같은 말이 두 번 나온다. 반대로 "(선택)"은 대신 알려 줄 속성이 없어 읽히게 둔다. | |
| 34 | + * | |
| 35 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 36 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 37 | + */ | |
| 38 | +export function FoxFormLabel({ | |
| 39 | + requirement, | |
| 40 | + optionalText = "(선택)", | |
| 41 | + hidden = false, | |
| 42 | + className, | |
| 43 | + children, | |
| 44 | + ...rest | |
| 45 | +}: FoxFormLabelProps) { | |
| 46 | + if (hidden) { | |
| 47 | + return null; | |
| 48 | + } | |
| 49 | + | |
| 50 | + return ( | |
| 51 | + <label {...rest} className={cx("fox-form-label", className)}> | |
| 52 | + {children} | |
| 53 | + {requirement === "required" ? ( | |
| 54 | + <span className="fox-form-label__required" aria-hidden="true"> | |
| 55 | + * | |
| 56 | + </span> | |
| 57 | + ) : null} | |
| 58 | + {requirement === "optional" ? ( | |
| 59 | + <span className="fox-form-label__optional">{optionalText}</span> | |
| 60 | + ) : null} | |
| 61 | + </label> | |
| 62 | + ); | |
| 63 | +} |
+++ @fox/core/components/fox-form-label/index.ts
... | ... | @@ -0,0 +1,5 @@ |
| 1 | +export { | |
| 2 | + FoxFormLabel, | |
| 3 | + type FoxFormLabelProps, | |
| 4 | + type FoxFormLabelRequirement, | |
| 5 | +} from "./fox-form-label"; |
--- @fox/core/components/fox-input/fox-input.tsx
+++ @fox/core/components/fox-input/fox-input.tsx
... | ... | @@ -10,6 +10,7 @@ |
| 10 | 10 |
type ReactNode, |
| 11 | 11 |
} from "react"; |
| 12 | 12 |
import { cx } from "../../utils";
|
| 13 |
+import { FoxFormLabel } from "../fox-form-label";
|
|
| 13 | 14 |
|
| 14 | 15 |
export type FoxInputSize = "lg" | "md" | "sm"; |
| 15 | 16 |
export type FoxInputState = "default" | "focused" | "completed" | "error" | "disabled" | "view"; |
... | ... | @@ -137,9 +138,7 @@ |
| 137 | 138 |
data-state={state}
|
| 138 | 139 |
> |
| 139 | 140 |
{label && (
|
| 140 |
- <label className="fox-input__label" htmlFor={fieldId}>
|
|
| 141 |
- {label}
|
|
| 142 |
- </label> |
|
| 141 |
+ <FoxFormLabel htmlFor={fieldId}>{label}</FoxFormLabel>
|
|
| 143 | 142 |
)} |
| 144 | 143 |
|
| 145 | 144 |
<div className="fox-input__box"> |
+++ @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
... | ... | @@ -10,6 +10,7 @@ |
| 10 | 10 |
type Ref, |
| 11 | 11 |
} from "react"; |
| 12 | 12 |
import { cx } from "../../utils";
|
| 13 |
+import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label";
|
|
| 13 | 14 |
import { FoxSelectOption } from "../fox-select-option";
|
| 14 | 15 |
import { FoxSelectOptionItem } from "../fox-select-option-item";
|
| 15 | 16 |
|
... | ... | @@ -37,7 +38,16 @@ |
| 37 | 38 |
* 호출부마다 `useState`를 반복하는 비용이 크기 때문이다(사용자 승인). |
| 38 | 39 |
*/ |
| 39 | 40 |
defaultValue?: string; |
| 40 |
- /** 값이 바뀔 때 호출된다. 이름이 `onChange`가 아닌 이유는 DOM 이벤트가 아니기 때문이다. */ |
|
| 41 |
+ /** |
|
| 42 |
+ * 값이 바뀔 때 호출된다. |
|
| 43 |
+ * |
|
| 44 |
+ * ⚠️ 이름을 `onChange`로 바꾸지 않는다. 이 패키지의 기준은 "네이티브 요소를 감싸면 |
|
| 45 |
+ * `onChange`(그 네이티브 것을 `Omit`으로 걷어내고 대신한다), 감싸는 요소가 없는 커스텀 |
|
| 46 |
+ * 위젯이면 `onValueChange`"다. FoxInput·FoxTextArea·FoxRadio는 앞쪽이고, 이 컴포넌트는 |
|
| 47 |
+ * 네이티브 `<select>`가 아니라 버튼 + 리스트박스라 뒤쪽이다. |
|
| 48 |
+ * |
|
| 49 |
+ * 게다가 `FoxEmail`·`FoxPhoneNumber`가 이 이름으로 호출하고 있어 바꾸면 함께 깨진다. |
|
| 50 |
+ */ |
|
| 41 | 51 |
onValueChange?: (value: string) => void; |
| 42 | 52 |
/** 아무 옵션도 선택되지 않았을 때 트리거에 보여줄 문구. */ |
| 43 | 53 |
placeholder?: ReactNode; |
... | ... | @@ -51,6 +61,8 @@ |
| 51 | 61 |
readOnly?: boolean; |
| 52 | 62 |
/** 필드 위 라벨. 넘기지 않으면 라벨을 렌더링하지 않는다. */ |
| 53 | 63 |
label?: ReactNode; |
| 64 |
+ /** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다. */ |
|
| 65 |
+ requirement?: FoxFormLabelRequirement; |
|
| 54 | 66 |
/** select 아래 힌트 메시지. 넘기지 않으면 힌트 영역을 렌더링하지 않는다. */ |
| 55 | 67 |
hint?: ReactNode; |
| 56 | 68 |
/** 힌트 메시지 앞에 붙는 아이콘. `hint`가 없으면 의미 없다. */ |
... | ... | @@ -93,6 +105,7 @@ |
| 93 | 105 |
disabled = false, |
| 94 | 106 |
readOnly = false, |
| 95 | 107 |
label, |
| 108 |
+ requirement, |
|
| 96 | 109 |
hint, |
| 97 | 110 |
hintIcon, |
| 98 | 111 |
id, |
... | ... | @@ -223,9 +236,9 @@ |
| 223 | 236 |
onMouseLeave={onHoverChange ? () => onHoverChange(false) : undefined}
|
| 224 | 237 |
> |
| 225 | 238 |
{label ? (
|
| 226 |
- <label id={labelId} htmlFor={triggerId} className="fox-select__label">
|
|
| 239 |
+ <FoxFormLabel id={labelId} htmlFor={triggerId} requirement={requirement}>
|
|
| 227 | 240 |
{label}
|
| 228 |
- </label> |
|
| 241 |
+ </FoxFormLabel> |
|
| 229 | 242 |
) : null} |
| 230 | 243 |
|
| 231 | 244 |
<div className="fox-select__control"> |
+++ @fox/core/components/fox-tag-list/fox-tag-list.tsx
... | ... | @@ -0,0 +1,101 @@ |
| 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 { FoxTagSize, FoxTagType } from "../fox-tag"; | |
| 14 | + | |
| 15 | +export interface FoxTagListProps { | |
| 16 | + /** 자식 태그의 `size`를 전부 이 값으로 덮어쓴다. 넘기지 않으면 자식이 각자 정한다. */ | |
| 17 | + size?: FoxTagSize; | |
| 18 | + /** 자식 태그의 `type`을 전부 이 값으로 덮어쓴다. 넘기지 않으면 자식이 각자 정한다. */ | |
| 19 | + type?: FoxTagType; | |
| 20 | + /** 묶음을 읽어 줄 이름. 화면에 보이는 제목이 이미 있으면 `labelledBy`를 쓴다. */ | |
| 21 | + label?: string; | |
| 22 | + /** 묶음 이름 역할을 하는 요소의 id. `label`보다 우선한다 — 화면의 글자와 어긋나지 않는다. */ | |
| 23 | + labelledBy?: string; | |
| 24 | + /** `FoxTag`들. */ | |
| 25 | + children?: ReactNode; | |
| 26 | + /** 배치 조정용. */ | |
| 27 | + className?: string; | |
| 28 | + ref?: Ref<HTMLDivElement>; | |
| 29 | +} | |
| 30 | + | |
| 31 | +type Overrides = { size?: FoxTagSize; type?: FoxTagType }; | |
| 32 | + | |
| 33 | +/** | |
| 34 | + * 자식 태그에 묶음 값을 강제로 덮어쓴다. | |
| 35 | + * | |
| 36 | + * - 호스트 요소(`div` 등)는 건드리지 않는다 — DOM에 없는 속성이 넘어가면 React가 경고한다. | |
| 37 | + * `type`은 DOM에 있는 속성이라 더 위험하다: 그냥 넘기면 `<div type="sorting">`이 된다. | |
| 38 | + * - `Children.map`은 프래그먼트 안으로 들어가지 않아 직접 내려간다. 그러지 않으면 | |
| 39 | + * `<>…</>`로 감싼 태그들이 덮어쓰기를 피해 가고 Fragment에 `size`가 붙어 경고가 난다. | |
| 40 | + */ | |
| 41 | +function overrideChildren(children: ReactNode, overrides: Overrides): ReactNode { | |
| 42 | + return Children.map(children, (child) => { | |
| 43 | + if (!isValidElement(child)) { | |
| 44 | + return child; | |
| 45 | + } | |
| 46 | + | |
| 47 | + if (child.type === Fragment) { | |
| 48 | + const fragment = child as ReactElement<{ children?: ReactNode }>; | |
| 49 | + return overrideChildren(fragment.props.children, overrides); | |
| 50 | + } | |
| 51 | + | |
| 52 | + if (typeof child.type === "string") { | |
| 53 | + return child; | |
| 54 | + } | |
| 55 | + | |
| 56 | + return cloneElement(child as ReactElement<Record<string, unknown>>, overrides); | |
| 57 | + }); | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** | |
| 61 | + * @fox 태그 묶음. 배치와 `size`·`type` 통일만 책임지고 상태를 갖지 않는다. | |
| 62 | + * | |
| 63 | + * 방향 수식어가 없다 — 태그는 가로로 흐르다 폭이 모자라면 다음 줄로 넘어가는 것이 유일한 | |
| 64 | + * 배치다. 무엇이 걸려 있는지(어떤 태그를 렌더할지)는 호출부가 소유한다. | |
| 65 | + * | |
| 66 | + * `role="radiogroup"`이 아니라 `role="group"`이다 — 하나만 고르는 묶음이 아니고, 각 태그가 | |
| 67 | + * 독립적으로 Tab을 받는 버튼이다. | |
| 68 | + * | |
| 69 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 70 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 71 | + */ | |
| 72 | +export function FoxTagList({ | |
| 73 | + size, | |
| 74 | + type, | |
| 75 | + label, | |
| 76 | + labelledBy, | |
| 77 | + children, | |
| 78 | + className, | |
| 79 | + ref, | |
| 80 | +}: FoxTagListProps) { | |
| 81 | + // 값을 준 것만 넣는다 — `undefined`를 그대로 넘기면 자식이 스스로 정한 값을 지운다. | |
| 82 | + const overrides: Overrides = {}; | |
| 83 | + if (size !== undefined) { | |
| 84 | + overrides.size = size; | |
| 85 | + } | |
| 86 | + if (type !== undefined) { | |
| 87 | + overrides.type = type; | |
| 88 | + } | |
| 89 | + | |
| 90 | + return ( | |
| 91 | + <div | |
| 92 | + ref={ref} | |
| 93 | + role="group" | |
| 94 | + aria-label={labelledBy ? undefined : label} | |
| 95 | + aria-labelledby={labelledBy} | |
| 96 | + className={cx("fox-tag-list", className)} | |
| 97 | + > | |
| 98 | + {overrideChildren(children, overrides)} | |
| 99 | + </div> | |
| 100 | + ); | |
| 101 | +} |
+++ @fox/core/components/fox-tag-list/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxTagList, type FoxTagListProps } from "./fox-tag-list"; |
+++ @fox/core/components/fox-tag/fox-tag.tsx
... | ... | @@ -0,0 +1,131 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { Ref } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | + | |
| 6 | +export type FoxTagType = "sorting" | "button"; | |
| 7 | +export type FoxTagSize = "lg" | "md" | "sm"; | |
| 8 | + | |
| 9 | +export interface FoxTagProps { | |
| 10 | + /** | |
| 11 | + * 아이콘의 종류와 자리를 정한다. | |
| 12 | + * | |
| 13 | + * - `sorting`: 글자 뒤에 닫기 아이콘. 걸어 둔 조건을 떼는 조각이다. | |
| 14 | + * - `button`: 글자 앞에 택 아이콘. 눌러서 무언가로 넘어가는 조각이라 hover·pressed에서 | |
| 15 | + * 글자에 밑줄이 붙는다. | |
| 16 | + */ | |
| 17 | + type?: FoxTagType; | |
| 18 | + size?: FoxTagSize; | |
| 19 | + /** 네이티브 `type` — prop의 `type`이 시각 계열을 쓰므로 분리했다(`FoxButton`과 같은 방식). */ | |
| 20 | + htmlType?: "button" | "submit" | "reset"; | |
| 21 | + label: string; | |
| 22 | + /** | |
| 23 | + * 보이는 글자를 대신할 읽어 줄 이름. 넘기지 않으면 글자가 그대로 이름이 된다. | |
| 24 | + * | |
| 25 | + * sorting은 보이는 글자가 조건 이름("서울")뿐이고 닫기 아이콘은 `aria-hidden`이라, 누르면 | |
| 26 | + * 무슨 일이 나는지가 소리로 전달되지 않는다. `"서울 조건 제거"`처럼 적어 준다. | |
| 27 | + */ | |
| 28 | + ariaLabel?: string; | |
| 29 | + disabled?: boolean; | |
| 30 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 31 | + hidden?: boolean; | |
| 32 | + onAction?: () => void; | |
| 33 | + /** 배치 조정용. 모양이 달라야 하면 여기 말고 `type`을 추가한다. */ | |
| 34 | + className?: string; | |
| 35 | + ref?: Ref<HTMLButtonElement>; | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** `Record`로 고정해 계열을 추가하면 항목 누락이 타입 에러가 되게 한다. */ | |
| 39 | +const TYPE_CLASS: Record<FoxTagType, string> = { | |
| 40 | + sorting: "fox-tag--sorting", | |
| 41 | + button: "fox-tag--button", | |
| 42 | +}; | |
| 43 | + | |
| 44 | +const SIZE_CLASS: Record<FoxTagSize, string> = { | |
| 45 | + lg: "fox-tag--lg", | |
| 46 | + md: "fox-tag--md", | |
| 47 | + sm: "fox-tag--sm", | |
| 48 | +}; | |
| 49 | + | |
| 50 | +/** | |
| 51 | + * @fox 태그. 알약 모양의 작은 조각이고 상태를 갖지 않는다. | |
| 52 | + * | |
| 53 | + * 루트가 `<button>`이라 hover·pressed·포커스·키보드가 전부 네이티브다 — 시안의 pressed는 | |
| 54 | + * `:active`가 받는다. 아이콘만 누르는 게 아니라 **조각 전체가 버튼이다**(시안이 hover 배경을 | |
| 55 | + * 태그 전체에 준다). | |
| 56 | + * | |
| 57 | + * 아이콘은 계열이 정하므로 호출부가 넘기지 않는다. 아이콘이 앞/뒤 중 어디에 오는지는 아래 | |
| 58 | + * 마크업 순서가 정하고, CSS는 계열별 순서 규칙을 갖지 않는다. | |
| 59 | + * | |
| 60 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 61 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 62 | + */ | |
| 63 | +export function FoxTag({ | |
| 64 | + type = "sorting", | |
| 65 | + size = "lg", | |
| 66 | + htmlType = "button", | |
| 67 | + label, | |
| 68 | + ariaLabel, | |
| 69 | + disabled = false, | |
| 70 | + hidden = false, | |
| 71 | + onAction, | |
| 72 | + className, | |
| 73 | + ref, | |
| 74 | +}: FoxTagProps) { | |
| 75 | + if (hidden) { | |
| 76 | + return null; | |
| 77 | + } | |
| 78 | + | |
| 79 | + return ( | |
| 80 | + <button | |
| 81 | + ref={ref} | |
| 82 | + type={htmlType} | |
| 83 | + className={cx("fox-tag", TYPE_CLASS[type], SIZE_CLASS[size], className)} | |
| 84 | + disabled={disabled} | |
| 85 | + aria-label={ariaLabel} | |
| 86 | + onClick={disabled ? undefined : onAction} | |
| 87 | + > | |
| 88 | + {type === "button" && ( | |
| 89 | + <span className="fox-tag__icon" aria-hidden="true"> | |
| 90 | + <TagMark /> | |
| 91 | + </span> | |
| 92 | + )} | |
| 93 | + | |
| 94 | + <span className="fox-tag__label">{label}</span> | |
| 95 | + | |
| 96 | + {type === "sorting" && ( | |
| 97 | + <span className="fox-tag__icon" aria-hidden="true"> | |
| 98 | + <CloseMark /> | |
| 99 | + </span> | |
| 100 | + )} | |
| 101 | + </button> | |
| 102 | + ); | |
| 103 | +} | |
| 104 | + | |
| 105 | +// 시안 아이콘 둘. `fill`을 시안의 `black / 25%` 리터럴 대신 `currentColor`로 두면 슬롯 규칙이 | |
| 106 | +// 정한 `icon-neutral-fill`을 물려받는다 — 값은 같고, 다크에서 흰색 25%로 뒤집힌다. | |
| 107 | +// `width`·`height`를 쓰지 않는다 — 크기는 슬롯이 정하고 SVG는 100%로 따라온다. | |
| 108 | + | |
| 109 | +// sorting의 닫기 표시. 원 안에 X다. | |
| 110 | +function CloseMark() { | |
| 111 | + return ( | |
| 112 | + <svg viewBox="0 0 16 16" fill="none" aria-hidden="true"> | |
| 113 | + <path | |
| 114 | + d="M8 1.5C6.71442 1.5 5.45772 1.88122 4.3888 2.59545C3.31988 3.30968 2.48676 4.32484 1.99479 5.51256C1.50282 6.70028 1.37409 8.00721 1.6249 9.26809C1.8757 10.529 2.49477 11.6872 3.40381 12.5962C4.31285 13.5052 5.47104 14.1243 6.73192 14.3751C7.99279 14.6259 9.29973 14.4972 10.4874 14.0052C11.6752 13.5132 12.6903 12.6801 13.4046 11.6112C14.1188 10.5423 14.5 9.28558 14.5 8C14.4982 6.27665 13.8128 4.62441 12.5942 3.40582C11.3756 2.18722 9.72335 1.50182 8 1.5ZM10.3538 9.64625C10.4002 9.69271 10.4371 9.74786 10.4622 9.80855C10.4873 9.86925 10.5003 9.9343 10.5003 10C10.5003 10.0657 10.4873 10.1308 10.4622 10.1914C10.4371 10.2521 10.4002 10.3073 10.3538 10.3538C10.3073 10.4002 10.2521 10.4371 10.1915 10.4622C10.1308 10.4873 10.0657 10.5003 10 10.5003C9.93431 10.5003 9.86925 10.4873 9.80855 10.4622C9.74786 10.4371 9.69271 10.4002 9.64625 10.3538L8 8.70687L6.35375 10.3538C6.3073 10.4002 6.25215 10.4371 6.19145 10.4622C6.13075 10.4873 6.0657 10.5003 6 10.5003C5.93431 10.5003 5.86925 10.4873 5.80855 10.4622C5.74786 10.4371 5.69271 10.4002 5.64625 10.3538C5.5998 10.3073 5.56295 10.2521 5.53781 10.1914C5.51266 10.1308 5.49972 10.0657 5.49972 10C5.49972 9.9343 5.51266 9.86925 5.53781 9.80855C5.56295 9.74786 5.5998 9.69271 5.64625 9.64625L7.29313 8L5.64625 6.35375C5.55243 6.25993 5.49972 6.13268 5.49972 6C5.49972 5.86732 5.55243 5.74007 5.64625 5.64625C5.74007 5.55243 5.86732 5.49972 6 5.49972C6.13268 5.49972 6.25993 5.55243 6.35375 5.64625L8 7.29313L9.64625 5.64625C9.69271 5.59979 9.74786 5.56294 9.80855 5.5378C9.86925 5.51266 9.93431 5.49972 10 5.49972C10.0657 5.49972 10.1308 5.51266 10.1915 5.5378C10.2521 5.56294 10.3073 5.59979 10.3538 5.64625C10.4002 5.6927 10.4371 5.74786 10.4622 5.80855C10.4873 5.86925 10.5003 5.9343 10.5003 6C10.5003 6.0657 10.4873 6.13075 10.4622 6.19145C10.4371 6.25214 10.4002 6.3073 10.3538 6.35375L8.70688 8L10.3538 9.64625Z" | |
| 115 | + fill="currentColor" | |
| 116 | + /> | |
| 117 | + </svg> | |
| 118 | + ); | |
| 119 | +} | |
| 120 | + | |
| 121 | +// button 계열의 택(가격표) 표시. | |
| 122 | +function TagMark() { | |
| 123 | + return ( | |
| 124 | + <svg viewBox="0 0 16 16" fill="none" aria-hidden="true"> | |
| 125 | + <path | |
| 126 | + d="M15.2069 8.50001L9.00001 2.29313C8.90748 2.19987 8.79735 2.12593 8.67599 2.0756C8.55464 2.02528 8.4245 1.99959 8.29313 2.00001H2.50001C2.3674 2.00001 2.24022 2.05268 2.14645 2.14645C2.05268 2.24022 2.00001 2.3674 2.00001 2.50001V8.29313C1.99959 8.4245 2.02528 8.55464 2.0756 8.67599C2.12593 8.79735 2.19987 8.90748 2.29313 9.00001L8.50001 15.2069C8.59287 15.2998 8.70312 15.3734 8.82446 15.4237C8.9458 15.474 9.07585 15.4999 9.20719 15.4999C9.33853 15.4999 9.46859 15.474 9.58993 15.4237C9.71127 15.3734 9.82152 15.2998 9.91438 15.2069L15.2069 9.91438C15.2998 9.82152 15.3734 9.71127 15.4237 9.58993C15.474 9.46859 15.4999 9.33853 15.4999 9.20719C15.4999 9.07585 15.474 8.9458 15.4237 8.82446C15.3734 8.70312 15.2998 8.59287 15.2069 8.50001ZM5.25001 6.00001C5.10167 6.00001 4.95666 5.95602 4.83333 5.87361C4.70999 5.7912 4.61386 5.67406 4.5571 5.53702C4.50033 5.39997 4.48548 5.24917 4.51442 5.10369C4.54335 4.9582 4.61479 4.82456 4.71967 4.71967C4.82456 4.61479 4.9582 4.54335 5.10369 4.51442C5.24917 4.48548 5.39997 4.50033 5.53702 4.5571C5.67406 4.61386 5.7912 4.70999 5.87361 4.83333C5.95602 4.95666 6.00001 5.10167 6.00001 5.25001C6.00001 5.44892 5.92099 5.63968 5.78034 5.78034C5.63968 5.92099 5.44892 6.00001 5.25001 6.00001Z" | |
| 127 | + fill="currentColor" | |
| 128 | + /> | |
| 129 | + </svg> | |
| 130 | + ); | |
| 131 | +} |
+++ @fox/core/components/fox-tag/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxTag, type FoxTagProps, type FoxTagSize, type FoxTagType } from "./fox-tag"; |
--- @fox/core/components/fox-text-area/fox-text-area.tsx
+++ @fox/core/components/fox-text-area/fox-text-area.tsx
... | ... | @@ -9,6 +9,7 @@ |
| 9 | 9 |
type ReactNode, |
| 10 | 10 |
} from "react"; |
| 11 | 11 |
import { cx } from "../../utils";
|
| 12 |
+import { FoxFormLabel } from "../fox-form-label";
|
|
| 12 | 13 |
|
| 13 | 14 |
export type FoxTextAreaState = "default" | "focused" | "completed" | "error" | "disabled" | "view"; |
| 14 | 15 |
|
... | ... | @@ -95,9 +96,7 @@ |
| 95 | 96 |
return ( |
| 96 | 97 |
<div className={cx("fox-text-area", className)} data-state={state}>
|
| 97 | 98 |
{label && (
|
| 98 |
- <label className="fox-text-area__label" htmlFor={fieldId}>
|
|
| 99 |
- {label}
|
|
| 100 |
- </label> |
|
| 99 |
+ <FoxFormLabel htmlFor={fieldId}>{label}</FoxFormLabel>
|
|
| 101 | 100 |
)} |
| 102 | 101 |
|
| 103 | 102 |
<div className="fox-text-area__box"> |
+++ @fox/core/components/fox-toggle-switch/fox-toggle-switch.tsx
... | ... | @@ -0,0 +1,133 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { ChangeEvent, ComponentPropsWithRef, ReactNode } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | + | |
| 6 | +export type FoxToggleSwitchSize = "lg" | "md"; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * 네이티브 `<input type="checkbox">`가 받는 것을 그대로 넘겨받는다 — name·value·checked· | |
| 10 | + * defaultChecked·disabled·required·form 등. 의미가 겹치는 것만 걷어내고 아래에서 다시 정의한다. | |
| 11 | + * `role`도 걷어낸다 — 스위치로 읽히는 것이 이 컴포넌트의 정체라 호출부가 바꿀 값이 아니다. | |
| 12 | + */ | |
| 13 | +type NativeCheckboxProps = Omit< | |
| 14 | + ComponentPropsWithRef<"input">, | |
| 15 | + "type" | "role" | "size" | "className" | "children" | "hidden" | "onChange" | |
| 16 | +>; | |
| 17 | + | |
| 18 | +export interface FoxToggleSwitchProps extends NativeCheckboxProps { | |
| 19 | + size?: FoxToggleSwitchSize; | |
| 20 | + /** | |
| 21 | + * 스위치 옆 글자. 넘기지 않으면 영역을 렌더링하지 않는다. | |
| 22 | + * | |
| 23 | + * ⚠️ 생략하면 스위치를 읽어 줄 이름이 없어진다 — 그때는 `aria-label`을 함께 넘긴다 | |
| 24 | + * (네이티브 속성이라 그대로 전달된다). | |
| 25 | + */ | |
| 26 | + label?: ReactNode; | |
| 27 | + /** | |
| 28 | + * 켜지거나 꺼질 때 부른다. 켜짐 여부가 첫 인자다 — 네이티브 `onChange`를 `Omit`으로 | |
| 29 | + * 걷어내고 그 자리를 대신한다(`FoxCheckbox`와 같은 방식). | |
| 30 | + */ | |
| 31 | + onChange?: (checked: boolean, event: ChangeEvent<HTMLInputElement>) => void; | |
| 32 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 33 | + hidden?: boolean; | |
| 34 | + /** 배치 조정용. */ | |
| 35 | + className?: string; | |
| 36 | +} | |
| 37 | + | |
| 38 | +const SIZE_CLASS: Record<FoxToggleSwitchSize, string> = { | |
| 39 | + lg: "fox-toggle-switch--lg", | |
| 40 | + md: "fox-toggle-switch--md", | |
| 41 | +}; | |
| 42 | + | |
| 43 | +/** | |
| 44 | + * @fox 토글 스위치. 켜짐 여부는 네이티브 `<input type="checkbox">`가 소유한다. | |
| 45 | + * | |
| 46 | + * 체크박스와 마크업은 같지만 `role="switch"`를 얹어 "선택됨"이 아니라 "켜짐/꺼짐"으로 읽히게 | |
| 47 | + * 한다. 폼에도 체크박스처럼 참여한다 — 켜져 있으면 `value`가 함께 전송된다. | |
| 48 | + * | |
| 49 | + * 루트가 `<label>`이라 글자를 눌러도 바뀐다. 동그라미는 꺼짐이면 왼쪽, 켜짐이면 오른쪽이고 | |
| 50 | + * X·체크 표시를 둘 다 그려 두고 CSS가 골라 보여 준다 — 켜졌을 때만 넣으면 비제어 입력에서 | |
| 51 | + * React가 상태를 몰라 어긋난다. | |
| 52 | + * | |
| 53 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 54 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 55 | + */ | |
| 56 | +export function FoxToggleSwitch({ | |
| 57 | + size = "lg", | |
| 58 | + label, | |
| 59 | + onChange, | |
| 60 | + hidden = false, | |
| 61 | + className, | |
| 62 | + ...rest | |
| 63 | +}: FoxToggleSwitchProps) { | |
| 64 | + if (hidden) { | |
| 65 | + return null; | |
| 66 | + } | |
| 67 | + | |
| 68 | + // 끌 때도 change가 뛴다. 그래서 켜짐 여부를 그대로 넘긴다. | |
| 69 | + const handleChange = (event: ChangeEvent<HTMLInputElement>) => { | |
| 70 | + onChange?.(event.target.checked, event); | |
| 71 | + }; | |
| 72 | + | |
| 73 | + return ( | |
| 74 | + <label className={cx("fox-toggle-switch", SIZE_CLASS[size], className)}> | |
| 75 | + {/* 형제 선택자(`~`)가 뒤따르는 트랙과 글자에 닿아야 하므로 맨 앞에 둔다. */} | |
| 76 | + <input | |
| 77 | + {...rest} | |
| 78 | + type="checkbox" | |
| 79 | + role="switch" | |
| 80 | + className="fox-toggle-switch__input" | |
| 81 | + onChange={handleChange} | |
| 82 | + /> | |
| 83 | + <span className="fox-toggle-switch__track"> | |
| 84 | + <span className="fox-toggle-switch__thumb"> | |
| 85 | + <XMark /> | |
| 86 | + <CheckMark /> | |
| 87 | + </span> | |
| 88 | + </span> | |
| 89 | + {label === undefined ? null : ( | |
| 90 | + <span className="fox-toggle-switch__label">{label}</span> | |
| 91 | + )} | |
| 92 | + </label> | |
| 93 | + ); | |
| 94 | +} | |
| 95 | + | |
| 96 | +// 시안 아이콘 둘. `fill`을 시안의 색 리터럴(#6D7882·#256EF4·#CDD1D5) 대신 `currentColor`로 | |
| 97 | +// 두면 트랙 규칙이 정한 색을 그대로 물려받는다 — 시안에서 표시 색은 언제나 트랙 배경과 같은 | |
| 98 | +// 값이라, 상태가 셋(꺼짐·켜짐·비활성)이어도 SVG는 한 벌로 끝난다. | |
| 99 | +// `width`·`height`를 쓰지 않는다 — 크기는 CSS가 정한다. | |
| 100 | + | |
| 101 | +// 꺼짐 표시. | |
| 102 | +function XMark() { | |
| 103 | + return ( | |
| 104 | + <svg | |
| 105 | + className="fox-toggle-switch__mark-off" | |
| 106 | + viewBox="0 0 16 16" | |
| 107 | + fill="none" | |
| 108 | + aria-hidden="true" | |
| 109 | + > | |
| 110 | + <path | |
| 111 | + d="M13.0306 11.9694C13.1715 12.1103 13.2507 12.3014 13.2507 12.5006C13.2507 12.6999 13.1715 12.891 13.0306 13.0319C12.8897 13.1728 12.6986 13.2519 12.4994 13.2519C12.3001 13.2519 12.109 13.1728 11.9681 13.0319L8 9.06249L4.03063 13.0306C3.88973 13.1715 3.69864 13.2507 3.49938 13.2507C3.30012 13.2507 3.10902 13.1715 2.96813 13.0306C2.82723 12.8897 2.74808 12.6986 2.74808 12.4994C2.74808 12.3001 2.82723 12.109 2.96813 11.9681L6.9375 7.99999L2.96938 4.03061C2.82848 3.88972 2.74933 3.69862 2.74933 3.49936C2.74933 3.30011 2.82848 3.10901 2.96938 2.96811C3.11027 2.82722 3.30137 2.74806 3.50063 2.74806C3.69989 2.74806 3.89098 2.82722 4.03188 2.96811L8 6.93749L11.9694 2.96749C12.1103 2.82659 12.3014 2.74744 12.5006 2.74744C12.6999 2.74744 12.891 2.82659 13.0319 2.96749C13.1728 3.10838 13.2519 3.29948 13.2519 3.49874C13.2519 3.69799 13.1728 3.88909 13.0319 4.02999L9.0625 7.99999L13.0306 11.9694Z" | |
| 112 | + fill="currentColor" | |
| 113 | + /> | |
| 114 | + </svg> | |
| 115 | + ); | |
| 116 | +} | |
| 117 | + | |
| 118 | +// 켜짐 표시. | |
| 119 | +function CheckMark() { | |
| 120 | + return ( | |
| 121 | + <svg | |
| 122 | + className="fox-toggle-switch__mark-on" | |
| 123 | + viewBox="0 0 16 16" | |
| 124 | + fill="none" | |
| 125 | + aria-hidden="true" | |
| 126 | + > | |
| 127 | + <path | |
| 128 | + d="M14.5306 5.03057L6.53063 13.0306C6.46095 13.1005 6.37816 13.156 6.28699 13.1938C6.19583 13.2317 6.09809 13.2512 5.99938 13.2512C5.90067 13.2512 5.80293 13.2317 5.71176 13.1938C5.6206 13.156 5.53781 13.1005 5.46813 13.0306L1.96813 9.53057C1.89836 9.4608 1.84302 9.37798 1.80527 9.28683C1.76751 9.19568 1.74808 9.09798 1.74808 8.99932C1.74808 8.90066 1.76751 8.80296 1.80527 8.71181C1.84302 8.62066 1.89836 8.53783 1.96813 8.46807C2.03789 8.3983 2.12072 8.34296 2.21187 8.30521C2.30302 8.26745 2.40072 8.24802 2.49938 8.24802C2.59804 8.24802 2.69574 8.26745 2.78689 8.30521C2.87804 8.34296 2.96086 8.3983 3.03063 8.46807L6 11.4374L13.4694 3.96932C13.6103 3.82842 13.8014 3.74927 14.0006 3.74927C14.1999 3.74927 14.391 3.82842 14.5319 3.96932C14.6728 4.11021 14.7519 4.30131 14.7519 4.50057C14.7519 4.69983 14.6728 4.89092 14.5319 5.03182L14.5306 5.03057Z" | |
| 129 | + fill="currentColor" | |
| 130 | + /> | |
| 131 | + </svg> | |
| 132 | + ); | |
| 133 | +} |
+++ @fox/core/components/fox-toggle-switch/index.ts
... | ... | @@ -0,0 +1,5 @@ |
| 1 | +export { | |
| 2 | + FoxToggleSwitch, | |
| 3 | + type FoxToggleSwitchProps, | |
| 4 | + type FoxToggleSwitchSize, | |
| 5 | +} from "./fox-toggle-switch"; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -7,15 +7,26 @@ |
| 7 | 7 |
export * from "./fox-carousel-button"; |
| 8 | 8 |
export * from "./fox-carousel-indicator"; |
| 9 | 9 |
export * from "./fox-certified"; |
| 10 |
+export * from "./fox-check-icon"; |
|
| 11 |
+export * from "./fox-checkbox"; |
|
| 12 |
+export * from "./fox-checkbox-group"; |
|
| 13 |
+export * from "./fox-conditional-option"; |
|
| 10 | 14 |
export * from "./fox-email"; |
| 11 | 15 |
export * from "./fox-file-upload"; |
| 16 |
+export * from "./fox-form-label"; |
|
| 12 | 17 |
export * from "./fox-icon-button"; |
| 13 | 18 |
export * from "./fox-input"; |
| 14 | 19 |
export * from "./fox-link-button"; |
| 15 | 20 |
export * from "./fox-pagination"; |
| 16 | 21 |
export * from "./fox-phone-number"; |
| 22 |
+export * from "./fox-radio"; |
|
| 23 |
+export * from "./fox-radio-group"; |
|
| 24 |
+export * from "./fox-radio-icon"; |
|
| 17 | 25 |
export * from "./fox-select"; |
| 18 | 26 |
export * from "./fox-select-option"; |
| 19 | 27 |
export * from "./fox-select-option-item"; |
| 28 |
+export * from "./fox-tag"; |
|
| 29 |
+export * from "./fox-tag-list"; |
|
| 20 | 30 |
export * from "./fox-text-area"; |
| 21 | 31 |
export * from "./fox-text-button"; |
| 32 |
+export * from "./fox-toggle-switch"; |
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -2,23 +2,34 @@ |
| 2 | 2 |
|
| 3 | 3 |
import { FoxInput, FoxInputState, FoxSelect, FoxSelectItem, FoxSelectOption, FoxSelectOptionItem } from "@/@fox/core/components";
|
| 4 | 4 |
import { useState, type CSSProperties, type ReactNode } from "react";
|
| 5 |
+import { FoxAddress, type FoxAddressSearchResult, type FoxAddressValue } from "../core/components/fox-address";
|
|
| 6 |
+import { FoxBreadcrumb } from "../core/components/fox-breadcrumb";
|
|
| 5 | 7 |
import { FoxButton } from "../core/components/fox-button";
|
| 6 | 8 |
import { FoxButtonGroup } from "../core/components/fox-button-group";
|
| 7 | 9 |
import { FoxButtonPanel } from "../core/components/fox-button-panel";
|
| 8 |
-import { FoxIconButton } from "../core/components/fox-icon-button";
|
|
| 9 |
-import { FoxLinkButton } from "../core/components/fox-link-button";
|
|
| 10 |
-import { FoxAddress, type FoxAddressSearchResult, type FoxAddressValue } from "../core/components/fox-address";
|
|
| 11 |
-import { FoxCertified } from "../core/components/fox-certified";
|
|
| 12 |
-import { FoxEmail } from "../core/components/fox-email";
|
|
| 13 |
-import { FoxFileUpload, type FoxFileItem } from "../core/components/fox-file-upload";
|
|
| 14 |
-import { FoxPhoneNumber, type FoxPhoneNumberState } from "../core/components/fox-phone-number";
|
|
| 15 |
-import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
|
|
| 16 |
-import { FoxTextButton } from "../core/components/fox-text-button";
|
|
| 17 |
-import { FoxBreadcrumb } from "../core/components/fox-breadcrumb";
|
|
| 18 | 10 |
import { FoxCarouselArea } from "../core/components/fox-carousel-area";
|
| 19 | 11 |
import { FoxCarouselButton } from "../core/components/fox-carousel-button";
|
| 20 | 12 |
import { FoxCarouselIndicator } from "../core/components/fox-carousel-indicator";
|
| 13 |
+import { FoxCertified } from "../core/components/fox-certified";
|
|
| 14 |
+import { FoxCheckIcon } from "../core/components/fox-check-icon";
|
|
| 15 |
+import { FoxCheckbox } from "../core/components/fox-checkbox";
|
|
| 16 |
+import { FoxCheckboxGroup } from "../core/components/fox-checkbox-group";
|
|
| 17 |
+import { FoxConditionalOption } from "../core/components/fox-conditional-option";
|
|
| 18 |
+import { FoxEmail } from "../core/components/fox-email";
|
|
| 19 |
+import { FoxFileUpload, type FoxFileItem } from "../core/components/fox-file-upload";
|
|
| 20 |
+import { FoxFormLabel } from "../core/components/fox-form-label";
|
|
| 21 |
+import { FoxIconButton } from "../core/components/fox-icon-button";
|
|
| 22 |
+import { FoxLinkButton } from "../core/components/fox-link-button";
|
|
| 21 | 23 |
import { FoxPagination } from "../core/components/fox-pagination";
|
| 24 |
+import { FoxPhoneNumber, type FoxPhoneNumberState } from "../core/components/fox-phone-number";
|
|
| 25 |
+import { FoxRadio } from "../core/components/fox-radio";
|
|
| 26 |
+import { FoxRadioGroup } from "../core/components/fox-radio-group";
|
|
| 27 |
+import { FoxRadioIcon } from "../core/components/fox-radio-icon";
|
|
| 28 |
+import { FoxTag } from "../core/components/fox-tag";
|
|
| 29 |
+import { FoxTagList } from "../core/components/fox-tag-list";
|
|
| 30 |
+import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
|
|
| 31 |
+import { FoxTextButton } from "../core/components/fox-text-button";
|
|
| 32 |
+import { FoxToggleSwitch } from "../core/components/fox-toggle-switch";
|
|
| 22 | 33 |
import {
|
| 23 | 34 |
FoxCaretLeftIcon, |
| 24 | 35 |
FoxCaretRightIcon, |
... | ... | @@ -368,6 +379,7 @@ |
| 368 | 379 |
const [page, setPage] = useState(1); |
| 369 | 380 |
const [playing, setPlaying] = useState(true); |
| 370 | 381 |
const move = (step: number) => setPage((prev) => ((prev - 1 + step + total) % total) + 1); |
| 382 |
+ |
|
| 371 | 383 |
|
| 372 | 384 |
return ( |
| 373 | 385 |
<FoxCarouselArea> |
... | ... | @@ -391,6 +403,36 @@ |
| 391 | 403 |
onAction={() => setPlaying((prev) => !prev)}
|
| 392 | 404 |
/> |
| 393 | 405 |
</FoxCarouselArea> |
| 406 |
+ |
|
| 407 |
+ ); |
|
| 408 |
+} |
|
| 409 |
+ |
|
| 410 |
+// 태그 목록은 상태를 갖지 않으므로, 닫기 아이콘이 실제로 뭔가를 떼는지 보려면 예제가 목록을 |
|
| 411 |
+// 들고 있어야 한다. 전부 떼면 다시 채울 방법이 없어 초기화 버튼을 같이 둔다. |
|
| 412 |
+const TAG_FILTERS = ["서울", "경기", "인천", "부산"]; |
|
| 413 |
+ |
|
| 414 |
+function TagListLiveDemo() {
|
|
| 415 |
+ const [filters, setFilters] = useState(TAG_FILTERS); |
|
| 416 |
+ |
|
| 417 |
+ return ( |
|
| 418 |
+ <> |
|
| 419 |
+ <FoxTagList size="md" label="걸어 둔 조건"> |
|
| 420 |
+ {filters.map((name) => (
|
|
| 421 |
+ <FoxTag |
|
| 422 |
+ key={name}
|
|
| 423 |
+ label={name}
|
|
| 424 |
+ ariaLabel={`${name} 조건 제거`}
|
|
| 425 |
+ onAction={() => setFilters((prev) => prev.filter((it) => it !== name))}
|
|
| 426 |
+ /> |
|
| 427 |
+ ))} |
|
| 428 |
+ </FoxTagList> |
|
| 429 |
+ <FoxButton |
|
| 430 |
+ size="xsm" |
|
| 431 |
+ label="초기화" |
|
| 432 |
+ onAction={() => setFilters(TAG_FILTERS)}
|
|
| 433 |
+ disabled={filters.length === TAG_FILTERS.length}
|
|
| 434 |
+ /> |
|
| 435 |
+ </> |
|
| 394 | 436 |
); |
| 395 | 437 |
} |
| 396 | 438 |
|
... | ... | @@ -819,6 +861,48 @@ |
| 819 | 861 |
}, |
| 820 | 862 |
], |
| 821 | 863 |
}, |
| 864 |
+ {
|
|
| 865 |
+ id: "fox-form-label", |
|
| 866 |
+ name: "FoxFormLabel", |
|
| 867 |
+ description: |
|
| 868 |
+ "Figma 시안(form-label 180:609) 입력 칸 위에 붙는 라벨입니다. 글자 자체는 form-field의 label 믹스인이 갖습니다 — 시안에서 이 컴포넌트와 입력 계열의 라벨이 같은 공용 조각이라 값이 갈라질 이유가 없습니다. 필수는 글자 뒤에 *, 선택은 (선택)을 붙이고 둘은 배타적이라 boolean 두 개가 아니라 값 하나로 받습니다. *는 aria-hidden이라 스크린리더가 읽지 않습니다 — 필수 여부는 입력 요소의 required가 알리는 것이고 별표는 그 눈에 보이는 짝이라, 둘 다 읽히면 같은 말이 두 번 나옵니다. 반대로 (선택)은 대신 알려 줄 속성이 없어 읽히게 둡니다.", |
|
| 869 |
+ variants: [ |
|
| 870 |
+ {
|
|
| 871 |
+ label: "기본 / 필수 / 선택", |
|
| 872 |
+ node: ( |
|
| 873 |
+ <div className={styles.demoStack}>
|
|
| 874 |
+ <FoxFormLabel>레이블</FoxFormLabel> |
|
| 875 |
+ <FoxFormLabel requirement="required">레이블</FoxFormLabel> |
|
| 876 |
+ <FoxFormLabel requirement="optional">레이블</FoxFormLabel> |
|
| 877 |
+ </div> |
|
| 878 |
+ ), |
|
| 879 |
+ }, |
|
| 880 |
+ {
|
|
| 881 |
+ label: "optionalText — 문구를 바꿀 수 있다", |
|
| 882 |
+ node: ( |
|
| 883 |
+ <div className={styles.demoStack}>
|
|
| 884 |
+ <FoxFormLabel requirement="optional">기본 문구</FoxFormLabel> |
|
| 885 |
+ <FoxFormLabel requirement="optional" optionalText="(입력하지 않아도 됩니다)"> |
|
| 886 |
+ 직접 지정 |
|
| 887 |
+ </FoxFormLabel> |
|
| 888 |
+ </div> |
|
| 889 |
+ ), |
|
| 890 |
+ }, |
|
| 891 |
+ {
|
|
| 892 |
+ label: "htmlFor — 눌러 보면 아래 입력칸에 커서가 간다", |
|
| 893 |
+ node: ( |
|
| 894 |
+ <InputStage> |
|
| 895 |
+ <div className={styles.demoStack}>
|
|
| 896 |
+ <FoxFormLabel htmlFor="demo-form-label" requirement="required"> |
|
| 897 |
+ 라벨을 눌러 보세요 |
|
| 898 |
+ </FoxFormLabel> |
|
| 899 |
+ <FoxInput size="md" id="demo-form-label" placeholder="여기로 커서가 옵니다" /> |
|
| 900 |
+ </div> |
|
| 901 |
+ </InputStage> |
|
| 902 |
+ ), |
|
| 903 |
+ }, |
|
| 904 |
+ ], |
|
| 905 |
+ }, |
|
| 822 | 906 |
{
|
| 823 | 907 |
id: "fox-input", |
| 824 | 908 |
name: "FoxInput", |
... | ... | @@ -1004,6 +1088,16 @@ |
| 1004 | 1088 |
</div> |
| 1005 | 1089 |
), |
| 1006 | 1090 |
}, |
| 1091 |
+ {
|
|
| 1092 |
+ label: "requirement — 라벨을 FoxFormLabel이 그린다", |
|
| 1093 |
+ node: ( |
|
| 1094 |
+ <div className={styles.demoFields}>
|
|
| 1095 |
+ <FoxSelect size="md" label="표시 없음" options={SELECT_ITEMS} />
|
|
| 1096 |
+ <FoxSelect size="md" label="필수" options={SELECT_ITEMS} requirement="required" />
|
|
| 1097 |
+ <FoxSelect size="md" label="선택" options={SELECT_ITEMS} requirement="optional" />
|
|
| 1098 |
+ </div> |
|
| 1099 |
+ ), |
|
| 1100 |
+ }, |
|
| 1007 | 1101 |
], |
| 1008 | 1102 |
}, |
| 1009 | 1103 |
{
|
... | ... | @@ -1067,6 +1161,232 @@ |
| 1067 | 1161 |
<FoxSelectOptionItem label="셀렉트 옵션" icon={<DemoIcon />} />
|
| 1068 | 1162 |
<FoxSelectOptionItem label="셀렉트 옵션-selected" selected icon={<DemoIcon />} />
|
| 1069 | 1163 |
</FoxSelectOption> |
| 1164 |
+ </div> |
|
| 1165 |
+ ), |
|
| 1166 |
+ }, |
|
| 1167 |
+ ], |
|
| 1168 |
+ }, |
|
| 1169 |
+ {
|
|
| 1170 |
+ id: "fox-radio-icon", |
|
| 1171 |
+ name: "FoxRadioIcon", |
|
| 1172 |
+ description: |
|
| 1173 |
+ "Figma 시안(radio-icon 107:551) 라디오의 동그란 표시만 담당합니다. SVG가 아니라 CSS로 그립니다 — 시안이 바깥 원과 안쪽 점을 별개 레이어로 두었고, 네 상태가 모양이 아니라 색만 다르기 때문입니다(테두리 두께는 전부 border-1). FoxRadio 안에서는 네이티브 input의 :checked·:disabled가 스타일 훅을 겸해서 클래스가 필요 없고, 아래처럼 단독으로 상태를 비교할 때만 모디파이어로 못박습니다.", |
|
| 1174 |
+ variants: [ |
|
| 1175 |
+ {
|
|
| 1176 |
+ label: "lg — 기본 / 선택 / 비활성 / 선택+비활성", |
|
| 1177 |
+ node: ( |
|
| 1178 |
+ <> |
|
| 1179 |
+ <FoxRadioIcon size="lg" /> |
|
| 1180 |
+ <FoxRadioIcon size="lg" checked /> |
|
| 1181 |
+ <FoxRadioIcon size="lg" disabled /> |
|
| 1182 |
+ <FoxRadioIcon size="lg" checked disabled /> |
|
| 1183 |
+ </> |
|
| 1184 |
+ ), |
|
| 1185 |
+ }, |
|
| 1186 |
+ {
|
|
| 1187 |
+ label: "md — 기본 / 선택 / 비활성 / 선택+비활성", |
|
| 1188 |
+ node: ( |
|
| 1189 |
+ <> |
|
| 1190 |
+ <FoxRadioIcon size="md" /> |
|
| 1191 |
+ <FoxRadioIcon size="md" checked /> |
|
| 1192 |
+ <FoxRadioIcon size="md" disabled /> |
|
| 1193 |
+ <FoxRadioIcon size="md" checked disabled /> |
|
| 1194 |
+ </> |
|
| 1195 |
+ ), |
|
| 1196 |
+ }, |
|
| 1197 |
+ ], |
|
| 1198 |
+ }, |
|
| 1199 |
+ {
|
|
| 1200 |
+ id: "fox-radio", |
|
| 1201 |
+ name: "FoxRadio", |
|
| 1202 |
+ description: |
|
| 1203 |
+ "Figma 시안(radio 180:278) 동그란 표시 + 라벨 + 부가설명을 묶은 라디오 한 칸입니다.", |
|
| 1204 |
+ variants: [ |
|
| 1205 |
+ {
|
|
| 1206 |
+ label: "size-lg", |
|
| 1207 |
+ node: ( |
|
| 1208 |
+ <div className={styles.demoStack}>
|
|
| 1209 |
+ <FoxRadio name="demo-plan" value="basic" label="라디오버튼" size="lg" description="부가적인 설명이 들어갑니다." /> |
|
| 1210 |
+ <FoxRadio name="demo-plan" value="pro" label="라디오버튼" size="lg" description="부가적인 설명이 들어갑니다." defaultChecked/> |
|
| 1211 |
+ <FoxRadio name="demo-plan" value="team" label="라디오버튼" size="lg" description="부가적인 설명이 들어갑니다." disabled /> |
|
| 1212 |
+ <FoxRadio name="demo-plan" value="team" label="라디오버튼" size="lg" description="부가적인 설명이 들어갑니다." disabled defaultChecked /> |
|
| 1213 |
+ </div> |
|
| 1214 |
+ ), |
|
| 1215 |
+ }, |
|
| 1216 |
+ {
|
|
| 1217 |
+ label: "size-md", |
|
| 1218 |
+ node: ( |
|
| 1219 |
+ <div className={styles.demoStack}>
|
|
| 1220 |
+ <FoxRadio name="demo-plan" value="basic" label="라디오버튼" size="md" description="부가적인 설명이 들어갑니다." /> |
|
| 1221 |
+ <FoxRadio name="demo-plan" value="pro" label="라디오버튼" size="md" description="부가적인 설명이 들어갑니다." defaultChecked/> |
|
| 1222 |
+ <FoxRadio name="demo-plan" value="team" label="라디오버튼" size="md" description="부가적인 설명이 들어갑니다." disabled /> |
|
| 1223 |
+ <FoxRadio name="demo-plan" value="team" label="라디오버튼" size="md" description="부가적인 설명이 들어갑니다." disabled defaultChecked /> |
|
| 1224 |
+ </div> |
|
| 1225 |
+ ), |
|
| 1226 |
+ }, |
|
| 1227 |
+ ], |
|
| 1228 |
+ }, |
|
| 1229 |
+ {
|
|
| 1230 |
+ id: "fox-radio-group", |
|
| 1231 |
+ name: "FoxRadioGroup", |
|
| 1232 |
+ description: |
|
| 1233 |
+ "Figma 시안(radio-group 194:4327)라디오 여러 칸의 배치와 name·size 통일을 책임집니다. 어느 칸이 선택됐는지는 각 FoxRadio의 네이티브 input이 소유하므로 그룹은 상태를 갖지 않습니다. name은 그룹이 단일 진실 공급원이라 자식마다 적지 않아도 되고, 한 칸만 빼먹어 두 개가 선택되는 실수가 막힙니다. ⚠️ 시안의 변형 이름이 실제 동작과 반대라서(direction=row가 세로로 쌓습니다) 동작 기준으로 다시 이름 붙였습니다 — 시안 row가 vertical, 시안 col이 horizontal입니다.", |
|
| 1234 |
+ variants: [ |
|
| 1235 |
+ {
|
|
| 1236 |
+ label: "vertical (기본) — 간격 gap-7, 왼쪽 끝 정렬", |
|
| 1237 |
+ node: ( |
|
| 1238 |
+ <FoxRadioGroup name="vertical" label="vertical"> |
|
| 1239 |
+ <FoxRadio label="라디오버튼" defaultChecked /> |
|
| 1240 |
+ <FoxRadio label="라디오버튼" /> |
|
| 1241 |
+ </FoxRadioGroup> |
|
| 1242 |
+ ), |
|
| 1243 |
+ }, |
|
| 1244 |
+ {
|
|
| 1245 |
+ label: "horizontal — 행 gap-7 / 열 gap-9, 폭이 모자라면 줄바꿈", |
|
| 1246 |
+ node: ( |
|
| 1247 |
+ <FoxRadioGroup orientation="horizontal" name="horizontal" label="horizontal"> |
|
| 1248 |
+ <FoxRadio label="라디오버튼" defaultChecked /> |
|
| 1249 |
+ <FoxRadio label="라디오버튼" /> |
|
| 1250 |
+ <FoxRadio label="라디오버튼" /> |
|
| 1251 |
+ </FoxRadioGroup> |
|
| 1252 |
+ ), |
|
| 1253 |
+ }, |
|
| 1254 |
+ {
|
|
| 1255 |
+ label: "horizontal + 줄바꿈 — 다음 줄로 넘어간다", |
|
| 1256 |
+ node: ( |
|
| 1257 |
+ <div className={styles.demoNarrow}>
|
|
| 1258 |
+ <FoxRadioGroup orientation="horizontal" name="rg-wrap" label="줄바꿈 확인"> |
|
| 1259 |
+ {["첫째", "둘째", "셋째", "넷째", "다섯째"].map((label, i) => (
|
|
| 1260 |
+ <FoxRadio key={label} value={label} label={label} defaultChecked={i === 0} />
|
|
| 1261 |
+ ))} |
|
| 1262 |
+ </FoxRadioGroup> |
|
| 1263 |
+ </div> |
|
| 1264 |
+ ), |
|
| 1265 |
+ }, |
|
| 1266 |
+ {
|
|
| 1267 |
+ label: "size — 자식이 준 값은 무시되고 그룹 값으로 통일된다", |
|
| 1268 |
+ node: ( |
|
| 1269 |
+ <FoxRadioGroup orientation="horizontal" name="rg-md" size="md" label="md 크기"> |
|
| 1270 |
+ {/* 일부러 어긋난 size를 준다 — 둘 다 md로 나와야 정상 */}
|
|
| 1271 |
+ <FoxRadio value="a" label="첫째" size="lg" defaultChecked /> |
|
| 1272 |
+ <FoxRadio value="b" label="둘째" /> |
|
| 1273 |
+ </FoxRadioGroup> |
|
| 1274 |
+ ), |
|
| 1275 |
+ }, |
|
| 1276 |
+ ], |
|
| 1277 |
+ }, |
|
| 1278 |
+ {
|
|
| 1279 |
+ id: "fox-conditional-option", |
|
| 1280 |
+ name: "FoxConditionalOption", |
|
| 1281 |
+ description: |
|
| 1282 |
+ "Figma 시안(conditional-option 190:2163) 컨트롤 한 칸과 select가 합쳐 하나의 답을 이룹니다. control로 라디오·체크박스를 고릅니다(시안의 trigger — 이 패키지에서 trigger는 fox-select__trigger라는 다른 뜻으로 이미 쓰여 이름을 바꿨습니다). select는 이 칸이 골라지지 않아도 조작할 수 있고, 값을 고르면 라디오가 자동으로 켜집니다. 잠가 두면 그 자동 선택 자체가 불가능해지기 때문입니다(disabled는 클릭을 먹지 않습니다). 켜는 방법은 숨은 input을 실제로 클릭하는 것이라 형제 칸이 꺼지는 것과 change 이벤트가 브라우저 규칙 그대로 일어납니다. ⚠️ 시안의 변형 이름이 실제 동작과 반대라서(FoxRadioGroup과 같은 문제) 동작 기준으로 다시 이름 붙였습니다 — 시안 row가 vertical, 시안 col이 horizontal입니다.", |
|
| 1283 |
+ variants: [ |
|
| 1284 |
+ {
|
|
| 1285 |
+ label: "vertical (기본) — select가 아래로 내려가고 왼쪽 끝에 붙는다", |
|
| 1286 |
+ node: ( |
|
| 1287 |
+ <FoxConditionalOption |
|
| 1288 |
+ name="co-v" |
|
| 1289 |
+ value="other" |
|
| 1290 |
+ label="다른 주소로 배송" |
|
| 1291 |
+ description="주소를 고르면 이 항목이 자동으로 선택됩니다." |
|
| 1292 |
+ options={SELECT_ITEMS}
|
|
| 1293 |
+ placeholder="주소를 고르세요" |
|
| 1294 |
+ /> |
|
| 1295 |
+ ), |
|
| 1296 |
+ }, |
|
| 1297 |
+ {
|
|
| 1298 |
+ label: "horizontal — select가 라디오 옆에 선다", |
|
| 1299 |
+ node: ( |
|
| 1300 |
+ <FoxConditionalOption |
|
| 1301 |
+ orientation="horizontal" |
|
| 1302 |
+ name="co-h" |
|
| 1303 |
+ value="other" |
|
| 1304 |
+ label="다른 주소로 배송" |
|
| 1305 |
+ options={SELECT_ITEMS}
|
|
| 1306 |
+ placeholder="주소를 고르세요" |
|
| 1307 |
+ /> |
|
| 1308 |
+ ), |
|
| 1309 |
+ }, |
|
| 1310 |
+ {
|
|
| 1311 |
+ label: "", |
|
| 1312 |
+ node: ( |
|
| 1313 |
+ <FoxRadioGroup name="co-auto" label="배송지"> |
|
| 1314 |
+ <FoxRadio value="default" label="기본 배송지" defaultChecked /> |
|
| 1315 |
+ <FoxConditionalOption |
|
| 1316 |
+ value="other" |
|
| 1317 |
+ label="다른 주소로 배송" |
|
| 1318 |
+ options={SELECT_ITEMS}
|
|
| 1319 |
+ placeholder="주소를 고르세요" |
|
| 1320 |
+ /> |
|
| 1321 |
+ </FoxRadioGroup> |
|
| 1322 |
+ ), |
|
| 1323 |
+ }, |
|
| 1324 |
+ {
|
|
| 1325 |
+ label: "FoxRadioGroup 안 — name·size를 그룹이 넣어 준다(md로 통일된다)", |
|
| 1326 |
+ node: ( |
|
| 1327 |
+ <FoxRadioGroup name="co-group" size="md" label="결제 수단"> |
|
| 1328 |
+ <FoxRadio value="card" label="카드" defaultChecked /> |
|
| 1329 |
+ <FoxConditionalOption |
|
| 1330 |
+ value="bank" |
|
| 1331 |
+ label="계좌 이체" |
|
| 1332 |
+ description="은행을 고르면 이 항목이 선택됩니다." |
|
| 1333 |
+ options={SELECT_ITEMS}
|
|
| 1334 |
+ placeholder="은행을 고르세요" |
|
| 1335 |
+ /> |
|
| 1336 |
+ </FoxRadioGroup> |
|
| 1337 |
+ ), |
|
| 1338 |
+ }, |
|
| 1339 |
+ {
|
|
| 1340 |
+ label: "disabled — 컨트롤과 select가 함께 잠긴다", |
|
| 1341 |
+ node: ( |
|
| 1342 |
+ <FoxConditionalOption |
|
| 1343 |
+ name="co-off" |
|
| 1344 |
+ value="other" |
|
| 1345 |
+ label="다른 주소로 배송" |
|
| 1346 |
+ description="선택할 수 없습니다." |
|
| 1347 |
+ options={SELECT_ITEMS}
|
|
| 1348 |
+ placeholder="주소를 고르세요" |
|
| 1349 |
+ disabled |
|
| 1350 |
+ /> |
|
| 1351 |
+ ), |
|
| 1352 |
+ }, |
|
| 1353 |
+ {
|
|
| 1354 |
+ label: "control=\"checkbox\" — 여러 개를 동시에 켤 수 있다", |
|
| 1355 |
+ node: ( |
|
| 1356 |
+ <FoxCheckboxGroup name="co-cb" label="추가 옵션"> |
|
| 1357 |
+ <FoxCheckbox value="gift" label="선물 포장" /> |
|
| 1358 |
+ <FoxConditionalOption |
|
| 1359 |
+ control="checkbox" |
|
| 1360 |
+ value="card" |
|
| 1361 |
+ label="카드 동봉" |
|
| 1362 |
+ description="문구를 고르면 이 항목이 자동으로 켜집니다." |
|
| 1363 |
+ options={SELECT_ITEMS}
|
|
| 1364 |
+ placeholder="문구를 고르세요" |
|
| 1365 |
+ /> |
|
| 1366 |
+ </FoxCheckboxGroup> |
|
| 1367 |
+ ), |
|
| 1368 |
+ }, |
|
| 1369 |
+ {
|
|
| 1370 |
+ label: "control 비교 — 라디오는 하나만, 체크박스는 여럿", |
|
| 1371 |
+ node: ( |
|
| 1372 |
+ <div className={styles.demoStack}>
|
|
| 1373 |
+ <FoxConditionalOption |
|
| 1374 |
+ orientation="horizontal" |
|
| 1375 |
+ name="co-cmp-r" |
|
| 1376 |
+ value="a" |
|
| 1377 |
+ label="radio (기본)" |
|
| 1378 |
+ options={SELECT_ITEMS}
|
|
| 1379 |
+ placeholder="고르세요" |
|
| 1380 |
+ /> |
|
| 1381 |
+ <FoxConditionalOption |
|
| 1382 |
+ orientation="horizontal" |
|
| 1383 |
+ control="checkbox" |
|
| 1384 |
+ name="co-cmp-c" |
|
| 1385 |
+ value="a" |
|
| 1386 |
+ label="checkbox" |
|
| 1387 |
+ options={SELECT_ITEMS}
|
|
| 1388 |
+ placeholder="고르세요" |
|
| 1389 |
+ /> |
|
| 1070 | 1390 |
</div> |
| 1071 | 1391 |
), |
| 1072 | 1392 |
}, |
... | ... | @@ -1458,6 +1778,275 @@ |
| 1458 | 1778 |
), |
| 1459 | 1779 |
}, |
| 1460 | 1780 |
], |
| 1781 |
+ }, {
|
|
| 1782 |
+ id: "fox-check-icon", |
|
| 1783 |
+ name: "FoxCheckIcon", |
|
| 1784 |
+ description: |
|
| 1785 |
+ "Figma 시안(check-icon 108:234) 체크박스의 네모 표시만 담당합니다. 상자는 CSS로 그리고 안쪽 표시만 SVG입니다 — 상자는 테두리·배경·모서리로 끝나지만 체크는 임의의 path라 CSS로 그릴 수 없습니다(FoxRadioIcon은 안쪽이 원이라 전부 CSS였습니다). 선택과 부분선택은 상자 모양이 같고 안쪽 표시만 다릅니다. FoxCheckbox 안에서는 네이티브 input의 :checked·:indeterminate·:disabled가 스타일 훅을 겸해서 클래스가 필요 없고, 아래처럼 단독으로 상태를 비교할 때만 모디파이어로 못박습니다.", |
|
| 1786 |
+ variants: [ |
|
| 1787 |
+ {
|
|
| 1788 |
+ label: "lg — 기본 / 선택 / 부분선택 / 비활성 / 선택+비활성 / 부분선택+비활성", |
|
| 1789 |
+ node: ( |
|
| 1790 |
+ <> |
|
| 1791 |
+ <FoxCheckIcon size="lg" /> |
|
| 1792 |
+ <FoxCheckIcon size="lg" checked /> |
|
| 1793 |
+ <FoxCheckIcon size="lg" indeterminate /> |
|
| 1794 |
+ <FoxCheckIcon size="lg" disabled /> |
|
| 1795 |
+ <FoxCheckIcon size="lg" checked disabled /> |
|
| 1796 |
+ <FoxCheckIcon size="lg" indeterminate disabled /> |
|
| 1797 |
+ </> |
|
| 1798 |
+ ), |
|
| 1799 |
+ }, |
|
| 1800 |
+ {
|
|
| 1801 |
+ label: "md — 기본 / 선택 / 부분선택 / 비활성 / 선택+비활성 / 부분선택+비활성", |
|
| 1802 |
+ node: ( |
|
| 1803 |
+ <> |
|
| 1804 |
+ <FoxCheckIcon size="md" /> |
|
| 1805 |
+ <FoxCheckIcon size="md" checked /> |
|
| 1806 |
+ <FoxCheckIcon size="md" indeterminate /> |
|
| 1807 |
+ <FoxCheckIcon size="md" disabled /> |
|
| 1808 |
+ <FoxCheckIcon size="md" checked disabled /> |
|
| 1809 |
+ <FoxCheckIcon size="md" indeterminate disabled /> |
|
| 1810 |
+ </> |
|
| 1811 |
+ ), |
|
| 1812 |
+ }, |
|
| 1813 |
+ ], |
|
| 1814 |
+ }, |
|
| 1815 |
+ {
|
|
| 1816 |
+ id: "fox-checkbox", |
|
| 1817 |
+ name: "FoxCheckbox", |
|
| 1818 |
+ description: |
|
| 1819 |
+ "Figma 시안(checkbox 108:469) 네모 표시 + 라벨 + 부가설명을 묶은 체크박스 한 칸입니다. 루트가 <label>이라 라벨이나 설명을 눌러도 켜집니다. 라디오와 달리 name이 같아도 서로를 끄지 않아 여러 개를 동시에 고를 수 있고, 폼 전송 시 고른 것들이 함께 나갑니다. ⚠️ 부분선택은 HTML 속성이 아니라 DOM 프로퍼티라 마크업만으로는 켤 수 없습니다 — 컴포넌트가 대신 넣어 줍니다.", |
|
| 1820 |
+ variants: [ |
|
| 1821 |
+ {
|
|
| 1822 |
+ label: "여러 개를 동시에 고를 수 있다 (같은 name이어도 서로 끄지 않는다)", |
|
| 1823 |
+ node: ( |
|
| 1824 |
+ <div className={styles.demoStack}>
|
|
| 1825 |
+ <FoxCheckbox name="cb-terms" value="service" label="서비스 이용약관" defaultChecked /> |
|
| 1826 |
+ <FoxCheckbox name="cb-terms" value="privacy" label="개인정보 처리방침" defaultChecked /> |
|
| 1827 |
+ <FoxCheckbox name="cb-terms" value="marketing" label="마케팅 정보 수신" /> |
|
| 1828 |
+ </div> |
|
| 1829 |
+ ), |
|
| 1830 |
+ }, |
|
| 1831 |
+ {
|
|
| 1832 |
+ label: "부분선택 — 전체 동의처럼 자식 일부만 켜진 상태", |
|
| 1833 |
+ node: ( |
|
| 1834 |
+ <div className={styles.demoStack}>
|
|
| 1835 |
+ <FoxCheckbox name="cb-ind" value="all" label="전체 동의" indeterminate /> |
|
| 1836 |
+ <FoxCheckbox name="cb-ind" value="a" label="필수 약관" defaultChecked /> |
|
| 1837 |
+ <FoxCheckbox name="cb-ind" value="b" label="선택 약관" /> |
|
| 1838 |
+ </div> |
|
| 1839 |
+ ), |
|
| 1840 |
+ }, |
|
| 1841 |
+ {
|
|
| 1842 |
+ label: "부가설명 — 여러 줄이어도 네모 표시는 첫 줄에 붙는다", |
|
| 1843 |
+ node: ( |
|
| 1844 |
+ <div className={styles.demoStack}>
|
|
| 1845 |
+ <FoxCheckbox |
|
| 1846 |
+ name="cb-desc" |
|
| 1847 |
+ value="a" |
|
| 1848 |
+ label="마케팅 정보 수신" |
|
| 1849 |
+ description="이벤트·혜택 소식을 받아봅니다." |
|
| 1850 |
+ defaultChecked |
|
| 1851 |
+ /> |
|
| 1852 |
+ <FoxCheckbox |
|
| 1853 |
+ name="cb-desc" |
|
| 1854 |
+ value="b" |
|
| 1855 |
+ label="야간 알림" |
|
| 1856 |
+ description="밤 9시 이후에도 알림을 받습니다. 설명이 길어지면 이렇게 줄이 넘어가고, 네모 표시는 첫 줄에 붙어 있어야 정상입니다." |
|
| 1857 |
+ /> |
|
| 1858 |
+ </div> |
|
| 1859 |
+ ), |
|
| 1860 |
+ }, |
|
| 1861 |
+ {
|
|
| 1862 |
+ label: "크기 (size)", |
|
| 1863 |
+ node: ( |
|
| 1864 |
+ <div className={styles.demoStack}>
|
|
| 1865 |
+ <FoxCheckbox name="cb-lg" value="a" label="lg" description="부가설명" defaultChecked /> |
|
| 1866 |
+ <FoxCheckbox name="cb-md" value="a" label="md" description="부가설명" size="md" defaultChecked /> |
|
| 1867 |
+ </div> |
|
| 1868 |
+ ), |
|
| 1869 |
+ }, |
|
| 1870 |
+ {
|
|
| 1871 |
+ label: "비활성 — 글자색도 흐려지고 클릭이 막힌다", |
|
| 1872 |
+ node: ( |
|
| 1873 |
+ <div className={styles.demoStack}>
|
|
| 1874 |
+ <FoxCheckbox name="cb-off" value="a" label="비활성" description="선택할 수 없습니다." disabled /> |
|
| 1875 |
+ <FoxCheckbox name="cb-off2" value="a" label="비활성 + 선택됨" description="체크가 남고 색만 흐려집니다." disabled defaultChecked /> |
|
| 1876 |
+ <FoxCheckbox name="cb-off3" value="a" label="비활성 + 부분선택" description="대시가 남고 색만 흐려집니다." disabled indeterminate /> |
|
| 1877 |
+ </div> |
|
| 1878 |
+ ), |
|
| 1879 |
+ }, |
|
| 1880 |
+ ], |
|
| 1881 |
+ }, |
|
| 1882 |
+ {
|
|
| 1883 |
+ id: "fox-checkbox-group", |
|
| 1884 |
+ name: "FoxCheckboxGroup", |
|
| 1885 |
+ description: |
|
| 1886 |
+ "Figma 시안(checkbox-group 194:4000) 체크박스 여러 칸의 배치와 name·size 통일을 책임집니다. 켜짐 여부는 각 FoxCheckbox의 네이티브 input이 소유하므로 그룹은 상태를 갖지 않습니다. role이 radiogroup이 아니라 group입니다 — 체크박스는 서로를 끄지 않아 하나만 고르는 묶음이 아니고, 화살표 이동으로 옮겨 다니지도 않아 각 칸이 독립적으로 Tab을 받습니다. 배치 값은 FoxRadioGroup과 같습니다.", |
|
| 1887 |
+ variants: [ |
|
| 1888 |
+ {
|
|
| 1889 |
+ label: "vertical (기본) — 간격 gap-7, 왼쪽 끝 정렬", |
|
| 1890 |
+ node: ( |
|
| 1891 |
+ <FoxCheckboxGroup name="cbg-v" label="약관 동의"> |
|
| 1892 |
+ <FoxCheckbox value="service" label="서비스 이용약관" defaultChecked /> |
|
| 1893 |
+ <FoxCheckbox value="privacy" label="개인정보 처리방침" /> |
|
| 1894 |
+ <FoxCheckbox value="marketing" label="마케팅 정보 수신" /> |
|
| 1895 |
+ </FoxCheckboxGroup> |
|
| 1896 |
+ ), |
|
| 1897 |
+ }, |
|
| 1898 |
+ {
|
|
| 1899 |
+ label: "horizontal — 행 gap-7 / 열 gap-9, 폭이 모자라면 줄바꿈", |
|
| 1900 |
+ node: ( |
|
| 1901 |
+ <FoxCheckboxGroup orientation="horizontal" name="cbg-h" label="관심 분야"> |
|
| 1902 |
+ <FoxCheckbox value="dev" label="개발" defaultChecked /> |
|
| 1903 |
+ <FoxCheckbox value="design" label="디자인" /> |
|
| 1904 |
+ <FoxCheckbox value="pm" label="기획" /> |
|
| 1905 |
+ </FoxCheckboxGroup> |
|
| 1906 |
+ ), |
|
| 1907 |
+ }, |
|
| 1908 |
+ {
|
|
| 1909 |
+ label: "horizontal + 줄바꿈 — 좁은 무대에서 다음 줄로 넘어간다", |
|
| 1910 |
+ node: ( |
|
| 1911 |
+ <div className={styles.demoNarrow}>
|
|
| 1912 |
+ <FoxCheckboxGroup orientation="horizontal" name="cbg-wrap" label="줄바꿈 확인"> |
|
| 1913 |
+ {["첫째", "둘째", "셋째", "넷째", "다섯째"].map((label, i) => (
|
|
| 1914 |
+ <FoxCheckbox key={label} value={label} label={label} defaultChecked={i === 0} />
|
|
| 1915 |
+ ))} |
|
| 1916 |
+ </FoxCheckboxGroup> |
|
| 1917 |
+ </div> |
|
| 1918 |
+ ), |
|
| 1919 |
+ }, |
|
| 1920 |
+ {
|
|
| 1921 |
+ label: "size — 자식이 준 값은 무시되고 그룹 값으로 통일된다", |
|
| 1922 |
+ node: ( |
|
| 1923 |
+ <FoxCheckboxGroup orientation="horizontal" name="cbg-md" size="md" label="md 크기"> |
|
| 1924 |
+ {/* 일부러 어긋난 size를 준다 — 둘 다 md로 나와야 정상 */}
|
|
| 1925 |
+ <FoxCheckbox value="a" label="첫째" size="lg" defaultChecked /> |
|
| 1926 |
+ <FoxCheckbox value="b" label="둘째" /> |
|
| 1927 |
+ </FoxCheckboxGroup> |
|
| 1928 |
+ ), |
|
| 1929 |
+ }, |
|
| 1930 |
+ ], |
|
| 1931 |
+ }, |
|
| 1932 |
+ {
|
|
| 1933 |
+ id: "fox-tag", |
|
| 1934 |
+ name: "FoxTag", |
|
| 1935 |
+ description: |
|
| 1936 |
+ "Figma 시안(tag 132:195) 아이콘만 누르는 게 아니라 조각 전체가 버튼입니다 — 시안이 hover 배경을 태그 전체에 줍니다. type이 아이콘의 종류와 자리를 정합니다: sorting은 글자 뒤에 닫기 아이콘(걸어 둔 조건을 뗄 때), button은 글자 앞에 택 아이콘입니다. 기본 배경은 action-secondary, 즉 투명이라 아래 무대 색이 그대로 보이는 것이 정상입니다 — hover(연한 파랑)·pressed(진한 파랑)는 마우스를 올리고 눌러서 확인해야 합니다. button만 hover·pressed에서 글자에 밑줄이 붙습니다.", |
|
| 1937 |
+ variants: [ |
|
| 1938 |
+ {
|
|
| 1939 |
+ label: "sorting (기본) — 크기 lg / md / sm", |
|
| 1940 |
+ node: ( |
|
| 1941 |
+ <> |
|
| 1942 |
+ <FoxTag size="lg" label="태그" ariaLabel="태그 조건 제거" /> |
|
| 1943 |
+ <FoxTag size="md" label="태그" ariaLabel="태그 조건 제거" /> |
|
| 1944 |
+ <FoxTag size="sm" label="태그" ariaLabel="태그 조건 제거" /> |
|
| 1945 |
+ </> |
|
| 1946 |
+ ), |
|
| 1947 |
+ }, |
|
| 1948 |
+ {
|
|
| 1949 |
+ label: "button — 크기 lg / md / sm (마우스를 올리면 글자에 밑줄)", |
|
| 1950 |
+ node: ( |
|
| 1951 |
+ <> |
|
| 1952 |
+ <FoxTag type="button" size="lg" label="태그" /> |
|
| 1953 |
+ <FoxTag type="button" size="md" label="태그" /> |
|
| 1954 |
+ <FoxTag type="button" size="sm" label="태그" /> |
|
| 1955 |
+ </> |
|
| 1956 |
+ ), |
|
| 1957 |
+ }, |
|
| 1958 |
+ ], |
|
| 1959 |
+ }, |
|
| 1960 |
+ {
|
|
| 1961 |
+ id: "fox-tag-list", |
|
| 1962 |
+ name: "FoxTagList", |
|
| 1963 |
+ description: |
|
| 1964 |
+ "Figma 시안(tag-list 190:2565) 태그 여러 개의 배치와 size·type 통일을 책임집니다. 무엇이 걸려 있는지는 호출부가 소유하므로 목록은 상태를 갖지 않습니다. 방향 수식어가 없습니다 — 태그는 가로로 흐르다 폭이 모자라면 다음 줄로 넘어가는 것이 유일한 배치입니다. role이 radiogroup이 아니라 group입니다 — 하나만 고르는 묶음이 아니고 각 태그가 독립적으로 Tab을 받는 버튼입니다. 태그 사이 간격은 없습니다 — 각 태그의 좌우 padding이 여백 역할을 하고, 배경이 투명해서 서로 닿아도 경계가 겹쳐 보이지 않습니다.", |
|
| 1965 |
+ variants: [ |
|
| 1966 |
+ {
|
|
| 1967 |
+ label: "닫기를 누르면 실제로 떨어진다 — 목록은 호출부가 소유한다", |
|
| 1968 |
+ node: <TagListLiveDemo />, |
|
| 1969 |
+ }, |
|
| 1970 |
+ {
|
|
| 1971 |
+ label: "size — 자식이 준 값은 무시되고 묶음 값으로 통일된다", |
|
| 1972 |
+ node: ( |
|
| 1973 |
+ <FoxTagList size="sm" label="sm 크기"> |
|
| 1974 |
+ {/* 일부러 어긋난 size를 준다 — 셋 다 sm으로 나와야 정상 */}
|
|
| 1975 |
+ <FoxTag label="서울" size="lg" /> |
|
| 1976 |
+ <FoxTag label="경기" size="md" /> |
|
| 1977 |
+ <FoxTag label="인천" /> |
|
| 1978 |
+ </FoxTagList> |
|
| 1979 |
+ ), |
|
| 1980 |
+ }, |
|
| 1981 |
+ {
|
|
| 1982 |
+ label: "type — 묶음이 계열도 통일한다", |
|
| 1983 |
+ node: ( |
|
| 1984 |
+ <FoxTagList type="button" size="md" label="button 계열"> |
|
| 1985 |
+ <FoxTag label="공지사항" /> |
|
| 1986 |
+ <FoxTag label="1:1 문의" /> |
|
| 1987 |
+ <FoxTag label="FAQ" /> |
|
| 1988 |
+ </FoxTagList> |
|
| 1989 |
+ ), |
|
| 1990 |
+ }, |
|
| 1991 |
+ {
|
|
| 1992 |
+ label: "줄바꿈 — 좁은 무대에서 다음 줄로 넘어간다", |
|
| 1993 |
+ node: ( |
|
| 1994 |
+ <div className={styles.demoNarrow}>
|
|
| 1995 |
+ <FoxTagList size="md" label="줄바꿈 확인"> |
|
| 1996 |
+ {["서울", "경기", "인천", "부산", "대구", "광주"].map((name) => (
|
|
| 1997 |
+ <FoxTag key={name} label={name} ariaLabel={`${name} 조건 제거`} />
|
|
| 1998 |
+ ))} |
|
| 1999 |
+ </FoxTagList> |
|
| 2000 |
+ </div> |
|
| 2001 |
+ ), |
|
| 2002 |
+ }, |
|
| 2003 |
+ ], |
|
| 2004 |
+ }, |
|
| 2005 |
+ {
|
|
| 2006 |
+ id: "fox-toggle-switch", |
|
| 2007 |
+ name: "FoxToggleSwitch", |
|
| 2008 |
+ description: |
|
| 2009 |
+ "Figma 시안(toggle-switch 134:442) 토글 스위치 입니다. 마크업은 체크박스와 같고 role=\"switch\"만 얹어 선택됨이 아니라 켜짐/꺼짐으로 읽히게 합니다 — 폼에도 체크박스처럼 참여합니다. 루트가 <label>이라 글자를 눌러도 바뀌고, 켜짐 여부는 네이티브 input이 소유하므로 아래 예제는 전부 실제로 눌러서 확인할 수 있습니다. 동그라미는 꺼짐이면 왼쪽·켜짐이면 오른쪽이고, 표시 색은 언제나 트랙 배경과 같은 값이라 흰 동그라미에 트랙 색으로 글리프를 파낸 모양이 됩니다. 크기는 트랙·동그라미·표시·글자를 함께 바꿉니다 — lg는 트랙 40×24, md는 트랙 32×20입니다.", |
|
| 2010 |
+ variants: [ |
|
| 2011 |
+ {
|
|
| 2012 |
+ label: "꺼짐 / 켜짐 — 눌러서 바꿔 보세요", |
|
| 2013 |
+ node: ( |
|
| 2014 |
+ <div className={styles.demoStack}>
|
|
| 2015 |
+ <FoxToggleSwitch name="ts-off" label="알림 받기" /> |
|
| 2016 |
+ <FoxToggleSwitch name="ts-on" label="알림 받기" defaultChecked /> |
|
| 2017 |
+ </div> |
|
| 2018 |
+ ), |
|
| 2019 |
+ }, |
|
| 2020 |
+ {
|
|
| 2021 |
+ label: "크기 — lg 트랙 40×24 / md 트랙 32×20, 꺼짐·켜짐 모두", |
|
| 2022 |
+ node: ( |
|
| 2023 |
+ <div className={styles.demoStack}>
|
|
| 2024 |
+ <FoxToggleSwitch name="ts-lg-off" label="lg 꺼짐" /> |
|
| 2025 |
+ <FoxToggleSwitch name="ts-lg" label="lg 켜짐" defaultChecked /> |
|
| 2026 |
+ <FoxToggleSwitch name="ts-md-off" size="md" label="md 꺼짐" /> |
|
| 2027 |
+ <FoxToggleSwitch name="ts-md" size="md" label="md 켜짐" defaultChecked /> |
|
| 2028 |
+ </div> |
|
| 2029 |
+ ), |
|
| 2030 |
+ }, |
|
| 2031 |
+ {
|
|
| 2032 |
+ label: "label 없음 — 글자 영역을 렌더하지 않는다 (읽어 줄 이름은 aria-label로 준다)", |
|
| 2033 |
+ node: ( |
|
| 2034 |
+ <> |
|
| 2035 |
+ <FoxToggleSwitch name="ts-bare-off" aria-label="알림 받기" /> |
|
| 2036 |
+ <FoxToggleSwitch name="ts-bare-on" aria-label="알림 받기" defaultChecked /> |
|
| 2037 |
+ </> |
|
| 2038 |
+ ), |
|
| 2039 |
+ }, |
|
| 2040 |
+ {
|
|
| 2041 |
+ label: "비활성 — 꺼짐 / 켜짐", |
|
| 2042 |
+ node: ( |
|
| 2043 |
+ <div className={styles.demoStack}>
|
|
| 2044 |
+ <FoxToggleSwitch name="ts-off-dis" label="비활성 + 꺼짐" disabled /> |
|
| 2045 |
+ <FoxToggleSwitch name="ts-on-dis" label="비활성 + 켜짐" disabled defaultChecked /> |
|
| 2046 |
+ </div> |
|
| 2047 |
+ ), |
|
| 2048 |
+ }, |
|
| 2049 |
+ ], |
|
| 1461 | 2050 |
}, |
| 1462 | 2051 |
{
|
| 1463 | 2052 |
id: "fox-breadcrumb", |
+++ @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
... | ... | @@ -0,0 +1,62 @@ |
| 1 | +// FoxConditionalOption — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma conditional-option | |
| 3 | +// | |
| 4 | +// 컨트롤 한 칸 + 그 칸을 켰을 때만 의미가 생기는 select를 한 덩어리로 묶는다. 둘이 합쳐 | |
| 5 | +// 하나의 답이다 — "다른 주소로 배송(컨트롤) → 어느 주소인지(select)"처럼 후속 질문이 딸린 | |
| 6 | +// 선택지에 쓴다. | |
| 7 | +// | |
| 8 | +// 앞의 컨트롤은 라디오와 체크박스 둘 다 될 수 있다(시안의 trigger). 하나만 골라야 하면 | |
| 9 | +// 라디오, 여러 개를 켤 수 있어야 하면 체크박스다. | |
| 10 | +// | |
| 11 | +// 마크업 계약 (React 밖 소비자용): | |
| 12 | +// <div class="fox-conditional-option fox-conditional-option--vertical"> | |
| 13 | +// <label class="fox-radio fox-radio--lg">…</label> <!-- 또는 fox-checkbox --> | |
| 14 | +// <div class="fox-conditional-option__detail"> | |
| 15 | +// <div class="fox-select fox-select--md">…</div> | |
| 16 | +// </div> | |
| 17 | +// </div> | |
| 18 | +// | |
| 19 | +// select는 컨트롤이 안 켜져 있어도 조작할 수 있다 — 값을 고르면 컨트롤이 자동으로 켜지는데, | |
| 20 | +// 잠가 두면 그 조작 자체가 불가능해진다. 켜짐 여부는 컨트롤의 네이티브 input이 소유하므로 | |
| 21 | +// 이 파일은 배치만 맡는다. | |
| 22 | +// | |
| 23 | +// ⚠️ 시안의 변형 이름이 실제 동작과 반대다(`fox-radio-group`과 같은 문제). 동작 기준으로 | |
| 24 | +// 다시 이름 붙였다. | |
| 25 | +// | |
| 26 | +// 시안 direction=row → `--vertical` (select가 라디오 아래로) | |
| 27 | +// 시안 direction=col → `--horizontal` (select가 라디오 옆에) | |
| 28 | +// | |
| 29 | +// 세로일 때 select는 들여쓰지 않고 **맨 앞**에 붙는다 — 동그란 표시와 왼쪽 끝이 맞는다. | |
| 30 | +// | |
| 31 | +// 토큰이 아닌 값(사용자 승인): | |
| 32 | +// - display / flex / align-*: 배치 규칙. | |
| 33 | + | |
| 34 | +@use "@fox/styles/abstracts" as fox; | |
| 35 | + | |
| 36 | +.fox-conditional-option { | |
| 37 | + display: flex; | |
| 38 | + // 간격은 방향과 무관하게 `gap-3` 하나다 — 옆에 서든 아래로 내려가든 시안이 같은 값을 쓴다. | |
| 39 | + gap: fox.gap(3); | |
| 40 | + | |
| 41 | + // select가 컨트롤 옆에 선다. 라벨 한 줄과 높이를 맞춘다. | |
| 42 | + &--horizontal { | |
| 43 | + flex-direction: row; | |
| 44 | + align-items: center; | |
| 45 | + } | |
| 46 | + | |
| 47 | + // select가 컨트롤 아래로 내려간다. | |
| 48 | + &--vertical { | |
| 49 | + flex-direction: column; | |
| 50 | + align-items: flex-start; | |
| 51 | + } | |
| 52 | + | |
| 53 | + // select가 행 폭을 채운다. `FoxSelect`는 폭을 스스로 정하지 않으므로(부모가 정한다) 여기서 | |
| 54 | + // 늘려 주지 않으면 트리거 안 글자 폭으로 줄어들고, 값을 고를 때마다 상자가 넓어진다. | |
| 55 | + // | |
| 56 | + // ⚠️ 세로에서만 듣는다. 가로에서는 교차축이 세로라 `stretch`가 높이를 늘릴 뿐이고 폭은 | |
| 57 | + // 그대로 내용 폭이다 — 가로에서도 채워야 하면 `flex: 1 0 0`을 따로 걸어야 한다. | |
| 58 | + &--vertical &__detail { | |
| 59 | + align-self: stretch; | |
| 60 | + } | |
| 61 | +} | |
| 62 | + |
+++ @fox/styles/_fox-form-label.scss
... | ... | @@ -0,0 +1,49 @@ |
| 1 | +// FoxFormLabel — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// Figma form-label | |
| 3 | +// | |
| 4 | +// 입력 칸 위에 붙는 라벨. 필수·선택 표시를 뒤에 달 수 있다. | |
| 5 | +// | |
| 6 | +// 마크업 계약 (React 밖 소비자용): | |
| 7 | +// <label class="fox-form-label" for="…"> | |
| 8 | +// 이름 | |
| 9 | +// <span class="fox-form-label__required" aria-hidden="true">*</span> <!-- 필수일 때 --> | |
| 10 | +// <span class="fox-form-label__optional">(선택)</span> <!-- 선택일 때 --> | |
| 11 | +// </label> | |
| 12 | +// | |
| 13 | +// 글자 자체는 `form-field`의 `label` 믹스인이 갖는다 — 시안에서 이 컴포넌트와 입력 계열의 | |
| 14 | +// 라벨이 같은 공용 조각이라 값이 갈라질 이유가 없다. 이 파일에는 표시 두 개만 남는다. | |
| 15 | +// | |
| 16 | +// 필수의 `*`는 `aria-hidden`이다. 필수 여부는 입력 요소의 `required`(또는 `aria-required`)가 | |
| 17 | +// 알리는 것이고, 별표는 그 눈에 보이는 짝일 뿐이라 두 번 읽히면 안 된다. 반대로 "(선택)"은 | |
| 18 | +// 대신 알려 줄 속성이 없어 그대로 읽히게 둔다. | |
| 19 | +// | |
| 20 | +// 표시는 서로 배타적이다 — 필수이면서 선택일 수는 없어 React 래퍼가 하나만 받는다. | |
| 21 | +// | |
| 22 | +// 토큰이 아닌 값(FoxButton과 동일한 근거로 승인된 항목): | |
| 23 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 export하지 | |
| 24 | +// 않는다. 자간은 px이 아니라 em이라 브레이크포인트가 바뀌어도 비율이 유지된다. | |
| 25 | +// - display / align-items: 구조 규칙. | |
| 26 | + | |
| 27 | +@use "@fox/styles/abstracts" as fox; | |
| 28 | +@use "@fox/styles/form-field" as field; | |
| 29 | + | |
| 30 | +.fox-form-label { | |
| 31 | + @include field.label; | |
| 32 | + | |
| 33 | + display: inline-flex; | |
| 34 | + align-items: center; | |
| 35 | + | |
| 36 | + // 시안 label/md-em — 기본 라벨보다 굵고 강조 색이다. | |
| 37 | + &__required { | |
| 38 | + color: fox.color(font-theme-accent); | |
| 39 | + font-weight: fox.font-weight(bold); | |
| 40 | + } | |
| 41 | + | |
| 42 | + // 시안 label/sm — 기본 라벨보다 한 단계 작고 흐리다. | |
| 43 | + &__optional { | |
| 44 | + color: fox.color(font-neutral-subtle); | |
| 45 | + font-size: fox.font-size(label-sm); | |
| 46 | + font-weight: fox.font-weight(regular); | |
| 47 | + letter-spacing: -0.025em; | |
| 48 | + } | |
| 49 | +} |
--- @fox/styles/_fox-input.scss
+++ @fox/styles/_fox-input.scss
... | ... | @@ -3,7 +3,7 @@ |
| 3 | 3 |
// |
| 4 | 4 |
// 마크업 계약 (React 밖 소비자용): |
| 5 | 5 |
// <div class="fox-input fox-input--md"> |
| 6 |
-// <label class="fox-input__label" for="…">레이블</label> |
|
| 6 |
+// <label class="fox-form-label" for="…">레이블</label> |
|
| 7 | 7 |
// <div class="fox-input__box"> |
| 8 | 8 |
// <input class="fox-input__field" id="…" placeholder="…"> |
| 9 | 9 |
// <button class="fox-input__clear" type="button">…svg…</button> <!-- 선택 --> |
... | ... | @@ -35,10 +35,6 @@ |
| 35 | 35 |
|
| 36 | 36 |
.fox-input {
|
| 37 | 37 |
@include field.root; |
| 38 |
- |
|
| 39 |
- &__label {
|
|
| 40 |
- @include field.label; |
|
| 41 |
- } |
|
| 42 | 38 |
|
| 43 | 39 |
// 크기(높이·좌우 여백·모서리)는 아래 크기 수식어가 정한다. |
| 44 | 40 |
&__box {
|
+++ @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,115 @@ |
| 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 | +// - 아이콘의 위쪽 여백 `0.1rem`(1px): 시안 icon-area의 `padding-top` 값이고 변수로 바인딩 | |
| 33 | +// 되어 있지 않다. 크기·브레이크포인트가 바뀌어도 필요한 보정이 0.75~1.25px 범위라 하나로 | |
| 34 | +// 쓴다(1rem = 10px). | |
| 35 | +// - cursor / display / flex / position: 구조·상호작용 규칙. | |
| 36 | +// - 숨긴 input의 `inline-size: 0`: 크기가 아니라 "보이지 않게" 하는 구조 값이다. | |
| 37 | + | |
| 38 | +@use "@fox/styles/abstracts" as fox; | |
| 39 | + | |
| 40 | +.fox-radio { | |
| 41 | + // 숨긴 input의 위치 기준. 안 주면 input이 조상까지 올라가 엉뚱한 곳에 붙는다. | |
| 42 | + position: relative; | |
| 43 | + display: flex; | |
| 44 | + // 부가설명이 여러 줄이어도 동그란 표시는 첫 줄에 붙어 있어야 한다. | |
| 45 | + align-items: flex-start; | |
| 46 | + gap: fox.gap(3); | |
| 47 | + cursor: pointer; | |
| 48 | + | |
| 49 | + // 시각적으로만 숨긴다. `opacity: 0`은 포커스를 유지하므로 키보드로 도달할 수 있고, | |
| 50 | + // 클릭은 루트 `<label>`이 받는다. | |
| 51 | + &__input { | |
| 52 | + position: absolute; | |
| 53 | + inline-size: 0; | |
| 54 | + block-size: 0; | |
| 55 | + margin: 0; | |
| 56 | + opacity: 0; | |
| 57 | + pointer-events: none; | |
| 58 | + } | |
| 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 | + | |
| 69 | + &__body { | |
| 70 | + display: flex; | |
| 71 | + flex-direction: column; | |
| 72 | + gap: fox.gap(1); | |
| 73 | + align-items: flex-start; | |
| 74 | + } | |
| 75 | + | |
| 76 | + // 자간은 px이 아니라 em이다 — 라벨은 크기 수식어(lg·md)마다, 부가설명은 브레이크포인트마다 | |
| 77 | + // 글자 크기가 달라지는데, px으로 박으면 그 자리에서 비율이 어긋난다. | |
| 78 | + &__label { | |
| 79 | + color: fox.color(font-neutral-strong); | |
| 80 | + font-family: fox.font-family(body); | |
| 81 | + font-weight: fox.font-weight(regular); | |
| 82 | + line-height: 1.5; | |
| 83 | + letter-spacing: -0.025em; | |
| 84 | + } | |
| 85 | + | |
| 86 | + &__description { | |
| 87 | + color: fox.color(font-neutral-subtle); | |
| 88 | + font-family: fox.font-family(body); | |
| 89 | + font-weight: fox.font-weight(regular); | |
| 90 | + font-size: fox.font-size(body-sm); | |
| 91 | + line-height: 1.5; | |
| 92 | + letter-spacing: -0.025em; | |
| 93 | + } | |
| 94 | + | |
| 95 | + // ── 크기 ──────────────────────────────────────────────────────────────────── | |
| 96 | + &--lg &__label { | |
| 97 | + font-size: fox.font-size(body-lg); | |
| 98 | + } | |
| 99 | + | |
| 100 | + &--md &__label { | |
| 101 | + font-size: fox.font-size(body-md); | |
| 102 | + } | |
| 103 | +} | |
| 104 | + | |
| 105 | +// ── 비활성 ──────────────────────────────────────────────────────────────────── | |
| 106 | +// 글자색도 함께 흐려진다. 동그란 표시는 `_fox-radio-icon.scss`가 같은 `:disabled`를 보고 | |
| 107 | +// 처리하므로 여기서 건드리지 않는다. | |
| 108 | +.fox-radio:has(.fox-radio__input:disabled) { | |
| 109 | + cursor: default; | |
| 110 | +} | |
| 111 | + | |
| 112 | +.fox-radio__input:disabled ~ .fox-radio__body .fox-radio__label, | |
| 113 | +.fox-radio__input:disabled ~ .fox-radio__body .fox-radio__description { | |
| 114 | + color: fox.color(font-neutral-disabled); | |
| 115 | +} |
--- @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
... | ... | @@ -5,7 +5,7 @@ |
| 5 | 5 |
// |
| 6 | 6 |
// 마크업 계약 (React 밖 소비자용): |
| 7 | 7 |
// <div class="fox-select fox-select--md"> |
| 8 |
-// <label class="fox-select__label" for="sel">라벨</label> <!-- 선택 --> |
|
| 8 |
+// <label class="fox-form-label" for="sel">라벨</label> <!-- 선택 --> |
|
| 9 | 9 |
// <div class="fox-select__control"> <!-- 리스트박스 위치 기준 --> |
| 10 | 10 |
// <button class="fox-select__trigger fox-select__trigger--completed" id="sel" |
| 11 | 11 |
// type="button" role="combobox" aria-haspopup="listbox" |
... | ... | @@ -36,8 +36,11 @@ |
| 36 | 36 |
// |
| 37 | 37 |
// 폭은 정하지 않는다 — 부모가 정한다. 높이만 `size`가 정한다. |
| 38 | 38 |
// |
| 39 |
-// 라벨·옵션·힌트는 `size`와 무관하게 공통 스타일이다. 크기 모디파이어는 트리거와 트리거 |
|
| 40 |
-// 아이콘만 바꾼다 — 드롭다운 목록은 어느 크기에서 열려도 같은 서체로 읽혀야 한다. |
|
| 39 |
+// 라벨은 `fox-form-label`이 갖는다 — 시안에서 입력 계열과 같은 공용 조각이라 값이 갈라질 |
|
| 40 |
+// 이유가 없고, 필수·선택 표시도 그쪽에 붙는다. 이 파일에는 라벨 규칙이 없다. |
|
| 41 |
+// |
|
| 42 |
+// 옵션·힌트는 `size`와 무관하게 공통 스타일이다. 크기 모디파이어는 트리거와 트리거 아이콘만 |
|
| 43 |
+// 바꾼다 — 드롭다운 목록은 어느 크기에서 열려도 같은 서체로 읽혀야 한다. |
|
| 41 | 44 |
// |
| 42 | 45 |
// 토큰이 아닌 값(FoxButton과 동일한 근거로 승인된 항목): |
| 43 | 46 |
// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 export하지 않는다. |
... | ... | @@ -49,13 +52,6 @@ |
| 49 | 52 |
display: flex; |
| 50 | 53 |
flex-direction: column; |
| 51 | 54 |
gap: fox.gap(4); |
| 52 |
- |
|
| 53 |
- &__label {
|
|
| 54 |
- color: fox.color(font-neutral-default); |
|
| 55 |
- font-family: fox.font-family(body); |
|
| 56 |
- font-size: fox.font-size(label-md); |
|
| 57 |
- font-weight: fox.font-weight(medium); |
|
| 58 |
- } |
|
| 59 | 55 |
|
| 60 | 56 |
&__control {
|
| 61 | 57 |
position: relative; |
... | ... | @@ -132,10 +128,11 @@ |
| 132 | 128 |
font-family: fox.font-family(body); |
| 133 | 129 |
font-weight: fox.font-weight(regular); |
| 134 | 130 |
font-size: fox.font-size(body-sm); |
| 135 |
- // line-height 150% / letter-spacing -0.325px — Figma 텍스트 스타일 값이며 변수로 |
|
| 136 |
- // export되지 않아 토큰이 없다(fox-button의 label/*-strong 사례와 같은 예외, 사용자 승인). |
|
| 131 |
+ // line-height 150% / letter-spacing — 시안 텍스트 스타일 값이나 Figma가 변수로 |
|
| 132 |
+ // export하지 않는다(사용자 승인). 자간은 px이 아니라 em이라 크기·브레이크포인트가 |
|
| 133 |
+ // 바뀌어도 비율이 유지된다. |
|
| 137 | 134 |
line-height: 1.5; |
| 138 |
- letter-spacing: -0.325px; |
|
| 135 |
+ letter-spacing: -0.025em; |
|
| 139 | 136 |
} |
| 140 | 137 |
|
| 141 | 138 |
// 힌트 색은 트리거의 `aria-invalid`를 따라간다 — 오류 표시를 트리거 한 곳에만 걸면 |
+++ @fox/styles/_fox-tag-list.scss
... | ... | @@ -0,0 +1,32 @@ |
| 1 | +// FoxTagList — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) Figma tag | |
| 2 | +// | |
| 3 | +// 태그 여러 개의 배치만 책임진다. 무엇이 걸려 있는지는 호출부가 소유한다 — 목록이 상태를 | |
| 4 | +// 갖지 않는다(`fox-checkbox-group`과 같은 역할 분담). | |
| 5 | +// | |
| 6 | +// 마크업 계약 (React 밖 소비자용): | |
| 7 | +// <div class="fox-tag-list" role="group" aria-label="걸어 둔 조건"> | |
| 8 | +// <button type="button" class="fox-tag fox-tag--md fox-tag--sorting">…</button> | |
| 9 | +// <button type="button" class="fox-tag fox-tag--md fox-tag--sorting">…</button> | |
| 10 | +// </div> | |
| 11 | +// | |
| 12 | +// 방향 수식어가 없다 — 태그는 가로로 흐르다 폭이 모자라면 다음 줄로 넘어가는 것이 유일한 | |
| 13 | +// 배치다. 세로로 쌓을 일이 생기면 그때 수식어를 추가한다. | |
| 14 | +// | |
| 15 | +// `role="radiogroup"`이 아니라 `role="group"`이다 — 하나만 고르는 묶음이 아니고, 각 태그가 | |
| 16 | +// 독립적으로 Tab을 받는 버튼이다. | |
| 17 | +// | |
| 18 | +// **간격이 없다(시안 확인 완료).** `gap`을 넣지 않는다 — 태그를 붙여 세우는 것이 시안 값이고, | |
| 19 | +// 각 태그의 좌우 `padding`이 그 자체로 글자 사이 여백 역할을 한다. 배경이 투명해서 서로 | |
| 20 | +// 닿아도 알약 경계가 겹쳐 보이지 않는다. 간격이 필요한 자리는 목록을 감싼 쪽에서 준다. | |
| 21 | +// | |
| 22 | +// 토큰이 아닌 값(사용자 승인): | |
| 23 | +// - display / flex-wrap / align-*: 배치 규칙. | |
| 24 | + | |
| 25 | +.fox-tag-list { | |
| 26 | + display: flex; | |
| 27 | + flex-wrap: wrap; | |
| 28 | + // 태그 높이가 크기마다 달라도(섞어 쓸 수 있다) 한 줄 안에서 가운데로 맞는다. | |
| 29 | + align-items: center; | |
| 30 | + // 여러 줄이 됐을 때 줄 묶음 전체가 가운데 정렬된다. | |
| 31 | + align-content: center; | |
| 32 | +} |
+++ @fox/styles/_fox-tag.scss
... | ... | @@ -0,0 +1,131 @@ |
| 1 | +// FoxTag — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) Figma tag (132:195) | |
| 2 | +// | |
| 3 | +// 알약 모양의 작은 조각이다 — 글자 하나와 아이콘 하나로 끝난다. | |
| 4 | +// | |
| 5 | +// 마크업 계약 (React 밖 소비자용): | |
| 6 | +// <button type="button" class="fox-tag fox-tag--lg fox-tag--sorting"> | |
| 7 | +// <span class="fox-tag__label">서울</span> | |
| 8 | +// <span class="fox-tag__icon">…닫기 svg…</span> | |
| 9 | +// </button> | |
| 10 | +// <button type="button" class="fox-tag fox-tag--lg fox-tag--button"> | |
| 11 | +// <span class="fox-tag__icon">…택 svg…</span> | |
| 12 | +// <span class="fox-tag__label">서울</span> | |
| 13 | +// </button> | |
| 14 | +// | |
| 15 | +// 아이콘이 앞이냐 뒤냐는 **마크업 순서가 정한다** — 계열별로 `order`나 `row-reverse`를 쓰지 | |
| 16 | +// 않는다. sorting은 글자 뒤에 닫기 아이콘, button은 글자 앞에 택 아이콘이다. 두 계열이 쓰는 | |
| 17 | +// 클래스가 `fox-tag__icon` 하나라 크기·색 규칙도 한 벌로 끝난다(`fox-button`과 같은 방식). | |
| 18 | +// | |
| 19 | +// 루트가 `<button>`이라 hover·pressed·포커스·키보드가 전부 네이티브다. 시안의 pressed는 | |
| 20 | +// `:active`로 받는다. | |
| 21 | +// | |
| 22 | +// 배경은 세 상태 모두 계열과 무관하게 같다. button 계열만 hover·pressed에서 **글자에 밑줄이 | |
| 23 | +// 추가로** 붙는다 — 시안이 배경 규칙을 계열 위에 두고 밑줄만 button에 얹는다. | |
| 24 | +// | |
| 25 | +// 아이콘 색은 시안의 `black / 25%`를 그대로 값으로 박지 않고 `icon-neutral-fill`을 쓴다 — | |
| 26 | +// 라이트에서 `alpha-black25`, 다크에서 `alpha-white25`로 뒤집히는 같은 값의 토큰이다. | |
| 27 | +// SVG는 `fill="currentColor"`로 그려야 이 색이 먹는다. | |
| 28 | +// | |
| 29 | +// 아이콘 크기는 세 크기가 모두 `icon-3`(16px)이다 — 시안 확인 완료. 크기 수식어가 바꾸는 것은 | |
| 30 | +// padding·gap·글자 크기뿐이고 아이콘은 건드리지 않으므로, 아이콘 규칙을 블록에 한 번만 둔다. | |
| 31 | +// | |
| 32 | +// ⚠️ 비활성은 시안에 없다. 색을 임의로 만들지 않고 hover·pressed만 막는다 — 네이티브 | |
| 33 | +// `disabled`를 주면 커서와 클릭만 죽고 모양은 기본과 같다. | |
| 34 | +// | |
| 35 | +// 토큰이 아닌 값(FoxButton과 동일한 근거로 승인된 항목): | |
| 36 | +// - line-height / letter-spacing: 시안 텍스트 스타일 label/*의 값이나 Figma가 변수로 | |
| 37 | +// export하지 않는다. 세 크기가 모두 `-0.025em`이라 규칙 하나로 끝난다(15px→-0.375px, | |
| 38 | +// 13px→-0.325px, 11px→-0.275px). px이 아니라 em인 이유는 글자 크기가 크기 수식어마다, | |
| 39 | +// 그리고 브레이크포인트마다 달라지기 때문이다 — px으로 박으면 그 자리에서 비율이 어긋난다. | |
| 40 | +// - box-sizing / display / flex / cursor / white-space / aspect-ratio: 구조·상호작용 규칙. | |
| 41 | + | |
| 42 | +@use "@fox/styles/abstracts" as fox; | |
| 43 | + | |
| 44 | +.fox-tag { | |
| 45 | + // 안쪽 여백이 바깥 크기를 늘리지 않게 한다 — 좁은 자리에서 알약 높이가 흔들리지 않는다. | |
| 46 | + box-sizing: border-box; | |
| 47 | + display: inline-flex; | |
| 48 | + align-items: center; | |
| 49 | + border: none; | |
| 50 | + border-radius: fox.radius(max); | |
| 51 | + background: fox.color(action-secondary); | |
| 52 | + cursor: pointer; | |
| 53 | + | |
| 54 | + color: fox.color(font-neutral-default); | |
| 55 | + font-family: fox.font-family(body); | |
| 56 | + font-weight: fox.font-weight(regular); | |
| 57 | + line-height: 1; | |
| 58 | + letter-spacing: -0.025em; | |
| 59 | + white-space: nowrap; | |
| 60 | + | |
| 61 | + &:disabled { | |
| 62 | + cursor: default; | |
| 63 | + } | |
| 64 | + | |
| 65 | + // 글자만 줄어들게 해 아이콘이 밀리지 않게 한다. | |
| 66 | + &__label { | |
| 67 | + min-inline-size: 0; | |
| 68 | + } | |
| 69 | + | |
| 70 | + &__icon { | |
| 71 | + display: inline-flex; | |
| 72 | + flex-shrink: 0; | |
| 73 | + align-items: center; | |
| 74 | + justify-content: center; | |
| 75 | + inline-size: fox.icon(3); | |
| 76 | + block-size: fox.icon(3); | |
| 77 | + // 시안이 아이콘에 정사각 비율을 못박는다. 자리가 좁아 눌려도 글리프가 찌그러지지 않는다. | |
| 78 | + aspect-ratio: 1 / 1; | |
| 79 | + color: fox.color(icon-neutral-fill); | |
| 80 | + } | |
| 81 | + | |
| 82 | + // 넘긴 SVG가 슬롯을 꽉 채운다 — 크기는 슬롯이 정하고 SVG는 따라온다. | |
| 83 | + &__icon > * { | |
| 84 | + inline-size: 100%; | |
| 85 | + block-size: 100%; | |
| 86 | + } | |
| 87 | + | |
| 88 | + // ── 크기 ──────────────────────────────────────────────────────────────────── | |
| 89 | + // 크기 이름과 글자 토큰 이름이 한 칸씩 어긋난다(lg→label-md). 시안이 그렇게 준다. | |
| 90 | + &--lg { | |
| 91 | + padding: fox.padding(3) fox.padding(5); | |
| 92 | + gap: fox.gap(2); | |
| 93 | + font-size: fox.font-size(label-md); | |
| 94 | + } | |
| 95 | + | |
| 96 | + &--md { | |
| 97 | + padding: fox.padding(2) fox.padding(3); | |
| 98 | + gap: fox.gap(2); | |
| 99 | + font-size: fox.font-size(label-sm); | |
| 100 | + } | |
| 101 | + | |
| 102 | + // sm만 gap이 한 단계 좁다. | |
| 103 | + &--sm { | |
| 104 | + padding: fox.padding(1) fox.padding(3); | |
| 105 | + gap: fox.gap(1); | |
| 106 | + font-size: fox.font-size(label-xsm); | |
| 107 | + } | |
| 108 | + | |
| 109 | + // ── 상태 ──────────────────────────────────────────────────────────────────── | |
| 110 | + // 계열과 무관하게 배경만 바뀐다. | |
| 111 | + &:hover:not(:disabled) { | |
| 112 | + background: fox.color(action-secondary-hover); | |
| 113 | + } | |
| 114 | + | |
| 115 | + &:active:not(:disabled) { | |
| 116 | + background: fox.color(action-secondary-pressed); | |
| 117 | + } | |
| 118 | + | |
| 119 | + // button 계열은 위 배경 변화에 밑줄이 더해진다. sorting은 배경 변화로 끝난다. | |
| 120 | + // | |
| 121 | + // `text-decoration` 단축을 쓰지 않는다 — 단축은 thickness를 `auto`로 되돌린다. 시안의 | |
| 122 | + // skip-ink·thickness·underline-offset은 값이 전부 `auto`, 즉 기본값이라 적지 않는다. | |
| 123 | + // 기본값과 다른 것은 `from-font` 하나뿐이다 — 폰트가 가진 밑줄 위치를 쓰므로 내림획 | |
| 124 | + // (g·y·다) 아래로 내려간다. | |
| 125 | + &--button:hover:not(:disabled) &__label, | |
| 126 | + &--button:active:not(:disabled) &__label { | |
| 127 | + text-decoration-line: underline; | |
| 128 | + text-decoration-style: solid; | |
| 129 | + text-underline-position: from-font; | |
| 130 | + } | |
| 131 | +} |
--- @fox/styles/_fox-text-area.scss
+++ @fox/styles/_fox-text-area.scss
... | ... | @@ -3,7 +3,7 @@ |
| 3 | 3 |
// |
| 4 | 4 |
// 마크업 계약 (React 밖 소비자용): |
| 5 | 5 |
// <div class="fox-text-area"> |
| 6 |
-// <label class="fox-text-area__label" for="…">레이블</label> |
|
| 6 |
+// <label class="fox-form-label" for="…">레이블</label> |
|
| 7 | 7 |
// <div class="fox-text-area__box"> |
| 8 | 8 |
// <textarea class="fox-text-area__field" id="…" rows="5" placeholder="…"></textarea> |
| 9 | 9 |
// </div> |
... | ... | @@ -37,10 +37,6 @@ |
| 37 | 37 |
|
| 38 | 38 |
.fox-text-area {
|
| 39 | 39 |
@include field.root; |
| 40 |
- |
|
| 41 |
- &__label {
|
|
| 42 |
- @include field.label; |
|
| 43 |
- } |
|
| 44 | 40 |
|
| 45 | 41 |
// 여러 줄이라 세로 가운데 정렬을 끄고, 크기 축이 없어 md 여백을 직접 준다. |
| 46 | 42 |
&__box {
|
+++ @fox/styles/_fox-toggle-switch.scss
... | ... | @@ -0,0 +1,229 @@ |
| 1 | +// FoxToggleSwitch — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) Figma toggle-switch | |
| 2 | +// | |
| 3 | +// 켜짐/꺼짐을 바로 바꾸는 스위치다 — 트랙(배경) + 동그라미(thumb) + 글자. | |
| 4 | +// | |
| 5 | +// 마크업 계약 (React 밖 소비자용): | |
| 6 | +// <label class="fox-toggle-switch fox-toggle-switch--lg"> | |
| 7 | +// <input type="checkbox" role="switch" class="fox-toggle-switch__input" name="alarm"> | |
| 8 | +// <span class="fox-toggle-switch__track"> | |
| 9 | +// <span class="fox-toggle-switch__thumb"> | |
| 10 | +// <svg class="fox-toggle-switch__mark-off">…X…</svg> | |
| 11 | +// <svg class="fox-toggle-switch__mark-on">…체크…</svg> | |
| 12 | +// </span> | |
| 13 | +// </span> | |
| 14 | +// <span class="fox-toggle-switch__label">알림 받기</span> <!-- 선택 --> | |
| 15 | +// </label> | |
| 16 | +// | |
| 17 | +// 루트가 `<label>`이라 글자를 눌러도 바뀐다. 상태는 전부 네이티브 `<input type="checkbox">`가 | |
| 18 | +// 소유한다(규약 3) — `role="switch"`만 얹어 체크박스가 아니라 스위치로 읽히게 한다. | |
| 19 | +// | |
| 20 | +// input은 순서상 **맨 앞**에 와야 한다. 트랙과 글자가 `~` 형제 선택자로 그 상태를 따라간다. | |
| 21 | +// 시각적으로만 숨기고 포커스는 살려 둔다 — `display: none`으로 지우면 키보드로 도달할 수 없다. | |
| 22 | +// | |
| 23 | +// 표시 둘(X·체크)을 **항상 마크업에 둔다.** 켜졌을 때만 넣는 게 아니라 CSS가 보이기/숨기기를 | |
| 24 | +// 정한다 — 그래야 비제어 입력에서도(React가 켜짐 여부를 모르는 상태에서도) 모양이 맞는다. | |
| 25 | +// `fox-check-icon`과 같은 처리다. | |
| 26 | +// | |
| 27 | +// 동그라미는 off일 때 왼쪽, on일 때 오른쪽이다. 트랙이 `display: flex`라 기본이 왼쪽이고, | |
| 28 | +// 켜지면 `margin-inline-start: auto`가 오른쪽으로 밀어낸다 — 이동 거리를 계산해 박지 않으므로 | |
| 29 | +// 트랙 폭이나 동그라미 크기가 바뀌어도 따라온다. 시안에 모션이 없고 `@fox` 전체에 | |
| 30 | +// `transition`을 쓰는 곳이 없어 위치만 바뀐다. | |
| 31 | +// | |
| 32 | +// **표시 색은 언제나 트랙 배경과 같은 값이다** — off는 `element-neutral-default`, on은 | |
| 33 | +// `element-theme-primary`, 비활성은 `element-neutral-disabled`. 흰 동그라미에 트랙 색으로 | |
| 34 | +// 글리프를 파낸 모양이다. 그래서 트랙 규칙이 `background`와 `color`를 항상 함께 주고, 표시는 | |
| 35 | +// `currentColor`로 그려 물려받는다 — 상태별로 색을 두 군데 적지 않는다. | |
| 36 | +// | |
| 37 | +// 크기가 바꾸는 것은 트랙 폭·동그라미 지름·표시 크기·글자 크기 넷이다. 안쪽 여백(`padding-1`)과 | |
| 38 | +// 바깥 간격(`gap-3`)은 두 크기가 같다. | |
| 39 | +// | |
| 40 | +// 트랙 동그라미 표시 글자 | |
| 41 | +// lg 40px 20px 16px label-lg | |
| 42 | +// md 32px 16px 12px label-md | |
| 43 | +// | |
| 44 | +// 트랙 높이는 값으로 주지 않는다 — 여백(2px×2) + 동그라미가 곧 높이다. 그래서 lg 24px, | |
| 45 | +// md 20px이 자동으로 나오고, 동그라미 이동 거리도 (트랙 폭 − 여백 − 동그라미)로 따라온다. | |
| 46 | +// | |
| 47 | +// | |
| 48 | +// ⚠️ 포커스 표시는 시안에 없다. 네이티브 input을 시각적으로 숨기므로 브라우저 기본 링도 | |
| 49 | +// 사라져서, 키보드 사용자를 위해 이 파일에서 만들어 준다(`fox-check-icon`과 같은 처리). | |
| 50 | +// | |
| 51 | +// 토큰이 아닌 값(사용자 승인): | |
| 52 | +// - line-height / letter-spacing: 시안 텍스트 스타일 label/*의 값이나 Figma가 변수로 | |
| 53 | +// export하지 않는다. 두 크기가 같은 `-0.025em`이라 규칙 하나로 끝난다(17px→-0.425px, | |
| 54 | +// 15px→-0.375px). px이 아니라 em인 이유는 글자 크기가 크기 수식어마다, 그리고 | |
| 55 | +// 브레이크포인트마다 달라지기 때문이다 — px으로 박으면 그 자리에서 비율이 어긋난다. | |
| 56 | +// - 트랙 폭 `4rem`·`3.2rem`(40·32px) / 동그라미 `2rem`·`1.6rem`(20·16px): 시안이 이 넷만 | |
| 57 | +// 변수 없이 값으로 준다(표시 크기는 `--icon-3`으로 바인딩되어 있어 `fox.icon(*)`을 쓴다). | |
| 58 | +// 스위치 몸통 치수라 값이 같은 `icon-*`을 끌어다 쓰면 뜻이 어긋나서 값으로 둔다 | |
| 59 | +// (1rem = 10px). | |
| 60 | +// - box-sizing: 안쪽 여백이 트랙 40px 안에 들어가야 한다. 없으면 44px이 된다. | |
| 61 | +// - display / flex / cursor / aspect-ratio: 구조·상호작용 규칙. | |
| 62 | +// - 숨긴 input의 `inline-size: 0`: 크기가 아니라 "보이지 않게" 하는 구조 값이다. | |
| 63 | +// | |
| 64 | +// 동그라미의 안쪽 여백(`padding: 6px 5px`)·`gap: 10px`·`flex-direction: column`은 쓰지 않는다. | |
| 65 | +// Figma auto-layout이 남긴 값이고 **그림에 영향을 주지 않는다** — 실제로 넣어 보고 확인했다. | |
| 66 | +// 안쪽 상자가 10×8px(md는 6×4px)로 표시(16px·12px)보다 작지만 `flex-shrink: 0`이라 표시가 줄지 | |
| 67 | +// 않고, 여백이 좌우·상하 대칭이어서 안쪽 상자의 중심이 동그라미 중심과 같다 — 넘친 만큼 양쪽으로 | |
| 68 | +// 고르게 삐져나와 가운데 정렬과 같은 자리에 놓인다. `gap`은 자식이 하나뿐이라, `flex-direction`도 | |
| 69 | +// 자식이 하나라 각각 계산에 참여하지 않는다. | |
| 70 | +// | |
| 71 | +// 게다가 넣으려면 `box-sizing: border-box`가 딸려 와야 한다 — 이 프로젝트에 전역 리셋이 없어 | |
| 72 | +// 그것 없이 여백만 주면 지름이 안쪽 크기가 되어 동그라미가 30×32px 타원으로 부푼다. 결과가 같고 | |
| 73 | +// 규칙이 다섯 줄 늘어날 뿐이라 가운데 정렬만 남긴다. | |
| 74 | + | |
| 75 | +@use "@fox/styles/abstracts" as fox; | |
| 76 | + | |
| 77 | +.fox-toggle-switch { | |
| 78 | + // 숨긴 input의 위치 기준. 안 주면 input이 조상까지 올라가 엉뚱한 곳에 붙는다. | |
| 79 | + position: relative; | |
| 80 | + display: inline-flex; | |
| 81 | + align-items: center; | |
| 82 | + // 크기와 무관하게 같은 값이다(시안). | |
| 83 | + gap: fox.gap(3); | |
| 84 | + cursor: pointer; | |
| 85 | + | |
| 86 | + // 시각적으로만 숨긴다. `opacity: 0`은 포커스를 유지하므로 키보드로 도달할 수 있고, | |
| 87 | + // 클릭은 루트 `<label>`이 받는다. | |
| 88 | + &__input { | |
| 89 | + position: absolute; | |
| 90 | + inline-size: 0; | |
| 91 | + block-size: 0; | |
| 92 | + margin: 0; | |
| 93 | + opacity: 0; | |
| 94 | + pointer-events: none; | |
| 95 | + } | |
| 96 | + | |
| 97 | + // 트랙. `background`와 `color`를 항상 함께 준다 — `color`는 안쪽 표시가 물려받는 값이다. | |
| 98 | + // 폭은 크기 수식어가 정하고, 높이는 여백 + 동그라미가 정한다. | |
| 99 | + &__track { | |
| 100 | + // 안쪽 여백이 트랙 폭 안에 들어가야 한다. 없으면 44px·36px이 된다. | |
| 101 | + box-sizing: border-box; | |
| 102 | + display: flex; | |
| 103 | + align-items: center; | |
| 104 | + flex-shrink: 0; | |
| 105 | + padding: fox.padding(1); | |
| 106 | + border-radius: fox.radius(max); | |
| 107 | + background: fox.color(element-neutral-default); | |
| 108 | + color: fox.color(element-neutral-default); | |
| 109 | + } | |
| 110 | + | |
| 111 | + // 동그라미. 지름은 크기 수식어가 정한다 — 그 값이 트랙 높이까지 정한다. | |
| 112 | + &__thumb { | |
| 113 | + display: flex; | |
| 114 | + flex-shrink: 0; | |
| 115 | + align-items: center; | |
| 116 | + justify-content: center; | |
| 117 | + border-radius: fox.radius(max); | |
| 118 | + background: fox.color(element-neutral-inverse); | |
| 119 | + } | |
| 120 | + | |
| 121 | + // 표시 둘. 보이기는 상태가, 크기는 크기 수식어가 정한다. 색은 `currentColor`라 트랙이 정한 | |
| 122 | + // 값이다. | |
| 123 | + &__mark-off, | |
| 124 | + &__mark-on { | |
| 125 | + flex-shrink: 0; | |
| 126 | + // 시안이 표시에 정사각 비율을 못박는다. 자리가 좁아 눌려도 글리프가 찌그러지지 않는다. | |
| 127 | + aspect-ratio: 1 / 1; | |
| 128 | + } | |
| 129 | + | |
| 130 | + // 꺼짐이 기본이라 X만 보인다. | |
| 131 | + &__mark-on { | |
| 132 | + display: none; | |
| 133 | + } | |
| 134 | + | |
| 135 | + &__label { | |
| 136 | + color: fox.color(font-neutral-default); | |
| 137 | + font-family: fox.font-family(body); | |
| 138 | + font-weight: fox.font-weight(regular); | |
| 139 | + line-height: 1; | |
| 140 | + letter-spacing: -0.025em; | |
| 141 | + } | |
| 142 | + | |
| 143 | + // ── 크기 ──────────────────────────────────────────────────────────────────── | |
| 144 | + // 트랙 40px · 동그라미 20px(→ 트랙 높이 24px) · 표시 16px. | |
| 145 | + &--lg &__track { | |
| 146 | + inline-size: 4rem; | |
| 147 | + } | |
| 148 | + | |
| 149 | + &--lg &__thumb { | |
| 150 | + inline-size: 2rem; | |
| 151 | + block-size: 2rem; | |
| 152 | + } | |
| 153 | + | |
| 154 | + &--lg &__mark-off, | |
| 155 | + &--lg &__mark-on { | |
| 156 | + inline-size: fox.icon(3); | |
| 157 | + block-size: fox.icon(3); | |
| 158 | + } | |
| 159 | + | |
| 160 | + &--lg &__label { | |
| 161 | + font-size: fox.font-size(label-lg); | |
| 162 | + } | |
| 163 | + | |
| 164 | + // 트랙 32px · 동그라미 16px(→ 트랙 높이 20px) · 표시 12px. | |
| 165 | + &--md &__track { | |
| 166 | + inline-size: 3.2rem; | |
| 167 | + } | |
| 168 | + | |
| 169 | + &--md &__thumb { | |
| 170 | + inline-size: 1.6rem; | |
| 171 | + block-size: 1.6rem; | |
| 172 | + } | |
| 173 | + | |
| 174 | + &--md &__mark-off, | |
| 175 | + &--md &__mark-on { | |
| 176 | + inline-size: fox.icon(2); | |
| 177 | + block-size: fox.icon(2); | |
| 178 | + } | |
| 179 | + | |
| 180 | + &--md &__label { | |
| 181 | + font-size: fox.font-size(label-md); | |
| 182 | + } | |
| 183 | +} | |
| 184 | + | |
| 185 | +// ── 켜짐 ────────────────────────────────────────────────────────────────────── | |
| 186 | +.fox-toggle-switch__input:checked ~ .fox-toggle-switch__track { | |
| 187 | + background: fox.color(element-theme-primary); | |
| 188 | + color: fox.color(element-theme-primary); | |
| 189 | +} | |
| 190 | + | |
| 191 | +// 동그라미가 오른쪽으로 간다. | |
| 192 | +.fox-toggle-switch__input:checked ~ .fox-toggle-switch__track .fox-toggle-switch__thumb { | |
| 193 | + margin-inline-start: auto; | |
| 194 | +} | |
| 195 | + | |
| 196 | +.fox-toggle-switch__input:checked ~ .fox-toggle-switch__track .fox-toggle-switch__mark-off { | |
| 197 | + display: none; | |
| 198 | +} | |
| 199 | + | |
| 200 | +.fox-toggle-switch__input:checked ~ .fox-toggle-switch__track .fox-toggle-switch__mark-on { | |
| 201 | + display: block; | |
| 202 | +} | |
| 203 | + | |
| 204 | +// ── 비활성 ──────────────────────────────────────────────────────────────────── | |
| 205 | +// 켜짐 규칙보다 뒤에 와야 이긴다 — 비활성이면 켜짐 여부와 무관하게 트랙·동그라미 색이 같다. | |
| 206 | +// 표시와 동그라미 위치는 건드리지 않아 켜짐 여부가 그대로 보인다. | |
| 207 | +.fox-toggle-switch:has(.fox-toggle-switch__input:disabled) { | |
| 208 | + cursor: default; | |
| 209 | +} | |
| 210 | + | |
| 211 | +.fox-toggle-switch__input:disabled ~ .fox-toggle-switch__track { | |
| 212 | + background: fox.color(element-neutral-disabled); | |
| 213 | + color: fox.color(element-neutral-disabled); | |
| 214 | +} | |
| 215 | + | |
| 216 | +.fox-toggle-switch__input:disabled ~ .fox-toggle-switch__track .fox-toggle-switch__thumb { | |
| 217 | + background: fox.color(element-neutral-disabled-strong); | |
| 218 | +} | |
| 219 | + | |
| 220 | +.fox-toggle-switch__input:disabled ~ .fox-toggle-switch__label { | |
| 221 | + color: fox.color(font-neutral-disabled); | |
| 222 | +} | |
| 223 | + | |
| 224 | +// ── 포커스 ──────────────────────────────────────────────────────────────────── | |
| 225 | +// input이 시각적으로 숨겨져 브라우저 기본 링이 보이지 않으므로 여기서 대신 그린다. | |
| 226 | +.fox-toggle-switch__input:focus-visible ~ .fox-toggle-switch__track { | |
| 227 | + outline: fox.border(2) solid fox.color(border-theme-primary); | |
| 228 | + outline-offset: fox.border(1); | |
| 229 | +} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -9,16 +9,27 @@ |
| 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"; |
|
| 15 |
+@use "fox-conditional-option"; |
|
| 12 | 16 |
@use "fox-email"; |
| 13 | 17 |
@use "fox-file-upload"; |
| 18 |
+@use "fox-form-label"; |
|
| 14 | 19 |
@use "fox-input"; |
| 15 | 20 |
@use "fox-phone-number"; |
| 21 |
+@use "fox-radio"; |
|
| 22 |
+@use "fox-radio-group"; |
|
| 23 |
+@use "fox-radio-icon"; |
|
| 16 | 24 |
@use "fox-select"; |
| 17 | 25 |
@use "fox-select-option"; |
| 18 | 26 |
@use "fox-select-option-item"; |
| 27 |
+@use "fox-tag"; |
|
| 28 |
+@use "fox-tag-list"; |
|
| 19 | 29 |
@use "fox-text-area"; |
| 20 | 30 |
@use "fox-breadcrumb"; |
| 21 | 31 |
@use "fox-pagination"; |
| 22 | 32 |
@use "fox-carousel-indicator"; |
| 23 | 33 |
@use "fox-carousel-button"; |
| 24 | 34 |
@use "fox-carousel-area"; |
| 35 |
+@use "fox-toggle-switch"; |
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?