@conscience-technology/ctds 0.0.0-stage → 0.1.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.
Files changed (289) hide show
  1. package/README.md +128 -2
  2. package/assets/components/AppFrame/AppFrame.module.css +177 -0
  3. package/assets/components/AppFrame/AppFrame.tsx +93 -0
  4. package/assets/components/AppFrame/index.ts +6 -0
  5. package/assets/components/AttachmentChip/AttachmentChip.module.css +71 -0
  6. package/assets/components/AttachmentChip/AttachmentChip.tsx +74 -0
  7. package/assets/components/AttachmentChip/index.ts +2 -0
  8. package/assets/components/AttachmentThumb/AttachmentThumb.module.css +107 -0
  9. package/assets/components/AttachmentThumb/AttachmentThumb.tsx +91 -0
  10. package/assets/components/AttachmentThumb/index.ts +2 -0
  11. package/assets/components/Avatar/Avatar.module.css +167 -0
  12. package/assets/components/Avatar/Avatar.tsx +228 -0
  13. package/assets/components/Avatar/index.ts +2 -0
  14. package/assets/components/AvatarUpload/AvatarUpload.module.css +130 -0
  15. package/assets/components/AvatarUpload/AvatarUpload.tsx +139 -0
  16. package/assets/components/AvatarUpload/index.ts +2 -0
  17. package/assets/components/Badge/Badge.module.css +76 -0
  18. package/assets/components/Badge/Badge.tsx +68 -0
  19. package/assets/components/Badge/index.ts +2 -0
  20. package/assets/components/Breadcrumb/Breadcrumb.module.css +213 -0
  21. package/assets/components/Breadcrumb/Breadcrumb.tsx +279 -0
  22. package/assets/components/Breadcrumb/index.ts +8 -0
  23. package/assets/components/Button/Button.module.css +216 -0
  24. package/assets/components/Button/Button.tsx +149 -0
  25. package/assets/components/Button/index.ts +2 -0
  26. package/assets/components/Callout/Callout.module.css +146 -0
  27. package/assets/components/Callout/Callout.tsx +138 -0
  28. package/assets/components/Callout/index.ts +2 -0
  29. package/assets/components/ChatInput/ChatInput.module.css +241 -0
  30. package/assets/components/ChatInput/ChatInput.tsx +322 -0
  31. package/assets/components/ChatInput/ModelSelector.tsx +40 -0
  32. package/assets/components/ChatInput/SendButton.tsx +41 -0
  33. package/assets/components/ChatInput/index.ts +10 -0
  34. package/assets/components/Checkbox/Checkbox.module.css +66 -0
  35. package/assets/components/Checkbox/Checkbox.tsx +36 -0
  36. package/assets/components/Checkbox/index.ts +2 -0
  37. package/assets/components/Chip/Chip.module.css +128 -0
  38. package/assets/components/Chip/Chip.tsx +103 -0
  39. package/assets/components/Chip/index.ts +2 -0
  40. package/assets/components/ChoiceCard/ChoiceCard.module.css +142 -0
  41. package/assets/components/ChoiceCard/ChoiceCard.tsx +132 -0
  42. package/assets/components/ChoiceCard/index.ts +2 -0
  43. package/assets/components/ContextMenu/ContextMenu.module.css +102 -0
  44. package/assets/components/ContextMenu/ContextMenu.tsx +392 -0
  45. package/assets/components/ContextMenu/index.ts +9 -0
  46. package/assets/components/DatePicker/Calendar.tsx +234 -0
  47. package/assets/components/DatePicker/DESIGN_NOTES.md +106 -0
  48. package/assets/components/DatePicker/DESIGN_NOTES_TIME.md +125 -0
  49. package/assets/components/DatePicker/DatePicker.module.css +295 -0
  50. package/assets/components/DatePicker/DatePicker.tsx +387 -0
  51. package/assets/components/DatePicker/TimeSelector.tsx +69 -0
  52. package/assets/components/DatePicker/index.ts +8 -0
  53. package/assets/components/DatePicker/presets.ts +80 -0
  54. package/assets/components/DatePicker/utils.ts +148 -0
  55. package/assets/components/Disclosure/Disclosure.module.css +73 -0
  56. package/assets/components/Disclosure/Disclosure.tsx +129 -0
  57. package/assets/components/Disclosure/index.ts +6 -0
  58. package/assets/components/Drawer/Drawer.module.css +257 -0
  59. package/assets/components/Drawer/Drawer.tsx +126 -0
  60. package/assets/components/Drawer/index.ts +7 -0
  61. package/assets/components/EmptyState/EmptyState.module.css +102 -0
  62. package/assets/components/EmptyState/EmptyState.tsx +80 -0
  63. package/assets/components/EmptyState/index.ts +2 -0
  64. package/assets/components/FileIcon/FileIcon.module.css +66 -0
  65. package/assets/components/FileIcon/FileIcon.tsx +84 -0
  66. package/assets/components/FileIcon/fileExtMap.ts +112 -0
  67. package/assets/components/FileIcon/index.ts +9 -0
  68. package/assets/components/FilterButton/FilterButton.module.css +130 -0
  69. package/assets/components/FilterButton/FilterButton.tsx +94 -0
  70. package/assets/components/FilterButton/index.ts +2 -0
  71. package/assets/components/GalleryItem/GalleryItem.module.css +220 -0
  72. package/assets/components/GalleryItem/GalleryItem.tsx +163 -0
  73. package/assets/components/GalleryItem/index.ts +7 -0
  74. package/assets/components/Icon/Icon.tsx +616 -0
  75. package/assets/components/Icon/context.tsx +39 -0
  76. package/assets/components/Icon/index.ts +4 -0
  77. package/assets/components/IconButton/IconButton.module.css +109 -0
  78. package/assets/components/IconButton/IconButton.tsx +110 -0
  79. package/assets/components/IconButton/index.ts +2 -0
  80. package/assets/components/Input/Input.module.css +338 -0
  81. package/assets/components/Input/Input.tsx +371 -0
  82. package/assets/components/Input/index.ts +2 -0
  83. package/assets/components/ListItem/ListItem.module.css +318 -0
  84. package/assets/components/ListItem/ListItem.tsx +262 -0
  85. package/assets/components/ListItem/index.ts +9 -0
  86. package/assets/components/MenuItem/MenuItem.module.css +437 -0
  87. package/assets/components/MenuItem/MenuItem.tsx +529 -0
  88. package/assets/components/MenuItem/index.ts +8 -0
  89. package/assets/components/MetricCard/MetricCard.module.css +153 -0
  90. package/assets/components/MetricCard/MetricCard.tsx +140 -0
  91. package/assets/components/MetricCard/index.ts +2 -0
  92. package/assets/components/Modal/Modal.module.css +175 -0
  93. package/assets/components/Modal/Modal.tsx +98 -0
  94. package/assets/components/Modal/index.ts +2 -0
  95. package/assets/components/Onboarding/DocsIndex.tsx +82 -0
  96. package/assets/components/Onboarding/LayoutBuilder.tsx +160 -0
  97. package/assets/components/Onboarding/Onboarding.module.css +1394 -0
  98. package/assets/components/Onboarding/OnboardingAxisPanel.tsx +515 -0
  99. package/assets/components/Onboarding/OnboardingPage.tsx +1193 -0
  100. package/assets/components/Onboarding/PresetsGallery.tsx +65 -0
  101. package/assets/components/Onboarding/actions.ts +250 -0
  102. package/assets/components/Onboarding/index.ts +8 -0
  103. package/assets/components/Onboarding/palette.css +40 -0
  104. package/assets/components/Onboarding/wizardData.ts +265 -0
  105. package/assets/components/Pagination/Pagination.module.css +82 -0
  106. package/assets/components/Pagination/Pagination.tsx +248 -0
  107. package/assets/components/Pagination/index.ts +6 -0
  108. package/assets/components/ProgressBar/ProgressBar.module.css +64 -0
  109. package/assets/components/ProgressBar/ProgressBar.tsx +54 -0
  110. package/assets/components/ProgressBar/index.ts +2 -0
  111. package/assets/components/QuotationBar/QuotationBar.module.css +61 -0
  112. package/assets/components/QuotationBar/QuotationBar.tsx +48 -0
  113. package/assets/components/QuotationBar/index.ts +2 -0
  114. package/assets/components/Radio/Radio.module.css +63 -0
  115. package/assets/components/Radio/Radio.tsx +33 -0
  116. package/assets/components/Radio/index.ts +2 -0
  117. package/assets/components/Select/Select.module.css +151 -0
  118. package/assets/components/Select/Select.tsx +425 -0
  119. package/assets/components/Select/index.ts +10 -0
  120. package/assets/components/SelectableCard/SelectableCard.module.css +177 -0
  121. package/assets/components/SelectableCard/SelectableCard.tsx +83 -0
  122. package/assets/components/SelectableCard/index.ts +2 -0
  123. package/assets/components/Shortcut/Shortcut.module.css +56 -0
  124. package/assets/components/Shortcut/Shortcut.tsx +156 -0
  125. package/assets/components/Shortcut/index.ts +2 -0
  126. package/assets/components/Sidebar/Sidebar.module.css +242 -0
  127. package/assets/components/Sidebar/Sidebar.tsx +264 -0
  128. package/assets/components/Sidebar/index.ts +7 -0
  129. package/assets/components/SidebarSplit/SidebarSplit.module.css +126 -0
  130. package/assets/components/SidebarSplit/SidebarSplit.tsx +222 -0
  131. package/assets/components/SidebarSplit/index.ts +6 -0
  132. package/assets/components/Slider/Slider.module.css +120 -0
  133. package/assets/components/Slider/Slider.tsx +88 -0
  134. package/assets/components/Slider/index.ts +2 -0
  135. package/assets/components/SplitButton/SplitButton.module.css +190 -0
  136. package/assets/components/SplitButton/SplitButton.tsx +145 -0
  137. package/assets/components/SplitButton/index.ts +6 -0
  138. package/assets/components/Stepper/Stepper.module.css +182 -0
  139. package/assets/components/Stepper/Stepper.tsx +185 -0
  140. package/assets/components/Stepper/index.ts +2 -0
  141. package/assets/components/Switch/Switch.module.css +116 -0
  142. package/assets/components/Switch/Switch.tsx +58 -0
  143. package/assets/components/Switch/index.ts +2 -0
  144. package/assets/components/TableCell/TableCell.module.css +137 -0
  145. package/assets/components/TableCell/TableCellAvatar.tsx +42 -0
  146. package/assets/components/TableCell/TableCellBadge.tsx +23 -0
  147. package/assets/components/TableCell/TableCellIcon.tsx +39 -0
  148. package/assets/components/TableCell/TableCellImage.tsx +49 -0
  149. package/assets/components/TableCell/TableCellLink.tsx +33 -0
  150. package/assets/components/TableCell/TableCellMeter.tsx +55 -0
  151. package/assets/components/TableCell/TableCellNumber.tsx +43 -0
  152. package/assets/components/TableCell/TableCellText.tsx +30 -0
  153. package/assets/components/TableCell/index.ts +23 -0
  154. package/assets/components/TableHeader/TableHeader.module.css +287 -0
  155. package/assets/components/TableHeader/TableHeader.tsx +590 -0
  156. package/assets/components/TableHeader/index.ts +9 -0
  157. package/assets/components/TableRow/TableRow.module.css +290 -0
  158. package/assets/components/TableRow/TableRow.tsx +253 -0
  159. package/assets/components/TableRow/index.ts +8 -0
  160. package/assets/components/TableRowEdit/TableRowEdit.module.css +126 -0
  161. package/assets/components/TableRowEdit/TableRowEdit.tsx +247 -0
  162. package/assets/components/TableRowEdit/index.ts +2 -0
  163. package/assets/components/TableRowSelect/TableRowSelect.module.css +23 -0
  164. package/assets/components/TableRowSelect/TableRowSelect.tsx +175 -0
  165. package/assets/components/TableRowSelect/index.ts +2 -0
  166. package/assets/components/Tabs/Tabs.module.css +234 -0
  167. package/assets/components/Tabs/Tabs.tsx +426 -0
  168. package/assets/components/Tabs/index.ts +9 -0
  169. package/assets/components/Textarea/Textarea.module.css +318 -0
  170. package/assets/components/Textarea/Textarea.tsx +426 -0
  171. package/assets/components/Textarea/index.ts +7 -0
  172. package/assets/components/ThemeController.tsx +271 -0
  173. package/assets/components/TimePicker/TimePicker.module.css +317 -0
  174. package/assets/components/TimePicker/TimePicker.tsx +821 -0
  175. package/assets/components/TimePicker/index.ts +8 -0
  176. package/assets/components/TimePicker/utils.ts +115 -0
  177. package/assets/components/Toast/Toast.module.css +264 -0
  178. package/assets/components/Toast/Toast.tsx +243 -0
  179. package/assets/components/Toast/index.ts +15 -0
  180. package/assets/components/Toolbar/Toolbar.module.css +72 -0
  181. package/assets/components/Toolbar/Toolbar.tsx +75 -0
  182. package/assets/components/Toolbar/index.ts +2 -0
  183. package/assets/components/Tooltip/Tooltip.module.css +42 -0
  184. package/assets/components/Tooltip/Tooltip.tsx +332 -0
  185. package/assets/components/Tooltip/index.ts +2 -0
  186. package/assets/components/Topbar/Topbar.module.css +26 -0
  187. package/assets/components/Topbar/Topbar.tsx +47 -0
  188. package/assets/components/Topbar/index.ts +2 -0
  189. package/assets/components/_cn.ts +3 -0
  190. package/assets/components/_color.ts +48 -0
  191. package/assets/components/_data.ts +659 -0
  192. package/assets/components/_utils.css +19 -0
  193. package/assets/components/axisOptions.ts +24 -0
  194. package/assets/components/tokens.css +742 -0
  195. package/assets/prompts/GUIDE.md +251 -0
  196. package/assets/prompts/OVERRIDE.md +170 -0
  197. package/assets/prompts/PRINCIPLES.md +130 -0
  198. package/assets/prompts/axes/density.md +38 -0
  199. package/assets/prompts/axes/font.md +36 -0
  200. package/assets/prompts/axes/hue.md +36 -0
  201. package/assets/prompts/axes/icon.md +48 -0
  202. package/assets/prompts/axes/mode.md +50 -0
  203. package/assets/prompts/axes/motion.md +77 -0
  204. package/assets/prompts/axes/palette.md +35 -0
  205. package/assets/prompts/axes/shape.md +33 -0
  206. package/assets/prompts/axes/voice.md +54 -0
  207. package/assets/prompts/components/README.md +84 -0
  208. package/assets/prompts/components/app-frame.md +94 -0
  209. package/assets/prompts/components/attachment-chip.md +46 -0
  210. package/assets/prompts/components/attachment-thumb.md +49 -0
  211. package/assets/prompts/components/avatar.md +86 -0
  212. package/assets/prompts/components/badge.md +104 -0
  213. package/assets/prompts/components/breadcrumb.md +77 -0
  214. package/assets/prompts/components/button.md +99 -0
  215. package/assets/prompts/components/callout.md +81 -0
  216. package/assets/prompts/components/chat-input.md +84 -0
  217. package/assets/prompts/components/checkbox.md +46 -0
  218. package/assets/prompts/components/chip.md +84 -0
  219. package/assets/prompts/components/choice-card.md +127 -0
  220. package/assets/prompts/components/context-menu.md +145 -0
  221. package/assets/prompts/components/date-picker.md +104 -0
  222. package/assets/prompts/components/drawer.md +152 -0
  223. package/assets/prompts/components/empty-state.md +69 -0
  224. package/assets/prompts/components/file-icon.md +73 -0
  225. package/assets/prompts/components/filter-button.md +42 -0
  226. package/assets/prompts/components/form-field.md +74 -0
  227. package/assets/prompts/components/gallery-item.md +158 -0
  228. package/assets/prompts/components/icon-button.md +82 -0
  229. package/assets/prompts/components/icon.md +68 -0
  230. package/assets/prompts/components/input.md +95 -0
  231. package/assets/prompts/components/list-item.md +235 -0
  232. package/assets/prompts/components/menu-item.md +234 -0
  233. package/assets/prompts/components/metric-card.md +132 -0
  234. package/assets/prompts/components/modal.md +131 -0
  235. package/assets/prompts/components/pagination.md +92 -0
  236. package/assets/prompts/components/progress-bar.md +61 -0
  237. package/assets/prompts/components/quotation-bar.md +39 -0
  238. package/assets/prompts/components/radio.md +41 -0
  239. package/assets/prompts/components/select.md +104 -0
  240. package/assets/prompts/components/selectable-card.md +67 -0
  241. package/assets/prompts/components/shortcut.md +76 -0
  242. package/assets/prompts/components/sidebar.md +107 -0
  243. package/assets/prompts/components/slider.md +89 -0
  244. package/assets/prompts/components/split-button.md +71 -0
  245. package/assets/prompts/components/stepper.md +93 -0
  246. package/assets/prompts/components/switch.md +86 -0
  247. package/assets/prompts/components/table-cell.md +87 -0
  248. package/assets/prompts/components/table-header.md +157 -0
  249. package/assets/prompts/components/table-row-edit.md +129 -0
  250. package/assets/prompts/components/table-row-select.md +82 -0
  251. package/assets/prompts/components/table-row.md +156 -0
  252. package/assets/prompts/components/tabs.md +122 -0
  253. package/assets/prompts/components/textarea.md +105 -0
  254. package/assets/prompts/components/toast.md +149 -0
  255. package/assets/prompts/components/tooltip.md +71 -0
  256. package/assets/prompts/components/topbar.md +148 -0
  257. package/assets/prompts/primitives/emerge.md +43 -0
  258. package/assets/prompts/primitives/fade.md +31 -0
  259. package/assets/prompts/primitives/scale.md +32 -0
  260. package/assets/prompts/primitives/scroll.md +75 -0
  261. package/assets/prompts/primitives/slide.md +31 -0
  262. package/assets/prompts/themes/_SCHEMA.md +135 -0
  263. package/assets/prompts/themes/_TEMPLATE.md +73 -0
  264. package/assets/prompts/themes/expressive.md +118 -0
  265. package/assets/prompts/themes/pure.md +87 -0
  266. package/assets/prompts/themes/spare.md +73 -0
  267. package/assets/prompts/tokens/breakpoint.md +29 -0
  268. package/assets/prompts/tokens/color.md +78 -0
  269. package/assets/prompts/tokens/radius.md +33 -0
  270. package/assets/prompts/tokens/shadow.md +35 -0
  271. package/assets/prompts/tokens/spacing.md +86 -0
  272. package/assets/prompts/tokens/typography.md +76 -0
  273. package/assets/prompts/tokens/z-index.md +35 -0
  274. package/assets/public/expressive.png +0 -0
  275. package/assets/public/pure.png +0 -0
  276. package/assets/public/spare.png +0 -0
  277. package/assets/skill/SKILL.md +36 -0
  278. package/bin/ctds.js +76 -0
  279. package/package.json +35 -4
  280. package/src/commands/init.js +215 -0
  281. package/src/commands/update.js +275 -0
  282. package/src/utils/assets.js +78 -0
  283. package/src/utils/deps.js +51 -0
  284. package/src/utils/detect.js +47 -0
  285. package/src/utils/diff.js +52 -0
  286. package/src/utils/hash.js +16 -0
  287. package/src/utils/log.js +41 -0
  288. package/src/utils/meta.js +73 -0
  289. package/src/utils/prompt.js +28 -0
