@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,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
+ });