@bug-on/md3-react 2.0.2 → 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 (296) hide show
  1. package/.turbo/turbo-build.log +33 -0
  2. package/CHANGELOG.md +55 -0
  3. package/dist/index.css +23 -0
  4. package/dist/index.css.d.ts +2 -0
  5. package/dist/index.d.mts +6127 -0
  6. package/dist/index.d.ts +6127 -69
  7. package/dist/index.js +2536 -665
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2443 -603
  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/typography.css.d.ts +2 -0
  14. package/package.json +23 -19
  15. package/scripts/copy-assets.js +82 -0
  16. package/src/assets/fonts/GoogleSansFlex-VariableFont.woff2 +0 -0
  17. package/src/assets/fonts/MaterialSymbolsOutlined-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  18. package/src/assets/fonts/MaterialSymbolsRounded-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  19. package/src/assets/fonts/MaterialSymbolsSharp-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  20. package/src/assets/loading-indicator.svg +19 -0
  21. package/src/assets/material-symbols-cdn.css +65 -0
  22. package/src/assets/material-symbols-self-hosted.css +90 -0
  23. package/src/css.d.ts +20 -0
  24. package/{dist/hooks/index.d.ts → src/hooks/index.ts} +1 -0
  25. package/src/hooks/useClickOutside.ts +37 -0
  26. package/src/hooks/useMediaQuery.ts +28 -0
  27. package/src/hooks/useRipple.ts +88 -0
  28. package/src/index.css +23 -0
  29. package/src/index.ts +349 -0
  30. package/src/lib/material-symbols-preconnect.tsx +82 -0
  31. package/src/lib/theme-utils.ts +180 -0
  32. package/src/lib/utils.ts +6 -0
  33. package/src/test/button.test.tsx +59 -0
  34. package/src/test/icon.test.tsx +91 -0
  35. package/src/test/loading-indicator.test.tsx +128 -0
  36. package/src/test/progress-indicator.test.tsx +306 -0
  37. package/src/test/setup.ts +80 -0
  38. package/src/test/typography.test.tsx +206 -0
  39. package/src/types/index.ts +7 -0
  40. package/src/types/md3.ts +31 -0
  41. package/src/ui/Text.tsx +60 -0
  42. package/src/ui/__snapshots__/divider.test.tsx.snap +63 -0
  43. package/src/ui/app-bar/app-bar-column.tsx +99 -0
  44. package/src/ui/app-bar/app-bar-item-button.tsx +71 -0
  45. package/src/ui/app-bar/app-bar-items.test.tsx +89 -0
  46. package/src/ui/app-bar/app-bar-overflow-indicator.tsx +108 -0
  47. package/src/ui/app-bar/app-bar-row.tsx +104 -0
  48. package/src/ui/app-bar/app-bar.test.tsx +87 -0
  49. package/src/ui/app-bar/app-bar.tokens.ts +223 -0
  50. package/src/ui/app-bar/app-bar.types.ts +441 -0
  51. package/src/ui/app-bar/bottom-app-bar.test.tsx +42 -0
  52. package/src/ui/app-bar/bottom-app-bar.tsx +84 -0
  53. package/src/ui/app-bar/docked-toolbar.test.tsx +34 -0
  54. package/src/ui/app-bar/docked-toolbar.tsx +54 -0
  55. package/src/ui/app-bar/flexible-app-bar.test.tsx +75 -0
  56. package/src/ui/app-bar/hooks/use-app-bar-scroll.ts +110 -0
  57. package/src/ui/app-bar/hooks/use-flexible-app-bar.ts +123 -0
  58. package/{dist/ui/app-bar/index.d.ts → src/ui/app-bar/index.ts} +35 -2
  59. package/src/ui/app-bar/large-flexible-app-bar.tsx +165 -0
  60. package/src/ui/app-bar/medium-flexible-app-bar.tsx +167 -0
  61. package/src/ui/app-bar/search-app-bar.test.tsx +49 -0
  62. package/src/ui/app-bar/search-app-bar.tsx +176 -0
  63. package/src/ui/app-bar/search-view.tsx +227 -0
  64. package/src/ui/app-bar/small-app-bar.test.tsx +48 -0
  65. package/src/ui/app-bar/small-app-bar.tsx +203 -0
  66. package/src/ui/badge.test.tsx +345 -0
  67. package/src/ui/badge.tsx +282 -0
  68. package/src/ui/button-group.test.tsx +71 -0
  69. package/src/ui/button-group.tsx +350 -0
  70. package/src/ui/button.test.tsx +297 -0
  71. package/src/ui/button.tsx +669 -0
  72. package/src/ui/card.test.tsx +187 -0
  73. package/src/ui/card.tsx +259 -0
  74. package/src/ui/checkbox.test.tsx +423 -0
  75. package/src/ui/checkbox.tsx +525 -0
  76. package/src/ui/chip.test.tsx +292 -0
  77. package/src/ui/chip.tsx +548 -0
  78. package/src/ui/code-block.tsx +219 -0
  79. package/src/ui/dialog.test.tsx +300 -0
  80. package/src/ui/dialog.tsx +384 -0
  81. package/src/ui/divider.test.tsx +314 -0
  82. package/src/ui/divider.tsx +412 -0
  83. package/src/ui/drawer.tsx +240 -0
  84. package/src/ui/fab-menu.test.tsx +494 -0
  85. package/src/ui/fab-menu.tsx +739 -0
  86. package/src/ui/fab.test.tsx +232 -0
  87. package/src/ui/fab.tsx +505 -0
  88. package/src/ui/icon-button.test.tsx +515 -0
  89. package/src/ui/icon-button.tsx +525 -0
  90. package/src/ui/icon.test.tsx +197 -0
  91. package/src/ui/icon.tsx +179 -0
  92. package/src/ui/loading-indicator.test.tsx +73 -0
  93. package/src/ui/loading-indicator.tsx +312 -0
  94. package/src/ui/menu/context-menu.tsx +275 -0
  95. package/src/ui/menu/index.ts +77 -0
  96. package/src/ui/menu/menu-animations.ts +102 -0
  97. package/src/ui/menu/menu-context.tsx +99 -0
  98. package/src/ui/menu/menu-divider.tsx +47 -0
  99. package/src/ui/menu/menu-group.tsx +200 -0
  100. package/src/ui/menu/menu-item.tsx +294 -0
  101. package/src/ui/menu/menu-tokens.ts +208 -0
  102. package/src/ui/menu/menu-types.ts +313 -0
  103. package/src/ui/menu/menu.test.tsx +624 -0
  104. package/src/ui/menu/menu.tsx +289 -0
  105. package/src/ui/menu/sub-menu.tsx +223 -0
  106. package/src/ui/menu/vertical-menu.tsx +382 -0
  107. package/src/ui/navigation-rail.test.tsx +404 -0
  108. package/src/ui/navigation-rail.tsx +604 -0
  109. package/src/ui/progress-indicator/circular.tsx +248 -0
  110. package/src/ui/progress-indicator/hooks.ts +51 -0
  111. package/{dist/ui/progress-indicator/index.d.ts → src/ui/progress-indicator/index.tsx} +20 -2
  112. package/src/ui/progress-indicator/linear-flat.tsx +83 -0
  113. package/src/ui/progress-indicator/linear-wavy.tsx +243 -0
  114. package/src/ui/progress-indicator/linear.tsx +143 -0
  115. package/src/ui/progress-indicator/types.ts +158 -0
  116. package/src/ui/progress-indicator/utils.ts +73 -0
  117. package/src/ui/radio-button.test.tsx +407 -0
  118. package/src/ui/radio-button.tsx +551 -0
  119. package/src/ui/ripple.test.tsx +72 -0
  120. package/src/ui/ripple.tsx +234 -0
  121. package/src/ui/scroll-area.test.tsx +58 -0
  122. package/src/ui/scroll-area.tsx +139 -0
  123. package/src/ui/search/animated-placeholder.tsx +145 -0
  124. package/src/ui/search/hooks/use-search-keyboard.test.ts +202 -0
  125. package/src/ui/search/hooks/use-search-keyboard.ts +104 -0
  126. package/src/ui/search/hooks/use-search-view-focus.test.ts +96 -0
  127. package/src/ui/search/hooks/use-search-view-focus.ts +24 -0
  128. package/src/ui/search/index.ts +44 -0
  129. package/src/ui/search/search-bar.tsx +220 -0
  130. package/src/ui/search/search-context.tsx +42 -0
  131. package/src/ui/search/search-view-docked.tsx +194 -0
  132. package/src/ui/search/search-view-fullscreen.tsx +247 -0
  133. package/src/ui/search/search.test.tsx +233 -0
  134. package/src/ui/search/search.tokens.ts +134 -0
  135. package/src/ui/search/search.tsx +131 -0
  136. package/src/ui/search/search.types.ts +154 -0
  137. package/src/ui/search/trailing-action.tsx +49 -0
  138. package/src/ui/shared/constants.ts +122 -0
  139. package/{dist/ui/shared/touch-target.d.ts → src/ui/shared/touch-target.tsx} +13 -1
  140. package/src/ui/slider/hooks/useSliderMath.ts +195 -0
  141. package/{dist/ui/slider/index.d.ts → src/ui/slider/index.ts} +12 -1
  142. package/src/ui/slider/range-slider.tsx +561 -0
  143. package/src/ui/slider/slider-thumb.tsx +379 -0
  144. package/src/ui/slider/slider-track.tsx +912 -0
  145. package/src/ui/slider/slider.tokens.ts +189 -0
  146. package/src/ui/slider/slider.tsx +259 -0
  147. package/src/ui/slider/slider.types.ts +288 -0
  148. package/src/ui/snackbar/index.ts +20 -0
  149. package/src/ui/snackbar/snackbar.test.tsx +338 -0
  150. package/src/ui/snackbar/snackbar.tsx +476 -0
  151. package/{dist/ui/switch/index.d.ts → src/ui/switch/index.ts} +1 -0
  152. package/src/ui/switch/switch.stories.tsx +309 -0
  153. package/src/ui/switch/switch.test.tsx +243 -0
  154. package/src/ui/switch/switch.tokens.ts +89 -0
  155. package/src/ui/switch/switch.tsx +504 -0
  156. package/src/ui/switch/switch.types.ts +62 -0
  157. package/{dist/ui/tabs/index.d.ts → src/ui/tabs/index.ts} +8 -1
  158. package/src/ui/tabs/tab.tsx +407 -0
  159. package/src/ui/tabs/tabs-content.tsx +89 -0
  160. package/src/ui/tabs/tabs-list.tsx +146 -0
  161. package/src/ui/tabs/tabs.test.tsx +290 -0
  162. package/src/ui/tabs/tabs.tokens.ts +121 -0
  163. package/src/ui/tabs/tabs.tsx +229 -0
  164. package/src/ui/tabs/tabs.types.ts +185 -0
  165. package/{dist/ui/text-field/index.d.ts → src/ui/text-field/index.ts} +8 -1
  166. package/src/ui/text-field/subcomponents/active-indicator.tsx +67 -0
  167. package/src/ui/text-field/subcomponents/floating-label.tsx +161 -0
  168. package/src/ui/text-field/subcomponents/leading-icon.tsx +46 -0
  169. package/src/ui/text-field/subcomponents/outline-container.tsx +170 -0
  170. package/src/ui/text-field/subcomponents/prefix-suffix.tsx +59 -0
  171. package/src/ui/text-field/subcomponents/supporting-text.tsx +145 -0
  172. package/src/ui/text-field/subcomponents/trailing-icon.tsx +199 -0
  173. package/src/ui/text-field/text-field.test.tsx +454 -0
  174. package/src/ui/text-field/text-field.tokens.ts +104 -0
  175. package/src/ui/text-field/text-field.tsx +548 -0
  176. package/src/ui/text-field/text-field.types.ts +180 -0
  177. package/src/ui/theme-provider/index.tsx +190 -0
  178. package/src/ui/toc.test.tsx +108 -0
  179. package/src/ui/toc.tsx +172 -0
  180. package/src/ui/tooltip/plain-tooltip.tsx +63 -0
  181. package/src/ui/tooltip/rich-tooltip.tsx +94 -0
  182. package/src/ui/tooltip/tooltip-box.tsx +266 -0
  183. package/src/ui/tooltip/tooltip-caret-shape.tsx +68 -0
  184. package/src/ui/tooltip/tooltip.tokens.ts +26 -0
  185. package/src/ui/tooltip/tooltip.types.ts +70 -0
  186. package/src/ui/tooltip/use-tooltip-position.ts +208 -0
  187. package/src/ui/tooltip/use-tooltip-state.ts +41 -0
  188. package/src/ui/typography/__tests__/typography.test.tsx +170 -0
  189. package/{dist/ui/typography/index.d.ts → src/ui/typography/index.ts} +21 -3
  190. package/src/ui/typography/type-scale-tokens.ts +205 -0
  191. package/src/ui/typography/typography-key-tokens.ts +43 -0
  192. package/src/ui/typography/typography-tokens.ts +360 -0
  193. package/src/ui/typography/typography.css +22 -0
  194. package/src/ui/typography/typography.tsx +559 -0
  195. package/test-render.tsx +4 -0
  196. package/test-shadow.html +26 -0
  197. package/test_output.txt +164 -0
  198. package/test_output_v2.txt +5 -0
  199. package/tsconfig.build.json +10 -0
  200. package/tsconfig.json +18 -0
  201. package/tsup.config.ts +20 -0
  202. package/vitest.config.ts +11 -0
  203. package/dist/hooks/useMediaQuery.d.ts +0 -11
  204. package/dist/hooks/useRipple.d.ts +0 -26
  205. package/dist/lib/material-symbols-preconnect.d.ts +0 -42
  206. package/dist/lib/theme-utils.d.ts +0 -63
  207. package/dist/lib/utils.d.ts +0 -2
  208. package/dist/types/index.d.ts +0 -1
  209. package/dist/types/md3.d.ts +0 -14
  210. package/dist/ui/app-bar/app-bar-column.d.ts +0 -28
  211. package/dist/ui/app-bar/app-bar-item-button.d.ts +0 -16
  212. package/dist/ui/app-bar/app-bar-overflow-indicator.d.ts +0 -18
  213. package/dist/ui/app-bar/app-bar-row.d.ts +0 -36
  214. package/dist/ui/app-bar/app-bar.tokens.d.ts +0 -184
  215. package/dist/ui/app-bar/app-bar.types.d.ts +0 -392
  216. package/dist/ui/app-bar/bottom-app-bar.d.ts +0 -31
  217. package/dist/ui/app-bar/docked-toolbar.d.ts +0 -25
  218. package/dist/ui/app-bar/hooks/use-app-bar-scroll.d.ts +0 -42
  219. package/dist/ui/app-bar/hooks/use-flexible-app-bar.d.ts +0 -37
  220. package/dist/ui/app-bar/large-flexible-app-bar.d.ts +0 -26
  221. package/dist/ui/app-bar/medium-flexible-app-bar.d.ts +0 -28
  222. package/dist/ui/app-bar/search-app-bar.d.ts +0 -43
  223. package/dist/ui/app-bar/search-view.d.ts +0 -54
  224. package/dist/ui/app-bar/small-app-bar.d.ts +0 -37
  225. package/dist/ui/badge.d.ts +0 -125
  226. package/dist/ui/button-group.d.ts +0 -59
  227. package/dist/ui/button.d.ts +0 -148
  228. package/dist/ui/card.d.ts +0 -62
  229. package/dist/ui/checkbox.d.ts +0 -82
  230. package/dist/ui/chip.d.ts +0 -110
  231. package/dist/ui/code-block.d.ts +0 -14
  232. package/dist/ui/dialog.d.ts +0 -111
  233. package/dist/ui/divider.d.ts +0 -164
  234. package/dist/ui/drawer.d.ts +0 -39
  235. package/dist/ui/dropdown.d.ts +0 -29
  236. package/dist/ui/fab-menu.d.ts +0 -204
  237. package/dist/ui/fab.d.ts +0 -162
  238. package/dist/ui/icon-button.d.ts +0 -131
  239. package/dist/ui/icon.d.ts +0 -88
  240. package/dist/ui/loading-indicator.d.ts +0 -42
  241. package/dist/ui/navigation-rail.d.ts +0 -29
  242. package/dist/ui/progress-indicator/circular.d.ts +0 -3
  243. package/dist/ui/progress-indicator/hooks.d.ts +0 -3
  244. package/dist/ui/progress-indicator/linear-flat.d.ts +0 -10
  245. package/dist/ui/progress-indicator/linear-wavy.d.ts +0 -18
  246. package/dist/ui/progress-indicator/linear.d.ts +0 -3
  247. package/dist/ui/progress-indicator/types.d.ts +0 -151
  248. package/dist/ui/progress-indicator/utils.d.ts +0 -3
  249. package/dist/ui/radio-button.d.ts +0 -106
  250. package/dist/ui/ripple.d.ts +0 -126
  251. package/dist/ui/scroll-area.d.ts +0 -27
  252. package/dist/ui/shared/constants.d.ts +0 -86
  253. package/dist/ui/slider/hooks/useSliderMath.d.ts +0 -101
  254. package/dist/ui/slider/range-slider.d.ts +0 -47
  255. package/dist/ui/slider/slider-thumb.d.ts +0 -33
  256. package/dist/ui/slider/slider-track.d.ts +0 -25
  257. package/dist/ui/slider/slider.d.ts +0 -60
  258. package/dist/ui/slider/slider.tokens.d.ts +0 -151
  259. package/dist/ui/slider/slider.types.d.ts +0 -259
  260. package/dist/ui/snackbar/index.d.ts +0 -6
  261. package/dist/ui/snackbar/snackbar.d.ts +0 -197
  262. package/dist/ui/switch/switch.d.ts +0 -30
  263. package/dist/ui/switch/switch.stories.d.ts +0 -48
  264. package/dist/ui/switch/switch.tokens.d.ts +0 -67
  265. package/dist/ui/switch/switch.types.d.ts +0 -59
  266. package/dist/ui/tabs/tab.d.ts +0 -43
  267. package/dist/ui/tabs/tabs-content.d.ts +0 -36
  268. package/dist/ui/tabs/tabs-list.d.ts +0 -40
  269. package/dist/ui/tabs/tabs.d.ts +0 -60
  270. package/dist/ui/tabs/tabs.tokens.d.ts +0 -94
  271. package/dist/ui/tabs/tabs.types.d.ts +0 -172
  272. package/dist/ui/text-field/subcomponents/active-indicator.d.ts +0 -24
  273. package/dist/ui/text-field/subcomponents/floating-label.d.ts +0 -43
  274. package/dist/ui/text-field/subcomponents/leading-icon.d.ts +0 -23
  275. package/dist/ui/text-field/subcomponents/outline-container.d.ts +0 -42
  276. package/dist/ui/text-field/subcomponents/prefix-suffix.d.ts +0 -24
  277. package/dist/ui/text-field/subcomponents/supporting-text.d.ts +0 -37
  278. package/dist/ui/text-field/subcomponents/trailing-icon.d.ts +0 -41
  279. package/dist/ui/text-field/text-field.d.ts +0 -49
  280. package/dist/ui/text-field/text-field.tokens.d.ts +0 -76
  281. package/dist/ui/text-field/text-field.types.d.ts +0 -126
  282. package/dist/ui/theme-provider/index.d.ts +0 -48
  283. package/dist/ui/toc.d.ts +0 -80
  284. package/dist/ui/tooltip/plain-tooltip.d.ts +0 -2
  285. package/dist/ui/tooltip/rich-tooltip.d.ts +0 -2
  286. package/dist/ui/tooltip/tooltip-box.d.ts +0 -2
  287. package/dist/ui/tooltip/tooltip-caret-shape.d.ts +0 -9
  288. package/dist/ui/tooltip/tooltip.tokens.d.ts +0 -26
  289. package/dist/ui/tooltip/tooltip.types.d.ts +0 -56
  290. package/dist/ui/tooltip/use-tooltip-position.d.ts +0 -8
  291. package/dist/ui/tooltip/use-tooltip-state.d.ts +0 -2
  292. package/dist/ui/typography/type-scale-tokens.d.ts +0 -162
  293. package/dist/ui/typography/typography-key-tokens.d.ts +0 -40
  294. package/dist/ui/typography/typography-tokens.d.ts +0 -220
  295. package/dist/ui/typography/typography.d.ts +0 -265
  296. /package/{dist/ui/tooltip/index.d.ts → src/ui/tooltip/index.ts} +0 -0
