@bug-on/md3-react 2.0.3 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (316) hide show
  1. package/.turbo/turbo-build.log +42 -0
  2. package/CHANGELOG.md +69 -0
  3. package/dist/index.css +178 -0
  4. package/dist/index.css.d.ts +2 -0
  5. package/dist/index.d.mts +6135 -0
  6. package/dist/index.d.ts +6135 -71
  7. package/dist/index.js +1688 -631
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +1600 -564
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/material-symbols-cdn.css.d.ts +2 -0
  12. package/dist/material-symbols-self-hosted.css.d.ts +2 -0
  13. package/dist/plugin.d.mts +1 -0
  14. package/dist/plugin.d.ts +1 -0
  15. package/dist/plugin.js +13 -0
  16. package/dist/plugin.js.map +1 -0
  17. package/dist/plugin.mjs +3 -0
  18. package/dist/plugin.mjs.map +1 -0
  19. package/dist/typography.css.d.ts +2 -0
  20. package/package.json +28 -19
  21. package/scripts/copy-assets.js +115 -0
  22. package/src/assets/fonts/GoogleSansFlex-VariableFont.woff2 +0 -0
  23. package/src/assets/fonts/MaterialSymbolsOutlined-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  24. package/src/assets/fonts/MaterialSymbolsRounded-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  25. package/src/assets/fonts/MaterialSymbolsSharp-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  26. package/src/assets/loading-indicator.svg +19 -0
  27. package/src/assets/material-symbols-cdn.css +65 -0
  28. package/src/assets/material-symbols-self-hosted.css +90 -0
  29. package/src/css.d.ts +20 -0
  30. package/src/hooks/useClickOutside.ts +37 -0
  31. package/src/hooks/useMediaQuery.ts +28 -0
  32. package/src/hooks/useRipple.ts +88 -0
  33. package/src/index.css +23 -0
  34. package/src/index.ts +349 -0
  35. package/src/lib/material-symbols-preconnect.tsx +82 -0
  36. package/src/lib/theme-utils.ts +195 -0
  37. package/src/lib/utils.ts +6 -0
  38. package/src/plugin.ts +12 -0
  39. package/src/test/button.test.tsx +59 -0
  40. package/src/test/icon.test.tsx +91 -0
  41. package/src/test/loading-indicator.test.tsx +128 -0
  42. package/src/test/progress-indicator.test.tsx +306 -0
  43. package/src/test/setup.ts +80 -0
  44. package/src/test/typography.test.tsx +206 -0
  45. package/src/types/index.ts +7 -0
  46. package/src/types/md3.ts +31 -0
  47. package/src/ui/Text.tsx +60 -0
  48. package/src/ui/__snapshots__/divider.test.tsx.snap +63 -0
  49. package/src/ui/app-bar/app-bar-column.tsx +99 -0
  50. package/src/ui/app-bar/app-bar-item-button.tsx +71 -0
  51. package/src/ui/app-bar/app-bar-items.test.tsx +89 -0
  52. package/src/ui/app-bar/app-bar-overflow-indicator.tsx +108 -0
  53. package/src/ui/app-bar/app-bar-row.tsx +104 -0
  54. package/src/ui/app-bar/app-bar.test.tsx +87 -0
  55. package/src/ui/app-bar/app-bar.tokens.ts +223 -0
  56. package/src/ui/app-bar/app-bar.types.ts +441 -0
  57. package/src/ui/app-bar/bottom-app-bar.test.tsx +42 -0
  58. package/src/ui/app-bar/bottom-app-bar.tsx +84 -0
  59. package/src/ui/app-bar/docked-toolbar.test.tsx +34 -0
  60. package/src/ui/app-bar/docked-toolbar.tsx +54 -0
  61. package/src/ui/app-bar/flexible-app-bar.test.tsx +75 -0
  62. package/src/ui/app-bar/hooks/use-app-bar-scroll.ts +110 -0
  63. package/src/ui/app-bar/hooks/use-flexible-app-bar.ts +123 -0
  64. package/{dist/ui/app-bar/index.d.ts → src/ui/app-bar/index.ts} +35 -2
  65. package/src/ui/app-bar/large-flexible-app-bar.tsx +165 -0
  66. package/src/ui/app-bar/medium-flexible-app-bar.tsx +167 -0
  67. package/src/ui/app-bar/search-app-bar.test.tsx +49 -0
  68. package/src/ui/app-bar/search-app-bar.tsx +176 -0
  69. package/src/ui/app-bar/search-view.tsx +227 -0
  70. package/src/ui/app-bar/small-app-bar.test.tsx +48 -0
  71. package/src/ui/app-bar/small-app-bar.tsx +203 -0
  72. package/src/ui/badge.test.tsx +345 -0
  73. package/src/ui/badge.tsx +282 -0
  74. package/src/ui/button-group.test.tsx +71 -0
  75. package/src/ui/button-group.tsx +350 -0
  76. package/src/ui/button.test.tsx +306 -0
  77. package/src/ui/button.tsx +665 -0
  78. package/src/ui/card.test.tsx +187 -0
  79. package/src/ui/card.tsx +259 -0
  80. package/src/ui/checkbox.test.tsx +423 -0
  81. package/src/ui/checkbox.tsx +525 -0
  82. package/src/ui/chip.test.tsx +292 -0
  83. package/src/ui/chip.tsx +548 -0
  84. package/src/ui/code-block.tsx +219 -0
  85. package/src/ui/dialog.test.tsx +300 -0
  86. package/src/ui/dialog.tsx +384 -0
  87. package/src/ui/divider.test.tsx +314 -0
  88. package/src/ui/divider.tsx +412 -0
  89. package/src/ui/drawer.tsx +240 -0
  90. package/src/ui/fab-menu.test.tsx +494 -0
  91. package/src/ui/fab-menu.tsx +739 -0
  92. package/src/ui/fab.test.tsx +232 -0
  93. package/src/ui/fab.tsx +505 -0
  94. package/src/ui/icon-button.test.tsx +515 -0
  95. package/src/ui/icon-button.tsx +525 -0
  96. package/src/ui/icon.test.tsx +197 -0
  97. package/src/ui/icon.tsx +179 -0
  98. package/src/ui/loading-indicator.test.tsx +73 -0
  99. package/src/ui/loading-indicator.tsx +312 -0
  100. package/src/ui/menu/context-menu.tsx +275 -0
  101. package/src/ui/menu/index.ts +77 -0
  102. package/src/ui/menu/menu-animations.ts +102 -0
  103. package/src/ui/menu/menu-context.tsx +99 -0
  104. package/src/ui/menu/menu-divider.tsx +47 -0
  105. package/src/ui/menu/menu-group.tsx +200 -0
  106. package/src/ui/menu/menu-item.tsx +294 -0
  107. package/src/ui/menu/menu-tokens.ts +208 -0
  108. package/src/ui/menu/menu-types.ts +313 -0
  109. package/src/ui/menu/menu.test.tsx +624 -0
  110. package/src/ui/menu/menu.tsx +289 -0
  111. package/src/ui/menu/sub-menu.tsx +223 -0
  112. package/src/ui/menu/vertical-menu.tsx +382 -0
  113. package/src/ui/navigation-rail.test.tsx +404 -0
  114. package/src/ui/navigation-rail.tsx +607 -0
  115. package/src/ui/progress-indicator/circular.tsx +248 -0
  116. package/src/ui/progress-indicator/hooks.ts +51 -0
  117. package/{dist/ui/progress-indicator/index.d.ts → src/ui/progress-indicator/index.tsx} +20 -2
  118. package/src/ui/progress-indicator/linear-flat.tsx +83 -0
  119. package/src/ui/progress-indicator/linear-wavy.tsx +243 -0
  120. package/src/ui/progress-indicator/linear.tsx +143 -0
  121. package/src/ui/progress-indicator/types.ts +158 -0
  122. package/src/ui/progress-indicator/utils.ts +73 -0
  123. package/src/ui/radio-button.test.tsx +407 -0
  124. package/src/ui/radio-button.tsx +551 -0
  125. package/src/ui/ripple.test.tsx +72 -0
  126. package/src/ui/ripple.tsx +234 -0
  127. package/src/ui/scroll-area.test.tsx +58 -0
  128. package/src/ui/scroll-area.tsx +139 -0
  129. package/src/ui/search/animated-placeholder.tsx +145 -0
  130. package/src/ui/search/hooks/use-search-keyboard.test.ts +202 -0
  131. package/src/ui/search/hooks/use-search-keyboard.ts +104 -0
  132. package/src/ui/search/hooks/use-search-view-focus.test.ts +96 -0
  133. package/src/ui/search/hooks/use-search-view-focus.ts +24 -0
  134. package/src/ui/search/index.ts +44 -0
  135. package/src/ui/search/search-bar.tsx +220 -0
  136. package/src/ui/search/search-context.tsx +42 -0
  137. package/src/ui/search/search-view-docked.tsx +194 -0
  138. package/src/ui/search/search-view-fullscreen.tsx +247 -0
  139. package/src/ui/search/search.test.tsx +233 -0
  140. package/src/ui/search/search.tokens.ts +134 -0
  141. package/src/ui/search/search.tsx +131 -0
  142. package/src/ui/search/search.types.ts +154 -0
  143. package/src/ui/search/trailing-action.tsx +49 -0
  144. package/src/ui/shared/constants.ts +135 -0
  145. package/{dist/ui/shared/touch-target.d.ts → src/ui/shared/touch-target.tsx} +13 -1
  146. package/src/ui/slider/hooks/useSliderMath.ts +195 -0
  147. package/{dist/ui/slider/index.d.ts → src/ui/slider/index.ts} +12 -1
  148. package/src/ui/slider/range-slider.tsx +561 -0
  149. package/src/ui/slider/slider-thumb.tsx +379 -0
  150. package/src/ui/slider/slider-track.tsx +912 -0
  151. package/src/ui/slider/slider.tokens.ts +189 -0
  152. package/src/ui/slider/slider.tsx +259 -0
  153. package/src/ui/slider/slider.types.ts +288 -0
  154. package/src/ui/snackbar/index.ts +20 -0
  155. package/src/ui/snackbar/snackbar.test.tsx +338 -0
  156. package/src/ui/snackbar/snackbar.tsx +476 -0
  157. package/{dist/ui/switch/index.d.ts → src/ui/switch/index.ts} +1 -0
  158. package/src/ui/switch/switch.stories.tsx +309 -0
  159. package/src/ui/switch/switch.test.tsx +243 -0
  160. package/src/ui/switch/switch.tokens.ts +89 -0
  161. package/src/ui/switch/switch.tsx +504 -0
  162. package/src/ui/switch/switch.types.ts +62 -0
  163. package/{dist/ui/tabs/index.d.ts → src/ui/tabs/index.ts} +8 -1
  164. package/src/ui/tabs/tab.tsx +407 -0
  165. package/src/ui/tabs/tabs-content.tsx +89 -0
  166. package/src/ui/tabs/tabs-list.tsx +146 -0
  167. package/src/ui/tabs/tabs.test.tsx +290 -0
  168. package/src/ui/tabs/tabs.tokens.ts +121 -0
  169. package/src/ui/tabs/tabs.tsx +229 -0
  170. package/src/ui/tabs/tabs.types.ts +185 -0
  171. package/{dist/ui/text-field/index.d.ts → src/ui/text-field/index.ts} +8 -1
  172. package/src/ui/text-field/subcomponents/active-indicator.tsx +67 -0
  173. package/src/ui/text-field/subcomponents/floating-label.tsx +161 -0
  174. package/src/ui/text-field/subcomponents/leading-icon.tsx +46 -0
  175. package/src/ui/text-field/subcomponents/outline-container.tsx +170 -0
  176. package/src/ui/text-field/subcomponents/prefix-suffix.tsx +59 -0
  177. package/src/ui/text-field/subcomponents/supporting-text.tsx +145 -0
  178. package/src/ui/text-field/subcomponents/trailing-icon.tsx +199 -0
  179. package/src/ui/text-field/text-field.test.tsx +454 -0
  180. package/src/ui/text-field/text-field.tokens.ts +104 -0
  181. package/src/ui/text-field/text-field.tsx +548 -0
  182. package/src/ui/text-field/text-field.types.ts +180 -0
  183. package/src/ui/theme-provider/index.tsx +215 -0
  184. package/src/ui/toc.test.tsx +108 -0
  185. package/src/ui/toc.tsx +172 -0
  186. package/src/ui/tooltip/plain-tooltip.tsx +63 -0
  187. package/src/ui/tooltip/rich-tooltip.tsx +94 -0
  188. package/src/ui/tooltip/tooltip-box.tsx +266 -0
  189. package/src/ui/tooltip/tooltip-caret-shape.tsx +68 -0
  190. package/src/ui/tooltip/tooltip.tokens.ts +26 -0
  191. package/src/ui/tooltip/tooltip.types.ts +70 -0
  192. package/src/ui/tooltip/use-tooltip-position.ts +208 -0
  193. package/src/ui/tooltip/use-tooltip-state.ts +41 -0
  194. package/src/ui/typography/__tests__/typography.test.tsx +170 -0
  195. package/{dist/ui/typography/index.d.ts → src/ui/typography/index.ts} +21 -3
  196. package/src/ui/typography/type-scale-tokens.ts +205 -0
  197. package/src/ui/typography/typography-key-tokens.ts +43 -0
  198. package/src/ui/typography/typography-tokens.ts +360 -0
  199. package/src/ui/typography/typography.css +22 -0
  200. package/src/ui/typography/typography.tsx +559 -0
  201. package/test-render.tsx +4 -0
  202. package/test-shadow.html +26 -0
  203. package/test_output.txt +164 -0
  204. package/test_output_v2.txt +5 -0
  205. package/tsconfig.build.json +10 -0
  206. package/tsconfig.json +18 -0
  207. package/tsup.config.ts +20 -0
  208. package/vitest.config.ts +11 -0
  209. package/dist/hooks/useClickOutside.d.ts +0 -8
  210. package/dist/hooks/useMediaQuery.d.ts +0 -11
  211. package/dist/hooks/useRipple.d.ts +0 -26
  212. package/dist/lib/material-symbols-preconnect.d.ts +0 -42
  213. package/dist/lib/theme-utils.d.ts +0 -63
  214. package/dist/lib/utils.d.ts +0 -2
  215. package/dist/types/index.d.ts +0 -1
  216. package/dist/types/md3.d.ts +0 -14
  217. package/dist/ui/app-bar/app-bar-column.d.ts +0 -28
  218. package/dist/ui/app-bar/app-bar-item-button.d.ts +0 -16
  219. package/dist/ui/app-bar/app-bar-overflow-indicator.d.ts +0 -18
  220. package/dist/ui/app-bar/app-bar-row.d.ts +0 -36
  221. package/dist/ui/app-bar/app-bar.tokens.d.ts +0 -184
  222. package/dist/ui/app-bar/app-bar.types.d.ts +0 -392
  223. package/dist/ui/app-bar/bottom-app-bar.d.ts +0 -31
  224. package/dist/ui/app-bar/docked-toolbar.d.ts +0 -25
  225. package/dist/ui/app-bar/hooks/use-app-bar-scroll.d.ts +0 -42
  226. package/dist/ui/app-bar/hooks/use-flexible-app-bar.d.ts +0 -37
  227. package/dist/ui/app-bar/large-flexible-app-bar.d.ts +0 -26
  228. package/dist/ui/app-bar/medium-flexible-app-bar.d.ts +0 -28
  229. package/dist/ui/app-bar/search-app-bar.d.ts +0 -43
  230. package/dist/ui/app-bar/search-view.d.ts +0 -54
  231. package/dist/ui/app-bar/small-app-bar.d.ts +0 -37
  232. package/dist/ui/badge.d.ts +0 -125
  233. package/dist/ui/button-group.d.ts +0 -59
  234. package/dist/ui/button.d.ts +0 -148
  235. package/dist/ui/card.d.ts +0 -62
  236. package/dist/ui/checkbox.d.ts +0 -82
  237. package/dist/ui/chip.d.ts +0 -110
  238. package/dist/ui/code-block.d.ts +0 -14
  239. package/dist/ui/dialog.d.ts +0 -111
  240. package/dist/ui/divider.d.ts +0 -164
  241. package/dist/ui/drawer.d.ts +0 -39
  242. package/dist/ui/dropdown.d.ts +0 -29
  243. package/dist/ui/fab-menu.d.ts +0 -204
  244. package/dist/ui/fab.d.ts +0 -162
  245. package/dist/ui/icon-button.d.ts +0 -131
  246. package/dist/ui/icon.d.ts +0 -88
  247. package/dist/ui/loading-indicator.d.ts +0 -42
  248. package/dist/ui/navigation-rail.d.ts +0 -29
  249. package/dist/ui/progress-indicator/circular.d.ts +0 -3
  250. package/dist/ui/progress-indicator/hooks.d.ts +0 -3
  251. package/dist/ui/progress-indicator/linear-flat.d.ts +0 -10
  252. package/dist/ui/progress-indicator/linear-wavy.d.ts +0 -18
  253. package/dist/ui/progress-indicator/linear.d.ts +0 -3
  254. package/dist/ui/progress-indicator/types.d.ts +0 -151
  255. package/dist/ui/progress-indicator/utils.d.ts +0 -3
  256. package/dist/ui/radio-button.d.ts +0 -106
  257. package/dist/ui/ripple.d.ts +0 -126
  258. package/dist/ui/scroll-area.d.ts +0 -27
  259. package/dist/ui/search/animated-placeholder.d.ts +0 -54
  260. package/dist/ui/search/hooks/use-search-keyboard.d.ts +0 -32
  261. package/dist/ui/search/hooks/use-search-view-focus.d.ts +0 -6
  262. package/dist/ui/search/index.d.ts +0 -27
  263. package/dist/ui/search/search-bar.d.ts +0 -32
  264. package/dist/ui/search/search-context.d.ts +0 -24
  265. package/dist/ui/search/search-view-docked.d.ts +0 -25
  266. package/dist/ui/search/search-view-fullscreen.d.ts +0 -36
  267. package/dist/ui/search/search.d.ts +0 -50
  268. package/dist/ui/search/search.tokens.d.ts +0 -112
  269. package/dist/ui/search/search.types.d.ts +0 -131
  270. package/dist/ui/search/trailing-action.d.ts +0 -9
  271. package/dist/ui/shared/constants.d.ts +0 -86
  272. package/dist/ui/slider/hooks/useSliderMath.d.ts +0 -101
  273. package/dist/ui/slider/range-slider.d.ts +0 -47
  274. package/dist/ui/slider/slider-thumb.d.ts +0 -33
  275. package/dist/ui/slider/slider-track.d.ts +0 -25
  276. package/dist/ui/slider/slider.d.ts +0 -60
  277. package/dist/ui/slider/slider.tokens.d.ts +0 -151
  278. package/dist/ui/slider/slider.types.d.ts +0 -259
  279. package/dist/ui/snackbar/index.d.ts +0 -6
  280. package/dist/ui/snackbar/snackbar.d.ts +0 -197
  281. package/dist/ui/switch/switch.d.ts +0 -30
  282. package/dist/ui/switch/switch.stories.d.ts +0 -48
  283. package/dist/ui/switch/switch.tokens.d.ts +0 -67
  284. package/dist/ui/switch/switch.types.d.ts +0 -59
  285. package/dist/ui/tabs/tab.d.ts +0 -43
  286. package/dist/ui/tabs/tabs-content.d.ts +0 -36
  287. package/dist/ui/tabs/tabs-list.d.ts +0 -40
  288. package/dist/ui/tabs/tabs.d.ts +0 -60
  289. package/dist/ui/tabs/tabs.tokens.d.ts +0 -94
  290. package/dist/ui/tabs/tabs.types.d.ts +0 -172
  291. package/dist/ui/text-field/subcomponents/active-indicator.d.ts +0 -24
  292. package/dist/ui/text-field/subcomponents/floating-label.d.ts +0 -43
  293. package/dist/ui/text-field/subcomponents/leading-icon.d.ts +0 -23
  294. package/dist/ui/text-field/subcomponents/outline-container.d.ts +0 -42
  295. package/dist/ui/text-field/subcomponents/prefix-suffix.d.ts +0 -24
  296. package/dist/ui/text-field/subcomponents/supporting-text.d.ts +0 -37
  297. package/dist/ui/text-field/subcomponents/trailing-icon.d.ts +0 -41
  298. package/dist/ui/text-field/text-field.d.ts +0 -49
  299. package/dist/ui/text-field/text-field.tokens.d.ts +0 -76
  300. package/dist/ui/text-field/text-field.types.d.ts +0 -126
  301. package/dist/ui/theme-provider/index.d.ts +0 -48
  302. package/dist/ui/toc.d.ts +0 -80
  303. package/dist/ui/tooltip/plain-tooltip.d.ts +0 -2
  304. package/dist/ui/tooltip/rich-tooltip.d.ts +0 -2
  305. package/dist/ui/tooltip/tooltip-box.d.ts +0 -2
  306. package/dist/ui/tooltip/tooltip-caret-shape.d.ts +0 -9
  307. package/dist/ui/tooltip/tooltip.tokens.d.ts +0 -26
  308. package/dist/ui/tooltip/tooltip.types.d.ts +0 -56
  309. package/dist/ui/tooltip/use-tooltip-position.d.ts +0 -8
  310. package/dist/ui/tooltip/use-tooltip-state.d.ts +0 -2
  311. package/dist/ui/typography/type-scale-tokens.d.ts +0 -162
  312. package/dist/ui/typography/typography-key-tokens.d.ts +0 -40
  313. package/dist/ui/typography/typography-tokens.d.ts +0 -220
  314. package/dist/ui/typography/typography.d.ts +0 -265
  315. /package/{dist/hooks/index.d.ts → src/hooks/index.ts} +0 -0
  316. /package/{dist/ui/tooltip/index.d.ts → src/ui/tooltip/index.ts} +0 -0
