@conscience-technology/ctds 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/components/Button/Button.module.css +2 -2
- package/assets/components/Checkbox/Checkbox.module.css +1 -0
- package/assets/components/EventListItem/EventListItem.module.css +220 -0
- package/assets/components/EventListItem/EventListItem.tsx +205 -0
- package/assets/components/EventListItem/index.ts +8 -0
- package/assets/components/Icon/Icon.tsx +80 -41
- package/assets/components/ListItem/ListItem.module.css +91 -211
- package/assets/components/ListItem/ListItem.tsx +130 -154
- package/assets/components/ListItem/index.ts +2 -2
- package/assets/components/MenuItem/MenuItem.module.css +30 -5
- package/assets/components/MenuItem/MenuItem.tsx +86 -5
- package/assets/components/MetricCard/MetricCard.module.css +5 -5
- package/assets/components/Radio/Radio.module.css +1 -0
- package/assets/components/Sidebar/Sidebar.module.css +81 -4
- package/assets/components/Sidebar/Sidebar.tsx +15 -3
- package/assets/components/SplitButton/SplitButton.module.css +3 -3
- package/assets/components/Stepper/Stepper.module.css +196 -52
- package/assets/components/Stepper/Stepper.tsx +68 -13
- package/assets/components/Switch/Switch.module.css +1 -9
- package/assets/components/Topbar/Topbar.module.css +33 -5
- package/assets/components/Topbar/Topbar.tsx +62 -7
- package/assets/components/_data.ts +11 -4
- package/assets/components/index.ts +2 -0
- package/assets/components/tokens.css +23 -10
- package/assets/prompts/ASSEMBLY.md +219 -0
- package/assets/prompts/PRINCIPLES.md +70 -0
- package/assets/prompts/axes/icon.md +7 -1
- package/assets/prompts/axes/palette.md +11 -0
- package/assets/prompts/components/_variants.md +5 -1
- package/assets/prompts/components/callout.md +3 -3
- package/assets/prompts/components/event-list-item.md +103 -0
- package/assets/prompts/components/generated-image.md +80 -0
- package/assets/prompts/components/list-item.md +182 -204
- package/assets/prompts/components/menu-item.md +1 -0
- package/assets/prompts/components/metric-card.md +2 -2
- package/assets/prompts/components/toast.md +1 -0
- package/assets/prompts/components/topbar.md +77 -59
- package/assets/prompts/themes/focus.md +72 -0
- package/assets/skills/ctds/SKILL.md +1 -0
- package/assets/skills/ctds-page/SKILL.md +12 -2
- package/assets/skills/ctds-wireframe/SKILL.md +35 -1
- package/package.json +2 -4
|
@@ -73,9 +73,9 @@
|
|
|
73
73
|
Body 안쪽 콘텐츠 래퍼(nav/섹션 리스트 등)에 깐다. */
|
|
74
74
|
/* padding-top·padding-bottom도 두지 않음 — Header 아래 숨돌림, Footer 위
|
|
75
75
|
마지막 항목 숨돌림 모두 소비처가 안쪽 래퍼의 padding-block으로 공급. */
|
|
76
|
-
/* mask-image
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
/* mask-image 기본 off. 켜고 싶으면 Sidebar.Body의 `fade` prop 사용.
|
|
77
|
+
top fade는 Body 안에 "고정 상단 블록(primary CTA + 검색)"을 꽂는 소비처에선
|
|
78
|
+
그 블록 상단이 흐려져 "밝은 입체 바" 착시를 만들므로 주의. */
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/* Body 자식이 flex column에서 수축돼 header가 뭉개지는 걸 방지.
|
|
@@ -84,15 +84,92 @@
|
|
|
84
84
|
flex: 0 0 auto;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/* fade mask opt-in — Body에 `fade="top|bottom|both"` prop을 주면 켜짐.
|
|
88
|
+
페이드 크기는 `--sidebar-body-fade-size` 토큰(기본 --space-3). 소비처가 늘릴 수 있음.
|
|
89
|
+
주의: top fade는 Body 안에 고정 상단 블록(primary CTA 등)이 없을 때만 안전.
|
|
90
|
+
있으면 그 블록 상단이 깎여 "흰 입체 바" 착시가 생김. */
|
|
91
|
+
.body[data-fade] {
|
|
92
|
+
--sidebar-body-fade-size: var(--space-3);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* mask-image의 #000은 색이 아니라 "완전 불투명" alpha marker — lint-hardcoded ignore. */
|
|
96
|
+
.body[data-fade="top"] {
|
|
97
|
+
mask-image: linear-gradient(
|
|
98
|
+
to bottom,
|
|
99
|
+
transparent 0,
|
|
100
|
+
#000 var(--sidebar-body-fade-size), /* lint-hardcoded: ignore */
|
|
101
|
+
#000 100% /* lint-hardcoded: ignore */
|
|
102
|
+
);
|
|
103
|
+
-webkit-mask-image: linear-gradient(
|
|
104
|
+
to bottom,
|
|
105
|
+
transparent 0,
|
|
106
|
+
#000 var(--sidebar-body-fade-size), /* lint-hardcoded: ignore */
|
|
107
|
+
#000 100% /* lint-hardcoded: ignore */
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.body[data-fade="bottom"] {
|
|
112
|
+
mask-image: linear-gradient(
|
|
113
|
+
to bottom,
|
|
114
|
+
#000 0, /* lint-hardcoded: ignore */
|
|
115
|
+
#000 calc(100% - var(--sidebar-body-fade-size)), /* lint-hardcoded: ignore */
|
|
116
|
+
transparent 100%
|
|
117
|
+
);
|
|
118
|
+
-webkit-mask-image: linear-gradient(
|
|
119
|
+
to bottom,
|
|
120
|
+
#000 0, /* lint-hardcoded: ignore */
|
|
121
|
+
#000 calc(100% - var(--sidebar-body-fade-size)), /* lint-hardcoded: ignore */
|
|
122
|
+
transparent 100%
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.body[data-fade="both"] {
|
|
127
|
+
mask-image: linear-gradient(
|
|
128
|
+
to bottom,
|
|
129
|
+
transparent 0,
|
|
130
|
+
#000 var(--sidebar-body-fade-size), /* lint-hardcoded: ignore */
|
|
131
|
+
#000 calc(100% - var(--sidebar-body-fade-size)), /* lint-hardcoded: ignore */
|
|
132
|
+
transparent 100%
|
|
133
|
+
);
|
|
134
|
+
-webkit-mask-image: linear-gradient(
|
|
135
|
+
to bottom,
|
|
136
|
+
transparent 0,
|
|
137
|
+
#000 var(--sidebar-body-fade-size), /* lint-hardcoded: ignore */
|
|
138
|
+
#000 calc(100% - var(--sidebar-body-fade-size)), /* lint-hardcoded: ignore */
|
|
139
|
+
transparent 100%
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
87
143
|
/* Brand preset ─────────────────────────────────────────
|
|
88
144
|
Header 안에 꽂히는 preset. symbol은 항상, wordmark는 expanded일 때만 렌더.
|
|
89
145
|
상한선 토큰으로 max-height 강제 — 사용처가 그 안에서 자유롭게 크기 지정.
|
|
90
|
-
좌우/상하 padding은 소비처가
|
|
146
|
+
Header 자체 좌우/상하 padding은 소비처가 공급(래퍼 축).
|
|
147
|
+
Brand 안쪽 좌우 padding은 벤더가 공급 — MenuItem이 자체 padding-inline을
|
|
148
|
+
가지므로 Brand symbol이 아래 MenuItem 아이콘과 같은 X축에 서려면
|
|
149
|
+
Brand도 동일한 inner padding을 가져야 함(아니면 symbol이 라벨 영역과 anti-flush).
|
|
150
|
+
width: 100% + collapsed 중앙 정렬: Brand가 Header 폭을 전부 받아야 collapsed일 때
|
|
151
|
+
justify-content: center가 "Header 가로 중앙"으로 유효해짐. */
|
|
91
152
|
.brand {
|
|
92
153
|
display: flex;
|
|
93
154
|
align-items: center;
|
|
94
155
|
gap: var(--space-2);
|
|
95
156
|
min-height: var(--control-h-md);
|
|
157
|
+
/* MenuItem이 자체 padding-inline(space-2)을 가지므로 Brand symbol이 아래
|
|
158
|
+
라벨들의 아이콘과 같은 X축에 서려면 Brand도 동일한 inner padding을 가져야 함.
|
|
159
|
+
Header 안에 Brand + 다른 자식이 공존하는 소비처가 있으므로 width는 건드리지
|
|
160
|
+
않고 inner padding만 공급. */
|
|
161
|
+
padding-inline: var(--space-2);
|
|
162
|
+
box-sizing: border-box;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* collapsed일 땐 wordmark가 사라져 symbol 하나만 남으므로 Header 폭을 전부 받아
|
|
166
|
+
가로 중앙 정렬. 안 그러면 Brand가 symbol 폭에만 머물러 Header 좌측에 붙음.
|
|
167
|
+
inner padding도 제거 — collapsed Header는 자체적으로 좁고 symbol 1개만 있어
|
|
168
|
+
추가 안쪽 여백이 필요 없음. */
|
|
169
|
+
.root[data-collapsed] .brand {
|
|
170
|
+
width: 100%;
|
|
171
|
+
justify-content: center;
|
|
172
|
+
padding-inline: 0;
|
|
96
173
|
}
|
|
97
174
|
|
|
98
175
|
.brandSymbol {
|
|
@@ -75,8 +75,16 @@ function SidebarHeader({ children, className, ...rest }: HTMLAttributes<HTMLDivE
|
|
|
75
75
|
Header와 Footer 사이의 세로 스크롤 영역.
|
|
76
76
|
여러 개를 두면 각각 독립적인 스크롤 트랙이 됨.
|
|
77
77
|
ResizeObserver로 scrollHeight > clientHeight 감지 → Footer가 자동으로
|
|
78
|
-
상단 hairline을 켬. 메뉴가 적으면 선 없음, 많으면 자연스럽게 분리선 등장.
|
|
79
|
-
|
|
78
|
+
상단 hairline을 켬. 메뉴가 적으면 선 없음, 많으면 자연스럽게 분리선 등장.
|
|
79
|
+
|
|
80
|
+
fade: 스크롤 트랙 상/하단 소프트 fade mask. "top" | "bottom" | "both" | "none".
|
|
81
|
+
기본 "none" — 켤 때만 켠다(소비처가 Body 안에 고정 상단 블록을 두면 top fade가
|
|
82
|
+
그 블록을 깎아 "흰 입체 바" 착시를 만드므로 opt-in). */
|
|
83
|
+
export interface SidebarBodyProps extends HTMLAttributes<HTMLDivElement> {
|
|
84
|
+
fade?: "top" | "bottom" | "both" | "none";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function SidebarBody({ children, className, fade = "none", ...rest }: SidebarBodyProps) {
|
|
80
88
|
const ref = useRef<HTMLDivElement>(null);
|
|
81
89
|
const { setBodyOverflow } = useSidebar();
|
|
82
90
|
|
|
@@ -100,6 +108,7 @@ function SidebarBody({ children, className, ...rest }: HTMLAttributes<HTMLDivEle
|
|
|
100
108
|
<div
|
|
101
109
|
ref={ref}
|
|
102
110
|
data-sidebar-slot="body"
|
|
111
|
+
data-fade={fade !== "none" ? fade : undefined}
|
|
103
112
|
className={[styles.body, className ?? ""].filter(Boolean).join(" ")}
|
|
104
113
|
{...rest}
|
|
105
114
|
>
|
|
@@ -272,6 +281,9 @@ const SidebarItem = forwardRef<HTMLDivElement, SidebarItemProps>(function Sideba
|
|
|
272
281
|
const leadingActions: MenuItemAction[] = icon ? [icon] : [];
|
|
273
282
|
const trailingActions: MenuItemAction[] = trailing ? [trailing] : [];
|
|
274
283
|
|
|
284
|
+
/* ellipsis 시 Tooltip, collapsed 시 항상 Tooltip은 MenuItem이 자체 처리.
|
|
285
|
+
children을 넘기면 복잡 ReactNode라 Tooltip이 안 뜰 수 있으므로, 평문 라벨만 있을 땐
|
|
286
|
+
label 문자열을 그대로 children으로 흘려 MenuItem이 안전하게 Tooltip 콘텐츠를 결정하게 함. */
|
|
275
287
|
return (
|
|
276
288
|
<MenuItem
|
|
277
289
|
ref={ref}
|
|
@@ -280,13 +292,13 @@ const SidebarItem = forwardRef<HTMLDivElement, SidebarItemProps>(function Sideba
|
|
|
280
292
|
disabled={disabled}
|
|
281
293
|
asChild={asChild}
|
|
282
294
|
collapsed={collapsed}
|
|
295
|
+
truncateTooltip
|
|
283
296
|
leadingActions={leadingActions}
|
|
284
297
|
trailingActions={trailingActions}
|
|
285
298
|
shortcut={shortcut}
|
|
286
299
|
hasSubmenu={hasSubmenu}
|
|
287
300
|
onClick={onClick}
|
|
288
301
|
aria-label={collapsed ? label : undefined}
|
|
289
|
-
title={collapsed ? label : undefined}
|
|
290
302
|
>
|
|
291
303
|
{children ?? label}
|
|
292
304
|
</MenuItem>
|
|
@@ -94,9 +94,9 @@
|
|
|
94
94
|
.sm .menu { padding-inline: var(--space-1_5); }
|
|
95
95
|
|
|
96
96
|
.md { --split-h: var(--control-h-md); --btn-icon-size: var(--space-4); }
|
|
97
|
-
.md .part { font-size: var(--text-sm); gap: var(--space-
|
|
98
|
-
.md .main { padding-inline: var(--space-
|
|
99
|
-
.md .menu { padding-inline: var(--space-
|
|
97
|
+
.md .part { font-size: var(--text-sm); gap: var(--space-1_5); }
|
|
98
|
+
.md .main { padding-inline: var(--space-3); }
|
|
99
|
+
.md .menu { padding-inline: var(--space-2); }
|
|
100
100
|
|
|
101
101
|
.lg { --split-h: var(--control-h-lg); --btn-icon-size: var(--space-4); }
|
|
102
102
|
.lg .part { font-size: var(--text-sm); gap: var(--space-1_5); }
|
|
@@ -1,42 +1,78 @@
|
|
|
1
1
|
/* Stepper — 다단계 진행 표시 컴포넌트
|
|
2
|
-
구성: root(container) > item(button) > marker
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
─
|
|
6
|
-
─
|
|
2
|
+
구성: root(container) > item(button) > marker + body(title + hint)
|
|
3
|
+
|
|
4
|
+
축 (data-* attr):
|
|
5
|
+
─ data-orientation : vertical | horizontal
|
|
6
|
+
─ data-marker : dot | number
|
|
7
|
+
─ data-variant : md | sm
|
|
8
|
+
─ data-tone : primary | neutral
|
|
9
|
+
─ data-density : comfortable | spacious
|
|
10
|
+
─ data-hint-placement : below | inline
|
|
11
|
+
─ data-line-style : dashed | dotted | solid
|
|
7
12
|
|
|
8
13
|
로컬 색 변수 (root에서 오버라이드 가능):
|
|
9
|
-
─ --stepper-fg :
|
|
14
|
+
─ --stepper-fg : 활성 스텝 텍스트
|
|
10
15
|
─ --stepper-fg-muted : 완료 스텝 텍스트
|
|
11
16
|
─ --stepper-fg-subtle : 미도달 스텝 텍스트 · hint
|
|
12
|
-
─ --stepper-line : 스텝 사이
|
|
13
|
-
─ --stepper-dot-idle : 미도달
|
|
14
|
-
─ --stepper-marker
|
|
17
|
+
─ --stepper-line : 스텝 사이 연결선 색
|
|
18
|
+
─ --stepper-dot-idle : 미도달 marker border
|
|
19
|
+
─ --stepper-marker : active/done 공통 강조색 (기본 Primary)
|
|
20
|
+
─ --stepper-marker-fg : done marker 안쪽 글자/아이콘 색
|
|
21
|
+
─ --stepper-marker-soft: upcoming·neutral·active 등 soft fill 색
|
|
15
22
|
*/
|
|
16
23
|
|
|
17
24
|
.root {
|
|
18
25
|
--stepper-fg: var(--fg);
|
|
19
26
|
--stepper-fg-muted: var(--fg-muted);
|
|
20
27
|
--stepper-fg-subtle: var(--fg-subtle);
|
|
21
|
-
--stepper-line: var(--border);
|
|
22
|
-
--stepper-dot-idle: var(--border);
|
|
28
|
+
--stepper-line: var(--border-strong);
|
|
29
|
+
--stepper-dot-idle: var(--border-strong);
|
|
30
|
+
--stepper-marker: var(--primary);
|
|
23
31
|
--stepper-marker-fg: var(--bg);
|
|
32
|
+
--stepper-marker-soft: color-mix(in oklab, var(--fg) 8%, transparent);
|
|
24
33
|
|
|
25
34
|
--stepper-marker-size: var(--space-5);
|
|
26
35
|
--stepper-marker-gap: var(--space-4);
|
|
27
36
|
--stepper-item-gap: var(--space-6);
|
|
37
|
+
--stepper-title-lh: var(--lh-sm); /* marker 수직 보정 기준 */
|
|
38
|
+
--stepper-line-gap: var(--space-1); /* marker ↔ 선 간격 */
|
|
28
39
|
|
|
29
40
|
display: flex;
|
|
30
41
|
min-width: 0;
|
|
31
42
|
min-height: 0;
|
|
32
43
|
}
|
|
33
44
|
|
|
34
|
-
/* Orientation
|
|
45
|
+
/* ─ Orientation ────────────────────────────────────── */
|
|
35
46
|
|
|
36
47
|
.vertical { flex-direction: column; }
|
|
37
|
-
.horizontal {
|
|
48
|
+
.horizontal {
|
|
49
|
+
flex-direction: row;
|
|
50
|
+
align-items: flex-start;
|
|
51
|
+
--stepper-line-gap: var(--space-2);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* ─ Variant: md (기본, 큰 marker 24px) vs sm (작은 dot 10px) ─ */
|
|
55
|
+
|
|
56
|
+
.root[data-variant="md"] {
|
|
57
|
+
--stepper-marker-size: var(--space-6); /* 24px — 01 Stripe */
|
|
58
|
+
--stepper-marker-gap: var(--space-4);
|
|
59
|
+
--stepper-item-gap: var(--space-6);
|
|
60
|
+
--stepper-title-lh: var(--lh-base);
|
|
61
|
+
}
|
|
62
|
+
.root[data-variant="sm"] {
|
|
63
|
+
--stepper-marker-size: var(--space-2_5); /* 10px — 02 헤이딜러 */
|
|
64
|
+
--stepper-marker-gap: var(--space-3);
|
|
65
|
+
--stepper-item-gap: var(--space-10); /* 작은 marker는 호흡 넓게 */
|
|
66
|
+
--stepper-title-lh: var(--lh-sm);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ─ Tone: neutral이면 done·marker 색이 모노톤 ──────── */
|
|
70
|
+
|
|
71
|
+
.root[data-tone="neutral"] {
|
|
72
|
+
--stepper-marker: var(--fg);
|
|
73
|
+
}
|
|
38
74
|
|
|
39
|
-
/* Item
|
|
75
|
+
/* ─ Item ───────────────────────────────────────────── */
|
|
40
76
|
|
|
41
77
|
.item {
|
|
42
78
|
appearance: none;
|
|
@@ -61,7 +97,7 @@
|
|
|
61
97
|
box-shadow: var(--ring-shadow);
|
|
62
98
|
}
|
|
63
99
|
|
|
64
|
-
/* Vertical item: 2-column grid
|
|
100
|
+
/* Vertical item: 2-column grid */
|
|
65
101
|
.vertical .item {
|
|
66
102
|
display: grid;
|
|
67
103
|
grid-template-columns: var(--stepper-marker-size) 1fr;
|
|
@@ -70,23 +106,8 @@
|
|
|
70
106
|
padding-bottom: var(--stepper-item-gap);
|
|
71
107
|
}
|
|
72
108
|
.vertical .item:last-child { padding-bottom: 0; }
|
|
73
|
-
.vertical .item:not(:last-child)::before {
|
|
74
|
-
content: "";
|
|
75
|
-
position: absolute;
|
|
76
|
-
left: calc(var(--stepper-marker-size) / 2 - var(--hairline) / 2);
|
|
77
|
-
top: var(--stepper-marker-size);
|
|
78
|
-
bottom: 0;
|
|
79
|
-
width: var(--hairline);
|
|
80
|
-
background-image: linear-gradient(
|
|
81
|
-
to bottom,
|
|
82
|
-
var(--stepper-line) 50%,
|
|
83
|
-
transparent 50%
|
|
84
|
-
);
|
|
85
|
-
background-size: var(--hairline) var(--space-1_5);
|
|
86
|
-
background-repeat: repeat-y;
|
|
87
|
-
}
|
|
88
109
|
|
|
89
|
-
/* Horizontal item: marker 위, body
|
|
110
|
+
/* Horizontal item: marker 위, body 아래 */
|
|
90
111
|
.horizontal .item {
|
|
91
112
|
display: grid;
|
|
92
113
|
grid-template-rows: var(--stepper-marker-size) 1fr;
|
|
@@ -100,24 +121,61 @@
|
|
|
100
121
|
flex: 0 0 auto;
|
|
101
122
|
padding-right: 0;
|
|
102
123
|
}
|
|
124
|
+
|
|
125
|
+
/* ─ Connecting line (vertical: 기본 dashed) ─ */
|
|
126
|
+
|
|
127
|
+
.vertical .item:not(:last-child)::before {
|
|
128
|
+
content: "";
|
|
129
|
+
position: absolute;
|
|
130
|
+
left: calc(var(--stepper-marker-size) / 2 - var(--hairline) / 2);
|
|
131
|
+
/* marker가 margin-top만큼 아래로 밀렸고, 선은 marker 바닥에서
|
|
132
|
+
--stepper-line-gap 만큼 떨어진 지점부터 시작, 아래쪽도 같은 간격 띄움 */
|
|
133
|
+
top: calc(
|
|
134
|
+
(var(--stepper-title-lh) - var(--stepper-marker-size)) / 2 +
|
|
135
|
+
var(--stepper-marker-size) + var(--stepper-line-gap)
|
|
136
|
+
);
|
|
137
|
+
bottom: var(--stepper-line-gap);
|
|
138
|
+
width: var(--hairline);
|
|
139
|
+
background-image: linear-gradient(
|
|
140
|
+
to bottom,
|
|
141
|
+
var(--stepper-line) 65%,
|
|
142
|
+
transparent 35%
|
|
143
|
+
);
|
|
144
|
+
background-size: var(--hairline) var(--space-1_5);
|
|
145
|
+
background-repeat: repeat-y;
|
|
146
|
+
}
|
|
147
|
+
|
|
103
148
|
.horizontal .item:not(:last-child)::before {
|
|
104
149
|
content: "";
|
|
105
150
|
position: absolute;
|
|
106
151
|
top: calc(var(--stepper-marker-size) / 2 - var(--hairline) / 2);
|
|
107
|
-
left: var(--stepper-marker-size);
|
|
108
|
-
right:
|
|
152
|
+
left: calc(var(--stepper-marker-size) + var(--stepper-line-gap));
|
|
153
|
+
right: var(--stepper-line-gap);
|
|
109
154
|
height: var(--hairline);
|
|
110
155
|
background-image: linear-gradient(
|
|
111
156
|
to right,
|
|
112
|
-
var(--stepper-line)
|
|
113
|
-
transparent
|
|
157
|
+
var(--stepper-line) 65%,
|
|
158
|
+
transparent 35%
|
|
114
159
|
);
|
|
115
160
|
background-size: var(--space-1_5) var(--hairline);
|
|
116
161
|
background-repeat: repeat-x;
|
|
117
|
-
margin-inline: var(--space-2);
|
|
118
162
|
}
|
|
119
163
|
|
|
120
|
-
/*
|
|
164
|
+
/* line-style: solid (01 — 매끈한 1px) — selector 주의:
|
|
165
|
+
.vertical/.horizontal과 data-line-style attr가 같은 root element에 붙어 있으므로
|
|
166
|
+
descendant가 아닌 compound selector로 작성. */
|
|
167
|
+
.vertical[data-line-style="solid"] .item:not(:last-child)::before {
|
|
168
|
+
background: var(--stepper-line);
|
|
169
|
+
background-image: none;
|
|
170
|
+
background-size: auto;
|
|
171
|
+
}
|
|
172
|
+
.horizontal[data-line-style="solid"] .item:not(:last-child)::before {
|
|
173
|
+
background: var(--stepper-line);
|
|
174
|
+
background-image: none;
|
|
175
|
+
background-size: auto;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ─ Marker ────────────────────────────────────────── */
|
|
121
179
|
|
|
122
180
|
.marker {
|
|
123
181
|
width: var(--stepper-marker-size);
|
|
@@ -131,34 +189,98 @@
|
|
|
131
189
|
flex-shrink: 0;
|
|
132
190
|
transition: var(--transition-colors);
|
|
133
191
|
font-size: var(--text-xs);
|
|
134
|
-
font-weight: var(--font-weight-
|
|
192
|
+
font-weight: var(--font-weight-medium);
|
|
135
193
|
line-height: 1;
|
|
136
|
-
color:
|
|
194
|
+
color: var(--stepper-fg-subtle);
|
|
195
|
+
font-variant-numeric: tabular-nums;
|
|
196
|
+
position: relative;
|
|
197
|
+
/* vertical일 때 marker가 title 첫 줄 중앙선에 맞도록 아래로 밀기.
|
|
198
|
+
--stepper-title-lh는 variant별로 공급되는 title line-height. */
|
|
199
|
+
margin-top: calc(
|
|
200
|
+
(var(--stepper-title-lh) - var(--stepper-marker-size)) / 2
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
/* horizontal에서는 marker가 body 위에 위치하므로 보정 off */
|
|
204
|
+
.horizontal .marker {
|
|
205
|
+
margin-top: 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* ── variant=md (기본) marker scaling ──
|
|
209
|
+
24px marker 안의 숫자/check도 균형 잡힌 사이즈. */
|
|
210
|
+
.root[data-variant="md"] .marker {
|
|
211
|
+
font-size: var(--text-xs);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* ── variant=sm marker는 solid dot (border 없음, soft fill) ── */
|
|
215
|
+
.root[data-variant="sm"] .marker {
|
|
216
|
+
border: none;
|
|
217
|
+
background: var(--stepper-dot-idle);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* ── Upcoming (md variant): soft bg + muted 숫자 (01 Stripe) ── */
|
|
221
|
+
.root[data-variant="md"] .item[data-state="upcoming"] .marker {
|
|
222
|
+
background: var(--stepper-marker-soft);
|
|
223
|
+
border-color: transparent;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* ── Done marker ── */
|
|
227
|
+
/* md + primary tone (기본): Primary fill */
|
|
228
|
+
.item[data-state="done"] .marker {
|
|
229
|
+
border-color: transparent;
|
|
230
|
+
background: var(--stepper-marker);
|
|
231
|
+
color: var(--stepper-marker-fg);
|
|
232
|
+
}
|
|
233
|
+
/* md + neutral tone (01 Stripe): soft gray fill + muted check */
|
|
234
|
+
.root[data-tone="neutral"] .item[data-state="done"] .marker {
|
|
235
|
+
background: var(--stepper-marker-soft);
|
|
236
|
+
color: var(--stepper-fg-muted);
|
|
237
|
+
}
|
|
238
|
+
/* sm variant done: 색만 marker 색, dot 크기 그대로 */
|
|
239
|
+
.root[data-variant="sm"] .item[data-state="done"] .marker {
|
|
240
|
+
background: var(--stepper-dot-idle);
|
|
241
|
+
color: transparent;
|
|
242
|
+
}
|
|
243
|
+
.root[data-variant="sm"][data-tone="primary"]
|
|
244
|
+
.item[data-state="done"]
|
|
245
|
+
.marker {
|
|
246
|
+
background: var(--stepper-marker);
|
|
137
247
|
}
|
|
138
248
|
|
|
139
|
-
/* Active marker
|
|
249
|
+
/* ── Active marker ──
|
|
250
|
+
marker 자체가 큰 soft Primary 동그라미 + 중앙에 작은 Primary dot. */
|
|
140
251
|
.item[data-state="active"] .marker {
|
|
141
|
-
|
|
142
|
-
border-
|
|
143
|
-
color: var(--stepper-
|
|
252
|
+
background: color-mix(in oklab, var(--stepper-marker) 18%, transparent);
|
|
253
|
+
border-color: transparent;
|
|
254
|
+
color: var(--stepper-marker);
|
|
255
|
+
}
|
|
256
|
+
/* sm variant active: marker 자체는 solid Primary dot + 바깥에 soft Primary halo.
|
|
257
|
+
작은 dot(10px)이라 안쪽 split 대신 외곽 halo로 "지금 여기" 신호를 줌. */
|
|
258
|
+
.root[data-variant="sm"] .item[data-state="active"] .marker {
|
|
259
|
+
background: var(--stepper-marker);
|
|
260
|
+
color: var(--stepper-marker-fg);
|
|
261
|
+
box-shadow: 0 0 0 var(--space-1)
|
|
262
|
+
color-mix(in oklab, var(--stepper-marker) 22%, transparent);
|
|
144
263
|
}
|
|
145
264
|
|
|
146
|
-
/*
|
|
265
|
+
/* ─ markerInnerDot: active marker 중앙 작은 Primary dot ─ */
|
|
147
266
|
.markerInnerDot {
|
|
148
267
|
width: var(--space-1_5);
|
|
149
268
|
height: var(--space-1_5);
|
|
150
269
|
border-radius: var(--radius-full);
|
|
151
|
-
background: var(--stepper-
|
|
270
|
+
background: var(--stepper-marker);
|
|
271
|
+
flex-shrink: 0;
|
|
152
272
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
273
|
+
/* md (24px marker)에서는 inner dot도 조금 더 크게 */
|
|
274
|
+
.root[data-variant="md"] .markerInnerDot {
|
|
275
|
+
width: var(--space-2);
|
|
276
|
+
height: var(--space-2);
|
|
277
|
+
}
|
|
278
|
+
/* sm variant는 marker 자체가 10px dot이라 inner dot 숨김 */
|
|
279
|
+
.root[data-variant="sm"] .markerInnerDot {
|
|
280
|
+
display: none;
|
|
159
281
|
}
|
|
160
282
|
|
|
161
|
-
/* Body
|
|
283
|
+
/* ─ Body ─────────────────────────────────────────── */
|
|
162
284
|
|
|
163
285
|
.body {
|
|
164
286
|
display: flex;
|
|
@@ -168,15 +290,37 @@
|
|
|
168
290
|
padding-right: var(--space-2);
|
|
169
291
|
}
|
|
170
292
|
|
|
293
|
+
/* hintPlacement=inline (02): title | hint 가로 배치 */
|
|
294
|
+
.root[data-hint-placement="inline"] .body {
|
|
295
|
+
flex-direction: row;
|
|
296
|
+
align-items: baseline;
|
|
297
|
+
gap: var(--space-2);
|
|
298
|
+
flex-wrap: wrap;
|
|
299
|
+
}
|
|
300
|
+
|
|
171
301
|
.title {
|
|
172
302
|
font-size: var(--text-sm);
|
|
303
|
+
font-weight: var(--font-weight-medium);
|
|
304
|
+
line-height: var(--lh-sm);
|
|
305
|
+
}
|
|
306
|
+
/* md variant는 title 조금 더 큼 (01 Stripe 톤) */
|
|
307
|
+
.root[data-variant="md"] .title {
|
|
308
|
+
font-size: var(--text-base);
|
|
309
|
+
line-height: var(--lh-base);
|
|
310
|
+
}
|
|
311
|
+
.item[data-state="active"] .title {
|
|
173
312
|
font-weight: var(--font-weight-semibold);
|
|
174
313
|
}
|
|
314
|
+
|
|
175
315
|
.hint {
|
|
176
316
|
font-size: var(--text-xs);
|
|
177
317
|
color: var(--stepper-fg-subtle);
|
|
178
318
|
line-height: var(--lh-xs);
|
|
179
319
|
}
|
|
320
|
+
.root[data-variant="md"] .hint {
|
|
321
|
+
font-size: var(--text-sm);
|
|
322
|
+
line-height: var(--lh-sm);
|
|
323
|
+
}
|
|
180
324
|
.item[data-state="active"] .hint {
|
|
181
325
|
color: var(--stepper-fg-muted);
|
|
182
326
|
}
|
|
@@ -18,15 +18,23 @@ import styles from "./Stepper.module.css";
|
|
|
18
18
|
|
|
19
19
|
type Orientation = "vertical" | "horizontal";
|
|
20
20
|
type Marker = "dot" | "number";
|
|
21
|
+
type Variant = "md" | "sm";
|
|
22
|
+
type Tone = "primary" | "neutral";
|
|
23
|
+
type HintPlacement = "below" | "inline";
|
|
24
|
+
type LineStyle = "dashed" | "solid";
|
|
21
25
|
type ItemState = "upcoming" | "active" | "done";
|
|
22
26
|
|
|
23
27
|
type StepperContextValue = {
|
|
24
28
|
orientation: Orientation;
|
|
25
29
|
marker: Marker;
|
|
30
|
+
variant: Variant;
|
|
31
|
+
tone: Tone;
|
|
32
|
+
hintPlacement: HintPlacement;
|
|
26
33
|
activeIndex: number;
|
|
27
34
|
onStepClick?: (index: number) => void;
|
|
28
35
|
};
|
|
29
36
|
|
|
37
|
+
|
|
30
38
|
const StepperContext = createContext<StepperContextValue | null>(null);
|
|
31
39
|
|
|
32
40
|
function useStepperContext(): StepperContextValue {
|
|
@@ -46,6 +54,22 @@ export type StepperProps = Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> & {
|
|
|
46
54
|
orientation?: Orientation;
|
|
47
55
|
/** marker 종류. dot(기본) | number. */
|
|
48
56
|
marker?: Marker;
|
|
57
|
+
/**
|
|
58
|
+
* marker 사이즈.
|
|
59
|
+
* - `md` : 24px 동그라미 marker (기본, 01 Stripe 류)
|
|
60
|
+
* - `sm` : 10px 작은 solid dot (02 헤이딜러 류, title 중심)
|
|
61
|
+
*/
|
|
62
|
+
variant?: Variant;
|
|
63
|
+
/**
|
|
64
|
+
* 색 톤.
|
|
65
|
+
* - `primary` : done/active 모두 Primary (기본)
|
|
66
|
+
* - `neutral` : done은 모노톤, active만 Primary 혹은 fg-strong
|
|
67
|
+
*/
|
|
68
|
+
tone?: Tone;
|
|
69
|
+
/** hint 배치. below(기본) | inline(title 옆). */
|
|
70
|
+
hintPlacement?: HintPlacement;
|
|
71
|
+
/** 스텝 사이 연결선 스타일. dashed(기본) | solid. */
|
|
72
|
+
lineStyle?: LineStyle;
|
|
49
73
|
/** 스텝 클릭 시. 자식 Item에 onClick이 있으면 그쪽이 우선. */
|
|
50
74
|
onStepClick?: (index: number) => void;
|
|
51
75
|
children: ReactNode;
|
|
@@ -57,6 +81,10 @@ const StepperRoot = forwardRef<HTMLDivElement, StepperProps>(
|
|
|
57
81
|
activeIndex,
|
|
58
82
|
orientation = "vertical",
|
|
59
83
|
marker = "dot",
|
|
84
|
+
variant = "md",
|
|
85
|
+
tone = "primary",
|
|
86
|
+
hintPlacement = "below",
|
|
87
|
+
lineStyle = "dashed",
|
|
60
88
|
onStepClick,
|
|
61
89
|
className,
|
|
62
90
|
children,
|
|
@@ -65,11 +93,26 @@ const StepperRoot = forwardRef<HTMLDivElement, StepperProps>(
|
|
|
65
93
|
ref,
|
|
66
94
|
) => {
|
|
67
95
|
const ctxValue = useMemo<StepperContextValue>(
|
|
68
|
-
() => ({
|
|
69
|
-
|
|
96
|
+
() => ({
|
|
97
|
+
orientation,
|
|
98
|
+
marker,
|
|
99
|
+
variant,
|
|
100
|
+
tone,
|
|
101
|
+
hintPlacement,
|
|
102
|
+
activeIndex,
|
|
103
|
+
onStepClick,
|
|
104
|
+
}),
|
|
105
|
+
[
|
|
106
|
+
orientation,
|
|
107
|
+
marker,
|
|
108
|
+
variant,
|
|
109
|
+
tone,
|
|
110
|
+
hintPlacement,
|
|
111
|
+
activeIndex,
|
|
112
|
+
onStepClick,
|
|
113
|
+
],
|
|
70
114
|
);
|
|
71
115
|
|
|
72
|
-
// 자식 Item에 index를 자동 주입
|
|
73
116
|
const items = Children.toArray(children).filter(isValidElement);
|
|
74
117
|
const injected = items.map((child, i) =>
|
|
75
118
|
cloneElement(child as ReactElement<StepperItemProps>, {
|
|
@@ -91,6 +134,10 @@ const StepperRoot = forwardRef<HTMLDivElement, StepperProps>(
|
|
|
91
134
|
.join(" ")}
|
|
92
135
|
data-orientation={orientation}
|
|
93
136
|
data-marker={marker}
|
|
137
|
+
data-variant={variant}
|
|
138
|
+
data-tone={tone}
|
|
139
|
+
data-hint-placement={hintPlacement}
|
|
140
|
+
data-line-style={lineStyle}
|
|
94
141
|
{...rest}
|
|
95
142
|
>
|
|
96
143
|
{injected}
|
|
@@ -107,11 +154,9 @@ export type StepperItemProps = Omit<
|
|
|
107
154
|
ButtonHTMLAttributes<HTMLButtonElement>,
|
|
108
155
|
"title"
|
|
109
156
|
> & {
|
|
110
|
-
/** Stepper가 자동 주입. 수동 override 가능. */
|
|
111
157
|
index?: number;
|
|
112
158
|
title: ReactNode;
|
|
113
159
|
hint?: ReactNode;
|
|
114
|
-
/** 명시적 상태 지정. 미지정이면 activeIndex 기준으로 자동 결정. */
|
|
115
160
|
state?: ItemState;
|
|
116
161
|
};
|
|
117
162
|
|
|
@@ -130,7 +175,7 @@ const StepperItem = forwardRef<HTMLButtonElement, StepperItemProps>(
|
|
|
130
175
|
},
|
|
131
176
|
ref,
|
|
132
177
|
) => {
|
|
133
|
-
const { orientation, marker, activeIndex, onStepClick } =
|
|
178
|
+
const { orientation, marker, variant, activeIndex, onStepClick } =
|
|
134
179
|
useStepperContext();
|
|
135
180
|
|
|
136
181
|
const idx = index ?? 0;
|
|
@@ -138,6 +183,22 @@ const StepperItem = forwardRef<HTMLButtonElement, StepperItemProps>(
|
|
|
138
183
|
stateProp ??
|
|
139
184
|
(idx === activeIndex ? "active" : idx < activeIndex ? "done" : "upcoming");
|
|
140
185
|
|
|
186
|
+
// marker 내부 렌더링 규칙:
|
|
187
|
+
// ─ sm variant : 내부 글자/dot 없음 (marker 자체가 작은 solid dot)
|
|
188
|
+
// ─ done : check 아이콘
|
|
189
|
+
// ─ number marker: 숫자 (active여도 숫자 유지)
|
|
190
|
+
// ─ dot + active : 작은 Primary dot
|
|
191
|
+
const markerContent =
|
|
192
|
+
variant === "sm"
|
|
193
|
+
? null
|
|
194
|
+
: state === "done"
|
|
195
|
+
? <Icon name="check" size={10} />
|
|
196
|
+
: marker === "number"
|
|
197
|
+
? idx + 1
|
|
198
|
+
: state === "active"
|
|
199
|
+
? <span className={styles.markerInnerDot} />
|
|
200
|
+
: null;
|
|
201
|
+
|
|
141
202
|
return (
|
|
142
203
|
<button
|
|
143
204
|
ref={ref}
|
|
@@ -155,13 +216,7 @@ const StepperItem = forwardRef<HTMLButtonElement, StepperItemProps>(
|
|
|
155
216
|
{...rest}
|
|
156
217
|
>
|
|
157
218
|
<span className={styles.marker} aria-hidden>
|
|
158
|
-
{
|
|
159
|
-
<Icon name="check" size={12} />
|
|
160
|
-
) : marker === "number" ? (
|
|
161
|
-
idx + 1
|
|
162
|
-
) : state === "active" ? (
|
|
163
|
-
<span className={styles.markerInnerDot} />
|
|
164
|
-
) : null}
|
|
219
|
+
{markerContent}
|
|
165
220
|
</span>
|
|
166
221
|
<span className={styles.body}>
|
|
167
222
|
<span className={styles.title}>{title}</span>
|