@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,669 @@
1
+ /**
2
+ * @file button.tsx
3
+ *
4
+ * MD3 Expressive Button component.
5
+ *
6
+ * Spec: https://m3.material.io/components/buttons/overview
7
+ * Sizing (May 2025):
8
+ * XS → h:32dp | px: 12dp | icon: 18dp | gap: 8dp
9
+ * SM → h:40dp | px: 16dp | icon: 20dp | gap: 8dp
10
+ * MD → h:56dp | px: 24dp | icon: 24dp | gap: 8dp
11
+ * LG → h:96dp | px: 48dp | icon: 32dp | gap: 12dp
12
+ * XL → h:136dp | px: 48dp | icon: 40dp | gap: 12dp
13
+ */
14
+
15
+ import { Slot } from "@radix-ui/react-slot";
16
+ import { cva } from "class-variance-authority";
17
+ import type { HTMLMotionProps } from "motion/react";
18
+ import {
19
+ AnimatePresence,
20
+ animate,
21
+ domMax,
22
+ LazyMotion,
23
+ m,
24
+ useMotionValue,
25
+ } from "motion/react";
26
+ import * as React from "react";
27
+ import { cn } from "../lib/utils";
28
+ import { LoadingIndicator } from "./loading-indicator";
29
+ import { ProgressIndicator } from "./progress-indicator";
30
+ import { Ripple, useRippleState } from "./ripple";
31
+ import { SPRING_TRANSITION, SPRING_TRANSITION_FAST } from "./shared/constants";
32
+ import { TouchTarget } from "./shared/touch-target";
33
+
34
+ // ─── Design Tokens ────────────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * Per-size layout styles.
38
+ * Heights and paddings are taken directly from the MD3 Expressive spec (May 2025).
39
+ */
40
+ const SIZE_STYLES: Record<string, React.CSSProperties> = {
41
+ xs: {
42
+ height: "2rem",
43
+ minWidth: "4rem",
44
+ paddingInline: "0.75rem",
45
+ gap: "0.5rem",
46
+ },
47
+ sm: {
48
+ height: "2.5rem",
49
+ minWidth: "5rem",
50
+ paddingInline: "1rem",
51
+ gap: "0.5rem",
52
+ },
53
+ md: {
54
+ height: "3.5rem",
55
+ minWidth: "7rem",
56
+ paddingInline: "1.5rem",
57
+ gap: "0.5rem",
58
+ },
59
+ lg: {
60
+ height: "6rem",
61
+ minWidth: "11rem",
62
+ paddingInline: "3rem",
63
+ gap: "0.75rem",
64
+ },
65
+ xl: {
66
+ height: "8.5rem",
67
+ minWidth: "14rem",
68
+ paddingInline: "3rem",
69
+ gap: "0.75rem",
70
+ },
71
+ };
72
+
73
+ /** Per-size label typography classes. */
74
+ const SIZE_TEXT_CLASS: Record<string, string> = {
75
+ xs: "text-xs font-medium tracking-wide",
76
+ sm: "text-sm font-medium tracking-wide",
77
+ md: "text-base font-medium tracking-wide",
78
+ lg: "text-lg font-medium tracking-wide",
79
+ xl: "text-xl font-medium tracking-wide",
80
+ };
81
+
82
+ /**
83
+ * Per-size icon container Tailwind classes.
84
+ * MD3 icon sizes: XS=18dp, SM=20dp, MD=24dp, LG=32dp, XL=40dp.
85
+ */
86
+ const SIZE_ICON_CLASS: Record<string, string> = {
87
+ xs: "size-[1.125rem]",
88
+ sm: "size-5",
89
+ md: "size-6",
90
+ lg: "size-8",
91
+ xl: "size-10",
92
+ };
93
+
94
+ /**
95
+ * Icon pixel-sizes for given button sizes.
96
+ * MD3 icon sizes: XS=18, SM=20, MD=24, LG=32, XL=40.
97
+ */
98
+ const SIZE_ICON_PX: Record<string, number> = {
99
+ xs: 18,
100
+ sm: 20,
101
+ md: 24,
102
+ lg: 32,
103
+ xl: 40,
104
+ };
105
+
106
+ // ─── Shape Morphing ────────────────────────────────────────────────────────────
107
+ //
108
+ // IMPORTANT: Do NOT use 9999 for the "pill" default radius.
109
+ // CSS clips any border-radius > height/2 identically, so animating from
110
+ // 9999 → small value creates a perceptual dead zone (nothing looks different
111
+ // until the value drops below height/2). This makes the animation feel like
112
+ // it snaps/jerks. Use exact half-height values instead for truly smooth morph.
113
+ //
114
+ // Size heights: xs=32dp, sm=40dp, md=56dp, lg=96dp, xl=136dp
115
+
116
+ /** Per-size border radius values for a given shape state. */
117
+ type MorphRadius = { default: number; pressed: number };
118
+
119
+ /**
120
+ * Border-radius token map for the "round" (pill) shape variant.
121
+ * Values equal `height / 2` for each size to ensure the pill stays perceptually
122
+ * smooth during spring animation (no dead zone artefact).
123
+ */
124
+ const ROUND_RADIUS: Record<string, MorphRadius> = {
125
+ xs: { default: 16, pressed: 8 },
126
+ sm: { default: 20, pressed: 10 },
127
+ md: { default: 28, pressed: 16 },
128
+ lg: { default: 48, pressed: 28 },
129
+ xl: { default: 68, pressed: 40 },
130
+ };
131
+
132
+ /**
133
+ * Border-radius token map for the "square" (rounded-square) shape variant.
134
+ * Pressed values compress inward following MD3 Expressive morphing spec.
135
+ */
136
+ const SQUARE_RADIUS: Record<string, MorphRadius> = {
137
+ xs: { default: 4, pressed: 2 },
138
+ sm: { default: 8, pressed: 4 },
139
+ md: { default: 16, pressed: 10 },
140
+ lg: { default: 28, pressed: 20 },
141
+ xl: { default: 40, pressed: 28 },
142
+ };
143
+
144
+ // ─── Color Variants (CVA) ──────────────────────────────────────────────────────
145
+
146
+ const buttonColorVariants = cva(
147
+ [
148
+ "relative w-fit shrink-0 inline-flex flex-row items-center justify-center",
149
+ "whitespace-nowrap select-none cursor-pointer",
150
+ "transition-[background-color,color,border-color,box-shadow,opacity,filter] duration-200",
151
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-m3-primary focus-visible:ring-offset-2",
152
+ "disabled:pointer-events-none disabled:opacity-[0.38] disabled:shadow-none",
153
+ ],
154
+ {
155
+ variants: {
156
+ colorStyle: {
157
+ elevated: [
158
+ "bg-m3-surface-container-low text-m3-primary shadow-md",
159
+ "hover:bg-m3-primary/8",
160
+ "active:bg-m3-primary/12 active:shadow-sm",
161
+ "disabled:bg-m3-on-surface/12 disabled:text-m3-on-surface/[0.38]",
162
+ ],
163
+ // filled = default + toggle-selected state (routes here via effectiveColorStyle)
164
+ filled: [
165
+ "bg-m3-primary text-m3-on-primary",
166
+ "hover:brightness-95",
167
+ "active:brightness-90 active:shadow-none",
168
+ "disabled:bg-m3-on-surface/12 disabled:text-m3-on-surface/[0.38]",
169
+ ],
170
+ tonal: [
171
+ "bg-m3-secondary-container text-m3-on-secondary-container",
172
+ "hover:bg-m3-on-secondary-container/8",
173
+ "active:bg-m3-on-secondary-container/12 active:shadow-none",
174
+ "disabled:bg-m3-on-surface/12 disabled:text-m3-on-surface/[0.38]",
175
+ ],
176
+ outlined: [
177
+ "bg-transparent border border-m3-outline text-m3-primary",
178
+ "hover:bg-m3-primary/8",
179
+ "active:bg-m3-primary/12",
180
+ "disabled:border-m3-on-surface/12 disabled:text-m3-on-surface/[0.38]",
181
+ ],
182
+ text: [
183
+ "bg-transparent text-m3-primary px-3",
184
+ "hover:bg-m3-primary/8",
185
+ "active:bg-m3-primary/12",
186
+ "disabled:text-m3-on-surface/[0.38]",
187
+ ],
188
+ },
189
+ },
190
+ defaultVariants: { colorStyle: "filled" },
191
+ },
192
+ );
193
+
194
+ // ─── Types ─────────────────────────────────────────────────────────────────────
195
+ // Use HTMLMotionProps<"button"> as the base to avoid onDrag / event handler
196
+ // conflicts between native React and Framer Motion's extended prop types.
197
+
198
+ type MotionButtonProps = Omit<HTMLMotionProps<"button">, "children" | "color">;
199
+
200
+ /**
201
+ * Base props shared between the standard and toggle button variants.
202
+ *
203
+ * @see {@link ButtonProps} for the complete discriminated union type.
204
+ * @see https://m3.material.io/components/buttons/overview
205
+ */
206
+ export interface BaseButtonProps extends MotionButtonProps {
207
+ /**
208
+ * Visual style variant following MD3 color roles.
209
+ * @default "filled"
210
+ */
211
+ colorStyle?: "elevated" | "filled" | "tonal" | "outlined" | "text";
212
+ /**
213
+ * Color style applied when the toggle button is in the *selected* state.
214
+ * Only meaningful when `variant="toggle"`.
215
+ * Falls back to `"filled"` when not specified.
216
+ */
217
+ selectedColorStyle?: "elevated" | "filled" | "tonal" | "outlined" | "text";
218
+ /**
219
+ * Button size following MD3 Expressive size scale.
220
+ * @default "sm"
221
+ */
222
+ size?: "xs" | "sm" | "md" | "lg" | "xl";
223
+ /**
224
+ * Container shape — controls border-radius morphing.
225
+ * - `round`: pill shape (CornerFull), morphs to rounded-square when toggled.
226
+ * - `square`: rounded-square, morphs to pill when toggled.
227
+ * @default "round"
228
+ */
229
+ shape?: "round" | "square";
230
+ /**
231
+ * Optional leading or trailing icon node.
232
+ * Size is automatically scaled to match the button's `size` prop.
233
+ */
234
+ icon?: React.ReactNode;
235
+ /**
236
+ * Position of the icon relative to the label text.
237
+ * @default "leading"
238
+ */
239
+ iconPosition?: "leading" | "trailing";
240
+ /**
241
+ * When `true`, replaces the icon with an animated loading indicator
242
+ * and prevents interaction.
243
+ * @default false
244
+ */
245
+ loading?: boolean;
246
+ /**
247
+ * Controls which loading spinner is shown while `loading={true}`.
248
+ * - `loading-indicator`: MD3 Expressive morphing shape (default).
249
+ * - `circular`: Classic circular spinner.
250
+ * @default "loading-indicator"
251
+ */
252
+ loadingVariant?: "loading-indicator" | "circular";
253
+ /**
254
+ * When `true`, the Button renders its child element directly (using Radix Slot),
255
+ * merging all button props (className, style, event handlers) onto it.
256
+ * Useful for rendering a Next.js `<Link>` with Button styles.
257
+ *
258
+ * @example
259
+ * ```tsx
260
+ * <Button asChild size="lg">
261
+ * <Link href="/components">Explore Components</Link>
262
+ * </Button>
263
+ * ```
264
+ * @default false
265
+ */
266
+ asChild?: boolean;
267
+ /** Button label — any React content, typically a string. */
268
+ children: React.ReactNode;
269
+ }
270
+
271
+ /**
272
+ * Complete `Button` props — discriminated union that enforces
273
+ * `selected` is only valid for `variant="toggle"`.
274
+ *
275
+ * @example
276
+ * ```tsx
277
+ * // Standard button
278
+ * <Button colorStyle="filled" size="md">Confirm</Button>
279
+ *
280
+ * // Toggle button (selected state required)
281
+ * <Button variant="toggle" selected={isActive} onClick={toggle}>Filter</Button>
282
+ *
283
+ * // With leading icon and loading state
284
+ * <Button icon={<CheckIcon />} loading={isSubmitting}>Save</Button>
285
+ * ```
286
+ *
287
+ * @see https://m3.material.io/components/buttons/overview
288
+ */
289
+ export type ButtonProps = BaseButtonProps &
290
+ (
291
+ | { variant?: "default"; selected?: never }
292
+ | { variant: "toggle"; selected: boolean }
293
+ );
294
+
295
+ // ─── Helpers ───────────────────────────────────────────────────────────────────
296
+
297
+ function toSentenceCase(text: string): string {
298
+ return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
299
+ }
300
+
301
+ function resolveLabel(
302
+ children: React.ReactNode,
303
+ asChild: boolean,
304
+ ): React.ReactNode {
305
+ if (asChild) {
306
+ const child = React.Children.only(children) as React.ReactElement<{
307
+ children?: React.ReactNode;
308
+ }>;
309
+ return child.props.children;
310
+ }
311
+ return typeof children === "string" ? toSentenceCase(children) : children;
312
+ }
313
+
314
+ /** Framer Motion-specific props to strip before forwarding to a plain DOM element. */
315
+ const MOTION_PROP_KEYS = [
316
+ "animate",
317
+ "exit",
318
+ "initial",
319
+ "transition",
320
+ "variants",
321
+ "whileHover",
322
+ "whileTap",
323
+ "whileFocus",
324
+ "whileDrag",
325
+ "whileInView",
326
+ "onAnimationStart",
327
+ "onAnimationComplete",
328
+ "onUpdate",
329
+ "onDragStart",
330
+ "onDragEnd",
331
+ "onDrag",
332
+ "onDirectionLock",
333
+ "onDragTransitionEnd",
334
+ "layout",
335
+ "layoutId",
336
+ "onLayoutAnimationComplete",
337
+ ] as const;
338
+
339
+ function stripMotionProps(
340
+ props: Record<string, unknown>,
341
+ ): Record<string, unknown> {
342
+ const result = { ...props };
343
+ for (const key of MOTION_PROP_KEYS) delete result[key];
344
+ return result;
345
+ }
346
+
347
+ function springAnimate(
348
+ value: ReturnType<typeof useMotionValue<number>>,
349
+ to: number,
350
+ ) {
351
+ animate(value, to, { ...SPRING_TRANSITION_FAST, type: "spring" });
352
+ }
353
+
354
+ // ─── Sub-components ────────────────────────────────────────────────────────────
355
+
356
+ interface LoadingSpinnerProps {
357
+ size: number;
358
+ variant: "loading-indicator" | "circular";
359
+ }
360
+
361
+ function LoadingSpinner({ size, variant }: LoadingSpinnerProps) {
362
+ if (variant === "loading-indicator") {
363
+ return (
364
+ <LoadingIndicator size={size} color="currentColor" aria-label="Loading" />
365
+ );
366
+ }
367
+ return (
368
+ <ProgressIndicator
369
+ variant="circular"
370
+ size={size}
371
+ color="currentColor"
372
+ trackColor="transparent"
373
+ aria-label="Loading"
374
+ />
375
+ );
376
+ }
377
+
378
+ interface AnimatedIconSlotProps {
379
+ iconClass: string;
380
+ children: React.ReactNode;
381
+ ariaHidden?: boolean;
382
+ }
383
+
384
+ function AnimatedIconSlot({
385
+ iconClass,
386
+ children,
387
+ ariaHidden,
388
+ }: AnimatedIconSlotProps) {
389
+ return (
390
+ <m.span
391
+ initial={{ width: 0, opacity: 0, scale: 0.5 }}
392
+ animate={{ width: "auto", opacity: 1, scale: 1 }}
393
+ exit={{ width: 0, opacity: 0, scale: 0.5 }}
394
+ transition={SPRING_TRANSITION}
395
+ aria-hidden={ariaHidden ? "true" : undefined}
396
+ className={cn(
397
+ "flex items-center justify-center shrink-0 [&>svg]:w-full [&>svg]:h-full overflow-hidden",
398
+ iconClass,
399
+ )}
400
+ >
401
+ {children}
402
+ </m.span>
403
+ );
404
+ }
405
+
406
+ // ─── Component ─────────────────────────────────────────────────────────────────
407
+
408
+ const ButtonComponent = React.forwardRef<HTMLButtonElement, ButtonProps>(
409
+ (
410
+ {
411
+ className,
412
+ style,
413
+ variant = "default",
414
+ colorStyle = "filled",
415
+ selectedColorStyle,
416
+ size = "sm",
417
+ shape = "round",
418
+ selected,
419
+ icon,
420
+ iconPosition = "leading",
421
+ loading = false,
422
+ loadingVariant = "loading-indicator",
423
+ asChild = false,
424
+ children,
425
+ onClick,
426
+ onKeyDown,
427
+ "aria-label": ariaLabelProp,
428
+ ...restProps
429
+ },
430
+ ref,
431
+ ) => {
432
+ const isToggle = variant === "toggle";
433
+ const isSelected = isToggle ? !!selected : false;
434
+
435
+ // When toggle is selected, shape flips (round ↔ square).
436
+ const effectiveShape = isSelected
437
+ ? shape === "round"
438
+ ? "square"
439
+ : "round"
440
+ : shape;
441
+
442
+ // effectiveColorStyle is the single source of truth for color.
443
+ // Avoids CSS specificity battles between two bg-* classes.
444
+ const effectiveColorStyle =
445
+ isToggle && isSelected ? (selectedColorStyle ?? "filled") : colorStyle;
446
+
447
+ const radiusMap = effectiveShape === "round" ? ROUND_RADIUS : SQUARE_RADIUS;
448
+ const { default: animateRadius } = radiusMap[size] ?? radiusMap.sm;
449
+ const { pressed: pressedRadius } = radiusMap[size] ?? radiusMap.sm;
450
+
451
+ const iconClass = SIZE_ICON_CLASS[size] ?? "size-5";
452
+ const mergedStyle = { ...SIZE_STYLES[size], ...style };
453
+ const labelText = React.useMemo(
454
+ () => resolveLabel(children, asChild),
455
+ [children, asChild],
456
+ );
457
+ const computedAriaLabel =
458
+ ariaLabelProp || (typeof children === "string" ? children : undefined);
459
+ const needsTouchTarget = size === "xs" || size === "sm";
460
+
461
+ // Shape morphing motion value for asChild mode.
462
+ // Radix Slot clones the child, so Framer Motion loses DOM tracking.
463
+ // Instead we subscribe to motionRadius.on("change") and update style.borderRadius imperatively.
464
+ const motionRadius = useMotionValue(animateRadius);
465
+ const asChildRef = React.useRef<HTMLElement | null>(null);
466
+
467
+ // Merge forwardRef + asChildRef into a single callback ref (Slot accepts only one ref).
468
+ const mergedRef = React.useCallback(
469
+ (node: HTMLElement | null) => {
470
+ asChildRef.current = node;
471
+ if (typeof ref === "function") ref(node as HTMLButtonElement);
472
+ else if (ref)
473
+ (ref as React.MutableRefObject<HTMLButtonElement | null>).current =
474
+ node as HTMLButtonElement;
475
+ },
476
+ [ref],
477
+ );
478
+
479
+ // Keep DOM borderRadius synced with motionRadius.
480
+ React.useEffect(
481
+ () =>
482
+ motionRadius.on("change", (v) => {
483
+ if (asChildRef.current)
484
+ asChildRef.current.style.borderRadius = `${v}px`;
485
+ }),
486
+ [motionRadius],
487
+ );
488
+
489
+ // Animate to new target radius when toggle state or size changes.
490
+ React.useEffect(() => {
491
+ springAnimate(motionRadius, animateRadius);
492
+ }, [animateRadius, motionRadius]);
493
+
494
+ const { ripples, onPointerDown, removeRipple } = useRippleState({
495
+ disabled: loading,
496
+ });
497
+
498
+ const handleClick = React.useCallback(
499
+ (e: React.MouseEvent<HTMLButtonElement>) => {
500
+ if (loading) return e.preventDefault();
501
+ onClick?.(e);
502
+ },
503
+ [loading, onClick],
504
+ );
505
+
506
+ const handleKeyDown = React.useCallback(
507
+ (e: React.KeyboardEvent<HTMLButtonElement>) => {
508
+ if (loading) return;
509
+ if (onClick && (e.key === "Enter" || e.key === " ")) {
510
+ e.preventDefault();
511
+ (e.currentTarget as HTMLButtonElement).click();
512
+ }
513
+ onKeyDown?.(e);
514
+ },
515
+ [loading, onClick, onKeyDown],
516
+ );
517
+
518
+ const buttonClassName = cn(
519
+ buttonColorVariants({ colorStyle: effectiveColorStyle }),
520
+ // overflow-hidden clips Ripple to match the morphing border-radius
521
+ "overflow-hidden",
522
+ SIZE_TEXT_CLASS[size],
523
+ needsTouchTarget && "relative",
524
+ loading && "pointer-events-none opacity-75 cursor-not-allowed",
525
+ className,
526
+ );
527
+
528
+ const innerContent = (
529
+ <>
530
+ {/* Invisible touch-target expander (min 48×48dp) for small buttons */}
531
+ {needsTouchTarget && <TouchTarget />}
532
+
533
+ {/* MD3 Expressive Ripple layer */}
534
+ <Ripple ripples={ripples} onRippleDone={removeRipple} />
535
+
536
+ <AnimatePresence initial={false}>
537
+ {(loading || (icon && iconPosition === "leading")) && (
538
+ <AnimatedIconSlot iconClass={iconClass} ariaHidden={!loading}>
539
+ {loading ? (
540
+ <LoadingSpinner
541
+ size={SIZE_ICON_PX[size] ?? 20}
542
+ variant={loadingVariant}
543
+ />
544
+ ) : (
545
+ icon
546
+ )}
547
+ </AnimatedIconSlot>
548
+ )}
549
+ </AnimatePresence>
550
+
551
+ <m.span
552
+ layout="size"
553
+ className="inline-flex items-center gap-[inherit]"
554
+ transition={SPRING_TRANSITION}
555
+ >
556
+ {labelText}
557
+ </m.span>
558
+
559
+ <AnimatePresence initial={false}>
560
+ {icon && iconPosition === "trailing" && (
561
+ <AnimatedIconSlot iconClass={iconClass} ariaHidden>
562
+ {icon}
563
+ </AnimatedIconSlot>
564
+ )}
565
+ </AnimatePresence>
566
+ </>
567
+ );
568
+
569
+ // asChild: render Slot with imperative motion value driving borderRadius.
570
+ // Framer Motion works imperatively here because Radix Slot clones the child,
571
+ // breaking Framer Motion's internal DOM tracking.
572
+ if (asChild) {
573
+ const htmlProps = stripMotionProps(restProps as Record<string, unknown>);
574
+ const child = React.Children.only(children) as React.ReactElement<{
575
+ children?: React.ReactNode;
576
+ }>;
577
+
578
+ const handleAsChildPointerDown = (e: React.PointerEvent<HTMLElement>) => {
579
+ springAnimate(motionRadius, pressedRadius);
580
+ (onPointerDown as React.PointerEventHandler<HTMLElement>)?.(e);
581
+ };
582
+
583
+ const handleAsChildPointerUp = () => {
584
+ springAnimate(motionRadius, animateRadius);
585
+ };
586
+
587
+ return (
588
+ <LazyMotion features={domMax} strict>
589
+ <Slot
590
+ ref={mergedRef as React.Ref<HTMLButtonElement>}
591
+ aria-label={computedAriaLabel}
592
+ onClick={handleClick as React.MouseEventHandler<HTMLElement>}
593
+ onPointerDown={handleAsChildPointerDown}
594
+ onPointerUp={handleAsChildPointerUp}
595
+ onPointerLeave={handleAsChildPointerUp}
596
+ onPointerCancel={handleAsChildPointerUp}
597
+ onKeyDown={handleKeyDown as React.KeyboardEventHandler<HTMLElement>}
598
+ style={{
599
+ ...(mergedStyle as React.CSSProperties),
600
+ borderRadius: `${animateRadius}px`,
601
+ }}
602
+ className={buttonClassName}
603
+ {...htmlProps}
604
+ >
605
+ {React.cloneElement(child, { children: innerContent })}
606
+ </Slot>
607
+ </LazyMotion>
608
+ );
609
+ }
610
+
611
+ // Default: animated m.button
612
+ return (
613
+ <LazyMotion features={domMax} strict>
614
+ <m.button
615
+ ref={ref}
616
+ type="button"
617
+ aria-pressed={isToggle ? isSelected : undefined}
618
+ aria-label={computedAriaLabel}
619
+ aria-busy={loading ? true : undefined}
620
+ aria-disabled={loading ? true : restProps.disabled}
621
+ onClick={handleClick}
622
+ onPointerDown={onPointerDown}
623
+ onKeyDown={handleKeyDown}
624
+ style={mergedStyle}
625
+ animate={{ borderRadius: animateRadius }}
626
+ whileTap={{ borderRadius: pressedRadius }}
627
+ transition={{ borderRadius: SPRING_TRANSITION_FAST }}
628
+ className={buttonClassName}
629
+ {...restProps}
630
+ >
631
+ {innerContent}
632
+ </m.button>
633
+ </LazyMotion>
634
+ );
635
+ },
636
+ );
637
+
638
+ ButtonComponent.displayName = "Button";
639
+
640
+ /**
641
+ * MD3 Expressive Button component.
642
+ *
643
+ * Supports all five MD3 color styles, five sizes, shape morphing on toggle,
644
+ * leading/trailing icons, and an animated loading state.
645
+ *
646
+ * @remarks
647
+ * - `variant="toggle"` requires `selected: boolean` — enforced by the type system.
648
+ * - When `loading={true}`, the button is visually dimmed, pointer events are
649
+ * blocked, and `aria-busy` is set for screen readers.
650
+ * - Shape morphs smoothly between pill ↔ rounded-square when toggle state changes,
651
+ * using a critically-damped spring (no overshoot artefacts).
652
+ *
653
+ * @example
654
+ * ```tsx
655
+ * // Standard filled button
656
+ * <Button colorStyle="filled" size="md">Confirm</Button>
657
+ *
658
+ * // Button with icon
659
+ * <Button icon={<CheckIcon />} loading={isSubmitting}>Save</Button>
660
+ *
661
+ * // Toggle button
662
+ * <Button variant="toggle" selected={isActive} onClick={toggle}>
663
+ * Filter
664
+ * </Button>
665
+ * ```
666
+ *
667
+ * @see https://m3.material.io/components/buttons/overview
668
+ */
669
+ export const Button = React.memo(ButtonComponent);