@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,515 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import * as MotionReact from "motion/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { IconButton } from "./icon-button";
5
+
6
+ // ── Mock motion/react (same pattern as chip.test.tsx / button.test.tsx) ──────
7
+ vi.mock("motion/react", async (importOriginal) => {
8
+ const actual = await importOriginal<typeof import("motion/react")>();
9
+ return {
10
+ ...actual,
11
+ useReducedMotion: () => false,
12
+ };
13
+ });
14
+
15
+ // ── Helpers ───────────────────────────────────────────────────────────────────
16
+
17
+ const StarIcon = () => (
18
+ <svg data-testid="star-icon" viewBox="0 0 24 24" aria-hidden="true">
19
+ <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
20
+ </svg>
21
+ );
22
+
23
+ const renderIconButton = (
24
+ props: Partial<
25
+ Omit<
26
+ Parameters<typeof IconButton>[0],
27
+ "variant" | "selected" | "aria-label"
28
+ >
29
+ > & {
30
+ "aria-label"?: string;
31
+ variant?: "default" | "toggle";
32
+ selected?: boolean;
33
+ } = {},
34
+ ) => {
35
+ const { "aria-label": ariaLabel = "Test action", ...rest } = props;
36
+ return render(
37
+ <IconButton
38
+ {...(rest as Parameters<typeof IconButton>[0])}
39
+ aria-label={ariaLabel}
40
+ >
41
+ <StarIcon />
42
+ </IconButton>,
43
+ );
44
+ };
45
+
46
+ // ── Test Suites ───────────────────────────────────────────────────────────────
47
+
48
+ describe("IconButton Component", () => {
49
+ afterEach(() => {
50
+ vi.restoreAllMocks();
51
+ });
52
+
53
+ // ── Rendering ─────────────────────────────────────────────────────────────
54
+
55
+ describe("basic rendering", () => {
56
+ it("renders a <button> element", () => {
57
+ renderIconButton();
58
+ expect(screen.getByRole("button")).toBeInTheDocument();
59
+ });
60
+
61
+ it("renders children (icon) inside content span", () => {
62
+ renderIconButton();
63
+ expect(screen.getByTestId("star-icon")).toBeInTheDocument();
64
+ });
65
+
66
+ it("forwards ref to the underlying button element", () => {
67
+ const ref = { current: null };
68
+ render(
69
+ <IconButton aria-label="Ref test" ref={ref}>
70
+ <StarIcon />
71
+ </IconButton>,
72
+ );
73
+ expect(ref.current).not.toBeNull();
74
+ });
75
+
76
+ it("passes extra HTML attributes through to the button", () => {
77
+ render(
78
+ <IconButton aria-label="Test" data-testid="my-icon-btn">
79
+ <StarIcon />
80
+ </IconButton>,
81
+ );
82
+ expect(screen.getByTestId("my-icon-btn")).toBeInTheDocument();
83
+ });
84
+
85
+ it("has type='button' to prevent accidental form submission", () => {
86
+ renderIconButton();
87
+ expect(screen.getByRole("button")).toHaveAttribute("type", "button");
88
+ });
89
+ });
90
+
91
+ // ── Size Variants ─────────────────────────────────────────────────────────
92
+
93
+ describe("size prop", () => {
94
+ it.each([
95
+ ["xs", "h-8", "w-8"],
96
+ ["sm", "h-10", "w-10"],
97
+ ["md", "h-14", "w-14"],
98
+ ["lg", "h-24", "w-24"],
99
+ ] as const)("size='%s' applies correct Tailwind size classes (%s %s)", (size, hClass, wClass) => {
100
+ const { container } = renderIconButton({ size });
101
+ expect(container.firstChild).toHaveClass(hClass);
102
+ expect(container.firstChild).toHaveClass(wClass);
103
+ });
104
+
105
+ it("size='xl' applies h-[8.5rem] w-[8.5rem]", () => {
106
+ const { container } = renderIconButton({ size: "xl" });
107
+ // Class name contains bracket notation so check inline style or presence
108
+ expect(container.firstChild).toHaveClass("h-[8.5rem]");
109
+ expect(container.firstChild).toHaveClass("w-[8.5rem]");
110
+ });
111
+ });
112
+
113
+ // ── Touch Target (a11y) ───────────────────────────────────────────────────
114
+
115
+ describe("touch target expansion (WCAG 2.5.5 + MD3 a11y)", () => {
116
+ it("xs size renders aria-hidden touch target expander span", () => {
117
+ const { container } = renderIconButton({ size: "xs" });
118
+ const btn = container.firstChild as HTMLElement;
119
+ const expander = btn.querySelector("span[aria-hidden='true']");
120
+ expect(expander).not.toBeNull();
121
+ expect(expander).toHaveClass("min-w-12");
122
+ expect(expander).toHaveClass("min-h-12");
123
+ });
124
+
125
+ it("sm size renders aria-hidden touch target expander span", () => {
126
+ const { container } = renderIconButton({ size: "sm" });
127
+ const btn = container.firstChild as HTMLElement;
128
+ const expander = btn.querySelector("span[aria-hidden='true']");
129
+ expect(expander).not.toBeNull();
130
+ });
131
+
132
+ it("md size does NOT render touch target expander (already 56dp)", () => {
133
+ const { container } = renderIconButton({ size: "md" });
134
+ const btn = container.firstChild as HTMLElement;
135
+ // Only the content span should be aria-hidden; no touch target expander
136
+ const ariaHiddenSpans = Array.from(
137
+ btn.querySelectorAll("span[aria-hidden='true']"),
138
+ );
139
+ const hasMinWClass = ariaHiddenSpans.some((el) =>
140
+ el.classList.contains("min-w-12"),
141
+ );
142
+ expect(hasMinWClass).toBe(false);
143
+ });
144
+ });
145
+
146
+ // ── Color Style Variants ──────────────────────────────────────────────────
147
+
148
+ describe("colorStyle prop — default (unselected)", () => {
149
+ it("standard → text-m3-on-surface-variant", () => {
150
+ const { container } = renderIconButton({ colorStyle: "standard" });
151
+ expect(container.firstChild).toHaveClass("text-m3-on-surface-variant");
152
+ });
153
+
154
+ it("filled → bg-m3-surface-container + text-m3-on-surface-variant", () => {
155
+ const { container } = renderIconButton({ colorStyle: "filled" });
156
+ expect(container.firstChild).toHaveClass("bg-m3-surface-container");
157
+ expect(container.firstChild).toHaveClass("text-m3-on-surface-variant");
158
+ });
159
+
160
+ it("tonal → bg-m3-secondary-container + text-m3-on-secondary-container", () => {
161
+ const { container } = renderIconButton({ colorStyle: "tonal" });
162
+ expect(container.firstChild).toHaveClass("bg-m3-secondary-container");
163
+ expect(container.firstChild).toHaveClass(
164
+ "text-m3-on-secondary-container",
165
+ );
166
+ });
167
+
168
+ it("outlined → text-m3-on-surface-variant + border class", () => {
169
+ const { container } = renderIconButton({ colorStyle: "outlined" });
170
+ expect(container.firstChild).toHaveClass("text-m3-on-surface-variant");
171
+ expect(container.firstChild).toHaveClass("border-m3-outline-variant");
172
+ });
173
+ });
174
+
175
+ describe("colorStyle 'outlined' — outline width scales with size", () => {
176
+ it("xs/sm/md → border (1dp)", () => {
177
+ for (const size of ["xs", "sm", "md"] as const) {
178
+ const { container } = renderIconButton({
179
+ colorStyle: "outlined",
180
+ size,
181
+ });
182
+ expect(container.firstChild).toHaveClass("border");
183
+ }
184
+ });
185
+
186
+ it("lg → border-2 (2dp)", () => {
187
+ const { container } = renderIconButton({
188
+ colorStyle: "outlined",
189
+ size: "lg",
190
+ });
191
+ expect(container.firstChild).toHaveClass("border-2");
192
+ });
193
+
194
+ it("xl → border-[3px] (3dp)", () => {
195
+ const { container } = renderIconButton({
196
+ colorStyle: "outlined",
197
+ size: "xl",
198
+ });
199
+ expect(container.firstChild).toHaveClass("border-[3px]");
200
+ });
201
+ });
202
+
203
+ // ── Accessibility – ARIA attributes ───────────────────────────────────────
204
+
205
+ describe("accessibility — ARIA", () => {
206
+ it("aria-label is applied to the button", () => {
207
+ renderIconButton({ "aria-label": "Yêu thích" });
208
+ expect(
209
+ screen.getByRole("button", { name: "Yêu thích" }),
210
+ ).toBeInTheDocument();
211
+ });
212
+
213
+ it("default (non-toggle) button does NOT have aria-pressed", () => {
214
+ renderIconButton({ variant: "default" });
215
+ expect(screen.getByRole("button")).not.toHaveAttribute("aria-pressed");
216
+ });
217
+
218
+ it("toggle button unselected → aria-pressed='false'", () => {
219
+ renderIconButton({ variant: "toggle", selected: false });
220
+ expect(screen.getByRole("button")).toHaveAttribute(
221
+ "aria-pressed",
222
+ "false",
223
+ );
224
+ });
225
+
226
+ it("toggle button selected → aria-pressed='true'", () => {
227
+ renderIconButton({ variant: "toggle", selected: true });
228
+ expect(screen.getByRole("button")).toHaveAttribute(
229
+ "aria-pressed",
230
+ "true",
231
+ );
232
+ });
233
+
234
+ it("disabled → aria-disabled='true'", () => {
235
+ renderIconButton({ disabled: true });
236
+ expect(screen.getByRole("button")).toHaveAttribute(
237
+ "aria-disabled",
238
+ "true",
239
+ );
240
+ });
241
+
242
+ it("loading → aria-busy='true'", () => {
243
+ renderIconButton({ loading: true });
244
+ expect(screen.getByRole("button")).toHaveAttribute("aria-busy", "true");
245
+ });
246
+
247
+ it("loading → aria-disabled='true'", () => {
248
+ renderIconButton({ loading: true });
249
+ expect(screen.getByRole("button")).toHaveAttribute(
250
+ "aria-disabled",
251
+ "true",
252
+ );
253
+ });
254
+
255
+ it("not loading → aria-busy attribute absent", () => {
256
+ renderIconButton({ loading: false });
257
+ expect(screen.getByRole("button")).not.toHaveAttribute("aria-busy");
258
+ });
259
+ });
260
+
261
+ // ── Toggle Variant ────────────────────────────────────────────────────────
262
+
263
+ describe("toggle variant", () => {
264
+ it("selected=true + filled → bg-m3-primary (selected state)", () => {
265
+ const { container } = renderIconButton({
266
+ variant: "toggle",
267
+ selected: true,
268
+ colorStyle: "filled",
269
+ });
270
+ expect(container.firstChild).toHaveClass("bg-m3-primary");
271
+ });
272
+
273
+ it("selected=false + filled → bg-m3-surface-container (default state)", () => {
274
+ const { container } = renderIconButton({
275
+ variant: "toggle",
276
+ selected: false,
277
+ colorStyle: "filled",
278
+ });
279
+ expect(container.firstChild).toHaveClass("bg-m3-surface-container");
280
+ });
281
+
282
+ it("selected=true + tonal → bg-m3-secondary", () => {
283
+ const { container } = renderIconButton({
284
+ variant: "toggle",
285
+ selected: true,
286
+ colorStyle: "tonal",
287
+ });
288
+ expect(container.firstChild).toHaveClass("bg-m3-secondary");
289
+ });
290
+
291
+ it("selected=true + outlined → bg-m3-inverse-surface (border removed)", () => {
292
+ const { container } = renderIconButton({
293
+ variant: "toggle",
294
+ selected: true,
295
+ colorStyle: "outlined",
296
+ });
297
+ expect(container.firstChild).toHaveClass("bg-m3-inverse-surface");
298
+ expect(container.firstChild).toHaveClass("border-transparent");
299
+ });
300
+
301
+ it("selected=true + standard → text-m3-primary", () => {
302
+ const { container } = renderIconButton({
303
+ variant: "toggle",
304
+ selected: true,
305
+ colorStyle: "standard",
306
+ });
307
+ expect(container.firstChild).toHaveClass("text-m3-primary");
308
+ });
309
+ });
310
+
311
+ // ── Disabled State ────────────────────────────────────────────────────────
312
+
313
+ describe("disabled state", () => {
314
+ it("has disabled:opacity-[0.38] class", () => {
315
+ const { container } = renderIconButton({ disabled: true });
316
+ expect(container.firstChild).toHaveClass("disabled:opacity-[0.38]");
317
+ });
318
+
319
+ it("has disabled:pointer-events-none class", () => {
320
+ const { container } = renderIconButton({ disabled: true });
321
+ expect(container.firstChild).toHaveClass("disabled:pointer-events-none");
322
+ });
323
+
324
+ it("filled variant disabled → has disabled:bg-m3-on-surface/12", () => {
325
+ const { container } = renderIconButton({
326
+ colorStyle: "filled",
327
+ disabled: true,
328
+ });
329
+ expect(container.firstChild).toHaveClass("disabled:bg-m3-on-surface/12");
330
+ });
331
+
332
+ it("standard variant disabled → has disabled:text-m3-on-surface/[0.38] (no bg)", () => {
333
+ const { container } = renderIconButton({
334
+ colorStyle: "standard",
335
+ disabled: true,
336
+ });
337
+ expect(container.firstChild).toHaveClass(
338
+ "disabled:text-m3-on-surface/[0.38]",
339
+ );
340
+ expect(container.firstChild).not.toHaveClass(
341
+ "disabled:bg-m3-on-surface/12",
342
+ );
343
+ });
344
+
345
+ it("outlined variant disabled → has disabled:border-m3-on-surface/[0.12] (no bg)", () => {
346
+ const { container } = renderIconButton({
347
+ colorStyle: "outlined",
348
+ disabled: true,
349
+ });
350
+ expect(container.firstChild).toHaveClass(
351
+ "disabled:border-m3-on-surface/[0.12]",
352
+ );
353
+ expect(container.firstChild).not.toHaveClass(
354
+ "disabled:bg-m3-on-surface/12",
355
+ );
356
+ });
357
+
358
+ it("does not fire onClick when disabled", () => {
359
+ const handleClick = vi.fn();
360
+ renderIconButton({ disabled: true, onClick: handleClick });
361
+ fireEvent.click(screen.getByRole("button"));
362
+ expect(handleClick).not.toHaveBeenCalled();
363
+ });
364
+ });
365
+
366
+ // ── Loading State ─────────────────────────────────────────────────────────
367
+
368
+ describe("loading state", () => {
369
+ it("loading=true → pointer-events-none opacity-75", () => {
370
+ const { container } = renderIconButton({ loading: true });
371
+ expect(container.firstChild).toHaveClass("pointer-events-none");
372
+ expect(container.firstChild).toHaveClass("opacity-75");
373
+ });
374
+
375
+ it("loading=true → icon children hidden (loading-indicator shown)", () => {
376
+ renderIconButton({ loading: true });
377
+ // Star icon should NOT be present (AnimatePresence mode="wait")
378
+ expect(screen.queryByTestId("star-icon")).toBeNull();
379
+ });
380
+
381
+ it("loading=true + loadingVariant='loading-indicator' → LoadingIndicator rendered", () => {
382
+ const { container } = renderIconButton({
383
+ loading: true,
384
+ loadingVariant: "loading-indicator",
385
+ });
386
+ // LoadingIndicator renders an SVG with aria-label="Loading"
387
+ expect(container.querySelector("[aria-label='Loading']")).not.toBeNull();
388
+ });
389
+
390
+ it("loading=false → star icon is visible", () => {
391
+ renderIconButton({ loading: false });
392
+ expect(screen.getByTestId("star-icon")).toBeInTheDocument();
393
+ });
394
+
395
+ it("onClick is blocked when loading", () => {
396
+ const handleClick = vi.fn();
397
+ renderIconButton({ loading: true, onClick: handleClick });
398
+ fireEvent.click(screen.getByRole("button"));
399
+ expect(handleClick).not.toHaveBeenCalled();
400
+ });
401
+
402
+ it("does not render opacity: 0 on the loading wrapper during entrance to prevent SMIL freezing", () => {
403
+ const { container } = renderIconButton({ loading: true });
404
+ // Tìm khung wrapper bao quanh loading indicator
405
+ const spinnerWrapper = container.querySelector(
406
+ "span.flex.items-center.justify-center.shrink-0",
407
+ );
408
+
409
+ // Đối với lỗi SMIL freezing trên Chrome, Inline style không được phép có "opacity: 0"
410
+ // Nếu có thẻ style, đảm bảo không có giá trị này.
411
+ if (spinnerWrapper?.hasAttribute("style")) {
412
+ expect(spinnerWrapper.getAttribute("style")).not.toContain(
413
+ "opacity: 0",
414
+ );
415
+ }
416
+ // Đảm bảo AnimatePresence render đúng 1 wrapper
417
+ expect(spinnerWrapper).not.toBeNull();
418
+ });
419
+ });
420
+
421
+ // ── Interaction ───────────────────────────────────────────────────────────
422
+
423
+ describe("interaction", () => {
424
+ it("calls onClick when clicked", () => {
425
+ const handleClick = vi.fn();
426
+ renderIconButton({ onClick: handleClick });
427
+ fireEvent.click(screen.getByRole("button"));
428
+ expect(handleClick).toHaveBeenCalledTimes(1);
429
+ });
430
+
431
+ it("triggers ripple on pointerDown", () => {
432
+ renderIconButton({ onClick: vi.fn() });
433
+ const btn = screen.getByRole("button");
434
+ fireEvent.pointerDown(btn, { clientX: 10, clientY: 10 });
435
+ // Ripple renders an aria-hidden span inside the button
436
+ expect(btn.querySelector("[aria-hidden='true']")).not.toBeNull();
437
+ });
438
+
439
+ it("does not trigger ripple when loading", () => {
440
+ renderIconButton({ loading: true });
441
+ const btn = screen.getByRole("button");
442
+ const ariaHiddenBefore = btn.querySelectorAll(
443
+ "[aria-hidden='true']",
444
+ ).length;
445
+ fireEvent.pointerDown(btn, { clientX: 10, clientY: 10 });
446
+ // Count should not increase (ripple blocked by loading guard)
447
+ expect(btn.querySelectorAll("[aria-hidden='true']").length).toBe(
448
+ ariaHiddenBefore,
449
+ );
450
+ });
451
+
452
+ it("Enter key triggers click handler", () => {
453
+ const handleClick = vi.fn();
454
+ renderIconButton({ onClick: handleClick });
455
+ const btn = screen.getByRole("button");
456
+ fireEvent.keyDown(btn, { key: "Enter" });
457
+ // handleKeyDown calls btn.click() which fires handleClick
458
+ expect(handleClick).toHaveBeenCalled();
459
+ });
460
+
461
+ it("Space key triggers click handler", () => {
462
+ const handleClick = vi.fn();
463
+ renderIconButton({ onClick: handleClick });
464
+ const btn = screen.getByRole("button");
465
+ fireEvent.keyDown(btn, { key: " " });
466
+ expect(handleClick).toHaveBeenCalled();
467
+ });
468
+
469
+ it("custom onKeyDown is called alongside internal handler", () => {
470
+ const customKeyDown = vi.fn();
471
+ renderIconButton({ onKeyDown: customKeyDown });
472
+ fireEvent.keyDown(screen.getByRole("button"), { key: "Tab" });
473
+ expect(customKeyDown).toHaveBeenCalledTimes(1);
474
+ });
475
+ });
476
+
477
+ // ── Shape Prop ────────────────────────────────────────────────────────────
478
+
479
+ describe("shape prop", () => {
480
+ it("round (default) applies overflow-hidden for clip", () => {
481
+ const { container } = renderIconButton({ shape: "round" });
482
+ expect(container.firstChild).toHaveClass("overflow-hidden");
483
+ });
484
+
485
+ it("square applies overflow-hidden for clip", () => {
486
+ const { container } = renderIconButton({ shape: "square" });
487
+ expect(container.firstChild).toHaveClass("overflow-hidden");
488
+ });
489
+ });
490
+
491
+ // ── Custom className ──────────────────────────────────────────────────────
492
+
493
+ describe("className prop", () => {
494
+ it("merges custom className with base classes", () => {
495
+ const { container } = renderIconButton({ className: "my-custom-class" });
496
+ expect(container.firstChild).toHaveClass("my-custom-class");
497
+ });
498
+ });
499
+
500
+ // ── A11y: prefers-reduced-motion ─────────────────────────────────────────
501
+
502
+ describe("accessibility — reduced motion", () => {
503
+ it("Ripple renders nothing when prefers-reduced-motion is active", () => {
504
+ vi.spyOn(MotionReact, "useReducedMotion").mockReturnValue(true);
505
+ renderIconButton({ onClick: vi.fn() });
506
+ const btn = screen.getByRole("button");
507
+ // Count all aria-hidden elements BEFORE pointerDown (baseline: icon span + touch target if any)
508
+ const beforeCount = btn.querySelectorAll("[aria-hidden='true']").length;
509
+ fireEvent.pointerDown(btn, { clientX: 5, clientY: 5 });
510
+ // When reduced motion is on, Ripple returns null → count should NOT increase
511
+ const afterCount = btn.querySelectorAll("[aria-hidden='true']").length;
512
+ expect(afterCount).toBe(beforeCount);
513
+ });
514
+ });
515
+ });