@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
|
@@ -1,201 +1,102 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* ListItem —
|
|
4
|
+
* ListItem — 3구역(leading / content / trailing) 고정 뼈대 + content 내부 자유 조립.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* 축:
|
|
7
|
+
* size — 세로 여백감. md(기본, 작업/자원 리스트 밀도) | sm (활동 로그·알림 스트림 타이트)
|
|
8
|
+
* align — leading 세로 정렬. center(기본) | top (2줄 이상 본문용)
|
|
9
|
+
* interactive — hover/focus 피드백 (href 또는 onClick 있을 때 자동)
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* (B) verb+target — "박지훈 · 댓글 남김 · ERP 2.0 사이드바"
|
|
11
|
+
* chrome 없음 — bg·border·radius 자체 공급 X. 리스트 분리는 부모가 담당.
|
|
12
|
+
* 블록 외형(배경·테두리로 상태 신호)이 필요하면 Callout 사용.
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* content 안에서 쓰는 subcomponents:
|
|
15
|
+
* ListItem.Title 메인 제목 (앞에 Badge 등 inline 자유)
|
|
16
|
+
* ListItem.Description muted 서브 텍스트
|
|
17
|
+
* ListItem.Meta 하단 meta 라인 (타임스탬프·상태)
|
|
18
|
+
* ListItem.MetaDivider hairline 세로 구분자
|
|
19
|
+
*
|
|
20
|
+
* 일정·이벤트처럼 날짜가 leading 아이덴티티인 자리는 [[EventListItem]] 사용.
|
|
18
21
|
*/
|
|
19
22
|
|
|
20
23
|
import {
|
|
21
|
-
Children,
|
|
22
|
-
cloneElement,
|
|
23
24
|
forwardRef,
|
|
24
|
-
isValidElement,
|
|
25
25
|
type AnchorHTMLAttributes,
|
|
26
26
|
type HTMLAttributes,
|
|
27
|
-
type ReactElement,
|
|
28
27
|
type ReactNode,
|
|
29
28
|
type Ref,
|
|
30
29
|
} from "react";
|
|
31
|
-
import { Slot } from "@radix-ui/react-slot";
|
|
32
30
|
|
|
33
31
|
import styles from "./ListItem.module.css";
|
|
34
32
|
|
|
35
|
-
export type
|
|
36
|
-
export type
|
|
33
|
+
export type ListItemSize = "sm" | "md";
|
|
34
|
+
export type ListItemAlign = "center" | "top";
|
|
37
35
|
|
|
38
36
|
type Common = {
|
|
39
|
-
// 왼쪽
|
|
40
37
|
leading?: ReactNode;
|
|
41
|
-
/**
|
|
42
|
-
* 읽음/안읽음 신호.
|
|
43
|
-
* - undefined (default): 중립 톤. 리드/언리드 개념 없는 자리(활동 로그·히스토리).
|
|
44
|
-
* - true: 안 읽음. body/from 강조 + meta primary.
|
|
45
|
-
* - false: 읽음. 전체 톤 한두 단계 다운.
|
|
46
|
-
*/
|
|
47
|
-
unread?: boolean;
|
|
48
|
-
|
|
49
|
-
// 본문
|
|
50
|
-
from?: ReactNode;
|
|
51
|
-
body?: ReactNode;
|
|
52
|
-
verb?: ReactNode;
|
|
53
|
-
target?: ReactNode;
|
|
54
|
-
surface?: ReactNode;
|
|
55
|
-
|
|
56
|
-
// 메타
|
|
57
|
-
meta?: ReactNode;
|
|
58
38
|
trailing?: ReactNode;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
variant?: ListItemVariant;
|
|
63
|
-
layout?: ListItemLayout;
|
|
64
|
-
asChild?: boolean;
|
|
39
|
+
size?: ListItemSize;
|
|
40
|
+
align?: ListItemAlign;
|
|
41
|
+
children?: ReactNode;
|
|
65
42
|
};
|
|
66
43
|
|
|
67
44
|
type DivProps = Common &
|
|
68
|
-
Omit<HTMLAttributes<HTMLDivElement>, keyof Common> & {
|
|
69
|
-
href?: undefined;
|
|
70
|
-
};
|
|
45
|
+
Omit<HTMLAttributes<HTMLDivElement>, keyof Common> & { href?: undefined };
|
|
71
46
|
|
|
72
47
|
type AnchorProps = Common &
|
|
73
|
-
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof Common> & {
|
|
74
|
-
href: string;
|
|
75
|
-
};
|
|
48
|
+
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof Common> & { href: string };
|
|
76
49
|
|
|
77
50
|
export type ListItemProps = DivProps | AnchorProps;
|
|
78
51
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem(
|
|
52
|
+
const ListItemRoot = forwardRef<HTMLElement, ListItemProps>(function ListItem(
|
|
82
53
|
props,
|
|
83
54
|
ref,
|
|
84
55
|
) {
|
|
85
56
|
const {
|
|
86
57
|
leading,
|
|
87
|
-
unread,
|
|
88
|
-
from,
|
|
89
|
-
body,
|
|
90
|
-
verb,
|
|
91
|
-
target,
|
|
92
|
-
surface,
|
|
93
|
-
meta,
|
|
94
58
|
trailing,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
layout = "inline",
|
|
98
|
-
asChild,
|
|
59
|
+
size = "md",
|
|
60
|
+
align = "center",
|
|
99
61
|
className,
|
|
62
|
+
children,
|
|
100
63
|
...rest
|
|
101
64
|
} = props as Common & { className?: string } & Record<string, unknown>;
|
|
102
65
|
|
|
103
|
-
const isActionMode = verb !== undefined || target !== undefined;
|
|
104
66
|
const isLink = typeof (props as AnchorProps).href === "string";
|
|
105
|
-
const isInteractive =
|
|
106
|
-
|
|
107
|
-
// read-state class — unread 명시적으로 넘겼을 때만 신호. undefined면 중립.
|
|
108
|
-
const readStateClass =
|
|
109
|
-
unread === true ? styles.unread : unread === false ? styles.read : "";
|
|
67
|
+
const isInteractive =
|
|
68
|
+
isLink || typeof (props as HTMLAttributes<HTMLElement>).onClick === "function";
|
|
110
69
|
|
|
111
70
|
const rootClass = [
|
|
112
71
|
styles.root,
|
|
113
|
-
styles[
|
|
114
|
-
|
|
115
|
-
readStateClass,
|
|
72
|
+
styles[`size-${size}`],
|
|
73
|
+
styles[`align-${align}`],
|
|
116
74
|
isInteractive ? styles.interactive : "",
|
|
117
75
|
className ?? "",
|
|
118
76
|
]
|
|
119
77
|
.filter(Boolean)
|
|
120
78
|
.join(" ");
|
|
121
79
|
|
|
122
|
-
const leadingNode = leading ? (
|
|
123
|
-
<span className={styles.leading} aria-hidden>
|
|
124
|
-
{leading}
|
|
125
|
-
</span>
|
|
126
|
-
) : null;
|
|
127
|
-
|
|
128
|
-
// 본문 — 서술 모드 배타. verb/target이 하나라도 있으면 action 모드.
|
|
129
|
-
// layout=stacked이면 두 줄로 분리: 1줄째 from(+verb+target+surface), 2줄째 body
|
|
130
|
-
const isStacked = layout === "stacked";
|
|
131
|
-
const bodyNode = isActionMode ? (
|
|
132
|
-
isStacked ? (
|
|
133
|
-
<>
|
|
134
|
-
<span className={styles.actionBody}>
|
|
135
|
-
{from && <span className={styles.from}>{from}</span>}
|
|
136
|
-
{verb && <span className={styles.verb}>{verb}</span>}
|
|
137
|
-
{target && <span className={styles.target}>{target}</span>}
|
|
138
|
-
{surface && <span className={styles.surface}>{surface}</span>}
|
|
139
|
-
</span>
|
|
140
|
-
{body && <span className={styles.body}>{body}</span>}
|
|
141
|
-
</>
|
|
142
|
-
) : (
|
|
143
|
-
<span className={styles.actionBody}>
|
|
144
|
-
{from && <span className={styles.from}>{from}</span>}
|
|
145
|
-
{verb && <span className={styles.verb}>{verb}</span>}
|
|
146
|
-
{target && <span className={styles.target}>{target}</span>}
|
|
147
|
-
{surface && <span className={styles.surface}>{surface}</span>}
|
|
148
|
-
</span>
|
|
149
|
-
)
|
|
150
|
-
) : isStacked ? (
|
|
151
|
-
<>
|
|
152
|
-
{from && <span className={styles.from}>{from}</span>}
|
|
153
|
-
{body && <span className={styles.body}>{body}</span>}
|
|
154
|
-
</>
|
|
155
|
-
) : (
|
|
156
|
-
<span className={styles.narrativeBody}>
|
|
157
|
-
{from && <span className={styles.from}>{from}</span>}
|
|
158
|
-
{body && <span className={styles.body}>{body}</span>}
|
|
159
|
-
</span>
|
|
160
|
-
);
|
|
161
|
-
|
|
162
|
-
// trailing: 명시 trailing 우선, 없으면 meta
|
|
163
|
-
const trailingNode = trailing !== undefined ? (
|
|
164
|
-
<span className={styles.trailing}>{trailing}</span>
|
|
165
|
-
) : meta !== undefined ? (
|
|
166
|
-
<span className={styles.meta}>{meta}</span>
|
|
167
|
-
) : null;
|
|
168
|
-
|
|
169
|
-
// hoverActions: 배열이면 간격 유지하며 렌더
|
|
170
|
-
const hoverNode = hoverActions ? (
|
|
171
|
-
<span className={styles.hoverActions}>
|
|
172
|
-
{Children.map(hoverActions, (child) =>
|
|
173
|
-
isValidElement(child) ? child : child,
|
|
174
|
-
)}
|
|
175
|
-
</span>
|
|
176
|
-
) : null;
|
|
177
|
-
|
|
178
80
|
const content = (
|
|
179
81
|
<>
|
|
180
|
-
{
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
82
|
+
{leading !== undefined && leading !== null && (
|
|
83
|
+
<span className={styles.leading} aria-hidden>
|
|
84
|
+
{leading}
|
|
85
|
+
</span>
|
|
86
|
+
)}
|
|
87
|
+
<span className={styles.content}>{children}</span>
|
|
88
|
+
{trailing !== undefined && trailing !== null && (
|
|
89
|
+
<span className={styles.trailing}>{trailing}</span>
|
|
90
|
+
)}
|
|
184
91
|
</>
|
|
185
92
|
);
|
|
186
93
|
|
|
187
|
-
if (asChild) {
|
|
188
|
-
const only = Children.only(rest.children) as ReactElement<HTMLAttributes<HTMLElement>>;
|
|
189
|
-
return cloneElement(only, { className: rootClass, ...rest }, content);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
94
|
if (isLink) {
|
|
193
|
-
const { children, ...anchorRest } = rest as AnchorHTMLAttributes<HTMLAnchorElement>;
|
|
194
95
|
return (
|
|
195
96
|
<a
|
|
196
97
|
ref={ref as Ref<HTMLAnchorElement>}
|
|
197
98
|
className={rootClass}
|
|
198
|
-
{...
|
|
99
|
+
{...(rest as AnchorHTMLAttributes<HTMLAnchorElement>)}
|
|
199
100
|
href={(props as AnchorProps).href}
|
|
200
101
|
>
|
|
201
102
|
{content}
|
|
@@ -203,29 +104,86 @@ const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem(
|
|
|
203
104
|
);
|
|
204
105
|
}
|
|
205
106
|
|
|
206
|
-
const { children, ...divRest } = rest as HTMLAttributes<HTMLDivElement>;
|
|
207
107
|
return (
|
|
208
108
|
<div
|
|
209
109
|
ref={ref as Ref<HTMLDivElement>}
|
|
210
110
|
className={rootClass}
|
|
211
|
-
data-
|
|
212
|
-
{...
|
|
111
|
+
data-size={size}
|
|
112
|
+
{...(rest as HTMLAttributes<HTMLDivElement>)}
|
|
213
113
|
>
|
|
214
114
|
{content}
|
|
215
115
|
</div>
|
|
216
116
|
);
|
|
217
117
|
});
|
|
218
118
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
export { ListItem };
|
|
119
|
+
ListItemRoot.displayName = "ListItem";
|
|
222
120
|
|
|
223
121
|
/* ──────────────────────────────────────────────────────
|
|
224
|
-
|
|
122
|
+
content subcomponents
|
|
123
|
+
──────────────────────────────────────────────────── */
|
|
124
|
+
|
|
125
|
+
type SpanProps = HTMLAttributes<HTMLSpanElement>;
|
|
126
|
+
type DivOnlyProps = HTMLAttributes<HTMLDivElement>;
|
|
127
|
+
|
|
128
|
+
const Title = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
|
|
129
|
+
function ListItemTitle({ className, children, ...rest }, ref) {
|
|
130
|
+
return (
|
|
131
|
+
<div
|
|
132
|
+
ref={ref as unknown as Ref<HTMLHeadingElement>}
|
|
133
|
+
className={[styles.title, className ?? ""].filter(Boolean).join(" ")}
|
|
134
|
+
{...(rest as HTMLAttributes<HTMLDivElement>)}
|
|
135
|
+
>
|
|
136
|
+
{children}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
Title.displayName = "ListItem.Title";
|
|
225
142
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
143
|
+
const Description = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
|
|
144
|
+
function ListItemDescription({ className, children, ...rest }, ref) {
|
|
145
|
+
return (
|
|
146
|
+
<p
|
|
147
|
+
ref={ref}
|
|
148
|
+
className={[styles.description, className ?? ""].filter(Boolean).join(" ")}
|
|
149
|
+
{...rest}
|
|
150
|
+
>
|
|
151
|
+
{children}
|
|
152
|
+
</p>
|
|
153
|
+
);
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
Description.displayName = "ListItem.Description";
|
|
157
|
+
|
|
158
|
+
const Meta = forwardRef<HTMLDivElement, DivOnlyProps>(function ListItemMeta(
|
|
159
|
+
{ className, children, ...rest },
|
|
160
|
+
ref,
|
|
161
|
+
) {
|
|
162
|
+
return (
|
|
163
|
+
<div ref={ref} className={[styles.meta, className ?? ""].filter(Boolean).join(" ")} {...rest}>
|
|
164
|
+
{children}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
Meta.displayName = "ListItem.Meta";
|
|
169
|
+
|
|
170
|
+
const MetaDivider = forwardRef<HTMLSpanElement, SpanProps>(function ListItemMetaDivider(
|
|
171
|
+
{ className, ...rest },
|
|
172
|
+
ref,
|
|
173
|
+
) {
|
|
174
|
+
return (
|
|
175
|
+
<span
|
|
176
|
+
ref={ref}
|
|
177
|
+
aria-hidden
|
|
178
|
+
className={[styles.metaDivider, className ?? ""].filter(Boolean).join(" ")}
|
|
179
|
+
{...rest}
|
|
180
|
+
/>
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
MetaDivider.displayName = "ListItem.MetaDivider";
|
|
184
|
+
|
|
185
|
+
/* ──────────────────────────────────────────────────────
|
|
186
|
+
LeadingIcon — "배경색 + 아이콘" leading 타입. 기존 유지.
|
|
229
187
|
──────────────────────────────────────────────────── */
|
|
230
188
|
|
|
231
189
|
export type ListItemLeadingIconSize = "xs" | "sm" | "md" | "lg" | "auto";
|
|
@@ -244,8 +202,13 @@ export type ListItemLeadingIconProps = Omit<HTMLAttributes<HTMLSpanElement>, "ch
|
|
|
244
202
|
};
|
|
245
203
|
|
|
246
204
|
const ListItemLeadingIcon = forwardRef<HTMLSpanElement, ListItemLeadingIconProps>(
|
|
247
|
-
function ListItemLeadingIcon(
|
|
248
|
-
|
|
205
|
+
function ListItemLeadingIcon(
|
|
206
|
+
{ size = "sm", tone = "neutral", className, children, ...rest },
|
|
207
|
+
ref,
|
|
208
|
+
) {
|
|
209
|
+
const cls = [styles.leadingIcon, styles[`li-${size}`], className ?? ""]
|
|
210
|
+
.filter(Boolean)
|
|
211
|
+
.join(" ");
|
|
249
212
|
return (
|
|
250
213
|
<span ref={ref} aria-hidden {...rest} className={cls} data-tone={tone}>
|
|
251
214
|
{children}
|
|
@@ -253,10 +216,23 @@ const ListItemLeadingIcon = forwardRef<HTMLSpanElement, ListItemLeadingIconProps
|
|
|
253
216
|
);
|
|
254
217
|
},
|
|
255
218
|
);
|
|
256
|
-
|
|
257
219
|
ListItemLeadingIcon.displayName = "ListItemLeadingIcon";
|
|
258
220
|
|
|
259
|
-
|
|
221
|
+
/* ──────────────────────────────────────────────────────
|
|
222
|
+
compound export
|
|
223
|
+
──────────────────────────────────────────────────── */
|
|
224
|
+
|
|
225
|
+
type ListItemCompound = typeof ListItemRoot & {
|
|
226
|
+
Title: typeof Title;
|
|
227
|
+
Description: typeof Description;
|
|
228
|
+
Meta: typeof Meta;
|
|
229
|
+
MetaDivider: typeof MetaDivider;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const ListItem = ListItemRoot as ListItemCompound;
|
|
233
|
+
ListItem.Title = Title;
|
|
234
|
+
ListItem.Description = Description;
|
|
235
|
+
ListItem.Meta = Meta;
|
|
236
|
+
ListItem.MetaDivider = MetaDivider;
|
|
260
237
|
|
|
261
|
-
|
|
262
|
-
export const ListItemSlot = Slot;
|
|
238
|
+
export { ListItem, ListItemLeadingIcon };
|
|
@@ -41,13 +41,19 @@
|
|
|
41
41
|
- grid → inline-flex center: 라벨/trailing이 사라진 상태에서 아이콘 정중앙 정렬
|
|
42
42
|
- width = min-height: 완전한 정사각. 시각 폭이 rail 폭과 정합.
|
|
43
43
|
- padding-inline·gap 제거: 정사각 안에 아이콘 하나만 놓이도록.
|
|
44
|
+
- align-self: center: flex-column rail(.menu-rail)처럼 cross-axis가 가로인 부모 안에서
|
|
45
|
+
item이 왼쪽으로 쏠리는 걸 방지. parent가 flex가 아니면 무해.
|
|
46
|
+
- margin-inline: auto: block/grid 부모(라벨 숨긴 Sidebar.Item 등)에서도 가로 중앙.
|
|
44
47
|
- transition: width는 부모(Sidebar) 애니메이션과 짝지어져 자연스럽게. */
|
|
45
48
|
.root[data-collapsed] {
|
|
46
49
|
display: inline-flex;
|
|
50
|
+
align-items: center;
|
|
47
51
|
justify-content: center;
|
|
48
52
|
gap: 0;
|
|
49
53
|
padding-inline: 0;
|
|
50
54
|
width: var(--menu-item-h-md);
|
|
55
|
+
align-self: center;
|
|
56
|
+
margin-inline: auto;
|
|
51
57
|
}
|
|
52
58
|
.sm.root[data-collapsed] {
|
|
53
59
|
width: var(--menu-item-h-sm);
|
|
@@ -135,8 +141,11 @@
|
|
|
135
141
|
}
|
|
136
142
|
.trailing {
|
|
137
143
|
color: var(--fg-subtle);
|
|
138
|
-
/*
|
|
139
|
-
|
|
144
|
+
/* 라벨 ↔ trailing 사이 여백은 root grid의 gap(--space-2)이 전담.
|
|
145
|
+
여기 margin-inline-start를 더 두면 trailing이 왼쪽으로 쏠려 보이고
|
|
146
|
+
라벨 ellipsis가 실제 trailing 폭보다 훨씬 일찍 걸린다 (라벨 1fr 트랙이
|
|
147
|
+
그만큼 좁아지므로). min-width:0은 trailing 안쪽 콘텐츠가 길어질 때
|
|
148
|
+
부모 1fr 트랙을 밀지 않도록 유지. */
|
|
140
149
|
min-width: 0;
|
|
141
150
|
}
|
|
142
151
|
|
|
@@ -200,13 +209,29 @@
|
|
|
200
209
|
margin-inline-start: max(var(--menu-slot-gap), var(--half-overflow));
|
|
201
210
|
}
|
|
202
211
|
|
|
203
|
-
/* label
|
|
212
|
+
/* label — grid의 1fr 가운데 열. 긴 라벨은 ellipsis.
|
|
213
|
+
inline-flex가 아니라 block으로 두어야 text-overflow: ellipsis가 실제로 그려진다
|
|
214
|
+
(ellipsis는 블록 포매팅 컨텍스트의 line-box 끝에서만 생성). min-width:0 없이는
|
|
215
|
+
1fr 트랙이 콘텐츠 min-content로 터져 ellipsis가 안 걸리는 함정도 함께 방지. */
|
|
204
216
|
.label {
|
|
205
|
-
display:
|
|
206
|
-
align-items: center;
|
|
217
|
+
display: block;
|
|
207
218
|
min-width: 0;
|
|
208
219
|
overflow: hidden;
|
|
209
220
|
text-overflow: ellipsis;
|
|
221
|
+
white-space: nowrap;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* Tooltip 안 라벨 — 긴 문장은 자연스레 wrap. max-width로 2~3줄 안에 수렴.
|
|
225
|
+
Tooltip 콘텐츠는 기본이 nowrap(한 줄 유지)이라, 긴 메뉴 라벨이 뷰포트까지
|
|
226
|
+
쭉 뻗어 꼬리가 괴상하게 멀어진다. word-break는 CJK가 섞인 라벨에서
|
|
227
|
+
줄바꿈이 안 먹는 걸 보완. */
|
|
228
|
+
.tooltipLabel {
|
|
229
|
+
display: block;
|
|
230
|
+
max-width: 22ch;
|
|
231
|
+
white-space: normal;
|
|
232
|
+
word-break: break-word;
|
|
233
|
+
overflow-wrap: anywhere;
|
|
234
|
+
line-height: var(--lh-tight);
|
|
210
235
|
}
|
|
211
236
|
|
|
212
237
|
/* 2줄 레이아웃 — description prop이 있을 때만 활성.
|
|
@@ -5,6 +5,9 @@ import {
|
|
|
5
5
|
cloneElement,
|
|
6
6
|
forwardRef,
|
|
7
7
|
isValidElement,
|
|
8
|
+
useCallback,
|
|
9
|
+
useEffect,
|
|
10
|
+
useRef,
|
|
8
11
|
useState,
|
|
9
12
|
type HTMLAttributes,
|
|
10
13
|
type ReactElement,
|
|
@@ -14,6 +17,7 @@ import { Slot, Slottable } from "@radix-ui/react-slot";
|
|
|
14
17
|
|
|
15
18
|
import { Icon } from "../Icon";
|
|
16
19
|
import { Shortcut } from "../Shortcut";
|
|
20
|
+
import { Tooltip } from "../Tooltip";
|
|
17
21
|
import styles from "./MenuItem.module.css";
|
|
18
22
|
|
|
19
23
|
export type MenuItemSize = "sm" | "md";
|
|
@@ -90,6 +94,20 @@ export type MenuItemProps = Omit<HTMLAttributes<HTMLDivElement>, "onClick"> & {
|
|
|
90
94
|
*/
|
|
91
95
|
collapsed?: boolean;
|
|
92
96
|
|
|
97
|
+
/**
|
|
98
|
+
* true면 라벨이 ellipsis로 잘리거나 collapsed=true일 때 자동으로 Tooltip을 띄워
|
|
99
|
+
* 풀 라벨을 노출. Sidebar.Item처럼 좁은 폭이 상수인 사용처에서 유용.
|
|
100
|
+
*
|
|
101
|
+
* ⚠️ Dropdown/Context/Select/CommandPalette 어댑터(Radix Item + asChild) 안에서는
|
|
102
|
+
* 기본값 false 유지 — Radix의 asChild 체인 중간에 Tooltip이 끼면 포커스/하이라이트
|
|
103
|
+
* 라우팅과 포털 중첩이 꼬인다. 이 환경에서 Tooltip이 꼭 필요하면 사용처가
|
|
104
|
+
* MenuItem 바깥을 자체 Tooltip으로 감싸는 쪽이 안전.
|
|
105
|
+
*
|
|
106
|
+
* 라벨이 순수 텍스트(ReactNode에 요소가 섞이지 않음)일 때만 작동 — 복잡 ReactNode는
|
|
107
|
+
* Tooltip 콘텐츠를 안전하게 결정할 기준이 없어 무시.
|
|
108
|
+
*/
|
|
109
|
+
truncateTooltip?: boolean;
|
|
110
|
+
|
|
93
111
|
/**
|
|
94
112
|
* root click 핸들러. leadingActions/trailingActions 내부 요소에서 발생한 클릭은
|
|
95
113
|
* 자동으로 무시되지 않으므로, 하위 액션은 e.stopPropagation()을 스스로 호출해야 한다.
|
|
@@ -164,6 +182,24 @@ function firstCharOfChildren(children: ReactNode): string | null {
|
|
|
164
182
|
return Array.from(trimmed)[0] ?? null;
|
|
165
183
|
}
|
|
166
184
|
|
|
185
|
+
/** children을 평탄화해 Tooltip 콘텐츠용 문자열로 환산. 순수 텍스트가 아니면 null.
|
|
186
|
+
* (<Icon> 같은 요소가 섞인 커스텀 라벨에선 Tooltip을 띄우지 않음 — 어떤 텍스트로
|
|
187
|
+
* 보여줄지 결정할 안전한 기준이 없기 때문.) */
|
|
188
|
+
function textFromChildren(children: ReactNode): string | null {
|
|
189
|
+
let text = "";
|
|
190
|
+
let onlyPlain = true;
|
|
191
|
+
Children.forEach(children, (child) => {
|
|
192
|
+
if (typeof child === "string" || typeof child === "number") {
|
|
193
|
+
text += String(child);
|
|
194
|
+
} else if (child != null && child !== false) {
|
|
195
|
+
onlyPlain = false;
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
if (!onlyPlain) return null;
|
|
199
|
+
const trimmed = text.trim();
|
|
200
|
+
return trimmed || null;
|
|
201
|
+
}
|
|
202
|
+
|
|
167
203
|
/** MenuItemAction을 { node, hoverOnly } 튜플로 정규화. */
|
|
168
204
|
function normalizeAction(
|
|
169
205
|
action: MenuItemAction,
|
|
@@ -235,7 +271,7 @@ const SectionHeader = forwardRef<HTMLDivElement, SectionHeaderProps>(
|
|
|
235
271
|
if (!collapsible) {
|
|
236
272
|
return (
|
|
237
273
|
<div ref={ref} className={rootClass} role="presentation" {...rest}>
|
|
238
|
-
<span className={styles.label}>{children}</span>
|
|
274
|
+
<span className={styles.label} data-menuitem-label>{children}</span>
|
|
239
275
|
</div>
|
|
240
276
|
);
|
|
241
277
|
}
|
|
@@ -264,7 +300,7 @@ const SectionHeader = forwardRef<HTMLDivElement, SectionHeaderProps>(
|
|
|
264
300
|
}}
|
|
265
301
|
{...rest}
|
|
266
302
|
>
|
|
267
|
-
<span className={styles.label}>
|
|
303
|
+
<span className={styles.label} data-menuitem-label>
|
|
268
304
|
{children}
|
|
269
305
|
<Icon
|
|
270
306
|
name="caret-down"
|
|
@@ -295,6 +331,7 @@ const MenuItem = forwardRef<HTMLDivElement, MenuItemProps>(
|
|
|
295
331
|
disabled = false,
|
|
296
332
|
asChild = false,
|
|
297
333
|
collapsed = false,
|
|
334
|
+
truncateTooltip = false,
|
|
298
335
|
collapsible = false,
|
|
299
336
|
open: openProp,
|
|
300
337
|
defaultOpen = true,
|
|
@@ -315,6 +352,31 @@ const MenuItem = forwardRef<HTMLDivElement, MenuItemProps>(
|
|
|
315
352
|
const Comp = asChild ? Slot : "div";
|
|
316
353
|
const iconSize = ICON_SIZE[size];
|
|
317
354
|
|
|
355
|
+
/* 라벨 ellipsis 감지 — label span의 scrollWidth > clientWidth면 잘린 상태.
|
|
356
|
+
잘렸을 때만 아래에서 Tooltip으로 감싸 풀 라벨 노출. truncateTooltip이 꺼진
|
|
357
|
+
사용처(Dropdown/Context/Select/CommandPalette 어댑터 등)에선 ResizeObserver를
|
|
358
|
+
아예 만들지 않아 CPU 비용도 들지 않음.
|
|
359
|
+
ref callback은 element 교체 시마다 ResizeObserver를 재부착. */
|
|
360
|
+
const [truncated, setTruncated] = useState(false);
|
|
361
|
+
const observerRef = useRef<ResizeObserver | null>(null);
|
|
362
|
+
const labelRefCallback = useCallback(
|
|
363
|
+
(node: HTMLSpanElement | null) => {
|
|
364
|
+
observerRef.current?.disconnect();
|
|
365
|
+
observerRef.current = null;
|
|
366
|
+
if (!truncateTooltip || !node) {
|
|
367
|
+
if (!node) setTruncated(false);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const check = () => setTruncated(node.scrollWidth > node.clientWidth + 1);
|
|
371
|
+
check();
|
|
372
|
+
const ro = new ResizeObserver(check);
|
|
373
|
+
ro.observe(node);
|
|
374
|
+
observerRef.current = ro;
|
|
375
|
+
},
|
|
376
|
+
[truncateTooltip],
|
|
377
|
+
);
|
|
378
|
+
useEffect(() => () => observerRef.current?.disconnect(), []);
|
|
379
|
+
|
|
318
380
|
/* leading: checked면 맨 앞에 자동 checkmark */
|
|
319
381
|
const leadingComposed: MenuItemAction[] = checked
|
|
320
382
|
? [{ node: <Icon name="check" size={iconSize} />, key: "__check__" }, ...leadingActions]
|
|
@@ -414,7 +476,7 @@ const MenuItem = forwardRef<HTMLDivElement, MenuItemProps>(
|
|
|
414
476
|
);
|
|
415
477
|
}
|
|
416
478
|
|
|
417
|
-
|
|
479
|
+
const node = (
|
|
418
480
|
<Comp
|
|
419
481
|
ref={ref}
|
|
420
482
|
className={rootClass}
|
|
@@ -445,11 +507,11 @@ const MenuItem = forwardRef<HTMLDivElement, MenuItemProps>(
|
|
|
445
507
|
!effectiveCollapsed &&
|
|
446
508
|
(hasDescription ? (
|
|
447
509
|
<span className={styles.labelStack}>
|
|
448
|
-
<span className={styles.label}>{children}</span>
|
|
510
|
+
<span className={styles.label} data-menuitem-label ref={labelRefCallback}>{children}</span>
|
|
449
511
|
<span className={styles.description}>{description}</span>
|
|
450
512
|
</span>
|
|
451
513
|
) : (
|
|
452
|
-
<span className={styles.label}>{children}</span>
|
|
514
|
+
<span className={styles.label} data-menuitem-label ref={labelRefCallback}>{children}</span>
|
|
453
515
|
))
|
|
454
516
|
)}
|
|
455
517
|
{hasTrailing && (
|
|
@@ -459,6 +521,25 @@ const MenuItem = forwardRef<HTMLDivElement, MenuItemProps>(
|
|
|
459
521
|
)}
|
|
460
522
|
</Comp>
|
|
461
523
|
);
|
|
524
|
+
|
|
525
|
+
/* Tooltip 래핑 조건 — truncateTooltip이 켜진 경우에만 작동.
|
|
526
|
+
1) collapsed 상태: 라벨이 아이콘으로 수렴되어 보이지 않으므로 항상 풀 라벨을 띄움.
|
|
527
|
+
2) expanded + 라벨이 실제 ellipsis로 잘림: 중복 노출 없이 "안 보이는 부분만" 복원.
|
|
528
|
+
둘 다 children이 순수 텍스트일 때만 — 복잡 ReactNode 라벨은 Tooltip 콘텐츠로
|
|
529
|
+
무엇을 보일지 안전하게 결정할 기준이 없음. (asChild=링크 위임이어도 Tooltip은
|
|
530
|
+
MenuItem 바깥에서 감싸지므로 체인은 Tooltip → Slot → Link로 유지된다.)
|
|
531
|
+
|
|
532
|
+
side는 항상 right — 메뉴는 세로 리스트라 top/bottom으로 띄우면 바로 위/아래
|
|
533
|
+
아이템을 가린다. 뷰포트 끝에선 Radix가 자동 flip. */
|
|
534
|
+
const labelText = textFromChildren(children);
|
|
535
|
+
const shouldTooltip =
|
|
536
|
+
truncateTooltip && labelText != null && (effectiveCollapsed || truncated);
|
|
537
|
+
if (!shouldTooltip) return node;
|
|
538
|
+
return (
|
|
539
|
+
<Tooltip content={<span className={styles.tooltipLabel}>{labelText}</span>} side="right" sideOffset={8}>
|
|
540
|
+
{node}
|
|
541
|
+
</Tooltip>
|
|
542
|
+
);
|
|
462
543
|
},
|
|
463
544
|
);
|
|
464
545
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* MetricCard — 전부 CTDS 토큰 경유.
|
|
3
3
|
* variant:
|
|
4
|
-
* line — border
|
|
4
|
+
* line — border + 은은한 shadow-sm (조밀한 그리드, 기본)
|
|
5
5
|
* soft — bg-subtle 표면 (경계 대신 톤)
|
|
6
6
|
* bare — border·bg·padding 모두 제거. pure 좌표 전용.
|
|
7
7
|
* 부모 container가 hairline/톤으로 분리를 담당하는 자리에서만 쓴다.
|
|
8
|
-
*
|
|
8
|
+
* interactive: href 있을 때만 hover/focus ring.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
.root {
|
|
@@ -17,20 +17,20 @@
|
|
|
17
17
|
min-width: 0;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/* line/soft는 자체 chrome(padding+radius
|
|
20
|
+
/* line/soft는 자체 chrome(padding+radius)을 가진다.
|
|
21
|
+
line = hairline만, soft = 톤 배경만 — 둘 다 섞지 않는다. */
|
|
21
22
|
.line,
|
|
22
23
|
.soft {
|
|
23
24
|
padding: var(--space-5) var(--space-5);
|
|
24
25
|
border-radius: var(--radius-md);
|
|
25
|
-
background: var(--bg);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
.line {
|
|
29
29
|
border: 1px solid var(--border);
|
|
30
|
+
box-shadow: var(--shadow-sm);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
.soft {
|
|
33
|
-
border: 1px solid transparent;
|
|
34
34
|
background: var(--bg-subtle);
|
|
35
35
|
}
|
|
36
36
|
|