feat: Bullet 컴포넌트 2종 추가
Figma bullet(391:4049) · bullet-list-item(391:4081) · bullet-list(484:5722)를 FoxBulletList / FoxBulletListItem 둘로 옮겼다. bullet을 따로 두지 않은 이유는 마커가 목록 밖에서 홀로 쓰이는 자리가 없고, 모양이 전부 단계·번호 여부로 갈리기 때문이다. 그 두 축이 항목의 수식어로 들어가 있다. 호출부는 단계를 평평하게 늘어놓고 level만 적는다. 어느 항목 안에 무엇을 넣을지는 목록이 정한다.시안은 2·3단계를 형제 프레임으로 두고 왼쪽 여백만 20·40px 줬지만, 나가는 마크업은 레벨1 레벨2
- /
- 에 매달아 목록을 유효하게 둔다.
번호는 같은 묶음 안에서 1부터 다시 센다. 시안에 없는 범위는 z 다음 aa, ⑳ 다음 (21)로
잇고, 다르게 매겨야 하면 marker 프롭으로 덮는다.
승인된 비토큰 값: 마커 칸 2.2×2.6rem · 1.6×2.3rem · 2.4×2.3rem, 채운 원 0.6rem,
빈 원 0.4rem — 모두 시안 값이나 Figma가 변수로 export하지 않는다.
검증: 시안 대조 20항목 전부 일치(칸 크기·마커 모양·글자 크기·단계별 들여쓰기
0/20/40px). 목록의 자식이 li뿐이고 중첩 목록은 모두 li 안에 있다.
sass 463규칙 · tsc --noEmit · eslint 통과.
Co-Authored-By: Claude Opus 5
- 안에
- /
- 이 든 진짜 중첩이다 — 목록의 단계를 스크린리더에 알리려면
그래야 한다. 한 단계마다 20px씩 들어가 3단계가 40px이 되므로 서는 자리는 시안과 같다.
앞에 걸어 둘 항목이 없는 경우(첫 줄부터 2단계)는 빈
@b65f1b32ff3c6052ffdf24277413dab074232ba9
+++ @fox/core/components/fox-bullet-list-item/fox-bullet-list-item.tsx
... | ... | @@ -0,0 +1,107 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { ReactNode } from "react"; | |
| 4 | +import { cx } from "../../utils"; | |
| 5 | + | |
| 6 | +export type FoxBulletLevel = 1 | 2 | 3; | |
| 7 | + | |
| 8 | +/** ①~⑳. 시안 3단계가 쓰는 모양이고, 넘어가면 `(21)`처럼 괄호로 적는다. */ | |
| 9 | +const CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳"; | |
| 10 | + | |
| 11 | +/** a·b·c … z 다음에는 aa·ab로 이어진다(스프레드시트 열 이름과 같은 방식). */ | |
| 12 | +function alphaOrdinal(index: number): string { | |
| 13 | + let rest = index; | |
| 14 | + let text = ""; | |
| 15 | + while (rest > 0) { | |
| 16 | + const digit = (rest - 1) % 26; | |
| 17 | + text = String.fromCharCode(97 + digit) + text; | |
| 18 | + rest = Math.floor((rest - 1) / 26); | |
| 19 | + } | |
| 20 | + return text; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** 2단계 기호는 시안에서 글자 `-`다. 1·3단계는 도형이라 스타일이 그린다. */ | |
| 24 | +function unorderedMarker(level: FoxBulletLevel): string | null { | |
| 25 | + return level === 2 ? "-" : null; | |
| 26 | +} | |
| 27 | + | |
| 28 | +function orderedMarker(level: FoxBulletLevel, ordinal: number): string { | |
| 29 | + if (level === 2) { | |
| 30 | + return `${alphaOrdinal(ordinal)}.`; | |
| 31 | + } | |
| 32 | + if (level === 3) { | |
| 33 | + return ordinal <= CIRCLED.length ? CIRCLED[ordinal - 1] : `(${ordinal})`; | |
| 34 | + } | |
| 35 | + return `${ordinal}.`; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export interface FoxBulletListItemProps { | |
| 39 | + /** | |
| 40 | + * 몇 단계인지. 목록은 이 값만 보고 중첩을 만든다 — 호출부는 평평하게 늘어놓고 | |
| 41 | + * 단계만 적으면 된다. | |
| 42 | + */ | |
| 43 | + level?: FoxBulletLevel; | |
| 44 | + children: ReactNode; | |
| 45 | + /** 마커를 직접 정할 때. 번호 매김이 시안과 달라야 하는 경우에만 쓴다. */ | |
| 46 | + marker?: ReactNode; | |
| 47 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 48 | + hidden?: boolean; | |
| 49 | + /** 배치 조정용. */ | |
| 50 | + className?: string; | |
| 51 | + | |
| 52 | + /** FoxBulletList가 채운다 — 번호 목록인지. 직접 넘기지 않는다. */ | |
| 53 | + ordered?: boolean; | |
| 54 | + /** FoxBulletList가 채운다 — 같은 묶음 안에서 몇 번째인지. 직접 넘기지 않는다. */ | |
| 55 | + ordinal?: number; | |
| 56 | + /** FoxBulletList가 채운다 — 이 항목 아래에 붙는 중첩 목록. 직접 넘기지 않는다. */ | |
| 57 | + nested?: ReactNode; | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** | |
| 61 | + * @fox 글머리 목록의 한 줄. 마커와 글이 나란히 서고, 글이 길어지면 마커 오른쪽에서만 | |
| 62 | + * 접힌다. | |
| 63 | + * | |
| 64 | + * 마커 모양과 번호는 `level`·`ordered`가 정한다 — 채운 원 / `-` / 빈 원, 또는 | |
| 65 | + * `1.` / `a.` / `①`. 번호는 같은 묶음 안에서 1부터 다시 센다. | |
| 66 | + * | |
| 67 | + * 혼자 쓰지 않고 `FoxBulletList` 안에 둔다. 목록이 단계를 보고 중첩과 번호를 채워 준다. | |
| 68 | + * | |
| 69 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 70 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 71 | + */ | |
| 72 | +export function FoxBulletListItem({ | |
| 73 | + level = 1, | |
| 74 | + children, | |
| 75 | + marker, | |
| 76 | + hidden = false, | |
| 77 | + className, | |
| 78 | + ordered = false, | |
| 79 | + ordinal = 1, | |
| 80 | + nested, | |
| 81 | +}: FoxBulletListItemProps) { | |
| 82 | + if (hidden) { | |
| 83 | + return null; | |
| 84 | + } | |
| 85 | + | |
| 86 | + const kind = ordered ? "ordered" : "unordered"; | |
| 87 | + | |
| 88 | + return ( | |
| 89 | + <li | |
| 90 | + className={cx( | |
| 91 | + "fox-bullet-list-item", | |
| 92 | + `fox-bullet-list-item--lv${level}`, | |
| 93 | + `fox-bullet-list-item--${kind}`, | |
| 94 | + className | |
| 95 | + )} | |
| 96 | + > | |
| 97 | + <span className="fox-bullet-list-item__row"> | |
| 98 | + {/* 마커는 글을 읽는 데 필요 없다 — 몇 번째인지는 목록 구조가 이미 알린다. */} | |
| 99 | + <span className="fox-bullet-list-item__marker" aria-hidden="true"> | |
| 100 | + {marker ?? (ordered ? orderedMarker(level, ordinal) : unorderedMarker(level))} | |
| 101 | + </span> | |
| 102 | + <span className="fox-bullet-list-item__text">{children}</span> | |
| 103 | + </span> | |
| 104 | + {nested} | |
| 105 | + </li> | |
| 106 | + ); | |
| 107 | +} |
+++ @fox/core/components/fox-bullet-list-item/index.ts
... | ... | @@ -0,0 +1,5 @@ |
| 1 | +export { | |
| 2 | + FoxBulletListItem, | |
| 3 | + type FoxBulletListItemProps, | |
| 4 | + type FoxBulletLevel, | |
| 5 | +} from "./fox-bullet-list-item"; |
+++ @fox/core/components/fox-bullet-list/fox-bullet-list.tsx
... | ... | @@ -0,0 +1,134 @@ |
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + Children, | |
| 5 | + Fragment, | |
| 6 | + cloneElement, | |
| 7 | + isValidElement, | |
| 8 | + type ReactElement, | |
| 9 | + type ReactNode, | |
| 10 | +} from "react"; | |
| 11 | +import { cx } from "../../utils"; | |
| 12 | +import type { FoxBulletLevel, FoxBulletListItemProps } from "../fox-bullet-list-item"; | |
| 13 | + | |
| 14 | +export interface FoxBulletListProps { | |
| 15 | + /** `FoxBulletListItem`들. 단계는 평평하게 늘어놓고 각 항목의 `level`로 적는다. */ | |
| 16 | + children: ReactNode; | |
| 17 | + /** 참이면 번호 목록(`1.` · `a.` · `①`)이 되고, 아니면 기호 목록이다. */ | |
| 18 | + ordered?: boolean; | |
| 19 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 20 | + hidden?: boolean; | |
| 21 | + /** 배치 조정용. */ | |
| 22 | + className?: string; | |
| 23 | +} | |
| 24 | + | |
| 25 | +type ItemElement = ReactElement<FoxBulletListItemProps>; | |
| 26 | +type Entry = { level: FoxBulletLevel; element: ItemElement }; | |
| 27 | + | |
| 28 | +/** Fragment로 감싸 넘겨도 안쪽 항목까지 찾아낸다. */ | |
| 29 | +function collect(children: ReactNode): Entry[] { | |
| 30 | + const entries: Entry[] = []; | |
| 31 | + Children.forEach(children, (child) => { | |
| 32 | + if (!isValidElement(child)) { | |
| 33 | + return; | |
| 34 | + } | |
| 35 | + if (child.type === Fragment) { | |
| 36 | + const fragment = child as ReactElement<{ children?: ReactNode }>; | |
| 37 | + entries.push(...collect(fragment.props.children)); | |
| 38 | + return; | |
| 39 | + } | |
| 40 | + const element = child as ItemElement; | |
| 41 | + entries.push({ level: element.props.level ?? 1, element }); | |
| 42 | + }); | |
| 43 | + return entries; | |
| 44 | +} | |
| 45 | + | |
| 46 | +/** | |
| 47 | + * 평평한 단계 목록을 진짜 중첩으로 접는다. | |
| 48 | + * | |
| 49 | + * 더 깊은 항목들은 바로 앞 항목 **안쪽** 목록이 된다 — `<ul>`의 자식은 `<li>`뿐이라 | |
| 50 | + * 중첩 목록을 형제로 둘 수 없다. 앞에 걸어 둘 항목이 없으면(첫 줄부터 2단계로 시작하는 | |
| 51 | + * 경우) 빈 `<li>`에 매달아 유효한 목록을 지킨다. | |
| 52 | + */ | |
| 53 | +function build(entries: Entry[], depth: FoxBulletLevel, ordered: boolean): ReactNode[] { | |
| 54 | + const nodes: ReactNode[] = []; | |
| 55 | + let ordinal = 0; | |
| 56 | + let index = 0; | |
| 57 | + | |
| 58 | + while (index < entries.length) { | |
| 59 | + let next = index; | |
| 60 | + | |
| 61 | + if (entries[index].level > depth) { | |
| 62 | + while (next < entries.length && entries[next].level > depth) { | |
| 63 | + next += 1; | |
| 64 | + } | |
| 65 | + nodes.push( | |
| 66 | + <li className="fox-bullet-list__hanger" key={`hanger-${index}`}> | |
| 67 | + {sublist(entries.slice(index, next), depth, ordered)} | |
| 68 | + </li> | |
| 69 | + ); | |
| 70 | + index = next; | |
| 71 | + continue; | |
| 72 | + } | |
| 73 | + | |
| 74 | + next = index + 1; | |
| 75 | + while (next < entries.length && entries[next].level > depth) { | |
| 76 | + next += 1; | |
| 77 | + } | |
| 78 | + const deeper = entries.slice(index + 1, next); | |
| 79 | + ordinal += 1; | |
| 80 | + | |
| 81 | + nodes.push( | |
| 82 | + cloneElement(entries[index].element, { | |
| 83 | + key: `item-${index}`, | |
| 84 | + level: depth, | |
| 85 | + ordered, | |
| 86 | + ordinal, | |
| 87 | + nested: deeper.length > 0 ? sublist(deeper, depth, ordered) : undefined, | |
| 88 | + }) | |
| 89 | + ); | |
| 90 | + index = next; | |
| 91 | + } | |
| 92 | + | |
| 93 | + return nodes; | |
| 94 | +} | |
| 95 | + | |
| 96 | +function sublist(entries: Entry[], depth: FoxBulletLevel, ordered: boolean): ReactNode { | |
| 97 | + const Tag = ordered ? "ol" : "ul"; | |
| 98 | + const deeper = Math.min(depth + 1, 3) as FoxBulletLevel; | |
| 99 | + return ( | |
| 100 | + <Tag className="fox-bullet-list fox-bullet-list--nested">{build(entries, deeper, ordered)}</Tag> | |
| 101 | + ); | |
| 102 | +} | |
| 103 | + | |
| 104 | +/** | |
| 105 | + * @fox 글머리 목록. 항목의 `level`만 보고 중첩과 번호를 스스로 만든다. | |
| 106 | + * | |
| 107 | + * 호출부는 단계를 평평하게 늘어놓기만 하면 된다 — 어느 항목 안에 무엇을 넣을지 따질 | |
| 108 | + * 필요가 없다. 나가는 마크업은 `<ul>`/`<ol>` 안에 `<ul>`/`<ol>`이 든 진짜 중첩이라 | |
| 109 | + * 스크린리더가 몇 단계인지 알린다. | |
| 110 | + * | |
| 111 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 112 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 113 | + */ | |
| 114 | +export function FoxBulletList({ | |
| 115 | + children, | |
| 116 | + ordered = false, | |
| 117 | + hidden = false, | |
| 118 | + className, | |
| 119 | +}: FoxBulletListProps) { | |
| 120 | + if (hidden) { | |
| 121 | + return null; | |
| 122 | + } | |
| 123 | + | |
| 124 | + const entries = collect(children); | |
| 125 | + if (entries.length === 0) { | |
| 126 | + return null; | |
| 127 | + } | |
| 128 | + | |
| 129 | + const Tag = ordered ? "ol" : "ul"; | |
| 130 | + | |
| 131 | + return ( | |
| 132 | + <Tag className={cx("fox-bullet-list", className)}>{build(entries, 1, ordered)}</Tag> | |
| 133 | + ); | |
| 134 | +} |
+++ @fox/core/components/fox-bullet-list/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxBulletList, type FoxBulletListProps } from "./fox-bullet-list"; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -1,5 +1,7 @@ |
| 1 | 1 |
export * from "./fox-address"; |
| 2 | 2 |
export * from "./fox-breadcrumb"; |
| 3 |
+export * from "./fox-bullet-list"; |
|
| 4 |
+export * from "./fox-bullet-list-item"; |
|
| 3 | 5 |
export * from "./fox-button"; |
| 4 | 6 |
export * from "./fox-button-group"; |
| 5 | 7 |
export * from "./fox-button-panel"; |
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -37,6 +37,8 @@ |
| 37 | 37 |
FoxPlayIcon, |
| 38 | 38 |
FoxPlusIcon, |
| 39 | 39 |
} from "../core/icons"; |
| 40 |
+import { FoxBulletList } from "../core/components/fox-bullet-list";
|
|
| 41 |
+import { FoxBulletListItem } from "../core/components/fox-bullet-list-item";
|
|
| 40 | 42 |
import styles from "./dev-test.module.scss"; |
| 41 | 43 |
|
| 42 | 44 |
export interface ComponentExample {
|
... | ... | @@ -2275,4 +2277,110 @@ |
| 2275 | 2277 |
}, |
| 2276 | 2278 |
], |
| 2277 | 2279 |
}, |
| 2280 |
+ {
|
|
| 2281 |
+ id: "fox-bullet-list", |
|
| 2282 |
+ name: "FoxBulletList", |
|
| 2283 |
+ description: |
|
| 2284 |
+ "Figma 시안(bullet 391:4049 · bullet-list-item 391:4081 · bullet-list 484:5722)을 옮겼습니다. 호출부는 단계를 평평하게 늘어놓고 level만 적으면 되고, 목록이 중첩과 번호를 스스로 만듭니다. 나가는 마크업은 <ul>/<ol> 안에 <ul>/<ol>이 든 진짜 중첩입니다.", |
|
| 2285 |
+ variants: [ |
|
| 2286 |
+ {
|
|
| 2287 |
+ label: "기호 목록 — 시안 조합 그대로", |
|
| 2288 |
+ node: ( |
|
| 2289 |
+ <div className={styles.demoField}>
|
|
| 2290 |
+ <FoxBulletList> |
|
| 2291 |
+ <FoxBulletListItem>텍스트 목록 레벨1 텍스트 목록 레벨1텍스트 목록 레벨1</FoxBulletListItem> |
|
| 2292 |
+ <FoxBulletListItem>텍스트 목록 레벨1</FoxBulletListItem> |
|
| 2293 |
+ <FoxBulletListItem>텍스트 목록 레벨1</FoxBulletListItem> |
|
| 2294 |
+ <FoxBulletListItem level={2}>텍스트 목록 레벨2텍스트 목록 레벨2 텍스트 목록 레벨2</FoxBulletListItem>
|
|
| 2295 |
+ <FoxBulletListItem level={2}>텍스트 목록 레벨2</FoxBulletListItem>
|
|
| 2296 |
+ <FoxBulletListItem level={3}>텍스트 목록 레벨3 텍스트 목록 레벨3 텍스트 목록 레벨3</FoxBulletListItem>
|
|
| 2297 |
+ <FoxBulletListItem level={3}>텍스트 목록 레벨3</FoxBulletListItem>
|
|
| 2298 |
+ <FoxBulletListItem>텍스트 목록 레벨1</FoxBulletListItem> |
|
| 2299 |
+ </FoxBulletList> |
|
| 2300 |
+ </div> |
|
| 2301 |
+ ), |
|
| 2302 |
+ }, |
|
| 2303 |
+ {
|
|
| 2304 |
+ label: "번호 목록 — 같은 묶음 안에서 1부터 다시 셉니다", |
|
| 2305 |
+ node: ( |
|
| 2306 |
+ <div className={styles.demoField}>
|
|
| 2307 |
+ <FoxBulletList ordered> |
|
| 2308 |
+ <FoxBulletListItem>텍스트 목록 레벨1 텍스트 목록 레벨1텍스트 목록 레벨1</FoxBulletListItem> |
|
| 2309 |
+ <FoxBulletListItem>텍스트 목록 레벨1</FoxBulletListItem> |
|
| 2310 |
+ <FoxBulletListItem>텍스트 목록 레벨1</FoxBulletListItem> |
|
| 2311 |
+ <FoxBulletListItem level={2}>텍스트 목록 레벨2텍스트 목록 레벨2 텍스트 목록 레벨2</FoxBulletListItem>
|
|
| 2312 |
+ <FoxBulletListItem level={2}>텍스트 목록 레벨2</FoxBulletListItem>
|
|
| 2313 |
+ <FoxBulletListItem level={3}>텍스트 목록 레벨3 텍스트 목록 레벨3 텍스트 목록 레벨3</FoxBulletListItem>
|
|
| 2314 |
+ <FoxBulletListItem level={3}>텍스트 목록 레벨3</FoxBulletListItem>
|
|
| 2315 |
+ <FoxBulletListItem>텍스트 목록 레벨1</FoxBulletListItem> |
|
| 2316 |
+ </FoxBulletList> |
|
| 2317 |
+ </div> |
|
| 2318 |
+ ), |
|
| 2319 |
+ }, |
|
| 2320 |
+ {
|
|
| 2321 |
+ label: "마커 6종 — 단계별로 나란히", |
|
| 2322 |
+ node: ( |
|
| 2323 |
+ <div className={styles.demoFields}>
|
|
| 2324 |
+ <div className={styles.demoField}>
|
|
| 2325 |
+ <FoxBulletList> |
|
| 2326 |
+ <FoxBulletListItem>1단계 — 채운 원</FoxBulletListItem> |
|
| 2327 |
+ <FoxBulletListItem level={2}>2단계 — 짧은 선</FoxBulletListItem>
|
|
| 2328 |
+ <FoxBulletListItem level={3}>3단계 — 빈 원</FoxBulletListItem>
|
|
| 2329 |
+ </FoxBulletList> |
|
| 2330 |
+ </div> |
|
| 2331 |
+ <div className={styles.demoField}>
|
|
| 2332 |
+ <FoxBulletList ordered> |
|
| 2333 |
+ <FoxBulletListItem>1단계 — 1.</FoxBulletListItem> |
|
| 2334 |
+ <FoxBulletListItem level={2}>2단계 — a.</FoxBulletListItem>
|
|
| 2335 |
+ <FoxBulletListItem level={3}>3단계 — ①</FoxBulletListItem>
|
|
| 2336 |
+ </FoxBulletList> |
|
| 2337 |
+ </div> |
|
| 2338 |
+ </div> |
|
| 2339 |
+ ), |
|
| 2340 |
+ }, |
|
| 2341 |
+ {
|
|
| 2342 |
+ label: "번호가 이어질 때 — 같은 단계가 여러 줄", |
|
| 2343 |
+ node: ( |
|
| 2344 |
+ <div className={styles.demoField}>
|
|
| 2345 |
+ <FoxBulletList ordered> |
|
| 2346 |
+ <FoxBulletListItem>첫째</FoxBulletListItem> |
|
| 2347 |
+ <FoxBulletListItem level={2}>가</FoxBulletListItem>
|
|
| 2348 |
+ <FoxBulletListItem level={2}>나</FoxBulletListItem>
|
|
| 2349 |
+ <FoxBulletListItem level={3}>하나</FoxBulletListItem>
|
|
| 2350 |
+ <FoxBulletListItem level={3}>둘</FoxBulletListItem>
|
|
| 2351 |
+ <FoxBulletListItem level={3}>셋</FoxBulletListItem>
|
|
| 2352 |
+ <FoxBulletListItem>둘째</FoxBulletListItem> |
|
| 2353 |
+ <FoxBulletListItem>셋째</FoxBulletListItem> |
|
| 2354 |
+ </FoxBulletList> |
|
| 2355 |
+ </div> |
|
| 2356 |
+ ), |
|
| 2357 |
+ }, |
|
| 2358 |
+ {
|
|
| 2359 |
+ label: "항목 안에 링크·강조를 넣어도 됩니다", |
|
| 2360 |
+ node: ( |
|
| 2361 |
+ <div className={styles.demoField}>
|
|
| 2362 |
+ <FoxBulletList> |
|
| 2363 |
+ <FoxBulletListItem> |
|
| 2364 |
+ 자세한 내용은 <a href="#fox-bullet-list">이 문서</a>를 확인하세요. |
|
| 2365 |
+ </FoxBulletListItem> |
|
| 2366 |
+ <FoxBulletListItem level={2}>
|
|
| 2367 |
+ <strong>굵은 글씨</strong>도 그대로 들어갑니다. |
|
| 2368 |
+ </FoxBulletListItem> |
|
| 2369 |
+ </FoxBulletList> |
|
| 2370 |
+ </div> |
|
| 2371 |
+ ), |
|
| 2372 |
+ }, |
|
| 2373 |
+ {
|
|
| 2374 |
+ label: "2단계로 시작할 때 — 빈 칸에 매달아 목록이 유효하게 유지됩니다", |
|
| 2375 |
+ node: ( |
|
| 2376 |
+ <div className={styles.demoField}>
|
|
| 2377 |
+ <FoxBulletList> |
|
| 2378 |
+ <FoxBulletListItem level={2}>앞에 1단계가 없습니다</FoxBulletListItem>
|
|
| 2379 |
+ <FoxBulletListItem level={2}>그래도 들여쓰기는 유지됩니다</FoxBulletListItem>
|
|
| 2380 |
+ </FoxBulletList> |
|
| 2381 |
+ </div> |
|
| 2382 |
+ ), |
|
| 2383 |
+ }, |
|
| 2384 |
+ ], |
|
| 2385 |
+ }, |
|
| 2278 | 2386 |
]; |
+++ @fox/styles/_fox-bullet-list-item.scss
... | ... | @@ -0,0 +1,140 @@ |
| 1 | +// FoxBulletListItem — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// bullet-list-item (섹션 391:4081) · bullet (섹션 391:4049) | |
| 3 | +// | |
| 4 | +// 마크업 계약 (React 밖 소비자용): | |
| 5 | +// <li class="fox-bullet-list-item fox-bullet-list-item--lv1 fox-bullet-list-item--unordered"> | |
| 6 | +// <span class="fox-bullet-list-item__row"> | |
| 7 | +// <span class="fox-bullet-list-item__marker" aria-hidden="true"></span> | |
| 8 | +// <span class="fox-bullet-list-item__text">글</span> | |
| 9 | +// </span> | |
| 10 | +// <ul class="fox-bullet-list fox-bullet-list--nested">…</ul> <!-- 선택 --> | |
| 11 | +// </li> | |
| 12 | +// | |
| 13 | +// 시안의 bullet은 따로 두지 않았다 — 마커가 목록 밖에서 홀로 쓰이는 자리가 없고, 크기와 | |
| 14 | +// 모양이 전부 단계·번호 여부로 갈리기 때문이다. 그 두 축이 여기 수식어로 들어와 있다. | |
| 15 | +// | |
| 16 | +// 마커는 폭이 고정이고 글만 남는 폭을 먹는다. 그래서 글이 길어져도 마커 오른쪽에서만 | |
| 17 | +// 접히고 마커 아래로 흘러내리지 않는다. | |
| 18 | +// | |
| 19 | +// 1단계 채운 원과 3단계 빈 원은 시안에서 도형이라 가상 요소로 그린다. 2단계 `-`와 번호는 | |
| 20 | +// 시안에서 글자라 React가 글자로 넣는다. | |
| 21 | +// | |
| 22 | +// 항목 안에서 글줄과 중첩 목록 사이는 12px이다(시안에서 뿌리 목록의 줄 간격과 같다). | |
| 23 | +// | |
| 24 | +// 토큰이 아닌 값: | |
| 25 | +// - 마커 칸 2.2rem×2.6rem(1·2단계) · 1.6rem×2.3rem(기호 3단계) · 2.4rem×2.3rem(번호 | |
| 26 | +// 3단계), 채운 원 0.6rem, 빈 원 0.4rem: 모두 시안 값이나 Figma가 변수로 | |
| 27 | +// export하지 않는다(사용자 승인). | |
| 28 | +// - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 | |
| 29 | +// export하지 않는다. | |
| 30 | +// - list-style / flex 관련: 구조 규칙. | |
| 31 | + | |
| 32 | +@use "abstracts" as fox; | |
| 33 | + | |
| 34 | +.fox-bullet-list-item { | |
| 35 | + display: flex; | |
| 36 | + flex-direction: column; | |
| 37 | + gap: fox.gap(4); | |
| 38 | + inline-size: 100%; | |
| 39 | + list-style: none; | |
| 40 | + | |
| 41 | + &__row { | |
| 42 | + display: flex; | |
| 43 | + align-items: flex-start; | |
| 44 | + gap: fox.gap(1); | |
| 45 | + inline-size: 100%; | |
| 46 | + } | |
| 47 | + | |
| 48 | + // 시안 값이나 변수로 export되지 않는다 — 파일 상단 참고. | |
| 49 | + &__marker { | |
| 50 | + box-sizing: border-box; | |
| 51 | + display: flex; | |
| 52 | + flex-shrink: 0; | |
| 53 | + align-items: center; | |
| 54 | + justify-content: center; | |
| 55 | + inline-size: 2.2rem; | |
| 56 | + block-size: 2.6rem; | |
| 57 | + color: fox.color(font-neutral-default); | |
| 58 | + font-family: fox.font-family(body); | |
| 59 | + font-weight: fox.font-weight(regular); | |
| 60 | + line-height: 1.5; | |
| 61 | + white-space: nowrap; | |
| 62 | + } | |
| 63 | + | |
| 64 | + // 글만 남는 폭을 먹는다. | |
| 65 | + &__text { | |
| 66 | + flex: 1 0 0; | |
| 67 | + min-inline-size: 0; | |
| 68 | + color: fox.color(font-neutral-default); | |
| 69 | + font-family: fox.font-family(body); | |
| 70 | + font-size: fox.font-size(body-md); | |
| 71 | + font-weight: fox.font-weight(regular); | |
| 72 | + line-height: 1.5; | |
| 73 | + letter-spacing: -0.025em; | |
| 74 | + word-break: break-word; | |
| 75 | + } | |
| 76 | + | |
| 77 | + // ── 기호 마커 ───────────────────────────────────────────────────────────── | |
| 78 | + // 1·3단계는 시안에서 도형이라 가상 요소로 그린다. 2단계는 글자 `-`라 React가 넣는다. | |
| 79 | + &--unordered.fox-bullet-list-item--lv1 &__marker::before, | |
| 80 | + &--unordered.fox-bullet-list-item--lv3 &__marker::before { | |
| 81 | + content: ""; | |
| 82 | + display: block; | |
| 83 | + background: fox.color(element-neutral-default); | |
| 84 | + } | |
| 85 | + | |
| 86 | + &--unordered.fox-bullet-list-item--lv2 &__marker { | |
| 87 | + font-size: fox.font-size(body-md); | |
| 88 | + letter-spacing: -0.025em; | |
| 89 | + } | |
| 90 | + | |
| 91 | + &--unordered.fox-bullet-list-item--lv1 &__marker::before { | |
| 92 | + inline-size: 0.6rem; | |
| 93 | + block-size: 0.6rem; | |
| 94 | + border-radius: fox.radius(max); | |
| 95 | + } | |
| 96 | + | |
| 97 | + &--unordered.fox-bullet-list-item--lv3 &__marker::before { | |
| 98 | + box-sizing: border-box; | |
| 99 | + inline-size: 0.4rem; | |
| 100 | + block-size: 0.4rem; | |
| 101 | + border: fox.border(1) solid fox.color(element-neutral-default); | |
| 102 | + border-radius: fox.radius(max); | |
| 103 | + background: none; | |
| 104 | + } | |
| 105 | + | |
| 106 | + // ── 번호 마커 ───────────────────────────────────────────────────────────── | |
| 107 | + &--ordered.fox-bullet-list-item--lv1 &__marker { | |
| 108 | + font-size: fox.font-size(body-md); | |
| 109 | + letter-spacing: -0.025em; | |
| 110 | + } | |
| 111 | + | |
| 112 | + &--ordered.fox-bullet-list-item--lv2 &__marker { | |
| 113 | + font-size: fox.font-size(body-sm); | |
| 114 | + letter-spacing: -0.025em; | |
| 115 | + } | |
| 116 | + | |
| 117 | + &--ordered.fox-bullet-list-item--lv3 &__marker { | |
| 118 | + font-size: fox.font-size(body-xsm); | |
| 119 | + letter-spacing: -0.025em; | |
| 120 | + } | |
| 121 | + | |
| 122 | + // ── 3단계 ───────────────────────────────────────────────────────────────── | |
| 123 | + // 마커 칸이 한 단계 좁아지고 왼쪽으로 조금 밀려 있다. | |
| 124 | + &--lv3 &__marker { | |
| 125 | + block-size: 2.3rem; | |
| 126 | + padding-inline-start: fox.padding(2); | |
| 127 | + } | |
| 128 | + | |
| 129 | + &--unordered.fox-bullet-list-item--lv3 &__marker { | |
| 130 | + inline-size: 1.6rem; | |
| 131 | + } | |
| 132 | + | |
| 133 | + &--ordered.fox-bullet-list-item--lv3 &__marker { | |
| 134 | + inline-size: 2.4rem; | |
| 135 | + } | |
| 136 | + | |
| 137 | + &--lv3 &__text { | |
| 138 | + font-size: fox.font-size(body-sm); | |
| 139 | + } | |
| 140 | +} |
+++ @fox/styles/_fox-bullet-list.scss
... | ... | @@ -0,0 +1,44 @@ |
| 1 | +// FoxBulletList — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) | |
| 2 | +// bullet-list (484:5650 · 484:5723 · 섹션 484:5722) | |
| 3 | +// | |
| 4 | +// 마크업 계약 (React 밖 소비자용): | |
| 5 | +// <ul class="fox-bullet-list"> | |
| 6 | +// <li class="fox-bullet-list-item …">…</li> | |
| 7 | +// <li class="fox-bullet-list-item …">… | |
| 8 | +// <ul class="fox-bullet-list fox-bullet-list--nested">…</ul> | |
| 9 | +// </li> | |
| 10 | +// </ul> | |
| 11 | +// 번호 목록은 `<ul>` 자리에 `<ol>`을 쓴다. | |
| 12 | +// | |
| 13 | +// 시안은 2·3단계를 형제 프레임으로 두고 왼쪽 여백만 20·40px 줬지만, 여기서는 진짜 중첩 | |
| 14 | +// (`<ul>` 안의 `<ul>`)으로 낸다 — 목록의 단계를 스크린리더에 알리려면 그래야 한다. | |
| 15 | +// 한 단계마다 20px씩 들어가므로 3단계는 40px이 되어 시안과 같은 자리에 선다. | |
| 16 | +// | |
| 17 | +// 항목 사이 간격은 뿌리에서 12px, 중첩 안에서 8px이다(시안 그대로). | |
| 18 | +// | |
| 19 | +// 토큰이 아닌 값: | |
| 20 | +// - 목록의 margin/padding/list-style 0: `<ul>`·`<ol>`의 브라우저 기본값 지우기. | |
| 21 | + | |
| 22 | +@use "abstracts" as fox; | |
| 23 | + | |
| 24 | +.fox-bullet-list { | |
| 25 | + display: flex; | |
| 26 | + flex-direction: column; | |
| 27 | + align-items: flex-start; | |
| 28 | + gap: fox.gap(4); | |
| 29 | + inline-size: 100%; | |
| 30 | + margin: 0; | |
| 31 | + padding: 0; | |
| 32 | + list-style: none; | |
| 33 | + | |
| 34 | + // 안쪽 목록은 한 단계 들여쓰고 줄 간격이 좁다. | |
| 35 | + &--nested { | |
| 36 | + gap: fox.gap(3); | |
| 37 | + padding-inline-start: fox.padding(7); | |
| 38 | + } | |
| 39 | + | |
| 40 | + // 첫 줄부터 2단계로 시작할 때 중첩 목록을 매다는 빈 칸. 보이는 것이 없다. | |
| 41 | + &__hanger { | |
| 42 | + inline-size: 100%; | |
| 43 | + } | |
| 44 | +} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -33,3 +33,5 @@ |
| 33 | 33 |
@use "fox-carousel-button"; |
| 34 | 34 |
@use "fox-carousel-area"; |
| 35 | 35 |
@use "fox-toggle-switch"; |
| 36 |
+@use "fox-bullet-list"; |
|
| 37 |
+@use "fox-bullet-list-item"; |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?