+++ @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/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -20,5 +20,7 @@ |
| 20 | 20 |
export * from "./fox-select"; |
| 21 | 21 |
export * from "./fox-select-option"; |
| 22 | 22 |
export * from "./fox-select-option-item"; |
| 23 |
+export * from "./fox-tag"; |
|
| 24 |
+export * from "./fox-tag-list"; |
|
| 23 | 25 |
export * from "./fox-text-area"; |
| 24 | 26 |
export * from "./fox-text-button"; |
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -22,6 +22,8 @@ |
| 22 | 22 |
import { FoxRadio } from "../core/components/fox-radio";
|
| 23 | 23 |
import { FoxRadioGroup } from "../core/components/fox-radio-group";
|
| 24 | 24 |
import { FoxRadioIcon } from "../core/components/fox-radio-icon";
|
| 25 |
+import { FoxTag } from "../core/components/fox-tag";
|
|
| 26 |
+import { FoxTagList } from "../core/components/fox-tag-list";
|
|
| 25 | 27 |
import styles from "./dev-test.module.scss"; |
| 26 | 28 |
|
| 27 | 29 |
export interface ComponentExample {
|
... | ... | @@ -334,6 +336,35 @@ |
| 334 | 336 |
{ value: "b", label: "아이콘 없는 옵션" },
|
| 335 | 337 |
{ value: "c", label: "아주 긴 옵션 라벨 — 트리거에서는 말줄임으로 잘린다" },
|
| 336 | 338 |
]; |
| 339 |
+ |
|
| 340 |
+// 태그 목록은 상태를 갖지 않으므로, 닫기 아이콘이 실제로 뭔가를 떼는지 보려면 예제가 목록을 |
|
| 341 |
+// 들고 있어야 한다. 전부 떼면 다시 채울 방법이 없어 초기화 버튼을 같이 둔다. |
|
| 342 |
+const TAG_FILTERS = ["서울", "경기", "인천", "부산"]; |
|
| 343 |
+ |
|
| 344 |
+function TagListLiveDemo() {
|
|
| 345 |
+ const [filters, setFilters] = useState(TAG_FILTERS); |
|
| 346 |
+ |
|
| 347 |
+ return ( |
|
| 348 |
+ <> |
|
| 349 |
+ <FoxTagList size="md" label="걸어 둔 조건"> |
|
| 350 |
+ {filters.map((name) => (
|
|
| 351 |
+ <FoxTag |
|
| 352 |
+ key={name}
|
|
| 353 |
+ label={name}
|
|
| 354 |
+ ariaLabel={`${name} 조건 제거`}
|
|
| 355 |
+ onAction={() => setFilters((prev) => prev.filter((it) => it !== name))}
|
|
| 356 |
+ /> |
|
| 357 |
+ ))} |
|
| 358 |
+ </FoxTagList> |
|
| 359 |
+ <FoxButton |
|
| 360 |
+ size="xsm" |
|
| 361 |
+ label="초기화" |
|
| 362 |
+ onAction={() => setFilters(TAG_FILTERS)}
|
|
| 363 |
+ disabled={filters.length === TAG_FILTERS.length}
|
|
| 364 |
+ /> |
|
| 365 |
+ </> |
|
| 366 |
+ ); |
|
| 367 |
+} |
|
| 337 | 368 |
|
| 338 | 369 |
/** 컴포넌트를 만들 때마다 항목 하나를 추가한다 — 다른 파일은 고치지 않는다. */ |
| 339 | 370 |
export const COMPONENT_EXAMPLES: ComponentExample[] = [ |
... | ... | @@ -1677,11 +1708,11 @@ |
| 1677 | 1708 |
), |
| 1678 | 1709 |
}, |
| 1679 | 1710 |
], |
| 1680 |
- }, {
|
|
| 1711 |
+ }, {
|
|
| 1681 | 1712 |
id: "fox-check-icon", |
| 1682 | 1713 |
name: "FoxCheckIcon", |
| 1683 | 1714 |
description: |
| 1684 |
- "체크박스의 네모 표시만 담당합니다. 상자는 CSS로 그리고 안쪽 표시만 SVG입니다 — 상자는 테두리·배경·모서리로 끝나지만 체크는 임의의 path라 CSS로 그릴 수 없습니다(FoxRadioIcon은 안쪽이 원이라 전부 CSS였습니다). 선택과 부분선택은 상자 모양이 같고 안쪽 표시만 다릅니다. FoxCheckbox 안에서는 네이티브 input의 :checked·:indeterminate·:disabled가 스타일 훅을 겸해서 클래스가 필요 없고, 아래처럼 단독으로 상태를 비교할 때만 모디파이어로 못박습니다.", |
|
| 1715 |
+ "Figma 시안(check-icon 108:234) 체크박스의 네모 표시만 담당합니다. 상자는 CSS로 그리고 안쪽 표시만 SVG입니다 — 상자는 테두리·배경·모서리로 끝나지만 체크는 임의의 path라 CSS로 그릴 수 없습니다(FoxRadioIcon은 안쪽이 원이라 전부 CSS였습니다). 선택과 부분선택은 상자 모양이 같고 안쪽 표시만 다릅니다. FoxCheckbox 안에서는 네이티브 input의 :checked·:indeterminate·:disabled가 스타일 훅을 겸해서 클래스가 필요 없고, 아래처럼 단독으로 상태를 비교할 때만 모디파이어로 못박습니다.", |
|
| 1685 | 1716 |
variants: [ |
| 1686 | 1717 |
{
|
| 1687 | 1718 |
label: "lg — 기본 / 선택 / 부분선택 / 비활성 / 선택+비활성 / 부분선택+비활성", |
... | ... | @@ -1711,11 +1742,11 @@ |
| 1711 | 1742 |
}, |
| 1712 | 1743 |
], |
| 1713 | 1744 |
}, |
| 1714 |
- {
|
|
| 1745 |
+ {
|
|
| 1715 | 1746 |
id: "fox-checkbox", |
| 1716 | 1747 |
name: "FoxCheckbox", |
| 1717 | 1748 |
description: |
| 1718 |
- "네모 표시 + 라벨 + 부가설명을 묶은 체크박스 한 칸입니다. 루트가 <label>이라 라벨이나 설명을 눌러도 켜집니다. 라디오와 달리 name이 같아도 서로를 끄지 않아 여러 개를 동시에 고를 수 있고, 폼 전송 시 고른 것들이 함께 나갑니다. ⚠️ 부분선택은 HTML 속성이 아니라 DOM 프로퍼티라 마크업만으로는 켤 수 없습니다 — 컴포넌트가 대신 넣어 줍니다.", |
|
| 1749 |
+ "Figma 시안(checkbox 108:469) 네모 표시 + 라벨 + 부가설명을 묶은 체크박스 한 칸입니다. 루트가 <label>이라 라벨이나 설명을 눌러도 켜집니다. 라디오와 달리 name이 같아도 서로를 끄지 않아 여러 개를 동시에 고를 수 있고, 폼 전송 시 고른 것들이 함께 나갑니다. ⚠️ 부분선택은 HTML 속성이 아니라 DOM 프로퍼티라 마크업만으로는 켤 수 없습니다 — 컴포넌트가 대신 넣어 줍니다.", |
|
| 1719 | 1750 |
variants: [ |
| 1720 | 1751 |
{
|
| 1721 | 1752 |
label: "여러 개를 동시에 고를 수 있다 (같은 name이어도 서로 끄지 않는다)", |
... | ... | @@ -1782,7 +1813,7 @@ |
| 1782 | 1813 |
id: "fox-checkbox-group", |
| 1783 | 1814 |
name: "FoxCheckboxGroup", |
| 1784 | 1815 |
description: |
| 1785 |
- "체크박스 여러 칸의 배치와 name·size 통일을 책임집니다. 켜짐 여부는 각 FoxCheckbox의 네이티브 input이 소유하므로 그룹은 상태를 갖지 않습니다. role이 radiogroup이 아니라 group입니다 — 체크박스는 서로를 끄지 않아 하나만 고르는 묶음이 아니고, 화살표 이동으로 옮겨 다니지도 않아 각 칸이 독립적으로 Tab을 받습니다. 배치 값은 FoxRadioGroup과 같습니다.", |
|
| 1816 |
+ "Figma 시안(checkbox-group 194:4000) 체크박스 여러 칸의 배치와 name·size 통일을 책임집니다. 켜짐 여부는 각 FoxCheckbox의 네이티브 input이 소유하므로 그룹은 상태를 갖지 않습니다. role이 radiogroup이 아니라 group입니다 — 체크박스는 서로를 끄지 않아 하나만 고르는 묶음이 아니고, 화살표 이동으로 옮겨 다니지도 않아 각 칸이 독립적으로 Tab을 받습니다. 배치 값은 FoxRadioGroup과 같습니다.", |
|
| 1786 | 1817 |
variants: [ |
| 1787 | 1818 |
{
|
| 1788 | 1819 |
label: "vertical (기본) — 간격 gap-7, 왼쪽 끝 정렬", |
... | ... | @@ -1828,4 +1859,77 @@ |
| 1828 | 1859 |
}, |
| 1829 | 1860 |
], |
| 1830 | 1861 |
}, |
| 1862 |
+ {
|
|
| 1863 |
+ id: "fox-tag", |
|
| 1864 |
+ name: "FoxTag", |
|
| 1865 |
+ description: |
|
| 1866 |
+ "Figma 시안(tag 132:195) 아이콘만 누르는 게 아니라 조각 전체가 버튼입니다 — 시안이 hover 배경을 태그 전체에 줍니다. type이 아이콘의 종류와 자리를 정합니다: sorting은 글자 뒤에 닫기 아이콘(걸어 둔 조건을 뗄 때), button은 글자 앞에 택 아이콘입니다. 기본 배경은 action-secondary, 즉 투명이라 아래 무대 색이 그대로 보이는 것이 정상입니다 — hover(연한 파랑)·pressed(진한 파랑)는 마우스를 올리고 눌러서 확인해야 합니다. button만 hover·pressed에서 글자에 밑줄이 붙습니다.", |
|
| 1867 |
+ variants: [ |
|
| 1868 |
+ {
|
|
| 1869 |
+ label: "sorting (기본) — 크기 lg / md / sm", |
|
| 1870 |
+ node: ( |
|
| 1871 |
+ <> |
|
| 1872 |
+ <FoxTag size="lg" label="태그" ariaLabel="태그 조건 제거" /> |
|
| 1873 |
+ <FoxTag size="md" label="태그" ariaLabel="태그 조건 제거" /> |
|
| 1874 |
+ <FoxTag size="sm" label="태그" ariaLabel="태그 조건 제거" /> |
|
| 1875 |
+ </> |
|
| 1876 |
+ ), |
|
| 1877 |
+ }, |
|
| 1878 |
+ {
|
|
| 1879 |
+ label: "button — 크기 lg / md / sm (마우스를 올리면 글자에 밑줄)", |
|
| 1880 |
+ node: ( |
|
| 1881 |
+ <> |
|
| 1882 |
+ <FoxTag type="button" size="lg" label="태그" /> |
|
| 1883 |
+ <FoxTag type="button" size="md" label="태그" /> |
|
| 1884 |
+ <FoxTag type="button" size="sm" label="태그" /> |
|
| 1885 |
+ </> |
|
| 1886 |
+ ), |
|
| 1887 |
+ }, |
|
| 1888 |
+ ], |
|
| 1889 |
+ }, |
|
| 1890 |
+ {
|
|
| 1891 |
+ id: "fox-tag-list", |
|
| 1892 |
+ name: "FoxTagList", |
|
| 1893 |
+ description: |
|
| 1894 |
+ "Figma 시안(tag-list 190:2565) 태그 여러 개의 배치와 size·type 통일을 책임집니다. 무엇이 걸려 있는지는 호출부가 소유하므로 목록은 상태를 갖지 않습니다. 방향 수식어가 없습니다 — 태그는 가로로 흐르다 폭이 모자라면 다음 줄로 넘어가는 것이 유일한 배치입니다. role이 radiogroup이 아니라 group입니다 — 하나만 고르는 묶음이 아니고 각 태그가 독립적으로 Tab을 받는 버튼입니다. 태그 사이 간격은 없습니다 — 각 태그의 좌우 padding이 여백 역할을 하고, 배경이 투명해서 서로 닿아도 경계가 겹쳐 보이지 않습니다.", |
|
| 1895 |
+ variants: [ |
|
| 1896 |
+ {
|
|
| 1897 |
+ label: "닫기를 누르면 실제로 떨어진다 — 목록은 호출부가 소유한다", |
|
| 1898 |
+ node: <TagListLiveDemo />, |
|
| 1899 |
+ }, |
|
| 1900 |
+ {
|
|
| 1901 |
+ label: "size — 자식이 준 값은 무시되고 묶음 값으로 통일된다", |
|
| 1902 |
+ node: ( |
|
| 1903 |
+ <FoxTagList size="sm" label="sm 크기"> |
|
| 1904 |
+ {/* 일부러 어긋난 size를 준다 — 셋 다 sm으로 나와야 정상 */}
|
|
| 1905 |
+ <FoxTag label="서울" size="lg" /> |
|
| 1906 |
+ <FoxTag label="경기" size="md" /> |
|
| 1907 |
+ <FoxTag label="인천" /> |
|
| 1908 |
+ </FoxTagList> |
|
| 1909 |
+ ), |
|
| 1910 |
+ }, |
|
| 1911 |
+ {
|
|
| 1912 |
+ label: "type — 묶음이 계열도 통일한다", |
|
| 1913 |
+ node: ( |
|
| 1914 |
+ <FoxTagList type="button" size="md" label="button 계열"> |
|
| 1915 |
+ <FoxTag label="공지사항" /> |
|
| 1916 |
+ <FoxTag label="1:1 문의" /> |
|
| 1917 |
+ <FoxTag label="FAQ" /> |
|
| 1918 |
+ </FoxTagList> |
|
| 1919 |
+ ), |
|
| 1920 |
+ }, |
|
| 1921 |
+ {
|
|
| 1922 |
+ label: "줄바꿈 — 좁은 무대에서 다음 줄로 넘어간다", |
|
| 1923 |
+ node: ( |
|
| 1924 |
+ <div className={styles.demoNarrow}>
|
|
| 1925 |
+ <FoxTagList size="md" label="줄바꿈 확인"> |
|
| 1926 |
+ {["서울", "경기", "인천", "부산", "대구", "광주"].map((name) => (
|
|
| 1927 |
+ <FoxTag key={name} label={name} ariaLabel={`${name} 조건 제거`} />
|
|
| 1928 |
+ ))} |
|
| 1929 |
+ </FoxTagList> |
|
| 1930 |
+ </div> |
|
| 1931 |
+ ), |
|
| 1932 |
+ }, |
|
| 1933 |
+ ], |
|
| 1934 |
+ }, |
|
| 1831 | 1935 |
]; |
+++ @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/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -24,4 +24,6 @@ |
| 24 | 24 |
@use "fox-select"; |
| 25 | 25 |
@use "fox-select-option"; |
| 26 | 26 |
@use "fox-select-option-item"; |
| 27 |
+@use "fox-tag"; |
|
| 28 |
+@use "fox-tag-list"; |
|
| 27 | 29 |
@use "fox-text-area";(파일 끝에 줄바꿈 문자 없음) |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?