@bug-on/md3-react 2.0.3 → 3.0.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 (308) hide show
  1. package/.turbo/turbo-build.log +33 -0
  2. package/CHANGELOG.md +55 -0
  3. package/dist/index.css.d.ts +2 -0
  4. package/dist/index.d.mts +6127 -0
  5. package/dist/index.d.ts +6127 -71
  6. package/dist/index.js +1653 -614
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +1566 -547
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/material-symbols-cdn.css.d.ts +2 -0
  11. package/dist/material-symbols-self-hosted.css.d.ts +2 -0
  12. package/dist/typography.css.d.ts +2 -0
  13. package/package.json +22 -19
  14. package/scripts/copy-assets.js +82 -0
  15. package/src/assets/fonts/GoogleSansFlex-VariableFont.woff2 +0 -0
  16. package/src/assets/fonts/MaterialSymbolsOutlined-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  17. package/src/assets/fonts/MaterialSymbolsRounded-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  18. package/src/assets/fonts/MaterialSymbolsSharp-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  19. package/src/assets/loading-indicator.svg +19 -0
  20. package/src/assets/material-symbols-cdn.css +65 -0
  21. package/src/assets/material-symbols-self-hosted.css +90 -0
  22. package/src/css.d.ts +20 -0
  23. package/src/hooks/useClickOutside.ts +37 -0
  24. package/src/hooks/useMediaQuery.ts +28 -0
  25. package/src/hooks/useRipple.ts +88 -0
  26. package/src/index.css +23 -0
  27. package/src/index.ts +349 -0
  28. package/src/lib/material-symbols-preconnect.tsx +82 -0
  29. package/src/lib/theme-utils.ts +180 -0
  30. package/src/lib/utils.ts +6 -0
  31. package/src/test/button.test.tsx +59 -0
  32. package/src/test/icon.test.tsx +91 -0
  33. package/src/test/loading-indicator.test.tsx +128 -0
  34. package/src/test/progress-indicator.test.tsx +306 -0
  35. package/src/test/setup.ts +80 -0
  36. package/src/test/typography.test.tsx +206 -0
  37. package/src/types/index.ts +7 -0
  38. package/src/types/md3.ts +31 -0
  39. package/src/ui/Text.tsx +60 -0
  40. package/src/ui/__snapshots__/divider.test.tsx.snap +63 -0
  41. package/src/ui/app-bar/app-bar-column.tsx +99 -0
  42. package/src/ui/app-bar/app-bar-item-button.tsx +71 -0
  43. package/src/ui/app-bar/app-bar-items.test.tsx +89 -0
  44. package/src/ui/app-bar/app-bar-overflow-indicator.tsx +108 -0
  45. package/src/ui/app-bar/app-bar-row.tsx +104 -0
  46. package/src/ui/app-bar/app-bar.test.tsx +87 -0
  47. package/src/ui/app-bar/app-bar.tokens.ts +223 -0
  48. package/src/ui/app-bar/app-bar.types.ts +441 -0
  49. package/src/ui/app-bar/bottom-app-bar.test.tsx +42 -0
  50. package/src/ui/app-bar/bottom-app-bar.tsx +84 -0
  51. package/src/ui/app-bar/docked-toolbar.test.tsx +34 -0
  52. package/src/ui/app-bar/docked-toolbar.tsx +54 -0
  53. package/src/ui/app-bar/flexible-app-bar.test.tsx +75 -0
  54. package/src/ui/app-bar/hooks/use-app-bar-scroll.ts +110 -0
  55. package/src/ui/app-bar/hooks/use-flexible-app-bar.ts +123 -0
  56. package/{dist/ui/app-bar/index.d.ts → src/ui/app-bar/index.ts} +35 -2
  57. package/src/ui/app-bar/large-flexible-app-bar.tsx +165 -0
  58. package/src/ui/app-bar/medium-flexible-app-bar.tsx +167 -0
  59. package/src/ui/app-bar/search-app-bar.test.tsx +49 -0
  60. package/src/ui/app-bar/search-app-bar.tsx +176 -0
  61. package/src/ui/app-bar/search-view.tsx +227 -0
  62. package/src/ui/app-bar/small-app-bar.test.tsx +48 -0
  63. package/src/ui/app-bar/small-app-bar.tsx +203 -0
  64. package/src/ui/badge.test.tsx +345 -0
  65. package/src/ui/badge.tsx +282 -0
  66. package/src/ui/button-group.test.tsx +71 -0
  67. package/src/ui/button-group.tsx +350 -0
  68. package/src/ui/button.test.tsx +297 -0
  69. package/src/ui/button.tsx +669 -0
  70. package/src/ui/card.test.tsx +187 -0
  71. package/src/ui/card.tsx +259 -0
  72. package/src/ui/checkbox.test.tsx +423 -0
  73. package/src/ui/checkbox.tsx +525 -0
  74. package/src/ui/chip.test.tsx +292 -0
  75. package/src/ui/chip.tsx +548 -0
  76. package/src/ui/code-block.tsx +219 -0
  77. package/src/ui/dialog.test.tsx +300 -0
  78. package/src/ui/dialog.tsx +384 -0
  79. package/src/ui/divider.test.tsx +314 -0
  80. package/src/ui/divider.tsx +412 -0
  81. package/src/ui/drawer.tsx +240 -0
  82. package/src/ui/fab-menu.test.tsx +494 -0
  83. package/src/ui/fab-menu.tsx +739 -0
  84. package/src/ui/fab.test.tsx +232 -0
  85. package/src/ui/fab.tsx +505 -0
  86. package/src/ui/icon-button.test.tsx +515 -0
  87. package/src/ui/icon-button.tsx +525 -0
  88. package/src/ui/icon.test.tsx +197 -0
  89. package/src/ui/icon.tsx +179 -0
  90. package/src/ui/loading-indicator.test.tsx +73 -0
  91. package/src/ui/loading-indicator.tsx +312 -0
  92. package/src/ui/menu/context-menu.tsx +275 -0
  93. package/src/ui/menu/index.ts +77 -0
  94. package/src/ui/menu/menu-animations.ts +102 -0
  95. package/src/ui/menu/menu-context.tsx +99 -0
  96. package/src/ui/menu/menu-divider.tsx +47 -0
  97. package/src/ui/menu/menu-group.tsx +200 -0
  98. package/src/ui/menu/menu-item.tsx +294 -0
  99. package/src/ui/menu/menu-tokens.ts +208 -0
  100. package/src/ui/menu/menu-types.ts +313 -0
  101. package/src/ui/menu/menu.test.tsx +624 -0
  102. package/src/ui/menu/menu.tsx +289 -0
  103. package/src/ui/menu/sub-menu.tsx +223 -0
  104. package/src/ui/menu/vertical-menu.tsx +382 -0
  105. package/src/ui/navigation-rail.test.tsx +404 -0
  106. package/src/ui/navigation-rail.tsx +604 -0
  107. package/src/ui/progress-indicator/circular.tsx +248 -0
  108. package/src/ui/progress-indicator/hooks.ts +51 -0
  109. package/{dist/ui/progress-indicator/index.d.ts → src/ui/progress-indicator/index.tsx} +20 -2
  110. package/src/ui/progress-indicator/linear-flat.tsx +83 -0
  111. package/src/ui/progress-indicator/linear-wavy.tsx +243 -0
  112. package/src/ui/progress-indicator/linear.tsx +143 -0
  113. package/src/ui/progress-indicator/types.ts +158 -0
  114. package/src/ui/progress-indicator/utils.ts +73 -0
  115. package/src/ui/radio-button.test.tsx +407 -0
  116. package/src/ui/radio-button.tsx +551 -0
  117. package/src/ui/ripple.test.tsx +72 -0
  118. package/src/ui/ripple.tsx +234 -0
  119. package/src/ui/scroll-area.test.tsx +58 -0
  120. package/src/ui/scroll-area.tsx +139 -0
  121. package/src/ui/search/animated-placeholder.tsx +145 -0
  122. package/src/ui/search/hooks/use-search-keyboard.test.ts +202 -0
  123. package/src/ui/search/hooks/use-search-keyboard.ts +104 -0
  124. package/src/ui/search/hooks/use-search-view-focus.test.ts +96 -0
  125. package/src/ui/search/hooks/use-search-view-focus.ts +24 -0
  126. package/src/ui/search/index.ts +44 -0
  127. package/src/ui/search/search-bar.tsx +220 -0
  128. package/src/ui/search/search-context.tsx +42 -0
  129. package/src/ui/search/search-view-docked.tsx +194 -0
  130. package/src/ui/search/search-view-fullscreen.tsx +247 -0
  131. package/src/ui/search/search.test.tsx +233 -0
  132. package/src/ui/search/search.tokens.ts +134 -0
  133. package/src/ui/search/search.tsx +131 -0
  134. package/src/ui/search/search.types.ts +154 -0
  135. package/src/ui/search/trailing-action.tsx +49 -0
  136. package/src/ui/shared/constants.ts +122 -0
  137. package/{dist/ui/shared/touch-target.d.ts → src/ui/shared/touch-target.tsx} +13 -1
  138. package/src/ui/slider/hooks/useSliderMath.ts +195 -0
  139. package/{dist/ui/slider/index.d.ts → src/ui/slider/index.ts} +12 -1
  140. package/src/ui/slider/range-slider.tsx +561 -0
  141. package/src/ui/slider/slider-thumb.tsx +379 -0
  142. package/src/ui/slider/slider-track.tsx +912 -0
  143. package/src/ui/slider/slider.tokens.ts +189 -0
  144. package/src/ui/slider/slider.tsx +259 -0
  145. package/src/ui/slider/slider.types.ts +288 -0
  146. package/src/ui/snackbar/index.ts +20 -0
  147. package/src/ui/snackbar/snackbar.test.tsx +338 -0
  148. package/src/ui/snackbar/snackbar.tsx +476 -0
  149. package/{dist/ui/switch/index.d.ts → src/ui/switch/index.ts} +1 -0
  150. package/src/ui/switch/switch.stories.tsx +309 -0
  151. package/src/ui/switch/switch.test.tsx +243 -0
  152. package/src/ui/switch/switch.tokens.ts +89 -0
  153. package/src/ui/switch/switch.tsx +504 -0
  154. package/src/ui/switch/switch.types.ts +62 -0
  155. package/{dist/ui/tabs/index.d.ts → src/ui/tabs/index.ts} +8 -1
  156. package/src/ui/tabs/tab.tsx +407 -0
  157. package/src/ui/tabs/tabs-content.tsx +89 -0
  158. package/src/ui/tabs/tabs-list.tsx +146 -0
  159. package/src/ui/tabs/tabs.test.tsx +290 -0
  160. package/src/ui/tabs/tabs.tokens.ts +121 -0
  161. package/src/ui/tabs/tabs.tsx +229 -0
  162. package/src/ui/tabs/tabs.types.ts +185 -0
  163. package/{dist/ui/text-field/index.d.ts → src/ui/text-field/index.ts} +8 -1
  164. package/src/ui/text-field/subcomponents/active-indicator.tsx +67 -0
  165. package/src/ui/text-field/subcomponents/floating-label.tsx +161 -0
  166. package/src/ui/text-field/subcomponents/leading-icon.tsx +46 -0
  167. package/src/ui/text-field/subcomponents/outline-container.tsx +170 -0
  168. package/src/ui/text-field/subcomponents/prefix-suffix.tsx +59 -0
  169. package/src/ui/text-field/subcomponents/supporting-text.tsx +145 -0
  170. package/src/ui/text-field/subcomponents/trailing-icon.tsx +199 -0
  171. package/src/ui/text-field/text-field.test.tsx +454 -0
  172. package/src/ui/text-field/text-field.tokens.ts +104 -0
  173. package/src/ui/text-field/text-field.tsx +548 -0
  174. package/src/ui/text-field/text-field.types.ts +180 -0
  175. package/src/ui/theme-provider/index.tsx +190 -0
  176. package/src/ui/toc.test.tsx +108 -0
  177. package/src/ui/toc.tsx +172 -0
  178. package/src/ui/tooltip/plain-tooltip.tsx +63 -0
  179. package/src/ui/tooltip/rich-tooltip.tsx +94 -0
  180. package/src/ui/tooltip/tooltip-box.tsx +266 -0
  181. package/src/ui/tooltip/tooltip-caret-shape.tsx +68 -0
  182. package/src/ui/tooltip/tooltip.tokens.ts +26 -0
  183. package/src/ui/tooltip/tooltip.types.ts +70 -0
  184. package/src/ui/tooltip/use-tooltip-position.ts +208 -0
  185. package/src/ui/tooltip/use-tooltip-state.ts +41 -0
  186. package/src/ui/typography/__tests__/typography.test.tsx +170 -0
  187. package/{dist/ui/typography/index.d.ts → src/ui/typography/index.ts} +21 -3
  188. package/src/ui/typography/type-scale-tokens.ts +205 -0
  189. package/src/ui/typography/typography-key-tokens.ts +43 -0
  190. package/src/ui/typography/typography-tokens.ts +360 -0
  191. package/src/ui/typography/typography.css +22 -0
  192. package/src/ui/typography/typography.tsx +559 -0
  193. package/test-render.tsx +4 -0
  194. package/test-shadow.html +26 -0
  195. package/test_output.txt +164 -0
  196. package/test_output_v2.txt +5 -0
  197. package/tsconfig.build.json +10 -0
  198. package/tsconfig.json +18 -0
  199. package/tsup.config.ts +20 -0
  200. package/vitest.config.ts +11 -0
  201. package/dist/hooks/useClickOutside.d.ts +0 -8
  202. package/dist/hooks/useMediaQuery.d.ts +0 -11
  203. package/dist/hooks/useRipple.d.ts +0 -26
  204. package/dist/lib/material-symbols-preconnect.d.ts +0 -42
  205. package/dist/lib/theme-utils.d.ts +0 -63
  206. package/dist/lib/utils.d.ts +0 -2
  207. package/dist/types/index.d.ts +0 -1
  208. package/dist/types/md3.d.ts +0 -14
  209. package/dist/ui/app-bar/app-bar-column.d.ts +0 -28
  210. package/dist/ui/app-bar/app-bar-item-button.d.ts +0 -16
  211. package/dist/ui/app-bar/app-bar-overflow-indicator.d.ts +0 -18
  212. package/dist/ui/app-bar/app-bar-row.d.ts +0 -36
  213. package/dist/ui/app-bar/app-bar.tokens.d.ts +0 -184
  214. package/dist/ui/app-bar/app-bar.types.d.ts +0 -392
  215. package/dist/ui/app-bar/bottom-app-bar.d.ts +0 -31
  216. package/dist/ui/app-bar/docked-toolbar.d.ts +0 -25
  217. package/dist/ui/app-bar/hooks/use-app-bar-scroll.d.ts +0 -42
  218. package/dist/ui/app-bar/hooks/use-flexible-app-bar.d.ts +0 -37
  219. package/dist/ui/app-bar/large-flexible-app-bar.d.ts +0 -26
  220. package/dist/ui/app-bar/medium-flexible-app-bar.d.ts +0 -28
  221. package/dist/ui/app-bar/search-app-bar.d.ts +0 -43
  222. package/dist/ui/app-bar/search-view.d.ts +0 -54
  223. package/dist/ui/app-bar/small-app-bar.d.ts +0 -37
  224. package/dist/ui/badge.d.ts +0 -125
  225. package/dist/ui/button-group.d.ts +0 -59
  226. package/dist/ui/button.d.ts +0 -148
  227. package/dist/ui/card.d.ts +0 -62
  228. package/dist/ui/checkbox.d.ts +0 -82
  229. package/dist/ui/chip.d.ts +0 -110
  230. package/dist/ui/code-block.d.ts +0 -14
  231. package/dist/ui/dialog.d.ts +0 -111
  232. package/dist/ui/divider.d.ts +0 -164
  233. package/dist/ui/drawer.d.ts +0 -39
  234. package/dist/ui/dropdown.d.ts +0 -29
  235. package/dist/ui/fab-menu.d.ts +0 -204
  236. package/dist/ui/fab.d.ts +0 -162
  237. package/dist/ui/icon-button.d.ts +0 -131
  238. package/dist/ui/icon.d.ts +0 -88
  239. package/dist/ui/loading-indicator.d.ts +0 -42
  240. package/dist/ui/navigation-rail.d.ts +0 -29
  241. package/dist/ui/progress-indicator/circular.d.ts +0 -3
  242. package/dist/ui/progress-indicator/hooks.d.ts +0 -3
  243. package/dist/ui/progress-indicator/linear-flat.d.ts +0 -10
  244. package/dist/ui/progress-indicator/linear-wavy.d.ts +0 -18
  245. package/dist/ui/progress-indicator/linear.d.ts +0 -3
  246. package/dist/ui/progress-indicator/types.d.ts +0 -151
  247. package/dist/ui/progress-indicator/utils.d.ts +0 -3
  248. package/dist/ui/radio-button.d.ts +0 -106
  249. package/dist/ui/ripple.d.ts +0 -126
  250. package/dist/ui/scroll-area.d.ts +0 -27
  251. package/dist/ui/search/animated-placeholder.d.ts +0 -54
  252. package/dist/ui/search/hooks/use-search-keyboard.d.ts +0 -32
  253. package/dist/ui/search/hooks/use-search-view-focus.d.ts +0 -6
  254. package/dist/ui/search/index.d.ts +0 -27
  255. package/dist/ui/search/search-bar.d.ts +0 -32
  256. package/dist/ui/search/search-context.d.ts +0 -24
  257. package/dist/ui/search/search-view-docked.d.ts +0 -25
  258. package/dist/ui/search/search-view-fullscreen.d.ts +0 -36
  259. package/dist/ui/search/search.d.ts +0 -50
  260. package/dist/ui/search/search.tokens.d.ts +0 -112
  261. package/dist/ui/search/search.types.d.ts +0 -131
  262. package/dist/ui/search/trailing-action.d.ts +0 -9
  263. package/dist/ui/shared/constants.d.ts +0 -86
  264. package/dist/ui/slider/hooks/useSliderMath.d.ts +0 -101
  265. package/dist/ui/slider/range-slider.d.ts +0 -47
  266. package/dist/ui/slider/slider-thumb.d.ts +0 -33
  267. package/dist/ui/slider/slider-track.d.ts +0 -25
  268. package/dist/ui/slider/slider.d.ts +0 -60
  269. package/dist/ui/slider/slider.tokens.d.ts +0 -151
  270. package/dist/ui/slider/slider.types.d.ts +0 -259
  271. package/dist/ui/snackbar/index.d.ts +0 -6
  272. package/dist/ui/snackbar/snackbar.d.ts +0 -197
  273. package/dist/ui/switch/switch.d.ts +0 -30
  274. package/dist/ui/switch/switch.stories.d.ts +0 -48
  275. package/dist/ui/switch/switch.tokens.d.ts +0 -67
  276. package/dist/ui/switch/switch.types.d.ts +0 -59
  277. package/dist/ui/tabs/tab.d.ts +0 -43
  278. package/dist/ui/tabs/tabs-content.d.ts +0 -36
  279. package/dist/ui/tabs/tabs-list.d.ts +0 -40
  280. package/dist/ui/tabs/tabs.d.ts +0 -60
  281. package/dist/ui/tabs/tabs.tokens.d.ts +0 -94
  282. package/dist/ui/tabs/tabs.types.d.ts +0 -172
  283. package/dist/ui/text-field/subcomponents/active-indicator.d.ts +0 -24
  284. package/dist/ui/text-field/subcomponents/floating-label.d.ts +0 -43
  285. package/dist/ui/text-field/subcomponents/leading-icon.d.ts +0 -23
  286. package/dist/ui/text-field/subcomponents/outline-container.d.ts +0 -42
  287. package/dist/ui/text-field/subcomponents/prefix-suffix.d.ts +0 -24
  288. package/dist/ui/text-field/subcomponents/supporting-text.d.ts +0 -37
  289. package/dist/ui/text-field/subcomponents/trailing-icon.d.ts +0 -41
  290. package/dist/ui/text-field/text-field.d.ts +0 -49
  291. package/dist/ui/text-field/text-field.tokens.d.ts +0 -76
  292. package/dist/ui/text-field/text-field.types.d.ts +0 -126
  293. package/dist/ui/theme-provider/index.d.ts +0 -48
  294. package/dist/ui/toc.d.ts +0 -80
  295. package/dist/ui/tooltip/plain-tooltip.d.ts +0 -2
  296. package/dist/ui/tooltip/rich-tooltip.d.ts +0 -2
  297. package/dist/ui/tooltip/tooltip-box.d.ts +0 -2
  298. package/dist/ui/tooltip/tooltip-caret-shape.d.ts +0 -9
  299. package/dist/ui/tooltip/tooltip.tokens.d.ts +0 -26
  300. package/dist/ui/tooltip/tooltip.types.d.ts +0 -56
  301. package/dist/ui/tooltip/use-tooltip-position.d.ts +0 -8
  302. package/dist/ui/tooltip/use-tooltip-state.d.ts +0 -2
  303. package/dist/ui/typography/type-scale-tokens.d.ts +0 -162
  304. package/dist/ui/typography/typography-key-tokens.d.ts +0 -40
  305. package/dist/ui/typography/typography-tokens.d.ts +0 -220
  306. package/dist/ui/typography/typography.d.ts +0 -265
  307. /package/{dist/hooks/index.d.ts → src/hooks/index.ts} +0 -0
  308. /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
+ });