@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,525 @@
1
+ /**
2
+ * @file icon-button.tsx
3
+ *
4
+ * MD3 Expressive Icon Button component.
5
+ *
6
+ * An icon-only button with shape morphing, ripple effect, loading state,
7
+ * and toggle variant. Requires `aria-label` for accessibility since there
8
+ * is no visible text label.
9
+ *
10
+ * @see https://m3.material.io/components/icon-buttons/overview
11
+ */
12
+
13
+ import type { HTMLMotionProps } from "motion/react";
14
+ import { AnimatePresence, domMax, LazyMotion, m } from "motion/react";
15
+ import * as React from "react";
16
+ import { cn } from "../lib/utils";
17
+ import { LoadingIndicator } from "./loading-indicator";
18
+ import { ProgressIndicator } from "./progress-indicator";
19
+ import { Ripple, useRippleState } from "./ripple";
20
+ import {
21
+ ICON_SPAN_VARIANTS,
22
+ SPRING_TRANSITION,
23
+ SPRING_TRANSITION_FAST,
24
+ } from "./shared/constants";
25
+ import { TouchTarget } from "./shared/touch-target";
26
+
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+ // Design Tokens
29
+ // ─────────────────────────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Per-size container dimensions (Tailwind utility classes).
33
+ * MD3 Expressive sizing tokens (v14.1.0): XS=32dp, SM=40dp, MD=56dp, LG=96dp, XL=136dp.
34
+ * @internal
35
+ */
36
+ const SIZE_STYLES: Record<string, string> = {
37
+ xs: "h-8 w-8",
38
+ sm: "h-10 w-10",
39
+ md: "h-14 w-14",
40
+ lg: "h-24 w-24",
41
+ xl: "h-[8.5rem] w-[8.5rem]",
42
+ };
43
+
44
+ /**
45
+ * Per-size icon dimensions — Tailwind class + pixel value.
46
+ * Single source of truth to keep icon class and LoadingIndicator size in sync.
47
+ * @internal
48
+ */
49
+ const SIZE_ICON: Record<string, { cls: string; px: number }> = {
50
+ xs: { cls: "size-5", px: 20 },
51
+ sm: { cls: "size-6", px: 24 },
52
+ md: { cls: "size-6", px: 24 },
53
+ lg: { cls: "size-8", px: 32 },
54
+ xl: { cls: "size-10", px: 40 },
55
+ };
56
+
57
+ // ─────────────────────────────────────────────────────────────────────────────
58
+ // Shape Morphing — Border Radius Map
59
+ // Keys: round=CornerFull, square=CornerMedium–XL, pressed, selectedRound, selectedSquare
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Per-size shape morphing radius configuration for all interaction states.
64
+ *
65
+ * - `round` / `square`: idle border-radius per shape variant.
66
+ * - `pressed`: compressed radius on whileTap.
67
+ * - `selectedRound` / `selectedSquare`: radius when toggle is selected
68
+ * (shape flips — round selected → selectedRound which is squarer).
69
+ *
70
+ * @internal
71
+ */
72
+ const RADIUS_MAP: Record<
73
+ string,
74
+ {
75
+ round: number;
76
+ square: number;
77
+ pressed: number;
78
+ selectedRound: number;
79
+ selectedSquare: number;
80
+ }
81
+ > = {
82
+ xs: {
83
+ round: 16,
84
+ square: 12,
85
+ pressed: 8,
86
+ selectedRound: 12,
87
+ selectedSquare: 16,
88
+ },
89
+ sm: {
90
+ round: 20,
91
+ square: 12,
92
+ pressed: 8,
93
+ selectedRound: 12,
94
+ selectedSquare: 20,
95
+ },
96
+ md: {
97
+ round: 28,
98
+ square: 16,
99
+ pressed: 12,
100
+ selectedRound: 16,
101
+ selectedSquare: 28,
102
+ },
103
+ lg: {
104
+ round: 48,
105
+ square: 28,
106
+ pressed: 16,
107
+ selectedRound: 28,
108
+ selectedSquare: 48,
109
+ },
110
+ xl: {
111
+ round: 68,
112
+ square: 28,
113
+ pressed: 16,
114
+ selectedRound: 28,
115
+ selectedSquare: 68,
116
+ },
117
+ };
118
+
119
+ /**
120
+ * Outline border width per size for the `outlined` color style.
121
+ * XS/SM/MD = 1dp, LG = 2dp, XL = 3dp.
122
+ * @internal
123
+ */
124
+ const SIZE_OUTLINE_WIDTH: Record<string, string> = {
125
+ xs: "border",
126
+ sm: "border",
127
+ md: "border",
128
+ lg: "border-2",
129
+ xl: "border-[3px]",
130
+ };
131
+
132
+ // ─────────────────────────────────────────────────────────────────────────────
133
+ // Color Variants
134
+ // ─────────────────────────────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * Color-role class pairs for each `colorStyle` × selection state combination.
138
+ * @internal
139
+ */
140
+ const colorStyles = {
141
+ standard: {
142
+ default:
143
+ "text-m3-on-surface-variant hover:bg-m3-on-surface-variant/8 active:bg-m3-on-surface-variant/12",
144
+ selected: "text-m3-primary hover:bg-m3-primary/8 active:bg-m3-primary/12",
145
+ },
146
+ filled: {
147
+ default:
148
+ "bg-m3-surface-container text-m3-on-surface-variant hover:bg-m3-on-surface-variant/8 active:bg-m3-on-surface-variant/12",
149
+ selected:
150
+ "bg-m3-primary text-m3-on-primary hover:brightness-95 active:brightness-90",
151
+ },
152
+ tonal: {
153
+ default:
154
+ "bg-m3-secondary-container text-m3-on-secondary-container hover:bg-m3-on-secondary-container/8 active:bg-m3-on-secondary-container/12",
155
+ selected:
156
+ "bg-m3-secondary text-m3-on-secondary hover:brightness-95 active:brightness-90",
157
+ },
158
+ outlined: {
159
+ default:
160
+ "border-m3-outline-variant text-m3-on-surface-variant hover:bg-m3-on-surface-variant/8 active:bg-m3-on-surface-variant/12",
161
+ selected:
162
+ "bg-m3-inverse-surface text-m3-inverse-on-surface border-transparent hover:brightness-95 active:brightness-90",
163
+ },
164
+ };
165
+
166
+ /**
167
+ * Base Tailwind classes shared by all icon button variants.
168
+ * Separated from CVA to keep bundle output lean (no runtime variant lookups needed here).
169
+ * @internal
170
+ */
171
+ const baseIconButtonClasses = [
172
+ "relative shrink-0 inline-flex items-center justify-center",
173
+ "select-none cursor-pointer",
174
+ "transition-[background-color,color,border-color,box-shadow,opacity,filter] duration-200",
175
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-m3-primary focus-visible:ring-offset-2",
176
+ "disabled:pointer-events-none disabled:opacity-[0.38]",
177
+ ];
178
+
179
+ // ─────────────────────────────────────────────────────────────────────────────
180
+ // Types
181
+ // ─────────────────────────────────────────────────────────────────────────────
182
+
183
+ type MotionButtonProps = Omit<HTMLMotionProps<"button">, "children" | "color">;
184
+
185
+ /**
186
+ * Base props for the Icon Button component.
187
+ *
188
+ * @see {@link IconButtonProps} for the full discriminated union.
189
+ * @see https://m3.material.io/components/icon-buttons/overview
190
+ */
191
+ export interface BaseIconButtonProps extends MotionButtonProps {
192
+ /**
193
+ * Visual color style following MD3 color roles.
194
+ * @default "standard"
195
+ */
196
+ colorStyle?: "standard" | "filled" | "tonal" | "outlined";
197
+ /**
198
+ * Button container size.
199
+ * Sizes: XS=32dp, SM=40dp, MD=56dp, LG=96dp, XL=136dp.
200
+ * @default "sm"
201
+ */
202
+ size?: "xs" | "sm" | "md" | "lg" | "xl";
203
+ /**
204
+ * Container shape controlling border-radius morphing.
205
+ * - `round`: fully circular (CornerFull).
206
+ * - `square`: rounded square (CornerMedium–CornerExtraLarge per size).
207
+ * @default "round"
208
+ */
209
+ shape?: "round" | "square";
210
+ /**
211
+ * When `true`, replaces the icon with an animated loading indicator.
212
+ * Interaction is blocked and `aria-busy` is set.
213
+ * @default false
214
+ */
215
+ loading?: boolean;
216
+ /**
217
+ * Spinner style shown while `loading={true}`.
218
+ * @default "loading-indicator"
219
+ */
220
+ loadingVariant?: "loading-indicator" | "circular";
221
+ /** Icon content — typically a single SVG icon component. */
222
+ children: React.ReactNode;
223
+ /**
224
+ * Accessible label — **REQUIRED** because icon buttons have no visible text.
225
+ *
226
+ * @example "Close", "Add to favourites", "Toggle dark mode"
227
+ * @see https://m3.material.io/components/icon-buttons/accessibility
228
+ */
229
+ "aria-label": string;
230
+ }
231
+
232
+ /**
233
+ * Complete `IconButton` props — discriminated union that enforces
234
+ * `selected` is only valid with `variant="toggle"`.
235
+ *
236
+ * @example
237
+ * ```tsx
238
+ * // Standard
239
+ * <IconButton aria-label="Close" onClick={handleClose}>
240
+ * <XIcon />
241
+ * </IconButton>
242
+ *
243
+ * // Toggle
244
+ * <IconButton
245
+ * variant="toggle"
246
+ * selected={isLiked}
247
+ * aria-label={isLiked ? "Unlike" : "Like"}
248
+ * colorStyle="filled"
249
+ * onClick={() => setIsLiked(!isLiked)}
250
+ * >
251
+ * <HeartIcon />
252
+ * </IconButton>
253
+ * ```
254
+ *
255
+ * @see https://m3.material.io/components/icon-buttons/overview
256
+ */
257
+ export type IconButtonProps = BaseIconButtonProps &
258
+ (
259
+ | { variant?: "default"; selected?: never }
260
+ | { variant: "toggle"; selected: boolean }
261
+ );
262
+
263
+ // ─────────────────────────────────────────────────────────────────────────────
264
+ // Helpers
265
+ // ─────────────────────────────────────────────────────────────────────────────
266
+
267
+ /**
268
+ * Resolves the animated border-radius based on shape and toggle state.
269
+ *
270
+ * @param radiusConfig - The radius map entry for the current size.
271
+ * @param shape - Current shape variant.
272
+ * @param isToggle - Whether the button is a toggle variant.
273
+ * @param isSelected - Current toggle selection state.
274
+ * @returns Border radius in px.
275
+ * @internal
276
+ */
277
+ function resolveAnimateRadius(
278
+ radiusConfig: (typeof RADIUS_MAP)[string],
279
+ shape: "round" | "square",
280
+ isToggle: boolean,
281
+ isSelected: boolean,
282
+ ): number {
283
+ if (isToggle && isSelected) {
284
+ return shape === "round"
285
+ ? radiusConfig.selectedRound
286
+ : radiusConfig.selectedSquare;
287
+ }
288
+ return shape === "round" ? radiusConfig.round : radiusConfig.square;
289
+ }
290
+
291
+ /**
292
+ * Returns extra disabled-state classes specific to background-bearing color styles.
293
+ *
294
+ * `filled` and `tonal` need a dimmed background; `outlined` needs a dimmed border.
295
+ * `standard` only dims the icon colour (handled by global `disabled:opacity-[0.38]`).
296
+ *
297
+ * @param colorStyle - Current color style.
298
+ * @returns Tailwind disabled-state override classes.
299
+ * @internal
300
+ */
301
+ function resolveDisabledBgClass(colorStyle: string): string {
302
+ if (colorStyle === "filled" || colorStyle === "tonal") {
303
+ return "disabled:bg-m3-on-surface/12 disabled:text-m3-on-surface/[0.38]";
304
+ }
305
+ if (colorStyle === "outlined") {
306
+ return "disabled:text-m3-on-surface/[0.38] disabled:border-m3-on-surface/[0.12]";
307
+ }
308
+ return "disabled:text-m3-on-surface/[0.38]";
309
+ }
310
+
311
+ // ─────────────────────────────────────────────────────────────────────────────
312
+ // Component
313
+ // ─────────────────────────────────────────────────────────────────────────────
314
+
315
+ const IconButtonComponent = React.forwardRef<
316
+ HTMLButtonElement,
317
+ IconButtonProps
318
+ >(
319
+ (
320
+ {
321
+ className,
322
+ style,
323
+ variant = "default",
324
+ colorStyle = "standard",
325
+ size = "sm",
326
+ shape = "round",
327
+ selected,
328
+ loading = false,
329
+ loadingVariant = "loading-indicator",
330
+ children,
331
+ onClick,
332
+ onKeyDown,
333
+ "aria-label": ariaLabel,
334
+ ...restProps
335
+ },
336
+ ref,
337
+ ) => {
338
+ const isToggle = variant === "toggle";
339
+ const isSelected = isToggle && !!selected;
340
+
341
+ // Derived display values — memoized to avoid recalculation on every render
342
+ const resolvedColorClass = React.useMemo(
343
+ () =>
344
+ isSelected
345
+ ? colorStyles[colorStyle].selected
346
+ : colorStyles[colorStyle].default,
347
+ [isSelected, colorStyle],
348
+ );
349
+
350
+ const outlineWidthClass = React.useMemo(
351
+ () =>
352
+ colorStyle === "outlined" && !isSelected
353
+ ? (SIZE_OUTLINE_WIDTH[size] ?? "border")
354
+ : "",
355
+ [colorStyle, isSelected, size],
356
+ );
357
+
358
+ const disabledBgClass = React.useMemo(
359
+ () => resolveDisabledBgClass(colorStyle),
360
+ [colorStyle],
361
+ );
362
+
363
+ const radiusConfig = RADIUS_MAP[size] ?? RADIUS_MAP.sm;
364
+ const animateRadius = resolveAnimateRadius(
365
+ radiusConfig,
366
+ shape,
367
+ isToggle,
368
+ isSelected,
369
+ );
370
+ const pressedRadius = radiusConfig.pressed;
371
+
372
+ const sizeIcon = SIZE_ICON[size] ?? SIZE_ICON.sm;
373
+ const iconClass = sizeIcon.cls;
374
+ const iconPx = sizeIcon.px;
375
+
376
+ // xs/sm need 48dp touch target (WCAG 2.5.5 + MD3 a11y)
377
+ const needsTouchTarget = size === "xs" || size === "sm";
378
+
379
+ // ── Ripple ───────────────────────────────────────────────────────
380
+ const { ripples, onPointerDown, removeRipple } = useRippleState({
381
+ disabled: loading,
382
+ });
383
+
384
+ const handleClick = React.useCallback(
385
+ (e: React.MouseEvent<HTMLButtonElement>) => {
386
+ if (loading) {
387
+ e.preventDefault();
388
+ return;
389
+ }
390
+ onClick?.(e);
391
+ },
392
+ [loading, onClick],
393
+ );
394
+
395
+ const handleKeyDown = React.useCallback(
396
+ (e: React.KeyboardEvent<HTMLButtonElement>) => {
397
+ if (loading) return;
398
+ // Manually trigger click for Enter/Space — needed because JSDOM does not
399
+ // fire native click events from keyboard, and some custom scroll containers
400
+ // suppress the default browser behaviour.
401
+ if ((e.key === "Enter" || e.key === " ") && onClick) {
402
+ e.preventDefault();
403
+ (e.currentTarget as HTMLButtonElement).click();
404
+ }
405
+ onKeyDown?.(e);
406
+ },
407
+ [loading, onClick, onKeyDown],
408
+ );
409
+
410
+ return (
411
+ <LazyMotion features={domMax} strict>
412
+ <m.button
413
+ ref={ref}
414
+ type="button"
415
+ aria-pressed={isToggle ? isSelected : undefined}
416
+ aria-label={ariaLabel}
417
+ aria-busy={loading || undefined}
418
+ aria-disabled={loading || restProps.disabled}
419
+ onClick={handleClick}
420
+ onPointerDown={onPointerDown}
421
+ onKeyDown={handleKeyDown}
422
+ style={style}
423
+ animate={{ borderRadius: animateRadius }}
424
+ whileTap={{ borderRadius: pressedRadius }}
425
+ transition={{ borderRadius: SPRING_TRANSITION_FAST }}
426
+ className={cn(
427
+ baseIconButtonClasses,
428
+ resolvedColorClass,
429
+ outlineWidthClass,
430
+ disabledBgClass,
431
+ "overflow-hidden",
432
+ SIZE_STYLES[size],
433
+ loading && "pointer-events-none opacity-75 cursor-not-allowed",
434
+ className,
435
+ )}
436
+ {...restProps}
437
+ >
438
+ {/* Extended 48dp touch target for xs/sm (WCAG 2.5.5) */}
439
+ {needsTouchTarget && <TouchTarget />}
440
+
441
+ <Ripple ripples={ripples} onRippleDone={removeRipple} />
442
+
443
+ <AnimatePresence mode="wait" initial={false}>
444
+ {loading ? (
445
+ <m.span
446
+ key="loading"
447
+ {...ICON_SPAN_VARIANTS}
448
+ transition={SPRING_TRANSITION}
449
+ className={cn(
450
+ "flex items-center justify-center shrink-0",
451
+ iconClass,
452
+ )}
453
+ >
454
+ {loadingVariant === "loading-indicator" ? (
455
+ <LoadingIndicator
456
+ size={iconPx}
457
+ color="currentColor"
458
+ aria-label="Loading"
459
+ />
460
+ ) : (
461
+ <ProgressIndicator
462
+ variant="circular"
463
+ size={iconPx}
464
+ color="currentColor"
465
+ trackColor="transparent"
466
+ aria-label="Loading"
467
+ />
468
+ )}
469
+ </m.span>
470
+ ) : (
471
+ <m.span
472
+ key="content"
473
+ {...ICON_SPAN_VARIANTS}
474
+ transition={SPRING_TRANSITION}
475
+ aria-hidden="true"
476
+ className={cn(
477
+ "flex items-center justify-center shrink-0 [&_svg]:w-full [&_svg]:h-full [&_.md-icon]:text-[length:inherit]!",
478
+ iconClass,
479
+ )}
480
+ style={{ fontSize: iconPx }}
481
+ >
482
+ {children}
483
+ </m.span>
484
+ )}
485
+ </AnimatePresence>
486
+ </m.button>
487
+ </LazyMotion>
488
+ );
489
+ },
490
+ );
491
+
492
+ IconButtonComponent.displayName = "IconButton";
493
+
494
+ /**
495
+ * MD3 Expressive Icon Button.
496
+ *
497
+ * An icon-only button with spring shape morphing, ripple effect, loading state support,
498
+ * and an optional toggle variant. Compliant with MD3 Expressive sizing and WCAG 2.5.5
499
+ * (touch target minimum for XS and SM sizes).
500
+ *
501
+ * @remarks
502
+ * - `aria-label` is **required** — icon buttons have no visible text label.
503
+ * - `variant="toggle"` requires `selected: boolean`.
504
+ * - Touch target is automatically extended to 48dp for `xs` and `sm` sizes.
505
+ *
506
+ * @example
507
+ * ```tsx
508
+ * <IconButton aria-label="Close" onClick={handleClose}>
509
+ * <XIcon />
510
+ * </IconButton>
511
+ *
512
+ * <IconButton
513
+ * variant="toggle"
514
+ * selected={isBookmarked}
515
+ * aria-label={isBookmarked ? "Remove bookmark" : "Bookmark"}
516
+ * colorStyle="filled"
517
+ * onClick={toggleBookmark}
518
+ * >
519
+ * <BookmarkIcon />
520
+ * </IconButton>
521
+ * ```
522
+ *
523
+ * @see https://m3.material.io/components/icon-buttons/overview
524
+ */
525
+ export const IconButton = React.memo(IconButtonComponent);