@bug-on/md3-react 2.0.3 → 3.0.1

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 (316) hide show
  1. package/.turbo/turbo-build.log +42 -0
  2. package/CHANGELOG.md +69 -0
  3. package/dist/index.css +178 -0
  4. package/dist/index.css.d.ts +2 -0
  5. package/dist/index.d.mts +6135 -0
  6. package/dist/index.d.ts +6135 -71
  7. package/dist/index.js +1688 -631
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +1600 -564
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/material-symbols-cdn.css.d.ts +2 -0
  12. package/dist/material-symbols-self-hosted.css.d.ts +2 -0
  13. package/dist/plugin.d.mts +1 -0
  14. package/dist/plugin.d.ts +1 -0
  15. package/dist/plugin.js +13 -0
  16. package/dist/plugin.js.map +1 -0
  17. package/dist/plugin.mjs +3 -0
  18. package/dist/plugin.mjs.map +1 -0
  19. package/dist/typography.css.d.ts +2 -0
  20. package/package.json +28 -19
  21. package/scripts/copy-assets.js +115 -0
  22. package/src/assets/fonts/GoogleSansFlex-VariableFont.woff2 +0 -0
  23. package/src/assets/fonts/MaterialSymbolsOutlined-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  24. package/src/assets/fonts/MaterialSymbolsRounded-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  25. package/src/assets/fonts/MaterialSymbolsSharp-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  26. package/src/assets/loading-indicator.svg +19 -0
  27. package/src/assets/material-symbols-cdn.css +65 -0
  28. package/src/assets/material-symbols-self-hosted.css +90 -0
  29. package/src/css.d.ts +20 -0
  30. package/src/hooks/useClickOutside.ts +37 -0
  31. package/src/hooks/useMediaQuery.ts +28 -0
  32. package/src/hooks/useRipple.ts +88 -0
  33. package/src/index.css +23 -0
  34. package/src/index.ts +349 -0
  35. package/src/lib/material-symbols-preconnect.tsx +82 -0
  36. package/src/lib/theme-utils.ts +195 -0
  37. package/src/lib/utils.ts +6 -0
  38. package/src/plugin.ts +12 -0
  39. package/src/test/button.test.tsx +59 -0
  40. package/src/test/icon.test.tsx +91 -0
  41. package/src/test/loading-indicator.test.tsx +128 -0
  42. package/src/test/progress-indicator.test.tsx +306 -0
  43. package/src/test/setup.ts +80 -0
  44. package/src/test/typography.test.tsx +206 -0
  45. package/src/types/index.ts +7 -0
  46. package/src/types/md3.ts +31 -0
  47. package/src/ui/Text.tsx +60 -0
  48. package/src/ui/__snapshots__/divider.test.tsx.snap +63 -0
  49. package/src/ui/app-bar/app-bar-column.tsx +99 -0
  50. package/src/ui/app-bar/app-bar-item-button.tsx +71 -0
  51. package/src/ui/app-bar/app-bar-items.test.tsx +89 -0
  52. package/src/ui/app-bar/app-bar-overflow-indicator.tsx +108 -0
  53. package/src/ui/app-bar/app-bar-row.tsx +104 -0
  54. package/src/ui/app-bar/app-bar.test.tsx +87 -0
  55. package/src/ui/app-bar/app-bar.tokens.ts +223 -0
  56. package/src/ui/app-bar/app-bar.types.ts +441 -0
  57. package/src/ui/app-bar/bottom-app-bar.test.tsx +42 -0
  58. package/src/ui/app-bar/bottom-app-bar.tsx +84 -0
  59. package/src/ui/app-bar/docked-toolbar.test.tsx +34 -0
  60. package/src/ui/app-bar/docked-toolbar.tsx +54 -0
  61. package/src/ui/app-bar/flexible-app-bar.test.tsx +75 -0
  62. package/src/ui/app-bar/hooks/use-app-bar-scroll.ts +110 -0
  63. package/src/ui/app-bar/hooks/use-flexible-app-bar.ts +123 -0
  64. package/{dist/ui/app-bar/index.d.ts → src/ui/app-bar/index.ts} +35 -2
  65. package/src/ui/app-bar/large-flexible-app-bar.tsx +165 -0
  66. package/src/ui/app-bar/medium-flexible-app-bar.tsx +167 -0
  67. package/src/ui/app-bar/search-app-bar.test.tsx +49 -0
  68. package/src/ui/app-bar/search-app-bar.tsx +176 -0
  69. package/src/ui/app-bar/search-view.tsx +227 -0
  70. package/src/ui/app-bar/small-app-bar.test.tsx +48 -0
  71. package/src/ui/app-bar/small-app-bar.tsx +203 -0
  72. package/src/ui/badge.test.tsx +345 -0
  73. package/src/ui/badge.tsx +282 -0
  74. package/src/ui/button-group.test.tsx +71 -0
  75. package/src/ui/button-group.tsx +350 -0
  76. package/src/ui/button.test.tsx +306 -0
  77. package/src/ui/button.tsx +665 -0
  78. package/src/ui/card.test.tsx +187 -0
  79. package/src/ui/card.tsx +259 -0
  80. package/src/ui/checkbox.test.tsx +423 -0
  81. package/src/ui/checkbox.tsx +525 -0
  82. package/src/ui/chip.test.tsx +292 -0
  83. package/src/ui/chip.tsx +548 -0
  84. package/src/ui/code-block.tsx +219 -0
  85. package/src/ui/dialog.test.tsx +300 -0
  86. package/src/ui/dialog.tsx +384 -0
  87. package/src/ui/divider.test.tsx +314 -0
  88. package/src/ui/divider.tsx +412 -0
  89. package/src/ui/drawer.tsx +240 -0
  90. package/src/ui/fab-menu.test.tsx +494 -0
  91. package/src/ui/fab-menu.tsx +739 -0
  92. package/src/ui/fab.test.tsx +232 -0
  93. package/src/ui/fab.tsx +505 -0
  94. package/src/ui/icon-button.test.tsx +515 -0
  95. package/src/ui/icon-button.tsx +525 -0
  96. package/src/ui/icon.test.tsx +197 -0
  97. package/src/ui/icon.tsx +179 -0
  98. package/src/ui/loading-indicator.test.tsx +73 -0
  99. package/src/ui/loading-indicator.tsx +312 -0
  100. package/src/ui/menu/context-menu.tsx +275 -0
  101. package/src/ui/menu/index.ts +77 -0
  102. package/src/ui/menu/menu-animations.ts +102 -0
  103. package/src/ui/menu/menu-context.tsx +99 -0
  104. package/src/ui/menu/menu-divider.tsx +47 -0
  105. package/src/ui/menu/menu-group.tsx +200 -0
  106. package/src/ui/menu/menu-item.tsx +294 -0
  107. package/src/ui/menu/menu-tokens.ts +208 -0
  108. package/src/ui/menu/menu-types.ts +313 -0
  109. package/src/ui/menu/menu.test.tsx +624 -0
  110. package/src/ui/menu/menu.tsx +289 -0
  111. package/src/ui/menu/sub-menu.tsx +223 -0
  112. package/src/ui/menu/vertical-menu.tsx +382 -0
  113. package/src/ui/navigation-rail.test.tsx +404 -0
  114. package/src/ui/navigation-rail.tsx +607 -0
  115. package/src/ui/progress-indicator/circular.tsx +248 -0
  116. package/src/ui/progress-indicator/hooks.ts +51 -0
  117. package/{dist/ui/progress-indicator/index.d.ts → src/ui/progress-indicator/index.tsx} +20 -2
  118. package/src/ui/progress-indicator/linear-flat.tsx +83 -0
  119. package/src/ui/progress-indicator/linear-wavy.tsx +243 -0
  120. package/src/ui/progress-indicator/linear.tsx +143 -0
  121. package/src/ui/progress-indicator/types.ts +158 -0
  122. package/src/ui/progress-indicator/utils.ts +73 -0
  123. package/src/ui/radio-button.test.tsx +407 -0
  124. package/src/ui/radio-button.tsx +551 -0
  125. package/src/ui/ripple.test.tsx +72 -0
  126. package/src/ui/ripple.tsx +234 -0
  127. package/src/ui/scroll-area.test.tsx +58 -0
  128. package/src/ui/scroll-area.tsx +139 -0
  129. package/src/ui/search/animated-placeholder.tsx +145 -0
  130. package/src/ui/search/hooks/use-search-keyboard.test.ts +202 -0
  131. package/src/ui/search/hooks/use-search-keyboard.ts +104 -0
  132. package/src/ui/search/hooks/use-search-view-focus.test.ts +96 -0
  133. package/src/ui/search/hooks/use-search-view-focus.ts +24 -0
  134. package/src/ui/search/index.ts +44 -0
  135. package/src/ui/search/search-bar.tsx +220 -0
  136. package/src/ui/search/search-context.tsx +42 -0
  137. package/src/ui/search/search-view-docked.tsx +194 -0
  138. package/src/ui/search/search-view-fullscreen.tsx +247 -0
  139. package/src/ui/search/search.test.tsx +233 -0
  140. package/src/ui/search/search.tokens.ts +134 -0
  141. package/src/ui/search/search.tsx +131 -0
  142. package/src/ui/search/search.types.ts +154 -0
  143. package/src/ui/search/trailing-action.tsx +49 -0
  144. package/src/ui/shared/constants.ts +135 -0
  145. package/{dist/ui/shared/touch-target.d.ts → src/ui/shared/touch-target.tsx} +13 -1
  146. package/src/ui/slider/hooks/useSliderMath.ts +195 -0
  147. package/{dist/ui/slider/index.d.ts → src/ui/slider/index.ts} +12 -1
  148. package/src/ui/slider/range-slider.tsx +561 -0
  149. package/src/ui/slider/slider-thumb.tsx +379 -0
  150. package/src/ui/slider/slider-track.tsx +912 -0
  151. package/src/ui/slider/slider.tokens.ts +189 -0
  152. package/src/ui/slider/slider.tsx +259 -0
  153. package/src/ui/slider/slider.types.ts +288 -0
  154. package/src/ui/snackbar/index.ts +20 -0
  155. package/src/ui/snackbar/snackbar.test.tsx +338 -0
  156. package/src/ui/snackbar/snackbar.tsx +476 -0
  157. package/{dist/ui/switch/index.d.ts → src/ui/switch/index.ts} +1 -0
  158. package/src/ui/switch/switch.stories.tsx +309 -0
  159. package/src/ui/switch/switch.test.tsx +243 -0
  160. package/src/ui/switch/switch.tokens.ts +89 -0
  161. package/src/ui/switch/switch.tsx +504 -0
  162. package/src/ui/switch/switch.types.ts +62 -0
  163. package/{dist/ui/tabs/index.d.ts → src/ui/tabs/index.ts} +8 -1
  164. package/src/ui/tabs/tab.tsx +407 -0
  165. package/src/ui/tabs/tabs-content.tsx +89 -0
  166. package/src/ui/tabs/tabs-list.tsx +146 -0
  167. package/src/ui/tabs/tabs.test.tsx +290 -0
  168. package/src/ui/tabs/tabs.tokens.ts +121 -0
  169. package/src/ui/tabs/tabs.tsx +229 -0
  170. package/src/ui/tabs/tabs.types.ts +185 -0
  171. package/{dist/ui/text-field/index.d.ts → src/ui/text-field/index.ts} +8 -1
  172. package/src/ui/text-field/subcomponents/active-indicator.tsx +67 -0
  173. package/src/ui/text-field/subcomponents/floating-label.tsx +161 -0
  174. package/src/ui/text-field/subcomponents/leading-icon.tsx +46 -0
  175. package/src/ui/text-field/subcomponents/outline-container.tsx +170 -0
  176. package/src/ui/text-field/subcomponents/prefix-suffix.tsx +59 -0
  177. package/src/ui/text-field/subcomponents/supporting-text.tsx +145 -0
  178. package/src/ui/text-field/subcomponents/trailing-icon.tsx +199 -0
  179. package/src/ui/text-field/text-field.test.tsx +454 -0
  180. package/src/ui/text-field/text-field.tokens.ts +104 -0
  181. package/src/ui/text-field/text-field.tsx +548 -0
  182. package/src/ui/text-field/text-field.types.ts +180 -0
  183. package/src/ui/theme-provider/index.tsx +215 -0
  184. package/src/ui/toc.test.tsx +108 -0
  185. package/src/ui/toc.tsx +172 -0
  186. package/src/ui/tooltip/plain-tooltip.tsx +63 -0
  187. package/src/ui/tooltip/rich-tooltip.tsx +94 -0
  188. package/src/ui/tooltip/tooltip-box.tsx +266 -0
  189. package/src/ui/tooltip/tooltip-caret-shape.tsx +68 -0
  190. package/src/ui/tooltip/tooltip.tokens.ts +26 -0
  191. package/src/ui/tooltip/tooltip.types.ts +70 -0
  192. package/src/ui/tooltip/use-tooltip-position.ts +208 -0
  193. package/src/ui/tooltip/use-tooltip-state.ts +41 -0
  194. package/src/ui/typography/__tests__/typography.test.tsx +170 -0
  195. package/{dist/ui/typography/index.d.ts → src/ui/typography/index.ts} +21 -3
  196. package/src/ui/typography/type-scale-tokens.ts +205 -0
  197. package/src/ui/typography/typography-key-tokens.ts +43 -0
  198. package/src/ui/typography/typography-tokens.ts +360 -0
  199. package/src/ui/typography/typography.css +22 -0
  200. package/src/ui/typography/typography.tsx +559 -0
  201. package/test-render.tsx +4 -0
  202. package/test-shadow.html +26 -0
  203. package/test_output.txt +164 -0
  204. package/test_output_v2.txt +5 -0
  205. package/tsconfig.build.json +10 -0
  206. package/tsconfig.json +18 -0
  207. package/tsup.config.ts +20 -0
  208. package/vitest.config.ts +11 -0
  209. package/dist/hooks/useClickOutside.d.ts +0 -8
  210. package/dist/hooks/useMediaQuery.d.ts +0 -11
  211. package/dist/hooks/useRipple.d.ts +0 -26
  212. package/dist/lib/material-symbols-preconnect.d.ts +0 -42
  213. package/dist/lib/theme-utils.d.ts +0 -63
  214. package/dist/lib/utils.d.ts +0 -2
  215. package/dist/types/index.d.ts +0 -1
  216. package/dist/types/md3.d.ts +0 -14
  217. package/dist/ui/app-bar/app-bar-column.d.ts +0 -28
  218. package/dist/ui/app-bar/app-bar-item-button.d.ts +0 -16
  219. package/dist/ui/app-bar/app-bar-overflow-indicator.d.ts +0 -18
  220. package/dist/ui/app-bar/app-bar-row.d.ts +0 -36
  221. package/dist/ui/app-bar/app-bar.tokens.d.ts +0 -184
  222. package/dist/ui/app-bar/app-bar.types.d.ts +0 -392
  223. package/dist/ui/app-bar/bottom-app-bar.d.ts +0 -31
  224. package/dist/ui/app-bar/docked-toolbar.d.ts +0 -25
  225. package/dist/ui/app-bar/hooks/use-app-bar-scroll.d.ts +0 -42
  226. package/dist/ui/app-bar/hooks/use-flexible-app-bar.d.ts +0 -37
  227. package/dist/ui/app-bar/large-flexible-app-bar.d.ts +0 -26
  228. package/dist/ui/app-bar/medium-flexible-app-bar.d.ts +0 -28
  229. package/dist/ui/app-bar/search-app-bar.d.ts +0 -43
  230. package/dist/ui/app-bar/search-view.d.ts +0 -54
  231. package/dist/ui/app-bar/small-app-bar.d.ts +0 -37
  232. package/dist/ui/badge.d.ts +0 -125
  233. package/dist/ui/button-group.d.ts +0 -59
  234. package/dist/ui/button.d.ts +0 -148
  235. package/dist/ui/card.d.ts +0 -62
  236. package/dist/ui/checkbox.d.ts +0 -82
  237. package/dist/ui/chip.d.ts +0 -110
  238. package/dist/ui/code-block.d.ts +0 -14
  239. package/dist/ui/dialog.d.ts +0 -111
  240. package/dist/ui/divider.d.ts +0 -164
  241. package/dist/ui/drawer.d.ts +0 -39
  242. package/dist/ui/dropdown.d.ts +0 -29
  243. package/dist/ui/fab-menu.d.ts +0 -204
  244. package/dist/ui/fab.d.ts +0 -162
  245. package/dist/ui/icon-button.d.ts +0 -131
  246. package/dist/ui/icon.d.ts +0 -88
  247. package/dist/ui/loading-indicator.d.ts +0 -42
  248. package/dist/ui/navigation-rail.d.ts +0 -29
  249. package/dist/ui/progress-indicator/circular.d.ts +0 -3
  250. package/dist/ui/progress-indicator/hooks.d.ts +0 -3
  251. package/dist/ui/progress-indicator/linear-flat.d.ts +0 -10
  252. package/dist/ui/progress-indicator/linear-wavy.d.ts +0 -18
  253. package/dist/ui/progress-indicator/linear.d.ts +0 -3
  254. package/dist/ui/progress-indicator/types.d.ts +0 -151
  255. package/dist/ui/progress-indicator/utils.d.ts +0 -3
  256. package/dist/ui/radio-button.d.ts +0 -106
  257. package/dist/ui/ripple.d.ts +0 -126
  258. package/dist/ui/scroll-area.d.ts +0 -27
  259. package/dist/ui/search/animated-placeholder.d.ts +0 -54
  260. package/dist/ui/search/hooks/use-search-keyboard.d.ts +0 -32
  261. package/dist/ui/search/hooks/use-search-view-focus.d.ts +0 -6
  262. package/dist/ui/search/index.d.ts +0 -27
  263. package/dist/ui/search/search-bar.d.ts +0 -32
  264. package/dist/ui/search/search-context.d.ts +0 -24
  265. package/dist/ui/search/search-view-docked.d.ts +0 -25
  266. package/dist/ui/search/search-view-fullscreen.d.ts +0 -36
  267. package/dist/ui/search/search.d.ts +0 -50
  268. package/dist/ui/search/search.tokens.d.ts +0 -112
  269. package/dist/ui/search/search.types.d.ts +0 -131
  270. package/dist/ui/search/trailing-action.d.ts +0 -9
  271. package/dist/ui/shared/constants.d.ts +0 -86
  272. package/dist/ui/slider/hooks/useSliderMath.d.ts +0 -101
  273. package/dist/ui/slider/range-slider.d.ts +0 -47
  274. package/dist/ui/slider/slider-thumb.d.ts +0 -33
  275. package/dist/ui/slider/slider-track.d.ts +0 -25
  276. package/dist/ui/slider/slider.d.ts +0 -60
  277. package/dist/ui/slider/slider.tokens.d.ts +0 -151
  278. package/dist/ui/slider/slider.types.d.ts +0 -259
  279. package/dist/ui/snackbar/index.d.ts +0 -6
  280. package/dist/ui/snackbar/snackbar.d.ts +0 -197
  281. package/dist/ui/switch/switch.d.ts +0 -30
  282. package/dist/ui/switch/switch.stories.d.ts +0 -48
  283. package/dist/ui/switch/switch.tokens.d.ts +0 -67
  284. package/dist/ui/switch/switch.types.d.ts +0 -59
  285. package/dist/ui/tabs/tab.d.ts +0 -43
  286. package/dist/ui/tabs/tabs-content.d.ts +0 -36
  287. package/dist/ui/tabs/tabs-list.d.ts +0 -40
  288. package/dist/ui/tabs/tabs.d.ts +0 -60
  289. package/dist/ui/tabs/tabs.tokens.d.ts +0 -94
  290. package/dist/ui/tabs/tabs.types.d.ts +0 -172
  291. package/dist/ui/text-field/subcomponents/active-indicator.d.ts +0 -24
  292. package/dist/ui/text-field/subcomponents/floating-label.d.ts +0 -43
  293. package/dist/ui/text-field/subcomponents/leading-icon.d.ts +0 -23
  294. package/dist/ui/text-field/subcomponents/outline-container.d.ts +0 -42
  295. package/dist/ui/text-field/subcomponents/prefix-suffix.d.ts +0 -24
  296. package/dist/ui/text-field/subcomponents/supporting-text.d.ts +0 -37
  297. package/dist/ui/text-field/subcomponents/trailing-icon.d.ts +0 -41
  298. package/dist/ui/text-field/text-field.d.ts +0 -49
  299. package/dist/ui/text-field/text-field.tokens.d.ts +0 -76
  300. package/dist/ui/text-field/text-field.types.d.ts +0 -126
  301. package/dist/ui/theme-provider/index.d.ts +0 -48
  302. package/dist/ui/toc.d.ts +0 -80
  303. package/dist/ui/tooltip/plain-tooltip.d.ts +0 -2
  304. package/dist/ui/tooltip/rich-tooltip.d.ts +0 -2
  305. package/dist/ui/tooltip/tooltip-box.d.ts +0 -2
  306. package/dist/ui/tooltip/tooltip-caret-shape.d.ts +0 -9
  307. package/dist/ui/tooltip/tooltip.tokens.d.ts +0 -26
  308. package/dist/ui/tooltip/tooltip.types.d.ts +0 -56
  309. package/dist/ui/tooltip/use-tooltip-position.d.ts +0 -8
  310. package/dist/ui/tooltip/use-tooltip-state.d.ts +0 -2
  311. package/dist/ui/typography/type-scale-tokens.d.ts +0 -162
  312. package/dist/ui/typography/typography-key-tokens.d.ts +0 -40
  313. package/dist/ui/typography/typography-tokens.d.ts +0 -220
  314. package/dist/ui/typography/typography.d.ts +0 -265
  315. /package/{dist/hooks/index.d.ts → src/hooks/index.ts} +0 -0
  316. /package/{dist/ui/tooltip/index.d.ts → src/ui/tooltip/index.ts} +0 -0
