@conscience-technology/ctds 0.3.2 → 0.3.4

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.
@@ -3,6 +3,7 @@
3
3
  시각 요소 = .indicator, 접근성 = 실제 <input type="checkbox"> (.visually-hidden).
4
4
  FilterButton type="checkbox"도 indicator를 재사용한다. */
5
5
  .root {
6
+ position: relative;
6
7
  display: inline-flex;
7
8
  align-items: center;
8
9
  gap: var(--space-2);
@@ -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
- /* trailing 자체는 라벨과 자연스러운 여백을 두되, 내부 첫 아이템은 margin 없음 */
139
- margin-inline-start: var(--space-4);
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: inline-flex;
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
- return (
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
 
@@ -4,6 +4,7 @@
4
4
  같은 배경 레이어에 그려지므로 서브픽셀 어긋남이 없다). 접근성 = <input type="radio">.
5
5
  FilterButton type="radio"도 indicator를 재사용한다. */
6
6
  .root {
7
+ position: relative;
7
8
  display: inline-flex;
8
9
  align-items: center;
9
10
  gap: var(--space-2);
@@ -272,6 +272,9 @@ const SidebarItem = forwardRef<HTMLDivElement, SidebarItemProps>(function Sideba
272
272
  const leadingActions: MenuItemAction[] = icon ? [icon] : [];
273
273
  const trailingActions: MenuItemAction[] = trailing ? [trailing] : [];
274
274
 
275
+ /* ellipsis 시 Tooltip, collapsed 시 항상 Tooltip은 MenuItem이 자체 처리.
276
+ children을 넘기면 복잡 ReactNode라 Tooltip이 안 뜰 수 있으므로, 평문 라벨만 있을 땐
277
+ label 문자열을 그대로 children으로 흘려 MenuItem이 안전하게 Tooltip 콘텐츠를 결정하게 함. */
275
278
  return (
276
279
  <MenuItem
277
280
  ref={ref}
@@ -280,13 +283,13 @@ const SidebarItem = forwardRef<HTMLDivElement, SidebarItemProps>(function Sideba
280
283
  disabled={disabled}
281
284
  asChild={asChild}
282
285
  collapsed={collapsed}
286
+ truncateTooltip
283
287
  leadingActions={leadingActions}
284
288
  trailingActions={trailingActions}
285
289
  shortcut={shortcut}
286
290
  hasSubmenu={hasSubmenu}
287
291
  onClick={onClick}
288
292
  aria-label={collapsed ? label : undefined}
289
- title={collapsed ? label : undefined}
290
293
  >
291
294
  {children ?? label}
292
295
  </MenuItem>
@@ -488,7 +488,7 @@ export const shadows: [string, string, string][] = [
488
488
  ["shadow-sm", "var(--shadow-sm)", "Card 기본"],
489
489
  ["shadow-md", "var(--shadow-md)", "Dropdown, Popover"],
490
490
  ["shadow-lg", "var(--shadow-lg)", "Modal"],
491
- ["shadow-xl", "var(--shadow-xl)", "Command Palette"],
491
+ ["shadow-xl", "var(--shadow-xl)",],
492
492
  ["shadow-inner", "var(--shadow-inner)", "눌린 상태"],
493
493
  ];
494
494
 
@@ -554,8 +554,6 @@ export const componentSlugs: Record<string, string> = {
554
554
  "App Frame": "layout/app-frame",
555
555
  Stepper: "nav/stepper",
556
556
  "Context Menu": "overlay/context-menu",
557
- "Command Palette": "overlay/command-palette",
558
- "Accordion": "overlay/accordion",
559
557
  Callout: "feedback/callout",
560
558
  Modal: "feedback/modal",
561
559
  Drawer: "feedback/drawer",
@@ -564,7 +562,6 @@ export const componentSlugs: Record<string, string> = {
564
562
  "Table Row": "data/table-row",
565
563
  "Table Row Select": "data/table-row-select",
566
564
  "Table Row Edit": "data/table-row-edit",
567
- "Event Card": "data/event-card",
568
565
  "Event Chip": "data/event-chip",
569
566
  "List Item": "data/list-item",
570
567
  "Metric Card": "data/metric-card",
@@ -619,7 +616,7 @@ export const components: { id: string; title: string; items: string[] }[] = [
619
616
  title: "Data Display",
620
617
  items: [
621
618
  "Table Header", "Table Row", "Table Row Select", "Table Row Edit",
622
- "List Item", "Metric Card", "Gallery Item", "File Icon", "Attachment Item", "Event Chip", "Event Card",
619
+ "List Item", "Metric Card", "Gallery Item", "File Icon", "Attachment Item", "Event Chip",
623
620
  "Empty State", "Description List", "Timeline",
624
621
  ],
625
622
  },
@@ -643,8 +640,7 @@ export const components: { id: string; title: string; items: string[] }[] = [
643
640
  id: "overlay",
644
641
  title: "Overlay / Menu",
645
642
  items: [
646
- "Context Menu", "Command Palette",
647
- "Accordion", "Collapsible", "Toggle Group",
643
+ "Context Menu", "Collapsible", "Toggle Group",
648
644
  ],
649
645
  },
650
646
  {
@@ -28,7 +28,6 @@ export * from "./Disclosure";
28
28
  export * from "./Drawer";
29
29
  export * from "./DropdownMenu";
30
30
  export * from "./EmptyState";
31
- export * from "./EventCard";
32
31
  export * from "./EventChip";
33
32
  export * from "./FileIcon";
34
33
  export * from "./FilterButton";
@@ -82,6 +82,7 @@ type MenuItemAction =
82
82
  active={boolean} // 현재 페이지/선택 아이템 강조
83
83
  disabled={boolean}
84
84
  collapsed={boolean} // icon-only. leading 하나만 남기고 label/trailing/shortcut 전부 숨김. (Sidebar가 자동 주입)
85
+ truncateTooltip={boolean} // 라벨이 …로 잘리거나 collapsed일 때 호버 Tooltip으로 풀 라벨 노출. Sidebar.Item 자동 ON. Dropdown/Context/Select 어댑터 안에선 Radix asChild 체인 꼬임 방지로 끄는 걸 권장.
85
86
  asChild={boolean} // Radix Slot — <Link>/<a>/DropdownMenu.Item 등에 위임
86
87
  onClick={(e) => void}
87
88
  />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conscience-technology/ctds",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "CTDS — 하이브리드 디자인 시스템 설치 · 온보딩 CLI (shadcn식 코드 생성기)",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -1,247 +0,0 @@
1
- /* Accordion ─────────────────────────────────────────────────────
2
- 여러 섹션 접힘/펴짐. Radix Accordion 위의 시각 레이어.
3
- 두 variant:
4
- - plain: 테두리 없음, 아이템 사이 hairline만. 설정·FAQ 흐름.
5
- - card: 각 아이템이 독립 블록(테두리 + radius). 콘텐츠 섹션 리스트.
6
- 아이템 레이아웃(leading·라벨·trailing·caret)은 trigger 내부에서 담당. */
7
-
8
- /* Root ─────────────────────────────────────────────────── */
9
- .root {
10
- display: flex;
11
- flex-direction: column;
12
- width: 100%;
13
- color: var(--fg);
14
- font-family: var(--font-sans);
15
- --accordion-trigger-h: calc(var(--space-2) * 5); /* sm 기본 40 */
16
- /* leading 아이콘 크기 — MenuItem과 동일 톤(sm 14 / md 16).
17
- caret은 한 스텝 더 작게 — 상태 보조 신호라 라벨·아이콘보다 작아야 리듬이 잡힘. */
18
- --accordion-icon-size: calc(var(--space-1) * 3.5); /* 14 */
19
- --accordion-caret-size: var(--space-3); /* 12 */
20
- }
21
-
22
- .root[data-size="md"] {
23
- --accordion-trigger-h: calc(var(--space-2) * 6); /* md 48 */
24
- --accordion-icon-size: var(--space-4); /* 16 */
25
- --accordion-caret-size: calc(var(--space-1) * 3.5); /* 14 */
26
- }
27
-
28
- .root[data-size="lg"] {
29
- --accordion-trigger-h: calc(var(--space-2) * 7); /* lg 56 */
30
- --accordion-icon-size: calc(var(--space-1) * 4.5); /* 18 */
31
- --accordion-caret-size: var(--space-4); /* 16 */
32
- }
33
-
34
- .root[data-variant="card"] {
35
- gap: var(--space-2);
36
- }
37
-
38
- /* Item ─────────────────────────────────────────────────── */
39
- /* bare: 선·여백 전부 없음. 본문 흐름에 녹아듦.
40
- plain: 아이템 사이 hairline. 첫 아이템은 상단 선 없음.
41
- card: 각 아이템이 테두리 + radius. 사이 gap은 Root에서 담당. */
42
- .item {
43
- display: flex;
44
- flex-direction: column;
45
- }
46
-
47
- .root[data-variant="plain"] .item + .item {
48
- border-top: var(--hairline) solid var(--border);
49
- }
50
-
51
- .root[data-variant="card"] .item {
52
- border: var(--hairline) solid var(--border);
53
- border-radius: var(--radius-md);
54
- background: var(--bg);
55
- overflow: hidden;
56
- }
57
-
58
- /* bare — Trigger 좌우 padding 제거, content도 좌우 0.
59
- 세로 리듬은 그대로 유지(상/하 padding은 contentInner가 담당). */
60
- .root[data-variant="bare"] .trigger {
61
- padding-left: 0;
62
- padding-right: 0;
63
- }
64
-
65
- .root[data-variant="bare"] .contentInner {
66
- padding-left: 0;
67
- padding-right: 0;
68
- }
69
-
70
- /* bare에서는 hover 배경도 꺼야 "선 없는" 톤이 유지됨. 라벨 색만 상태 신호. */
71
- .root[data-variant="bare"] .trigger:hover:not([data-state="open"]):not([data-disabled]) {
72
- background: transparent;
73
- }
74
-
75
- /* Header + Trigger ─────────────────────────────────────── */
76
- /* Radix의 Header는 <h3>로 렌더됨. 폰트/weight은 Trigger가 결정 — Header는 wrapper. */
77
- .header {
78
- margin: 0;
79
- font-size: inherit;
80
- font-weight: inherit;
81
- }
82
-
83
- .trigger {
84
- display: flex;
85
- align-items: center;
86
- gap: var(--space-2);
87
- width: 100%;
88
- min-height: var(--accordion-trigger-h);
89
- padding: 0 var(--space-3);
90
- border: 0;
91
- background: transparent;
92
- color: var(--fg);
93
- font-family: inherit;
94
- font-size: var(--text-sm);
95
- line-height: var(--lh-sm);
96
- font-weight: var(--font-weight-medium);
97
- text-align: left;
98
- cursor: pointer;
99
- outline: none;
100
- transition: var(--transition-colors);
101
- }
102
-
103
- .root[data-size="md"] .trigger {
104
- font-size: var(--text-base);
105
- line-height: var(--lh-base);
106
- }
107
-
108
- /* hover: closed일 때만 배경 변화. open에선 상태 signal(fg-strong)만 유지. */
109
- .trigger:hover:not([data-state="open"]):not([data-disabled]) {
110
- background: var(--hover);
111
- color: var(--fg-strong);
112
- }
113
-
114
- .trigger[data-state="open"] {
115
- color: var(--fg-strong);
116
- }
117
-
118
- .trigger:focus-visible {
119
- outline: none;
120
- box-shadow: var(--ring-shadow);
121
- border-radius: var(--radius-sm);
122
- }
123
-
124
- .trigger[data-disabled] {
125
- color: var(--fg-disabled);
126
- cursor: not-allowed;
127
- }
128
-
129
- /* Leading / Label / Trailing ──────────────────────────── */
130
- .leading {
131
- display: inline-flex;
132
- align-items: center;
133
- gap: var(--space-1);
134
- color: var(--fg-muted);
135
- flex-shrink: 0;
136
- /* MenuItem과 동일 — 슬롯에 들어온 svg/img는 정사각 아이콘 크기로 수렴. */
137
- font-size: var(--accordion-icon-size);
138
- }
139
-
140
- .leading > *,
141
- .leading svg,
142
- .leading img {
143
- width: var(--accordion-icon-size);
144
- height: var(--accordion-icon-size);
145
- }
146
-
147
- .trigger[data-state="open"] .leading {
148
- color: var(--fg-strong);
149
- }
150
-
151
- .leadingItem {
152
- display: inline-flex;
153
- align-items: center;
154
- }
155
-
156
- .label {
157
- flex: 1 1 auto;
158
- min-width: 0;
159
- overflow: hidden;
160
- text-overflow: ellipsis;
161
- white-space: nowrap;
162
- }
163
-
164
- .trailing {
165
- display: inline-flex;
166
- align-items: center;
167
- gap: var(--space-1);
168
- color: var(--fg-muted);
169
- flex-shrink: 0;
170
- }
171
-
172
- .trailingItem {
173
- display: inline-flex;
174
- align-items: center;
175
- }
176
-
177
- /* Caret — open이면 180도 회전.
178
- 라벨·leading 아이콘보다 한 스텝 작게. 상태 보조 신호라 작아야 리듬이 잡힘. */
179
- .caret {
180
- display: inline-flex;
181
- align-items: center;
182
- color: var(--fg-muted);
183
- flex-shrink: 0;
184
- font-size: var(--accordion-caret-size);
185
- transition: transform var(--duration-base) var(--ease-out);
186
- }
187
-
188
- .caret svg {
189
- width: var(--accordion-caret-size);
190
- height: var(--accordion-caret-size);
191
- }
192
-
193
- .trigger[data-state="open"] .caret {
194
- transform: rotate(180deg);
195
- color: var(--fg-strong);
196
- }
197
-
198
- /* Content ──────────────────────────────────────────────── */
199
- /* Radix가 측정한 --radix-accordion-content-height로 slide 애니메이션.
200
- contentInner에 padding을 깔아 열림 애니메이션이 깔끔하게 흐르도록. */
201
- .content {
202
- overflow: hidden;
203
- font-size: var(--text-sm);
204
- line-height: var(--lh-sm);
205
- color: var(--fg);
206
- }
207
-
208
- .content[data-state="open"] {
209
- animation: accordion-slide-down var(--duration-base) var(--ease-out);
210
- }
211
-
212
- .content[data-state="closed"] {
213
- animation: accordion-slide-up var(--duration-fast) var(--ease-in);
214
- }
215
-
216
- .contentInner {
217
- /* 상단은 Trigger와 리듬 유지(겹치지 않게 살짝), 하단은 다음 Trigger와 거리. */
218
- padding: var(--space-1) var(--space-3) var(--space-4);
219
- color: var(--fg);
220
- }
221
-
222
- .root[data-size="md"] .contentInner {
223
- font-size: var(--text-base);
224
- line-height: var(--lh-base);
225
- }
226
-
227
- /* Animations ───────────────────────────────────────────── */
228
- @keyframes accordion-slide-down {
229
- from { height: 0; opacity: 0; }
230
- to { height: var(--radix-accordion-content-height); opacity: 1; }
231
- }
232
-
233
- @keyframes accordion-slide-up {
234
- from { height: var(--radix-accordion-content-height); opacity: 1; }
235
- to { height: 0; opacity: 0; }
236
- }
237
-
238
- @media (prefers-reduced-motion: reduce) {
239
- .content,
240
- .content[data-state="open"],
241
- .content[data-state="closed"] {
242
- animation: none;
243
- }
244
- .caret {
245
- transition: none;
246
- }
247
- }