@@ -0,0 +1,551 @@
1
+ /**
2
+ * @file radio-button.tsx
3
+ * MD3 Expressive RadioButton — single-select with RadioGroup support.
4
+ * Spec: https://m3.material.io/components/radio-button/overview
5
+ */
6
+
7
+ import { domMax, LazyMotion, m, useReducedMotion } from "motion/react";
8
+ import * as React from "react";
9
+ import { cn } from "../lib/utils";
10
+ import { Ripple, type RippleOrigin } from "./ripple";
11
+
12
+ // ─── Constants ────────────────────────────────────────────────────────────────
13
+
14
+ /** MD3 FastEffects easing: dot grow (emphasizedAccelerate). */
15
+ const MD3_FAST_EFFECTS = [0.3, 0, 1, 1] as const;
16
+
17
+ // ─── Types ────────────────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Color variant for `RadioButton`.
21
+ * - `"primary"` — standard selection (default)
22
+ * - `"error"` — error/invalid state
23
+ */
24
+ export type RadioButtonColors = "primary" | "error";
25
+
26
+ /** Props for `RadioButton`. */
27
+ export interface RadioButtonProps {
28
+ /** Whether this radio is selected. */
29
+ selected?: boolean;
30
+ /** Initial selected state (uncontrolled). @default false */
31
+ defaultSelected?: boolean;
32
+ /** Called when user clicks. Pass `null` to disable interaction. */
33
+ onClick?: (() => void) | null;
34
+ /** Disables the radio — visual disabled state + no interaction. @default false */
35
+ disabled?: boolean;
36
+ /** Color variant. @default "primary" */
37
+ color?: RadioButtonColors;
38
+ /** Error state — changes colors to `m3-error`. @default false */
39
+ error?: boolean;
40
+ /** Adjacent label text. Renders a `<label>` wrapper. */
41
+ label?: string;
42
+ /** Value used for form submission. */
43
+ value?: string;
44
+ /** Name for grouping (used in RadioGroup context). */
45
+ name?: string;
46
+ /** ID for the hidden `<input>`. Auto-generated when `label` is set. */
47
+ id?: string;
48
+ /** Extra class names on the outermost wrapper. */
49
+ className?: string;
50
+ /** ARIA label for the radio when no visible label exists. */
51
+ "aria-label"?: string;
52
+ "aria-labelledby"?: string;
53
+ "aria-describedby"?: string;
54
+ /** Whether the radio is required for form submission. */
55
+ required?: boolean;
56
+ /** Ref to the hidden `<input type="radio">`. */
57
+ ref?: React.Ref<HTMLInputElement>;
58
+ }
59
+
60
+ /** Props for `RadioGroup`. */
61
+ export interface RadioGroupProps {
62
+ /** The name attribute shared across all child RadioButtons. */
63
+ name: string;
64
+ /** The currently selected value (controlled). */
65
+ value?: string;
66
+ /** Default value (uncontrolled). */
67
+ defaultValue?: string;
68
+ /** Called when selection changes. */
69
+ onValueChange?: (value: string) => void;
70
+ /** Disables all radio buttons in the group. */
71
+ disabled?: boolean;
72
+ /** Error state for the entire group. */
73
+ error?: boolean;
74
+ /** Label for the group (renders as visually hidden or visible heading). */
75
+ label?: string;
76
+ /** ID of an external element that labels this group. */
77
+ "aria-labelledby"?: string;
78
+ /** Direction of layout. @default "vertical" */
79
+ orientation?: "horizontal" | "vertical";
80
+ /** Whether at least one radio in the group must be selected. */
81
+ required?: boolean;
82
+ children: React.ReactNode;
83
+ className?: string;
84
+ }
85
+
86
+ // ─── Context ──────────────────────────────────────────────────────────────────
87
+
88
+ interface RadioGroupContextValue {
89
+ name: string;
90
+ selectedValue: string | undefined;
91
+ onValueChange: (value: string) => void;
92
+ disabled: boolean;
93
+ error: boolean;
94
+ required: boolean;
95
+ }
96
+
97
+ const RadioGroupContext = React.createContext<RadioGroupContextValue | null>(
98
+ null,
99
+ );
100
+
101
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
102
+
103
+ /** Merges external + internal refs into a single callback ref. @internal */
104
+ function useMergedRef<T>(
105
+ externalRef: React.Ref<T> | undefined,
106
+ internalRef: React.RefObject<T | null>,
107
+ ): React.RefCallback<T> {
108
+ return React.useCallback(
109
+ (node: T | null) => {
110
+ (internalRef as React.MutableRefObject<T | null>).current = node;
111
+ if (!externalRef) return;
112
+ if (typeof externalRef === "function") {
113
+ externalRef(node);
114
+ } else {
115
+ (externalRef as React.MutableRefObject<T | null>).current = node;
116
+ }
117
+ },
118
+ [externalRef, internalRef],
119
+ );
120
+ }
121
+
122
+ // ─── RadioVisual ──────────────────────────────────────────────────────────────
123
+
124
+ interface RadioVisualProps {
125
+ isSelected: boolean;
126
+ disabled: boolean;
127
+ error: boolean;
128
+ isHovered: boolean;
129
+ prefersReduced: boolean;
130
+ }
131
+
132
+ /**
133
+ * Animated 20×20dp radio circle (outer ring + inner dot). @internal
134
+ * Uses `m.circle` for Framer Motion SVG animation.
135
+ */
136
+ const RadioVisual = React.memo(function RadioVisual({
137
+ isSelected,
138
+ disabled,
139
+ error,
140
+ isHovered,
141
+ prefersReduced,
142
+ }: RadioVisualProps) {
143
+ const accentColor = error
144
+ ? "var(--color-m3-error)"
145
+ : "var(--color-m3-primary)";
146
+
147
+ const disabledColor = "rgba(0, 0, 0, 0.38)";
148
+
149
+ const outerStroke = disabled
150
+ ? disabledColor
151
+ : isSelected
152
+ ? accentColor
153
+ : isHovered
154
+ ? "var(--color-m3-on-surface)"
155
+ : "var(--color-m3-on-surface-variant)";
156
+
157
+ const dotFill = disabled
158
+ ? disabledColor
159
+ : isSelected
160
+ ? accentColor
161
+ : "rgba(0, 0, 0, 0)";
162
+
163
+ const ringDuration = prefersReduced ? 0 : 0.15;
164
+ const dotDuration = prefersReduced ? 0 : isSelected ? 0.2 : 0.1;
165
+ const dotEase = isSelected ? MD3_FAST_EFFECTS : ("easeOut" as const);
166
+
167
+ return (
168
+ <svg
169
+ viewBox="0 0 20 20"
170
+ width={20}
171
+ height={20}
172
+ fill="none"
173
+ aria-hidden="true"
174
+ >
175
+ <m.circle
176
+ cx={10}
177
+ cy={10}
178
+ r={9}
179
+ strokeWidth={2}
180
+ fill="none"
181
+ animate={{ stroke: outerStroke }}
182
+ transition={{ duration: ringDuration, ease: "easeOut" }}
183
+ />
184
+ <m.circle
185
+ cx={10}
186
+ cy={10}
187
+ initial={{ r: 0 }}
188
+ animate={{ r: isSelected ? 5 : 0, fill: dotFill }}
189
+ transition={{
190
+ r: { duration: dotDuration, ease: dotEase },
191
+ fill: { duration: ringDuration, ease: "easeOut" },
192
+ }}
193
+ stroke="none"
194
+ />
195
+ </svg>
196
+ );
197
+ });
198
+
199
+ // ─── RadioButton ──────────────────────────────────────────────────────────────
200
+
201
+ const RadioButtonComponent = React.forwardRef<
202
+ HTMLInputElement,
203
+ RadioButtonProps
204
+ >(
205
+ (
206
+ {
207
+ selected,
208
+ defaultSelected = false,
209
+ onClick,
210
+ disabled: disabledProp = false,
211
+ color,
212
+ error: errorProp = false,
213
+ label,
214
+ value,
215
+ name: nameProp,
216
+ id: idProp,
217
+ className,
218
+ "aria-label": ariaLabel,
219
+ "aria-labelledby": ariaLabelledby,
220
+ "aria-describedby": ariaDescribedby,
221
+ required: requiredProp,
222
+ },
223
+ ref,
224
+ ) => {
225
+ const group = React.useContext(RadioGroupContext);
226
+ const prefersReduced = useReducedMotion() ?? false;
227
+
228
+ const generatedId = React.useId();
229
+ const inputId = idProp ?? (label ? `radio-${generatedId}` : undefined);
230
+
231
+ const name = group?.name ?? nameProp;
232
+ const disabled = group?.disabled || disabledProp;
233
+ const error = group?.error || errorProp || color === "error";
234
+ const required = group?.required || requiredProp;
235
+
236
+ const [internalSelected, setInternalSelected] =
237
+ React.useState(defaultSelected);
238
+
239
+ const isControlled = selected !== undefined;
240
+ const isSelected: boolean = group
241
+ ? group.selectedValue === value
242
+ : isControlled
243
+ ? (selected ?? false)
244
+ : internalSelected;
245
+
246
+ const [ripples, setRipples] = React.useState<RippleOrigin[]>([]);
247
+ const removeRipple = React.useCallback(
248
+ (id: number) => setRipples((prev) => prev.filter((r) => r.id !== id)),
249
+ [],
250
+ );
251
+
252
+ const onPointerDown = React.useCallback(
253
+ (e: React.PointerEvent<HTMLElement>) => {
254
+ if (disabled) return;
255
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
256
+ const x = e.clientX - rect.left - 4;
257
+ const y = e.clientY - rect.top - 4;
258
+ const rippleSize = Math.hypot(40, 40) * 2;
259
+ setRipples((prev) => [
260
+ ...prev,
261
+ { id: Date.now(), x, y, size: rippleSize },
262
+ ]);
263
+ },
264
+ [disabled],
265
+ );
266
+
267
+ const [isHovered, setIsHovered] = React.useState(false);
268
+ const onPointerEnter = React.useCallback(() => {
269
+ if (!disabled) setIsHovered(true);
270
+ }, [disabled]);
271
+ const onPointerLeave = React.useCallback(() => setIsHovered(false), []);
272
+
273
+ const handleChange = React.useCallback(
274
+ (_e: React.ChangeEvent<HTMLInputElement>) => {
275
+ if (disabled || onClick === null) return;
276
+
277
+ if (group) {
278
+ if (value !== undefined) group.onValueChange(value);
279
+ } else if (!isControlled) {
280
+ setInternalSelected(true);
281
+ onClick?.();
282
+ } else {
283
+ onClick?.();
284
+ }
285
+ },
286
+ [disabled, onClick, group, value, isControlled],
287
+ );
288
+
289
+ const inputRef = React.useRef<HTMLInputElement>(null);
290
+ const mergedRef = useMergedRef(ref, inputRef);
291
+
292
+ const stateLayerBg = error ? "before:bg-m3-error" : "before:bg-m3-primary";
293
+
294
+ const stateLayerClass = cn(
295
+ "before:absolute before:inset-0 before:rounded-full before:pointer-events-none",
296
+ "before:transition-opacity before:duration-150 before:opacity-0",
297
+ "group-hover/radio:before:opacity-[0.08]",
298
+ "group-focus-within/radio:before:opacity-[0.10]",
299
+ "group-active/radio:before:opacity-[0.10]",
300
+ stateLayerBg,
301
+ );
302
+
303
+ const touchTargetClass = cn(
304
+ "relative inline-flex items-center justify-center outline-none shrink-0",
305
+ "w-12 h-12 group/radio cursor-pointer",
306
+ disabled && "pointer-events-none",
307
+ );
308
+
309
+ const stateLayerAndRipple = (
310
+ <div
311
+ className={cn(
312
+ "absolute flex items-center justify-center w-10 h-10 m-auto inset-0 rounded-full overflow-hidden pointer-events-none",
313
+ stateLayerClass,
314
+ )}
315
+ aria-hidden="true"
316
+ >
317
+ <Ripple
318
+ ripples={ripples}
319
+ onRippleDone={removeRipple}
320
+ disabled={disabled}
321
+ />
322
+ </div>
323
+ );
324
+
325
+ const hiddenInput = (
326
+ <input
327
+ ref={mergedRef}
328
+ type="radio"
329
+ id={inputId}
330
+ name={name}
331
+ value={value}
332
+ checked={isSelected}
333
+ disabled={disabled}
334
+ aria-disabled={disabled || undefined}
335
+ aria-label={ariaLabel}
336
+ aria-labelledby={ariaLabelledby}
337
+ aria-describedby={ariaDescribedby}
338
+ required={required}
339
+ onChange={handleChange}
340
+ className="sr-only"
341
+ />
342
+ );
343
+
344
+ const visual = (
345
+ <RadioVisual
346
+ isSelected={isSelected}
347
+ disabled={disabled}
348
+ error={error}
349
+ isHovered={isHovered}
350
+ prefersReduced={prefersReduced}
351
+ />
352
+ );
353
+
354
+ if (label) {
355
+ return (
356
+ <LazyMotion features={domMax} strict>
357
+ <label
358
+ htmlFor={inputId}
359
+ className={cn(
360
+ "inline-flex items-center gap-2 cursor-pointer select-none",
361
+ disabled &&
362
+ "cursor-not-allowed opacity-[0.38] pointer-events-none",
363
+ className,
364
+ )}
365
+ >
366
+ <div
367
+ className={touchTargetClass}
368
+ onPointerDown={onPointerDown}
369
+ onPointerEnter={onPointerEnter}
370
+ onPointerLeave={onPointerLeave}
371
+ >
372
+ {stateLayerAndRipple}
373
+ {hiddenInput}
374
+ {visual}
375
+ </div>
376
+ <span className="text-sm leading-none text-m3-on-surface">
377
+ {label}
378
+ </span>
379
+ </label>
380
+ </LazyMotion>
381
+ );
382
+ }
383
+
384
+ return (
385
+ <LazyMotion features={domMax} strict>
386
+ <label
387
+ htmlFor={inputId}
388
+ className={cn(touchTargetClass, className)}
389
+ onPointerDown={onPointerDown}
390
+ onPointerEnter={onPointerEnter}
391
+ onPointerLeave={onPointerLeave}
392
+ >
393
+ {stateLayerAndRipple}
394
+ {hiddenInput}
395
+ {visual}
396
+ </label>
397
+ </LazyMotion>
398
+ );
399
+ },
400
+ );
401
+
402
+ RadioButtonComponent.displayName = "RadioButton";
403
+
404
+ /**
405
+ * MD3 Expressive RadioButton component.
406
+ *
407
+ * Single-select control. Supports standalone (controlled/uncontrolled) and
408
+ * `RadioGroup` context. Animated per MD3 spec: inner dot radius morph,
409
+ * outer ring color transition, state layer, and ripple.
410
+ *
411
+ * @example
412
+ * ```tsx
413
+ * <RadioButton selected={isSelected} onClick={() => setSelected(true)} label="Option A" />
414
+ *
415
+ * <RadioGroup name="plan" value={plan} onValueChange={setPlan}>
416
+ * <RadioButton value="free" label="Free" />
417
+ * <RadioButton value="pro" label="Pro" />
418
+ * </RadioGroup>
419
+ * ```
420
+ * @see https://m3.material.io/components/radio-button/overview
421
+ */
422
+ export const RadioButton = React.memo(RadioButtonComponent);
423
+
424
+ // ─── RadioGroup ────────────────────────────────────────────────────────────────
425
+
426
+ const RadioGroupComponent = React.forwardRef<HTMLDivElement, RadioGroupProps>(
427
+ (
428
+ {
429
+ name,
430
+ value: valueProp,
431
+ defaultValue,
432
+ onValueChange,
433
+ disabled = false,
434
+ error = false,
435
+ label,
436
+ "aria-labelledby": ariaLabelledby,
437
+ required = false,
438
+ orientation = "vertical",
439
+ children,
440
+ className,
441
+ },
442
+ ref,
443
+ ) => {
444
+ const [internalValue, setInternalValue] = React.useState<
445
+ string | undefined
446
+ >(defaultValue);
447
+ const isControlled = valueProp !== undefined;
448
+ const selectedValue = isControlled ? valueProp : internalValue;
449
+
450
+ const handleValueChange = React.useCallback(
451
+ (val: string) => {
452
+ if (!isControlled) setInternalValue(val);
453
+ onValueChange?.(val);
454
+ },
455
+ [isControlled, onValueChange],
456
+ );
457
+
458
+ const groupRef = React.useRef<HTMLDivElement>(null);
459
+ const mergedRef = useMergedRef(ref, groupRef);
460
+
461
+ const onKeyDown = React.useCallback(
462
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
463
+ if (disabled) return;
464
+
465
+ const isNext = e.key === "ArrowDown" || e.key === "ArrowRight";
466
+ const isPrev = e.key === "ArrowUp" || e.key === "ArrowLeft";
467
+ if (!isNext && !isPrev) return;
468
+
469
+ e.preventDefault();
470
+
471
+ const inputs = Array.from(
472
+ groupRef.current?.querySelectorAll<HTMLInputElement>(
473
+ 'input[type="radio"]:not(:disabled)',
474
+ ) ?? [],
475
+ );
476
+ if (inputs.length === 0) return;
477
+
478
+ const currentIdx = inputs.indexOf(
479
+ document.activeElement as HTMLInputElement,
480
+ );
481
+
482
+ const nextIdx = isNext
483
+ ? currentIdx < inputs.length - 1
484
+ ? currentIdx + 1
485
+ : 0
486
+ : currentIdx > 0
487
+ ? currentIdx - 1
488
+ : inputs.length - 1;
489
+
490
+ const target = inputs[nextIdx];
491
+ target.focus();
492
+ handleValueChange(target.value);
493
+ },
494
+ [disabled, handleValueChange],
495
+ );
496
+
497
+ const contextValue = React.useMemo<RadioGroupContextValue>(
498
+ () => ({
499
+ name,
500
+ selectedValue,
501
+ onValueChange: handleValueChange,
502
+ disabled,
503
+ error,
504
+ required,
505
+ }),
506
+ [name, selectedValue, handleValueChange, disabled, error, required],
507
+ );
508
+
509
+ return (
510
+ <RadioGroupContext.Provider value={contextValue}>
511
+ <div
512
+ ref={mergedRef}
513
+ role="radiogroup"
514
+ aria-label={label && !ariaLabelledby ? label : undefined}
515
+ aria-labelledby={ariaLabelledby}
516
+ aria-disabled={disabled || undefined}
517
+ aria-required={required || undefined}
518
+ className={cn(
519
+ "flex",
520
+ orientation === "horizontal" ? "flex-row gap-4" : "flex-col gap-1",
521
+ className,
522
+ )}
523
+ onKeyDown={onKeyDown}
524
+ >
525
+ {label && !ariaLabelledby && <span className="sr-only">{label}</span>}
526
+ {children}
527
+ </div>
528
+ </RadioGroupContext.Provider>
529
+ );
530
+ },
531
+ );
532
+
533
+ RadioGroupComponent.displayName = "RadioGroup";
534
+
535
+ /**
536
+ * MD3 Expressive RadioGroup component.
537
+ *
538
+ * Groups multiple `RadioButton` components under a shared `name` with keyboard
539
+ * navigation (Arrow keys with wrapping) and ARIA `radiogroup` semantics.
540
+ *
541
+ * @example
542
+ * ```tsx
543
+ * <RadioGroup name="theme" value={theme} onValueChange={setTheme} label="Theme">
544
+ * <RadioButton value="light" label="Light" />
545
+ * <RadioButton value="dark" label="Dark" />
546
+ * <RadioButton value="system" label="System" />
547
+ * </RadioGroup>
548
+ * ```
549
+ * @see https://m3.material.io/components/radio-button/overview
550
+ */
551
+ export const RadioGroup = React.memo(RadioGroupComponent);
@@ -0,0 +1,72 @@
1
+ import { render } from "@testing-library/react";
2
+ import * as MotionReact from "motion/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { Ripple } from "./ripple";
5
+
6
+ // Top-level mock is required by vitest (hoisted before any test runs)
7
+ vi.mock("motion/react", async (importOriginal) => {
8
+ const actual = await importOriginal<typeof import("motion/react")>();
9
+ return {
10
+ ...actual,
11
+ // Default: motion is allowed
12
+ useReducedMotion: () => false,
13
+ };
14
+ });
15
+
16
+ describe("Ripple Component", () => {
17
+ afterEach(() => {
18
+ vi.restoreAllMocks();
19
+ });
20
+
21
+ describe("when motion is allowed (default)", () => {
22
+ it("renders nothing without ripples passed in", () => {
23
+ const { container } = render(
24
+ <Ripple ripples={[]} onRippleDone={vi.fn()} />,
25
+ );
26
+ expect(container.textContent).toBe("");
27
+ });
28
+
29
+ it("renders ripple elements when provided", () => {
30
+ const ripples = [{ id: 1, x: 10, y: 10, size: 50 }];
31
+ const { container } = render(
32
+ <Ripple ripples={ripples} onRippleDone={vi.fn()} />,
33
+ );
34
+
35
+ const span = container.querySelector("span");
36
+ expect(span).toBeInTheDocument();
37
+ expect(span).toHaveAttribute("aria-hidden", "true");
38
+ expect(span?.style.left).toBe("-15px"); // x(10) - size(50)/2
39
+ expect(span?.style.top).toBe("-15px"); // y(10) - size(50)/2
40
+ expect(span?.style.width).toBe("50px");
41
+ expect(span?.style.height).toBe("50px");
42
+ });
43
+
44
+ it("marks ripple span as aria-hidden for screen readers", () => {
45
+ const ripples = [{ id: 2, x: 0, y: 0, size: 40 }];
46
+ const { container } = render(
47
+ <Ripple ripples={ripples} onRippleDone={vi.fn()} />,
48
+ );
49
+ expect(container.querySelector("span")).toHaveAttribute(
50
+ "aria-hidden",
51
+ "true",
52
+ );
53
+ });
54
+ });
55
+
56
+ // ── A11y: prefers-reduced-motion ──────────────────────────────────────────
57
+
58
+ describe("when prefers-reduced-motion is active", () => {
59
+ it("renders nothing even when ripples are provided", () => {
60
+ // Override the mocked hook for this single test via spyOn
61
+ vi.spyOn(MotionReact, "useReducedMotion").mockReturnValue(true);
62
+
63
+ const ripples = [{ id: 99, x: 5, y: 5, size: 30 }];
64
+ const { container } = render(
65
+ <Ripple ripples={ripples} onRippleDone={vi.fn()} />,
66
+ );
67
+
68
+ // When useReducedMotion returns true, Ripple renders null — no spans
69
+ expect(container.querySelector("span")).toBeNull();
70
+ });
71
+ });
72
+ });