@@ -0,0 +1,407 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import * as React from "react";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { RadioButton, RadioGroup } from "./radio-button";
5
+
6
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
7
+
8
+ /** Renders a basic RadioGroup with 3 options. */
9
+ function renderGroup(props: {
10
+ value?: string;
11
+ defaultValue?: string;
12
+ onValueChange?: (val: string) => void;
13
+ disabled?: boolean;
14
+ }) {
15
+ return render(
16
+ <RadioGroup name="test-group" {...props} label="Test Group">
17
+ <RadioButton value="a" label="Option A" />
18
+ <RadioButton value="b" label="Option B" />
19
+ <RadioButton value="c" label="Option C" />
20
+ </RadioGroup>,
21
+ );
22
+ }
23
+
24
+ // ─── Tests ────────────────────────────────────────────────────────────────────
25
+
26
+ describe("RadioButton Component", () => {
27
+ // ── 1. Rendering ──────────────────────────────────────────────────────────
28
+
29
+ describe("rendering", () => {
30
+ it("renders without label", () => {
31
+ render(<RadioButton value="x" aria-label="Option X" />);
32
+ const input = screen.getByRole("radio");
33
+ expect(input).toBeInTheDocument();
34
+ expect(input).toHaveAttribute("type", "radio");
35
+ });
36
+
37
+ it("renders with label", () => {
38
+ render(<RadioButton value="x" label="My Option" />);
39
+ expect(screen.getByRole("radio")).toBeInTheDocument();
40
+ expect(screen.getByText("My Option")).toBeInTheDocument();
41
+ });
42
+
43
+ it("is unselected by default", () => {
44
+ render(<RadioButton value="x" aria-label="Option X" />);
45
+ expect(screen.getByRole("radio")).not.toBeChecked();
46
+ });
47
+
48
+ it("renders as selected when selected prop is true", () => {
49
+ render(
50
+ <RadioButton
51
+ value="x"
52
+ selected
53
+ aria-label="Option X"
54
+ onClick={vi.fn()}
55
+ />,
56
+ );
57
+ expect(screen.getByRole("radio")).toBeChecked();
58
+ });
59
+
60
+ it("renders as unselected when selected prop is false", () => {
61
+ render(
62
+ <RadioButton
63
+ value="x"
64
+ selected={false}
65
+ aria-label="Option X"
66
+ onClick={vi.fn()}
67
+ />,
68
+ );
69
+ expect(screen.getByRole("radio")).not.toBeChecked();
70
+ });
71
+ });
72
+
73
+ // ── 2. Selection (Controlled) ─────────────────────────────────────────────
74
+
75
+ describe("controlled selection", () => {
76
+ it("fires onClick when clicked", () => {
77
+ const handleClick = vi.fn();
78
+ render(
79
+ <RadioButton value="x" aria-label="Option X" onClick={handleClick} />,
80
+ );
81
+ fireEvent.click(screen.getByRole("radio"));
82
+ expect(handleClick).toHaveBeenCalledTimes(1);
83
+ });
84
+
85
+ it("does not fire onClick when onClick is null", () => {
86
+ const handleClick = vi.fn();
87
+ render(<RadioButton value="x" aria-label="Option X" onClick={null} />);
88
+ fireEvent.click(screen.getByRole("radio"));
89
+ expect(handleClick).not.toHaveBeenCalled();
90
+ });
91
+
92
+ it("reflects controlled selected state", () => {
93
+ const { rerender } = render(
94
+ <RadioButton
95
+ value="x"
96
+ selected={false}
97
+ aria-label="Option X"
98
+ onClick={vi.fn()}
99
+ />,
100
+ );
101
+ expect(screen.getByRole("radio")).not.toBeChecked();
102
+
103
+ rerender(
104
+ <RadioButton
105
+ value="x"
106
+ selected={true}
107
+ aria-label="Option X"
108
+ onClick={vi.fn()}
109
+ />,
110
+ );
111
+ expect(screen.getByRole("radio")).toBeChecked();
112
+ });
113
+ });
114
+
115
+ // ── 3. Selection (Uncontrolled) ───────────────────────────────────────────
116
+
117
+ describe("uncontrolled selection", () => {
118
+ it("starts unselected with no defaultSelected", () => {
119
+ render(<RadioButton value="x" aria-label="Option X" />);
120
+ expect(screen.getByRole("radio")).not.toBeChecked();
121
+ });
122
+
123
+ it("starts selected when defaultSelected is true", () => {
124
+ render(<RadioButton value="x" defaultSelected aria-label="Option X" />);
125
+ expect(screen.getByRole("radio")).toBeChecked();
126
+ });
127
+
128
+ it("updates internal state on click", () => {
129
+ render(<RadioButton value="x" aria-label="Option X" />);
130
+ const input = screen.getByRole("radio");
131
+ expect(input).not.toBeChecked();
132
+
133
+ fireEvent.click(input);
134
+ expect(input).toBeChecked();
135
+ });
136
+ });
137
+
138
+ // ── 4. Disabled state ─────────────────────────────────────────────────────
139
+
140
+ describe("disabled state", () => {
141
+ it("has disabled attribute when disabled", () => {
142
+ render(<RadioButton value="x" disabled aria-label="Option X" />);
143
+ expect(screen.getByRole("radio")).toBeDisabled();
144
+ });
145
+
146
+ it("sets aria-disabled on the input", () => {
147
+ render(<RadioButton value="x" disabled aria-label="Option X" />);
148
+ expect(screen.getByRole("radio")).toHaveAttribute(
149
+ "aria-disabled",
150
+ "true",
151
+ );
152
+ });
153
+
154
+ it("does not fire onClick when disabled", () => {
155
+ const handleClick = vi.fn();
156
+ render(
157
+ <RadioButton
158
+ value="x"
159
+ disabled
160
+ aria-label="Option X"
161
+ onClick={handleClick}
162
+ />,
163
+ );
164
+ fireEvent.click(screen.getByRole("radio"));
165
+ expect(handleClick).not.toHaveBeenCalled();
166
+ });
167
+ });
168
+
169
+ // ── 5. RadioGroup behavior ────────────────────────────────────────────────
170
+
171
+ describe("RadioGroup", () => {
172
+ it("only one radio selected at a time (controlled)", () => {
173
+ const handleChange = vi.fn();
174
+ renderGroup({ value: "a", onValueChange: handleChange });
175
+
176
+ const radios = screen.getAllByRole("radio");
177
+ expect(radios[0]).toBeChecked();
178
+ expect(radios[1]).not.toBeChecked();
179
+ expect(radios[2]).not.toBeChecked();
180
+ });
181
+
182
+ it("fires onValueChange with correct value when selecting", () => {
183
+ const handleChange = vi.fn();
184
+ renderGroup({ value: "a", onValueChange: handleChange });
185
+
186
+ fireEvent.click(screen.getAllByRole("radio")[1]);
187
+ expect(handleChange).toHaveBeenCalledWith("b");
188
+ });
189
+
190
+ it("works in uncontrolled mode with defaultValue", () => {
191
+ renderGroup({ defaultValue: "b" });
192
+ const radios = screen.getAllByRole("radio");
193
+ expect(radios[1]).toBeChecked();
194
+ });
195
+
196
+ it("disables all radios when group is disabled", () => {
197
+ renderGroup({ disabled: true });
198
+ const radios = screen.getAllByRole("radio");
199
+ for (const radio of radios) {
200
+ expect(radio).toBeDisabled();
201
+ }
202
+ });
203
+
204
+ it("shares the same name across all radios", () => {
205
+ renderGroup({});
206
+ const radios = screen.getAllByRole("radio");
207
+ for (const radio of radios) {
208
+ expect(radio).toHaveAttribute("name", "test-group");
209
+ }
210
+ });
211
+
212
+ it("updates selection in uncontrolled mode on click", () => {
213
+ renderGroup({ defaultValue: "a" });
214
+ const radios = screen.getAllByRole("radio");
215
+
216
+ expect(radios[0]).toBeChecked();
217
+ fireEvent.click(radios[2]);
218
+ expect(radios[2]).toBeChecked();
219
+ });
220
+ });
221
+
222
+ // ── 6. Keyboard navigation ────────────────────────────────────────────────
223
+
224
+ describe("keyboard navigation", () => {
225
+ it("ArrowDown moves to next radio and selects it", () => {
226
+ const handleChange = vi.fn();
227
+ const { container } = renderGroup({
228
+ value: "a",
229
+ onValueChange: handleChange,
230
+ });
231
+
232
+ const group = container.querySelector("[role='radiogroup']");
233
+ if (!group) throw new Error("radiogroup not found");
234
+ const radios = screen.getAllByRole("radio");
235
+ radios[0].focus();
236
+
237
+ fireEvent.keyDown(group, { key: "ArrowDown" });
238
+ expect(handleChange).toHaveBeenCalledWith("b");
239
+ });
240
+
241
+ it("ArrowRight moves to next radio", () => {
242
+ const handleChange = vi.fn();
243
+ const { container } = renderGroup({
244
+ value: "a",
245
+ onValueChange: handleChange,
246
+ });
247
+
248
+ const group = container.querySelector("[role='radiogroup']");
249
+ if (!group) throw new Error("radiogroup not found");
250
+ const radios = screen.getAllByRole("radio");
251
+ radios[0].focus();
252
+
253
+ fireEvent.keyDown(group, { key: "ArrowRight" });
254
+ expect(handleChange).toHaveBeenCalledWith("b");
255
+ });
256
+
257
+ it("ArrowUp moves to previous radio", () => {
258
+ const handleChange = vi.fn();
259
+ const { container } = renderGroup({
260
+ value: "b",
261
+ onValueChange: handleChange,
262
+ });
263
+
264
+ const group = container.querySelector("[role='radiogroup']");
265
+ if (!group) throw new Error("radiogroup not found");
266
+ const radios = screen.getAllByRole("radio");
267
+ radios[1].focus();
268
+
269
+ fireEvent.keyDown(group, { key: "ArrowUp" });
270
+ expect(handleChange).toHaveBeenCalledWith("a");
271
+ });
272
+
273
+ it("ArrowLeft moves to previous radio", () => {
274
+ const handleChange = vi.fn();
275
+ const { container } = renderGroup({
276
+ value: "b",
277
+ onValueChange: handleChange,
278
+ });
279
+
280
+ const group = container.querySelector("[role='radiogroup']");
281
+ if (!group) throw new Error("radiogroup not found");
282
+ const radios = screen.getAllByRole("radio");
283
+ radios[1].focus();
284
+
285
+ fireEvent.keyDown(group, { key: "ArrowLeft" });
286
+ expect(handleChange).toHaveBeenCalledWith("a");
287
+ });
288
+
289
+ it("wraps from last to first with ArrowDown", () => {
290
+ const handleChange = vi.fn();
291
+ const { container } = renderGroup({
292
+ value: "c",
293
+ onValueChange: handleChange,
294
+ });
295
+
296
+ const group = container.querySelector("[role='radiogroup']");
297
+ if (!group) throw new Error("radiogroup not found");
298
+ const radios = screen.getAllByRole("radio");
299
+ radios[2].focus();
300
+
301
+ fireEvent.keyDown(group, { key: "ArrowDown" });
302
+ expect(handleChange).toHaveBeenCalledWith("a");
303
+ });
304
+
305
+ it("wraps from first to last with ArrowUp", () => {
306
+ const handleChange = vi.fn();
307
+ const { container } = renderGroup({
308
+ value: "a",
309
+ onValueChange: handleChange,
310
+ });
311
+
312
+ const group = container.querySelector("[role='radiogroup']");
313
+ if (!group) throw new Error("radiogroup not found");
314
+ const radios = screen.getAllByRole("radio");
315
+ radios[0].focus();
316
+
317
+ fireEvent.keyDown(group, { key: "ArrowUp" });
318
+ expect(handleChange).toHaveBeenCalledWith("c");
319
+ });
320
+
321
+ it("does not navigate when group is disabled", () => {
322
+ const handleChange = vi.fn();
323
+ const { container } = renderGroup({
324
+ value: "a",
325
+ onValueChange: handleChange,
326
+ disabled: true,
327
+ });
328
+
329
+ const group = container.querySelector("[role='radiogroup']");
330
+ if (!group) throw new Error("radiogroup not found");
331
+ fireEvent.keyDown(group, { key: "ArrowDown" });
332
+ expect(handleChange).not.toHaveBeenCalled();
333
+ });
334
+ });
335
+
336
+ // ── 7. Accessibility ──────────────────────────────────────────────────────
337
+
338
+ describe("accessibility", () => {
339
+ it("input has role radio", () => {
340
+ render(<RadioButton value="x" aria-label="Option X" />);
341
+ expect(screen.getByRole("radio")).toBeInTheDocument();
342
+ });
343
+
344
+ it("has aria-label when provided", () => {
345
+ render(<RadioButton value="x" aria-label="My Radio Label" />);
346
+ expect(screen.getByRole("radio")).toHaveAttribute(
347
+ "aria-label",
348
+ "My Radio Label",
349
+ );
350
+ });
351
+
352
+ it("RadioGroup has role radiogroup", () => {
353
+ renderGroup({});
354
+ expect(screen.getByRole("radiogroup")).toBeInTheDocument();
355
+ });
356
+
357
+ it("RadioGroup sets aria-disabled when disabled", () => {
358
+ renderGroup({ disabled: true });
359
+ expect(screen.getByRole("radiogroup")).toHaveAttribute(
360
+ "aria-disabled",
361
+ "true",
362
+ );
363
+ });
364
+
365
+ it("RadioGroup label is accessible via aria-label", () => {
366
+ renderGroup({});
367
+ const group = screen.getByRole("radiogroup");
368
+ expect(group).toHaveAttribute("aria-label", "Test Group");
369
+ });
370
+
371
+ it("RadioButton label associates with input via htmlFor", () => {
372
+ render(<RadioButton value="x" label="Labeled Option" />);
373
+ const input = screen.getByRole("radio");
374
+ const label = screen.getByText("Labeled Option").closest("label");
375
+ expect(label).toHaveAttribute("for", input.id);
376
+ });
377
+
378
+ it("input is visually hidden (sr-only)", () => {
379
+ render(<RadioButton value="x" aria-label="Option X" />);
380
+ const input = screen.getByRole("radio");
381
+ expect(input.className).toContain("sr-only");
382
+ });
383
+ });
384
+
385
+ // ── 8. Ref forwarding ─────────────────────────────────────────────────────
386
+
387
+ describe("ref forwarding", () => {
388
+ it("ref points to the hidden input element", () => {
389
+ const ref = React.createRef<HTMLInputElement>();
390
+ render(<RadioButton value="x" aria-label="Option X" ref={ref} />);
391
+ expect(ref.current).toBeTruthy();
392
+ if (!ref.current) throw new Error("ref.current is null");
393
+ expect(ref.current.tagName.toLowerCase()).toBe("input");
394
+ expect(ref.current.type).toBe("radio");
395
+ });
396
+ });
397
+
398
+ // ── 9. Reduced motion ─────────────────────────────────────────────────────
399
+
400
+ describe("reduced motion", () => {
401
+ it("renders correctly when reduced motion is preferred", () => {
402
+ // RadioVisual internal renders; just check component doesn't crash
403
+ render(<RadioButton value="x" aria-label="Option X" selected />);
404
+ expect(screen.getByRole("radio")).toBeChecked();
405
+ });
406
+ });
407
+ });