@@ -0,0 +1,219 @@
1
+ "use client";
2
+
3
+ import { createElement, useCallback, useEffect, useState } from "react";
4
+ import { cn } from "../lib/utils";
5
+ import { Button } from "./button";
6
+ import { Icon } from "./icon";
7
+ import { ScrollArea } from "./scroll-area";
8
+
9
+ const COPY_RESET_DELAY = 2000;
10
+
11
+ export interface CodeBlockProps {
12
+ /** Raw code string to display and copy. */
13
+ code: string;
14
+ /** Language label in the header (presentational only). @default "React" */
15
+ language?: string;
16
+ /** Additional CSS classes for the outer wrapper. */
17
+ className?: string;
18
+ /**
19
+ * Pre-highlighted HTML from Shiki SSR.
20
+ * Use `codeToHtml` with `themes: { light, dark }` for dual-theme support.
21
+ */
22
+ html?: string;
23
+ }
24
+
25
+ // ─── Sub-components ───────────────────────────────────────────────────────────
26
+
27
+ function CopyButton({
28
+ copied,
29
+ onCopy,
30
+ }: {
31
+ copied: boolean;
32
+ onCopy: () => void;
33
+ }) {
34
+ return (
35
+ <Button
36
+ type="button"
37
+ onClick={onCopy}
38
+ title="Copy code"
39
+ aria-label={copied ? "Code copied" : "Copy code"}
40
+ colorStyle="text"
41
+ className="h-8 px-2 gap-1.5"
42
+ >
43
+ {copied ? (
44
+ <>
45
+ <Icon
46
+ name="check"
47
+ size={14}
48
+ className="text-m3-primary"
49
+ aria-hidden="true"
50
+ />
51
+ <span className="text-[10px] font-bold uppercase tracking-wider text-m3-primary">
52
+ Copied!
53
+ </span>
54
+ </>
55
+ ) : (
56
+ <>
57
+ <Icon
58
+ name="content_copy"
59
+ size={14}
60
+ className="text-m3-on-surface-variant"
61
+ aria-hidden="true"
62
+ />
63
+ <span className="text-[10px] font-bold uppercase tracking-wider text-m3-on-surface-variant">
64
+ Copy
65
+ </span>
66
+ </>
67
+ )}
68
+ </Button>
69
+ );
70
+ }
71
+
72
+ /** Mappings for HTML attributes to React props */
73
+ const ATTR_MAP: Record<string, string> = {
74
+ class: "className",
75
+ tabindex: "tabIndex",
76
+ readonly: "readOnly",
77
+ maxlength: "maxLength",
78
+ autocomplete: "autoComplete",
79
+ autofocus: "autoFocus",
80
+ contenteditable: "contentEditable",
81
+ spellcheck: "spellCheck",
82
+ };
83
+
84
+ function parseStyle(styleStr: string): React.CSSProperties {
85
+ const styleObj: Record<string, string> = {};
86
+ for (const s of styleStr.split(";")) {
87
+ const part = s.trim();
88
+ if (!part) continue;
89
+ const colonIndex = part.indexOf(":");
90
+ if (colonIndex === -1) continue;
91
+
92
+ const k = part.slice(0, colonIndex).trim();
93
+ const v = part.slice(colonIndex + 1).trim();
94
+
95
+ if (k.startsWith("--")) {
96
+ // CSS variables must be passed as-is
97
+ styleObj[k] = v;
98
+ } else {
99
+ // Standard properties should be camelCased for React
100
+ const key = k.replace(/-./g, (x) => x[1].toUpperCase());
101
+ styleObj[key] = v;
102
+ }
103
+ }
104
+ return styleObj as React.CSSProperties;
105
+ }
106
+
107
+ function mapDomToReact(node: Node, key: string | number): React.ReactNode {
108
+ if (node.nodeType === Node.TEXT_NODE) {
109
+ return node.textContent;
110
+ }
111
+ if (node.nodeType === Node.ELEMENT_NODE) {
112
+ const el = node as Element;
113
+ const tagName = el.tagName.toLowerCase();
114
+ const props: Record<string, unknown> = { key };
115
+
116
+ for (const attr of Array.from(el.attributes)) {
117
+ const propName = ATTR_MAP[attr.name] || attr.name;
118
+ if (propName === "style") {
119
+ props.style = parseStyle(attr.value);
120
+ } else {
121
+ props[propName] = attr.value;
122
+ }
123
+ }
124
+
125
+ return createElement(
126
+ tagName,
127
+ props,
128
+ Array.from(el.childNodes).map((child, i) => mapDomToReact(child, i)),
129
+ );
130
+ }
131
+ return null;
132
+ }
133
+
134
+ function CodeContent({ html, code }: { html?: string; code: string }) {
135
+ const [parsedContent, setParsedContent] = useState<React.ReactNode>(null);
136
+
137
+ useEffect(() => {
138
+ if (html) {
139
+ const parser = new DOMParser();
140
+ const doc = parser.parseFromString(html, "text/html");
141
+ const content = Array.from(doc.body.childNodes).map((node, i) =>
142
+ mapDomToReact(node, i),
143
+ );
144
+ setParsedContent(content);
145
+ }
146
+ }, [html]);
147
+
148
+ if (html) {
149
+ return (
150
+ <div
151
+ className={cn(
152
+ "text-sm font-mono",
153
+ "[&>pre]:bg-transparent! [&>pre]:p-0! [&>pre]:m-0!",
154
+ )}
155
+ >
156
+ {parsedContent}
157
+ </div>
158
+ );
159
+ }
160
+
161
+ return (
162
+ <pre className="text-sm font-mono text-m3-on-surface whitespace-pre">
163
+ {code}
164
+ </pre>
165
+ );
166
+ }
167
+
168
+ // ─── CodeBlock ────────────────────────────────────────────────────────────────
169
+
170
+ export function CodeBlock({
171
+ code,
172
+ language = "React",
173
+ className,
174
+ html,
175
+ }: CodeBlockProps) {
176
+ const [copied, setCopied] = useState(false);
177
+
178
+ const handleCopy = useCallback(async () => {
179
+ try {
180
+ await navigator.clipboard.writeText(code);
181
+ setCopied(true);
182
+ setTimeout(() => setCopied(false), COPY_RESET_DELAY);
183
+ } catch {
184
+ // Clipboard API unavailable — fail silently
185
+ }
186
+ }, [code]);
187
+
188
+ return (
189
+ <div
190
+ className={cn(
191
+ "rounded-m3-lg overflow-hidden max-w-full",
192
+ "bg-m3-surface-container-lowest border border-m3-outline-variant/60",
193
+ className,
194
+ )}
195
+ >
196
+ <div
197
+ className={cn(
198
+ "px-4 py-2 flex justify-between items-center",
199
+ "bg-m3-surface-container-low border-b border-m3-outline-variant/60",
200
+ )}
201
+ >
202
+ <span className="text-xs font-mono text-m3-on-surface-variant">
203
+ {language}
204
+ </span>
205
+ <CopyButton copied={copied} onCopy={handleCopy} />
206
+ </div>
207
+
208
+ <ScrollArea
209
+ type="hover"
210
+ orientation="both"
211
+ className="max-h-120 flex-col w-full min-w-0"
212
+ >
213
+ <div className="p-4 min-w-0 w-full">
214
+ <CodeContent html={html} code={code} />
215
+ </div>
216
+ </ScrollArea>
217
+ </div>
218
+ );
219
+ }
@@ -0,0 +1,300 @@
1
+ "use client";
2
+
3
+ import { fireEvent, render, screen } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+ import * as React from "react";
6
+ import { describe, expect, it, vi } from "vitest";
7
+ import {
8
+ Dialog,
9
+ DialogBody,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogFooter,
13
+ DialogFullScreenContent,
14
+ DialogIcon,
15
+ DialogOverlay,
16
+ DialogPortal,
17
+ DialogTitle,
18
+ DialogTrigger,
19
+ } from "./dialog";
20
+
21
+ // Helper components for testing
22
+ const TestIcon = () => (
23
+ <svg data-testid="test-icon" aria-hidden="true" viewBox="0 0 24 24" />
24
+ );
25
+
26
+ interface ControlledDialogWrapperProps {
27
+ defaultOpen?: boolean;
28
+ onOpenChangeSpy?: (open: boolean) => void;
29
+ }
30
+
31
+ const ControlledDialogWrapper = ({
32
+ defaultOpen = false,
33
+ onOpenChangeSpy,
34
+ }: ControlledDialogWrapperProps) => {
35
+ const [open, setOpen] = React.useState(defaultOpen);
36
+ return (
37
+ <Dialog
38
+ open={open}
39
+ onOpenChange={(v) => {
40
+ setOpen(v);
41
+ onOpenChangeSpy?.(v);
42
+ }}
43
+ >
44
+ <DialogTrigger data-testid="trigger">Open</DialogTrigger>
45
+ <DialogPortal open={open}>
46
+ <DialogOverlay />
47
+ <DialogContent aria-describedby={undefined}>
48
+ <DialogTitle>Title</DialogTitle>
49
+ </DialogContent>
50
+ </DialogPortal>
51
+ </Dialog>
52
+ );
53
+ };
54
+
55
+ describe("Dialog", () => {
56
+ it("renders trigger button", () => {
57
+ render(
58
+ <Dialog>
59
+ <DialogTrigger data-testid="trigger">Open</DialogTrigger>
60
+ </Dialog>,
61
+ );
62
+ const trigger = screen.getByTestId("trigger");
63
+ expect(trigger).toBeInTheDocument();
64
+ expect(trigger).toHaveTextContent("Open");
65
+ });
66
+
67
+ it("opens dialog on trigger click", async () => {
68
+ const user = userEvent.setup();
69
+ render(<ControlledDialogWrapper />);
70
+
71
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
72
+
73
+ await user.click(screen.getByTestId("trigger"));
74
+
75
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
76
+ });
77
+
78
+ it("closes dialog on close button click", async () => {
79
+ const user = userEvent.setup();
80
+ const handleOpenChange = vi.fn();
81
+ render(
82
+ <ControlledDialogWrapper
83
+ defaultOpen={true}
84
+ onOpenChangeSpy={handleOpenChange}
85
+ />,
86
+ );
87
+
88
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
89
+
90
+ const closeBtn = screen.getByLabelText("Close");
91
+ await user.click(closeBtn);
92
+
93
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
94
+ });
95
+
96
+ it("renders accessible title with role=heading", () => {
97
+ render(
98
+ <Dialog open={true}>
99
+ <DialogPortal open={true}>
100
+ <DialogContent aria-describedby={undefined}>
101
+ <DialogTitle>Dialog Headline</DialogTitle>
102
+ </DialogContent>
103
+ </DialogPortal>
104
+ </Dialog>,
105
+ );
106
+ const title = screen.getByRole("heading", { name: "Dialog Headline" });
107
+ expect(title).toBeInTheDocument();
108
+ });
109
+
110
+ it("renders accessible description", () => {
111
+ render(
112
+ <Dialog open={true}>
113
+ <DialogPortal open={true}>
114
+ <DialogContent>
115
+ <DialogTitle>Title</DialogTitle>
116
+ <DialogDescription>Accessible description info.</DialogDescription>
117
+ </DialogContent>
118
+ </DialogPortal>
119
+ </Dialog>,
120
+ );
121
+ expect(
122
+ screen.getByText("Accessible description info."),
123
+ ).toBeInTheDocument();
124
+ });
125
+
126
+ it("hides close button when hideCloseButton=true", () => {
127
+ render(
128
+ <Dialog open={true}>
129
+ <DialogPortal open={true}>
130
+ <DialogContent hideCloseButton aria-describedby={undefined}>
131
+ <DialogTitle>Title</DialogTitle>
132
+ </DialogContent>
133
+ </DialogPortal>
134
+ </Dialog>,
135
+ );
136
+ expect(screen.queryByLabelText("Close")).not.toBeInTheDocument();
137
+ });
138
+
139
+ it("calls onOpenChange when closed via Escape", async () => {
140
+ const handleOpenChange = vi.fn();
141
+ render(
142
+ <ControlledDialogWrapper
143
+ defaultOpen={true}
144
+ onOpenChangeSpy={handleOpenChange}
145
+ />,
146
+ );
147
+
148
+ fireEvent.keyDown(document, { key: "Escape" });
149
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
150
+ });
151
+
152
+ it("renders DialogIcon slot", () => {
153
+ render(
154
+ <Dialog open={true}>
155
+ <DialogPortal open={true}>
156
+ <DialogContent aria-describedby={undefined}>
157
+ <DialogIcon>
158
+ <TestIcon />
159
+ </DialogIcon>
160
+ <DialogTitle>Title</DialogTitle>
161
+ </DialogContent>
162
+ </DialogPortal>
163
+ </Dialog>,
164
+ );
165
+ const icon = screen.getByTestId("test-icon");
166
+ expect(icon).toBeInTheDocument();
167
+ const iconWrapper = icon.parentElement;
168
+ expect(iconWrapper).toHaveAttribute("aria-hidden", "true");
169
+ });
170
+
171
+ it("renders DialogBody with overflow-y-auto", () => {
172
+ render(
173
+ <Dialog open={true}>
174
+ <DialogPortal open={true}>
175
+ <DialogContent aria-describedby={undefined}>
176
+ <DialogTitle>Title</DialogTitle>
177
+ <DialogBody data-testid="body">Content area</DialogBody>
178
+ </DialogContent>
179
+ </DialogPortal>
180
+ </Dialog>,
181
+ );
182
+ const body = screen.getByTestId("body");
183
+ expect(body).toBeInTheDocument();
184
+ // DialogBody is a flex container; ScrollArea inside handles overflow
185
+ expect(body.className).toContain("flex");
186
+ });
187
+
188
+ it("renders DialogFooter with end-aligned buttons", () => {
189
+ render(
190
+ <Dialog open={true}>
191
+ <DialogPortal open={true}>
192
+ <DialogContent aria-describedby={undefined}>
193
+ <DialogTitle>Title</DialogTitle>
194
+ <DialogFooter data-testid="footer">
195
+ <button type="button">Action</button>
196
+ </DialogFooter>
197
+ </DialogContent>
198
+ </DialogPortal>
199
+ </Dialog>,
200
+ );
201
+ const footer = screen.getByTestId("footer");
202
+ expect(footer).toBeInTheDocument();
203
+ expect(footer.className).toContain("justify-end");
204
+ });
205
+ });
206
+
207
+ describe("DialogFullScreenContent", () => {
208
+ it("renders full-screen dialog covering viewport", () => {
209
+ render(
210
+ <Dialog open={true}>
211
+ <DialogPortal open={true}>
212
+ <DialogFullScreenContent aria-describedby={undefined}>
213
+ <p>Full screen body</p>
214
+ </DialogFullScreenContent>
215
+ </DialogPortal>
216
+ </Dialog>,
217
+ );
218
+ const dialog = screen.getByRole("dialog");
219
+ expect(dialog).toBeInTheDocument();
220
+ expect(dialog.className).toContain("fixed inset-0 z-50 w-full h-full");
221
+ });
222
+
223
+ it("renders title in top app bar", () => {
224
+ render(
225
+ <Dialog open={true}>
226
+ <DialogPortal open={true}>
227
+ <DialogFullScreenContent
228
+ title="My Form Title"
229
+ aria-describedby={undefined}
230
+ >
231
+ <p>Full screen body</p>
232
+ </DialogFullScreenContent>
233
+ </DialogPortal>
234
+ </Dialog>,
235
+ );
236
+ expect(screen.getByText("My Form Title")).toBeInTheDocument();
237
+ });
238
+
239
+ it("renders action button in top bar when actionLabel provided", () => {
240
+ render(
241
+ <Dialog open={true}>
242
+ <DialogPortal open={true}>
243
+ <DialogFullScreenContent
244
+ title="Title"
245
+ actionLabel="Save Form"
246
+ onAction={vi.fn()}
247
+ aria-describedby={undefined}
248
+ >
249
+ <p>Full screen body</p>
250
+ </DialogFullScreenContent>
251
+ </DialogPortal>
252
+ </Dialog>,
253
+ );
254
+ const actionBtn = screen.getByRole("button", { name: "Save Form" });
255
+ expect(actionBtn).toBeInTheDocument();
256
+ });
257
+
258
+ it("calls onAction callback when action button clicked", async () => {
259
+ const user = userEvent.setup();
260
+ const handleAction = vi.fn();
261
+ render(
262
+ <Dialog open={true}>
263
+ <DialogPortal open={true}>
264
+ <DialogFullScreenContent
265
+ title="Title"
266
+ actionLabel="Save Data"
267
+ onAction={handleAction}
268
+ aria-describedby={undefined}
269
+ >
270
+ <p>Full screen body</p>
271
+ </DialogFullScreenContent>
272
+ </DialogPortal>
273
+ </Dialog>,
274
+ );
275
+
276
+ const actionBtn = screen.getByRole("button", { name: "Save Data" });
277
+ await user.click(actionBtn);
278
+ expect(handleAction).toHaveBeenCalledTimes(1);
279
+ });
280
+
281
+ it("renders divider when showDivider=true", () => {
282
+ render(
283
+ <Dialog open={true}>
284
+ <DialogPortal open={true}>
285
+ <DialogFullScreenContent
286
+ title="Title"
287
+ showDivider
288
+ aria-describedby={undefined}
289
+ >
290
+ <p>Full screen body</p>
291
+ </DialogFullScreenContent>
292
+ </DialogPortal>
293
+ </Dialog>,
294
+ );
295
+
296
+ const divider = document.querySelector("hr");
297
+ expect(divider).toBeInTheDocument();
298
+ expect(divider?.className).toContain("border-m3-outline-variant");
299
+ });
300
+ });