@@ -0,0 +1,529 @@
1
+ "use client";
2
+
3
+ import {
4
+ Children,
5
+ cloneElement,
6
+ forwardRef,
7
+ isValidElement,
8
+ useState,
9
+ type HTMLAttributes,
10
+ type ReactElement,
11
+ type ReactNode,
12
+ } from "react";
13
+ import { Slot, Slottable } from "@radix-ui/react-slot";
14
+
15
+ import { Icon } from "../Icon";
16
+ import { Shortcut } from "../Shortcut";
17
+ import styles from "./MenuItem.module.css";
18
+
19
+ export type MenuItemSize = "sm" | "md";
20
+ export type MenuItemVariant = "default" | "destructive" | "section";
21
+ /**
22
+ * active=true일 때의 시각 강조 방식.
23
+ * - fill: 표면 채움(hover 배경 + fg-strong). Notion·Docs 스타일. **기본.**
24
+ * - accent-bar: 배경 투명 + 좌측 primary 세로 바 + fg-strong. Linear·Slack 스타일.
25
+ * Shell 프리셋에 따라 컨테이너 단위로 지정하는 걸 권장.
26
+ */
27
+ export type MenuItemActiveStyle = "fill" | "accent-bar";
28
+
29
+ /**
30
+ * 왼쪽/오른쪽 슬롯에 넣을 수 있는 항목.
31
+ * 그냥 ReactNode를 넣으면 항상 노출.
32
+ * { node, hoverOnly: true }로 감싸면 MenuItem hover/focus 시에만 드러남 (Notion/Linear/VSCode 패턴).
33
+ */
34
+ export type MenuItemAction =
35
+ | ReactNode
36
+ | { node: ReactNode; hoverOnly?: boolean; key?: string };
37
+
38
+ export type MenuItemProps = Omit<HTMLAttributes<HTMLDivElement>, "onClick"> & {
39
+ size?: MenuItemSize;
40
+ variant?: MenuItemVariant;
41
+
42
+ /** 왼쪽 슬롯. 아이콘 / IconButton / 배지 등 무엇이든. checked=true면 이 배열 맨 앞에 checkmark 자동 삽입. */
43
+ leadingActions?: MenuItemAction[];
44
+ /** 오른쪽 슬롯. 카운트 / 메타 / ⋯메뉴 / hoverOnly 액션 등. shortcut/hasSubmenu는 이 배열 앞뒤에 자동 삽입. */
45
+ trailingActions?: MenuItemAction[];
46
+
47
+ /** 오른쪽 끝 단축키 텍스트 (예: "⌘K"). 내부에서 trailingActions 맨 앞에 자동 삽입. */
48
+ shortcut?: string;
49
+ /** true면 오른쪽 끝에 caret-right 인디케이터. 내부에서 trailingActions 맨 뒤에 자동 삽입. */
50
+ hasSubmenu?: boolean;
51
+
52
+ /** 체크박스/라디오 메뉴의 선택 상태. true면 leadingActions 맨 앞에 checkmark. */
53
+ checked?: boolean;
54
+ /** 현재 라우트/선택 아이템 강조. Sidebar에서 주로 사용. */
55
+ active?: boolean;
56
+ /**
57
+ * active 상태의 시각 강조 방식. 미지정 시 부모 컨테이너의 CSS 변수
58
+ * `--menu-active-style`을 따르고, 그것도 없으면 fill.
59
+ */
60
+ activeStyle?: MenuItemActiveStyle;
61
+ /** 비활성. click 무시 + fg-disabled. */
62
+ disabled?: boolean;
63
+ /** Radix Slot 패턴 — 상위 프리미티브(<Link>, Radix DropdownMenu.Item 등)에 위임. */
64
+ asChild?: boolean;
65
+
66
+ /**
67
+ * variant="section"일 때만 유효. true면 caret 붙고 클릭 가능한 접힘 헤더가 됨.
68
+ * open 상태(controlled 또는 defaultOpen)를 결합해 caret 방향/토글이 자동 처리.
69
+ */
70
+ collapsible?: boolean;
71
+ /** variant="section" + collapsible에서 controlled open 상태. */
72
+ open?: boolean;
73
+ /** variant="section" + collapsible에서 uncontrolled 초기 open. 기본 true. */
74
+ defaultOpen?: boolean;
75
+ /** open 변경 콜백. 사용처는 이 값을 받아 자식 항목을 조건부 렌더. */
76
+ onOpenChange?: (open: boolean) => void;
77
+
78
+ /**
79
+ * icon-only 정사각 축소 모드. Sidebar 등에서 rail 형태로 접힐 때 사용.
80
+ * - leadingActions의 첫 아이콘만 남기고 나머지 leading·label·trailing 전부 숨김.
81
+ * - 아이콘이 없으면 children 문자열의 첫 글자로 대체 (fallback).
82
+ * - 둘 다 없으면(아이콘 X + 문자열 라벨 X) 축소 무시 — 표시할 근거가 없기 때문.
83
+ * 축소된 root는 정사각형(width = min-height)으로 padding-inline 0.
84
+ */
85
+ collapsed?: boolean;
86
+
87
+ /**
88
+ * root click 핸들러. leadingActions/trailingActions 내부 요소에서 발생한 클릭은
89
+ * 자동으로 무시되지 않으므로, 하위 액션은 e.stopPropagation()을 스스로 호출해야 한다.
90
+ * (일반적으로 IconButton onClick 안에서 처리.)
91
+ */
92
+ onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
93
+
94
+ children?: ReactNode;
95
+ };
96
+
97
+ const sizeClass: Record<MenuItemSize, string> = {
98
+ sm: styles.sm,
99
+ md: styles.md,
100
+ };
101
+
102
+ const variantClass: Record<MenuItemVariant, string> = {
103
+ default: styles.default,
104
+ destructive: styles.destructive,
105
+ section: styles.section,
106
+ };
107
+
108
+ const ICON_SIZE: Record<MenuItemSize, number> = {
109
+ sm: 14,
110
+ md: 16,
111
+ };
112
+
113
+ // Submenu caret은 방향 인디케이터라 leading 아이콘보다 한 단계 작게 — 시각 부피가 라벨을 밀지 않도록.
114
+ const CARET_SIZE: Record<MenuItemSize, number> = {
115
+ sm: 10,
116
+ md: 12,
117
+ };
118
+
119
+ /* 슬롯 자식에 픽셀 size를 주입하는 건 우리 Icon 컴포넌트에 한정한다.
120
+ Badge·Chip 같은 컴포넌트는 자체 size 토큰("sm"|"md")을 갖고 있어
121
+ 숫자를 꽂아 넣으면 그 컴포넌트의 size 매핑을 부수기 때문(클래스 미부착 → 스타일 실종).
122
+ Icon만 화이트리스트로 처리 — 그 외는 원본 그대로 두고 자식 컴포넌트가 스스로 사이즈 결정. */
123
+ function sizedIcon(node: ReactNode, size: number): ReactNode {
124
+ if (!isValidElement(node)) return node;
125
+ const el = node as ReactElement<{ size?: number | string }>;
126
+ if (el.type !== Icon) return el;
127
+ if (el.props.size !== undefined) return el;
128
+ return cloneElement(el, { size });
129
+ }
130
+
131
+ /** 축소 모드에서 leading 자리에 세울 아이콘이 있는지 판별 —
132
+ * checked면 자동 checkmark가 붙으므로 그것도 유효 아이콘으로 취급. */
133
+ function findCollapsedLeading(
134
+ leadingActions: MenuItemAction[],
135
+ checked: boolean,
136
+ iconSize: number,
137
+ ): ReactNode | null {
138
+ if (checked) return <Icon name="check" size={iconSize} />;
139
+ for (let i = 0; i < leadingActions.length; i++) {
140
+ const { node } = normalizeAction(leadingActions[i], i);
141
+ if (node === null || node === undefined || node === false) continue;
142
+ return node;
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /** children이 순수 문자열/숫자로 환산되면 그 첫 글자를 리턴, 아니면 null.
148
+ * 이모지·서로게이트 페어까지 안전하게 하나의 grapheme으로 자르기 위해 Array.from 사용. */
149
+ function firstCharOfChildren(children: ReactNode): string | null {
150
+ let text = "";
151
+ Children.forEach(children, (child) => {
152
+ if (typeof child === "string" || typeof child === "number") {
153
+ text += String(child);
154
+ }
155
+ });
156
+ const trimmed = text.trim();
157
+ if (!trimmed) return null;
158
+ return Array.from(trimmed)[0] ?? null;
159
+ }
160
+
161
+ /** MenuItemAction을 { node, hoverOnly } 튜플로 정규화. */
162
+ function normalizeAction(
163
+ action: MenuItemAction,
164
+ idx: number,
165
+ ): { node: ReactNode; hoverOnly: boolean; key: string } {
166
+ if (
167
+ action !== null &&
168
+ typeof action === "object" &&
169
+ !isValidElement(action) &&
170
+ "node" in (action as { node: ReactNode })
171
+ ) {
172
+ const a = action as { node: ReactNode; hoverOnly?: boolean; key?: string };
173
+ return { node: a.node, hoverOnly: !!a.hoverOnly, key: a.key ?? String(idx) };
174
+ }
175
+ return { node: action as ReactNode, hoverOnly: false, key: String(idx) };
176
+ }
177
+
178
+ function renderActionSlot(
179
+ actions: MenuItemAction[],
180
+ iconSize: number,
181
+ slotClass: string,
182
+ ): ReactNode {
183
+ return actions.map((raw, i) => {
184
+ const { node, hoverOnly, key } = normalizeAction(raw, i);
185
+ if (node === null || node === undefined || node === false) return null;
186
+ return (
187
+ <span
188
+ key={key}
189
+ className={hoverOnly ? `${slotClass} ${styles.hoverOnly}` : slotClass}
190
+ >
191
+ {sizedIcon(node, iconSize)}
192
+ </span>
193
+ );
194
+ });
195
+ }
196
+
197
+ /* Section 헤더 — variant="section" 전용 내부 컴포넌트.
198
+ collapsible=true면 caret 붙고 hover/press 반응 + 토글 콜백. */
199
+ type SectionHeaderProps = {
200
+ rootClass: string;
201
+ size: MenuItemSize;
202
+ collapsible: boolean;
203
+ openProp?: boolean;
204
+ defaultOpen: boolean;
205
+ onOpenChange?: (open: boolean) => void;
206
+ onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
207
+ children?: ReactNode;
208
+ rest: Record<string, unknown>;
209
+ };
210
+
211
+ const SectionHeader = forwardRef<HTMLDivElement, SectionHeaderProps>(
212
+ function SectionHeader(
213
+ {
214
+ rootClass,
215
+ size,
216
+ collapsible,
217
+ openProp,
218
+ defaultOpen,
219
+ onOpenChange,
220
+ onClick,
221
+ children,
222
+ rest,
223
+ },
224
+ ref,
225
+ ) {
226
+ const [openState, setOpenState] = useState(defaultOpen);
227
+ const open = openProp ?? openState;
228
+
229
+ if (!collapsible) {
230
+ return (
231
+ <div ref={ref} className={rootClass} role="presentation" {...rest}>
232
+ <span className={styles.label}>{children}</span>
233
+ </div>
234
+ );
235
+ }
236
+
237
+ const toggle = (e: React.MouseEvent<HTMLDivElement>) => {
238
+ const next = !open;
239
+ setOpenState(next);
240
+ onOpenChange?.(next);
241
+ onClick?.(e);
242
+ };
243
+
244
+ return (
245
+ <div
246
+ ref={ref}
247
+ className={rootClass}
248
+ role="button"
249
+ tabIndex={0}
250
+ aria-expanded={open}
251
+ data-open={open || undefined}
252
+ onClick={toggle}
253
+ onKeyDown={(e) => {
254
+ if (e.key === "Enter" || e.key === " ") {
255
+ e.preventDefault();
256
+ (e.currentTarget as HTMLElement).click();
257
+ }
258
+ }}
259
+ {...rest}
260
+ >
261
+ <span className={styles.label}>
262
+ {children}
263
+ <Icon
264
+ name="caret-down"
265
+ size={CARET_SIZE[size]}
266
+ data-open={open || undefined}
267
+ className={styles.sectionCaret}
268
+ />
269
+ </span>
270
+ </div>
271
+ );
272
+ },
273
+ );
274
+
275
+ const MenuItem = forwardRef<HTMLDivElement, MenuItemProps>(
276
+ (
277
+ {
278
+ className,
279
+ size = "md",
280
+ variant = "default",
281
+ leadingActions = [],
282
+ trailingActions = [],
283
+ shortcut,
284
+ hasSubmenu = false,
285
+ checked = false,
286
+ active = false,
287
+ activeStyle,
288
+ disabled = false,
289
+ asChild = false,
290
+ collapsed = false,
291
+ collapsible = false,
292
+ open: openProp,
293
+ defaultOpen = true,
294
+ onOpenChange,
295
+ onClick,
296
+ children,
297
+ role,
298
+ tabIndex,
299
+ onKeyDown,
300
+ ...rest
301
+ },
302
+ ref,
303
+ ) => {
304
+ /* root를 <div role="menuitem">으로 하는 이유:
305
+ leadingActions/trailingActions에 <button>(IconButton 등)이 들어갈 수 있는데
306
+ nested <button>은 HTML 스펙 위반. div면 자식 인터랙션이 자유롭다.
307
+ 대신 키보드 접근을 위해 tabIndex + Enter/Space 핸들러를 직접 세팅. */
308
+ const Comp = asChild ? Slot : "div";
309
+ const iconSize = ICON_SIZE[size];
310
+
311
+ /* leading: checked면 맨 앞에 자동 checkmark */
312
+ const leadingComposed: MenuItemAction[] = checked
313
+ ? [{ node: <Icon name="check" size={iconSize} />, key: "__check__" }, ...leadingActions]
314
+ : leadingActions;
315
+
316
+ /* trailing: shortcut → [...trailingActions] → submenu caret 순서.
317
+ shortcut prop은 Shortcut 컴포넌트로 렌더 — 사이즈는 MenuItem 사이즈에 짝지음. */
318
+ const trailingComposed: MenuItemAction[] = [
319
+ ...(shortcut ? [{ node: <Shortcut size={size}>{shortcut}</Shortcut>, key: "__shortcut__" }] : []),
320
+ ...trailingActions,
321
+ ...(hasSubmenu ? [{ node: <Icon name="caret-right" size={CARET_SIZE[size]} />, key: "__caret__" }] : []),
322
+ ];
323
+
324
+ /* collapsed 결정 —
325
+ icon(있는 첫 leading action) 또는 문자열 라벨의 첫 글자 중 하나라도 있어야
326
+ 축소가 성립. 표시할 근거가 없으면 collapsed 요청을 무시(빈 정사각 방지). */
327
+ const collapsedLeadingIcon = collapsed
328
+ ? findCollapsedLeading(leadingActions, checked, iconSize)
329
+ : null;
330
+ const collapsedFallbackChar =
331
+ collapsed && !collapsedLeadingIcon ? firstCharOfChildren(children) : null;
332
+ const effectiveCollapsed =
333
+ collapsed && (collapsedLeadingIcon !== null || collapsedFallbackChar !== null);
334
+
335
+ /* collapsed일 땐 leading 하나만 남기고 trailing/label 전부 비움. */
336
+ const finalLeading: MenuItemAction[] = effectiveCollapsed
337
+ ? [
338
+ collapsedLeadingIcon !== null
339
+ ? { node: collapsedLeadingIcon, key: "__collapsed-icon__" }
340
+ : {
341
+ node: (
342
+ <span aria-hidden="true" className={styles.collapsedChar}>
343
+ {collapsedFallbackChar}
344
+ </span>
345
+ ),
346
+ key: "__collapsed-char__",
347
+ },
348
+ ]
349
+ : leadingComposed;
350
+ const finalTrailing: MenuItemAction[] = effectiveCollapsed ? [] : trailingComposed;
351
+
352
+ const hasLeading = finalLeading.length > 0;
353
+ const hasTrailing = finalTrailing.length > 0;
354
+
355
+ const rootClass = [
356
+ styles.root,
357
+ sizeClass[size],
358
+ variantClass[variant],
359
+ active ? styles.active : "",
360
+ hasLeading ? styles.hasLeading : "",
361
+ className ?? "",
362
+ ]
363
+ .filter(Boolean)
364
+ .join(" ");
365
+
366
+ const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
367
+ if (disabled) {
368
+ e.preventDefault();
369
+ e.stopPropagation();
370
+ return;
371
+ }
372
+ onClick?.(e);
373
+ };
374
+
375
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
376
+ onKeyDown?.(e);
377
+ if (e.defaultPrevented || disabled) return;
378
+ /* Enter/Space로 root 클릭 트리거. 단, 내부 button/link에서 시작된 이벤트는 통과. */
379
+ if ((e.key === "Enter" || e.key === " ") && e.currentTarget === e.target) {
380
+ e.preventDefault();
381
+ (e.currentTarget as HTMLElement).click();
382
+ }
383
+ };
384
+
385
+ /* section variant — 그룹 헤더. label만 렌더.
386
+ collapsible=false(기본): 클릭·포커스 불가한 정적 라벨.
387
+ collapsible=true: caret 자동, hover/press 반응, open 상태 토글. */
388
+ if (variant === "section") {
389
+ return (
390
+ <SectionHeader
391
+ ref={ref as React.Ref<HTMLDivElement>}
392
+ rootClass={rootClass}
393
+ size={size}
394
+ collapsible={collapsible}
395
+ openProp={openProp}
396
+ defaultOpen={defaultOpen}
397
+ onOpenChange={onOpenChange}
398
+ onClick={onClick}
399
+ rest={rest}
400
+ >
401
+ {children}
402
+ </SectionHeader>
403
+ );
404
+ }
405
+
406
+ return (
407
+ <Comp
408
+ ref={ref}
409
+ className={rootClass}
410
+ role={role ?? "menuitem"}
411
+ tabIndex={tabIndex ?? (disabled ? -1 : 0)}
412
+ aria-disabled={disabled || undefined}
413
+ data-active={active || undefined}
414
+ data-active-style={active && activeStyle ? activeStyle : undefined}
415
+ data-checked={checked || undefined}
416
+ data-disabled={disabled || undefined}
417
+ data-has-submenu={hasSubmenu || undefined}
418
+ data-collapsed={effectiveCollapsed || undefined}
419
+ onClick={handleClick}
420
+ onKeyDown={handleKeyDown}
421
+ {...rest}
422
+ >
423
+ {hasLeading && (
424
+ <span className={styles.leading}>
425
+ {renderActionSlot(finalLeading, iconSize, styles.leadingItem)}
426
+ </span>
427
+ )}
428
+ {asChild ? (
429
+ /* collapsed면 사용자의 children(라벨 텍스트)은 접어두고 leading만 남긴다.
430
+ asChild(예: <Link>Home</Link>)에서도 링크는 유지되며 내용만 아이콘으로 대체. */
431
+ <Slottable>{effectiveCollapsed ? null : children}</Slottable>
432
+ ) : (
433
+ !effectiveCollapsed && <span className={styles.label}>{children}</span>
434
+ )}
435
+ {hasTrailing && (
436
+ <span className={styles.trailing}>
437
+ {renderActionSlot(finalTrailing, iconSize, styles.trailingItem)}
438
+ </span>
439
+ )}
440
+ </Comp>
441
+ );
442
+ },
443
+ );
444
+
445
+ MenuItem.displayName = "MenuItem";
446
+
447
+ /* MenuSection ─────────────────────────────────────────
448
+ 섹션 헤더 + 접힘 가능한 자식 패널을 한 세트로 묶는다.
449
+ <MenuItem variant="section" collapsible>을 직접 쓰던 사용처가 자식 렌더링과
450
+ open 상태를 각자 처리하다 보니 열림/닫힘 애니메이션이 붙을 자리가 없었다.
451
+ 이 컴포넌트는 자식 컨테이너에 data-open을 걸어 grid-template-rows 트릭으로
452
+ CSS-only height + fade 트랜지션을 자동 적용한다.
453
+
454
+ heading-only(자식 없음)면 애니메이션 슬롯을 만들지 않고 헤더만 렌더 —
455
+ 비접힘 정적 라벨로도 사용 가능하도록. */
456
+ export type MenuSectionProps = {
457
+ /** 헤더 라벨. 문자열 또는 임의 ReactNode. */
458
+ title: ReactNode;
459
+ size?: MenuItemSize;
460
+ /** 접힘 지원. 기본 true. false면 헤더만 렌더되고 자식은 항상 표시. */
461
+ collapsible?: boolean;
462
+ /** controlled open. */
463
+ open?: boolean;
464
+ /** uncontrolled 초기 open. 기본 true. */
465
+ defaultOpen?: boolean;
466
+ onOpenChange?: (open: boolean) => void;
467
+ /** 헤더에 붙일 클래스. */
468
+ headerClassName?: string;
469
+ /** 자식 패널에 붙일 클래스. */
470
+ panelClassName?: string;
471
+ children?: ReactNode;
472
+ };
473
+
474
+ function MenuSection({
475
+ title,
476
+ size = "sm",
477
+ collapsible = true,
478
+ open: openProp,
479
+ defaultOpen = true,
480
+ onOpenChange,
481
+ headerClassName,
482
+ panelClassName,
483
+ children,
484
+ }: MenuSectionProps) {
485
+ const [openState, setOpenState] = useState(defaultOpen);
486
+ const open = openProp ?? openState;
487
+
488
+ const handleOpenChange = (next: boolean) => {
489
+ setOpenState(next);
490
+ onOpenChange?.(next);
491
+ };
492
+
493
+ const hasChildren = children !== undefined && children !== null && children !== false;
494
+ const showPanel = hasChildren && collapsible;
495
+
496
+ return (
497
+ <>
498
+ <MenuItem
499
+ variant="section"
500
+ size={size}
501
+ collapsible={collapsible && hasChildren}
502
+ open={collapsible && hasChildren ? open : undefined}
503
+ defaultOpen={defaultOpen}
504
+ onOpenChange={collapsible && hasChildren ? handleOpenChange : undefined}
505
+ className={headerClassName}
506
+ >
507
+ {title}
508
+ </MenuItem>
509
+ {showPanel ? (
510
+ <div
511
+ className={[styles.sectionPanel, panelClassName ?? ""].filter(Boolean).join(" ")}
512
+ data-open={open || undefined}
513
+ aria-hidden={!open || undefined}
514
+ >
515
+ <div className={styles.sectionPanelInner}>{children}</div>
516
+ </div>
517
+ ) : (
518
+ hasChildren && children
519
+ )}
520
+ </>
521
+ );
522
+ }
523
+
524
+ MenuSection.displayName = "MenuItem.Section";
525
+
526
+ const MenuItemWithSection = MenuItem as typeof MenuItem & { Section: typeof MenuSection };
527
+ MenuItemWithSection.Section = MenuSection;
528
+
529
+ export { MenuItemWithSection as MenuItem, MenuSection };
@@ -0,0 +1,8 @@
1
+ export { MenuItem } from "./MenuItem";
2
+ export type {
3
+ MenuItemProps,
4
+ MenuItemSize,
5
+ MenuItemVariant,
6
+ MenuItemActiveStyle,
7
+ MenuItemAction,
8
+ } from "./MenuItem";
@@ -0,0 +1,153 @@
1
+ /*
2
+ * MetricCard — 전부 CTDS 토큰 경유.
3
+ * variant:
4
+ * line — border만 (조밀한 그리드, 기본)
5
+ * soft — bg-subtle 표면 (경계 대신 톤)
6
+ * bare — border·bg·padding 모두 제거. pure 좌표 전용.
7
+ * 부모 container가 hairline/톤으로 분리를 담당하는 자리에서만 쓴다.
8
+ * 그림자 금지. interactive: href 있을 때만 hover/focus ring.
9
+ */
10
+
11
+ .root {
12
+ display: flex;
13
+ flex-direction: column;
14
+ gap: var(--space-2);
15
+ text-decoration: none;
16
+ color: inherit;
17
+ min-width: 0;
18
+ }
19
+
20
+ /* line/soft는 자체 chrome(padding+radius+bg/border)을 가진다. */
21
+ .line,
22
+ .soft {
23
+ padding: var(--space-5) var(--space-5);
24
+ border-radius: var(--radius-md);
25
+ background: var(--bg);
26
+ }
27
+
28
+ .line {
29
+ border: 1px solid var(--border);
30
+ }
31
+
32
+ .soft {
33
+ border: 1px solid transparent;
34
+ background: var(--bg-subtle);
35
+ }
36
+
37
+ /* bare — 가두는 장치 없음. padding·radius·bg·border 모두 없음.
38
+ 부모가 hairline/톤 교대로 분리를 담당하는 pure 좌표에서만 사용.
39
+ interactive라도 bg 교체 없이 fg 톤 변화만. */
40
+ .bare {
41
+ padding: 0;
42
+ border: 0;
43
+ background: transparent;
44
+ }
45
+
46
+ .interactive {
47
+ cursor: pointer;
48
+ transition: var(--transition-colors), var(--transition-ring);
49
+ }
50
+
51
+ .line.interactive:hover {
52
+ background: var(--hover);
53
+ }
54
+
55
+ .soft.interactive:hover {
56
+ background: var(--bg-subtle-hover, var(--hover));
57
+ }
58
+
59
+ .bare.interactive:hover {
60
+ color: var(--fg-strong, var(--fg));
61
+ }
62
+
63
+ .interactive:focus-visible {
64
+ outline: none;
65
+ box-shadow: var(--ring-shadow);
66
+ }
67
+
68
+ /* head: 아이콘 + 라벨 */
69
+ .head {
70
+ display: flex;
71
+ align-items: center;
72
+ gap: var(--space-2);
73
+ min-width: 0;
74
+ }
75
+
76
+ .icon {
77
+ display: inline-flex;
78
+ align-items: center;
79
+ justify-content: center;
80
+ color: var(--fg-muted);
81
+ font-size: var(--text-sm);
82
+ line-height: 1;
83
+ }
84
+
85
+ .label {
86
+ font-size: var(--text-xs);
87
+ line-height: var(--lh-xs);
88
+ font-weight: var(--font-weight-medium);
89
+ color: var(--fg-muted);
90
+ min-width: 0;
91
+ overflow: hidden;
92
+ text-overflow: ellipsis;
93
+ white-space: nowrap;
94
+ }
95
+
96
+ /* value row: 큰 숫자 + delta */
97
+ .valueRow {
98
+ display: flex;
99
+ align-items: baseline;
100
+ justify-content: flex-start;
101
+ gap: var(--space-3);
102
+ min-width: 0;
103
+ flex-wrap: wrap;
104
+ }
105
+
106
+ .value {
107
+ font-size: var(--text-2xl);
108
+ line-height: var(--lh-tight);
109
+ font-weight: var(--font-weight-semibold);
110
+ font-variant-numeric: tabular-nums;
111
+ color: var(--fg);
112
+ min-width: 0;
113
+ }
114
+
115
+ /* delta: inline 텍스트 + 아이콘. Badge pill 금지, 더 가볍게. */
116
+ .delta {
117
+ display: inline-flex;
118
+ align-items: center;
119
+ gap: calc(var(--space-unit) * 1);
120
+ font-size: var(--text-xs);
121
+ line-height: var(--lh-xs);
122
+ font-weight: var(--font-weight-medium);
123
+ font-variant-numeric: tabular-nums;
124
+ color: var(--fg-muted);
125
+ flex-shrink: 0;
126
+ }
127
+
128
+ .delta[data-trend="up"] {
129
+ color: var(--success);
130
+ }
131
+
132
+ .delta[data-trend="down"] {
133
+ color: var(--destructive);
134
+ }
135
+
136
+ .delta[data-trend="flat"] {
137
+ color: var(--fg-muted);
138
+ }
139
+
140
+ .deltaIcon {
141
+ display: inline-flex;
142
+ align-items: center;
143
+ justify-content: center;
144
+ font-size: var(--text-sm);
145
+ line-height: 1;
146
+ }
147
+
148
+ /* description: value 아래 */
149
+ .description {
150
+ font-size: var(--text-xs);
151
+ line-height: var(--lh-xs);
152
+ color: var(--fg-muted);
153
+ }