@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,504 @@
1
+ /**
2
+ * @file switch.tsx
3
+ * MD3 Expressive Switch — ARIA switch pattern with Framer Motion animations.
4
+ * Spec: https://m3.material.io/components/switch/overview
5
+ *
6
+ * Key decisions:
7
+ * - Uses `<button role="switch">` (no <input>) per MD3 accessibility spec
8
+ * - Framer Motion for ALL animations (thumb x, size morph, state layer, icons)
9
+ * - Hover state via useState (required for Framer Motion color animate)
10
+ * - Disabled colors via rgba() literals (color-mix() not animatable by FM)
11
+ */
12
+
13
+ import {
14
+ AnimatePresence,
15
+ domMax,
16
+ LazyMotion,
17
+ m,
18
+ useReducedMotion,
19
+ } from "motion/react";
20
+ import * as React from "react";
21
+ import { cn } from "../../lib/utils";
22
+ import { Ripple, type RippleOrigin } from "../ripple";
23
+ import { SwitchColors, SwitchTokens } from "./switch.tokens";
24
+ import type { SwitchProps } from "./switch.types";
25
+
26
+ // ─── Animation constants ───────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * FastSpatial spring — equivalent to MotionSchemeKeyTokens.FastSpatial.
30
+ * Used for thumb translation and size morph on checked state change.
31
+ */
32
+ const FAST_SPATIAL_SPRING = {
33
+ type: "spring",
34
+ stiffness: 500,
35
+ damping: 40,
36
+ } as const;
37
+
38
+ /** Instant transition (SnapSpec equivalent) — used when thumb is pressed. */
39
+ const SNAP_TRANSITION = { duration: 0 } as const;
40
+
41
+ /** Color transition for track/thumb color changes. */
42
+ const COLOR_TRANSITION = { duration: 0.2, ease: "easeInOut" } as const;
43
+
44
+ /** State layer spring — for hover/focus overlay. */
45
+ const STATE_LAYER_SPRING = {
46
+ type: "spring",
47
+ stiffness: 400,
48
+ damping: 30,
49
+ } as const;
50
+
51
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Computes thumb size in px based on current interaction state.
55
+ * @internal
56
+ */
57
+ function resolveThumbSize(
58
+ isPressed: boolean,
59
+ checked: boolean,
60
+ hasIcon: boolean,
61
+ ): number {
62
+ if (isPressed) return SwitchTokens.pressedHandleSize;
63
+ if (checked || hasIcon) return SwitchTokens.selectedHandleSize;
64
+ return SwitchTokens.unselectedHandleSize;
65
+ }
66
+
67
+ /**
68
+ * Computes thumb X offset. Mirrors ThumbNode.measure() from Switch.kt.
69
+ * @internal
70
+ */
71
+ function resolveThumbX(
72
+ checked: boolean,
73
+ isPressed: boolean,
74
+ thumbSize: number,
75
+ ): number {
76
+ const { trackHeight, trackWidth, trackOutlineWidth, selectedHandleSize } =
77
+ SwitchTokens;
78
+ const thumbPaddingStart = (trackHeight - thumbSize) / 2;
79
+ const thumbPadding = (trackHeight - selectedHandleSize) / 2;
80
+ const maxBound = trackWidth - selectedHandleSize - thumbPadding;
81
+
82
+ if (isPressed && checked) return maxBound - trackOutlineWidth;
83
+ if (isPressed) return thumbPaddingStart + trackOutlineWidth;
84
+ if (checked) return maxBound;
85
+ return thumbPaddingStart;
86
+ }
87
+
88
+ /**
89
+ * Resolves the thumb background color based on interaction state.
90
+ * @internal
91
+ */
92
+ function resolveThumbColor(
93
+ checked: boolean,
94
+ disabled: boolean,
95
+ isInteracting: boolean,
96
+ checkedThumbColor?: string,
97
+ uncheckedThumbColor?: string,
98
+ ): string {
99
+ if (disabled) {
100
+ return checked
101
+ ? SwitchColors.disabledCheckedThumb
102
+ : "rgba(28, 27, 31, 0.38)";
103
+ }
104
+ if (checked) {
105
+ return isInteracting
106
+ ? (checkedThumbColor ?? SwitchColors.hoverCheckedThumb)
107
+ : (checkedThumbColor ?? SwitchColors.checkedThumb);
108
+ }
109
+ return isInteracting
110
+ ? (uncheckedThumbColor ?? SwitchColors.hoverUncheckedThumb)
111
+ : (uncheckedThumbColor ?? SwitchColors.uncheckedThumb);
112
+ }
113
+
114
+ /**
115
+ * Determines if the thumb icon should be visible.
116
+ * @internal
117
+ */
118
+ function isIconVisible(
119
+ thumbContent: React.ReactNode | undefined,
120
+ icons: boolean,
121
+ showOnlySelectedIcon: boolean,
122
+ checked: boolean,
123
+ ): boolean {
124
+ if (thumbContent == null) return false;
125
+ if (icons) return true;
126
+ return showOnlySelectedIcon && checked;
127
+ }
128
+
129
+ // ─── SwitchVisual ──────────────────────────────────────────────────────────────
130
+
131
+ interface ColorOverrides {
132
+ checkedTrackColor?: string;
133
+ uncheckedTrackColor?: string;
134
+ checkedThumbColor?: string;
135
+ uncheckedThumbColor?: string;
136
+ }
137
+
138
+ interface SwitchVisualProps extends ColorOverrides {
139
+ checked: boolean;
140
+ disabled: boolean;
141
+ isPressed: boolean;
142
+ isHovered: boolean;
143
+ isFocused: boolean;
144
+ thumbContent: React.ReactNode | undefined;
145
+ icons: boolean;
146
+ showOnlySelectedIcon: boolean;
147
+ prefersReduced: boolean;
148
+ }
149
+
150
+ /** Animated switch visual (track + state layer + thumb + icon). @internal */
151
+ const SwitchVisual = React.memo(function SwitchVisual({
152
+ checked,
153
+ disabled,
154
+ isPressed,
155
+ isHovered,
156
+ isFocused,
157
+ thumbContent,
158
+ icons,
159
+ showOnlySelectedIcon,
160
+ prefersReduced,
161
+ checkedTrackColor,
162
+ uncheckedTrackColor,
163
+ checkedThumbColor,
164
+ uncheckedThumbColor,
165
+ }: SwitchVisualProps) {
166
+ const hasIcon = icons && thumbContent != null;
167
+ const thumbSize = resolveThumbSize(isPressed, checked, hasIcon);
168
+ const thumbX = resolveThumbX(checked, isPressed, thumbSize);
169
+
170
+ // ── Track colors ──────────────────────────────────────────────────────────
171
+ const trackBg = checked
172
+ ? (checkedTrackColor ?? SwitchColors.checkedTrack)
173
+ : (uncheckedTrackColor ?? SwitchColors.uncheckedTrack);
174
+
175
+ const trackBorderColor = checked
176
+ ? "rgba(0, 0, 0, 0)"
177
+ : SwitchColors.uncheckedTrackOutline;
178
+
179
+ const trackBorderWidth = checked ? 0 : SwitchTokens.trackOutlineWidth;
180
+
181
+ // Disabled track: use rgba literals (color-mix not animatable)
182
+ // Light: on-surface = #1c1b1f → rgba(28,27,31,0.12)
183
+ // We use CSS custom property approach with opacity wrapper instead
184
+ const trackOpacity = disabled ? SwitchTokens.disabledTrackOpacity : 1;
185
+
186
+ // ── Thumb colors ──────────────────────────────────────────────────────────
187
+ const isInteracting = isHovered || isFocused || isPressed;
188
+ const thumbBg = resolveThumbColor(
189
+ checked,
190
+ disabled,
191
+ isInteracting,
192
+ checkedThumbColor,
193
+ uncheckedThumbColor,
194
+ );
195
+
196
+ // ── Icon color ────────────────────────────────────────────────────────────
197
+ const iconColor = disabled
198
+ ? checked
199
+ ? "rgba(28, 27, 31, 0.38)"
200
+ : "rgba(230, 225, 229, 0.38)"
201
+ : checked
202
+ ? SwitchColors.checkedIcon
203
+ : SwitchColors.uncheckedIcon;
204
+
205
+ // ── State layer ───────────────────────────────────────────────────────────
206
+ const stateLayerColor = checked
207
+ ? SwitchColors.checkedStateLayer
208
+ : SwitchColors.uncheckedStateLayer;
209
+ const stateLayerOpacity =
210
+ isPressed || isFocused ? 0.12 : isHovered ? 0.08 : 0;
211
+ const stateLayerX = thumbX + thumbSize / 2 - SwitchTokens.stateLayerSize / 2;
212
+
213
+ // ── Icon visibility ───────────────────────────────────────────────────────
214
+ const showIcon = isIconVisible(
215
+ thumbContent,
216
+ icons,
217
+ showOnlySelectedIcon,
218
+ checked,
219
+ );
220
+
221
+ // ── Motion: no animation when reduced ─────────────────────────────────────
222
+ const colorTransition = prefersReduced ? { duration: 0 } : COLOR_TRANSITION;
223
+
224
+ const stateLayerTransition = prefersReduced
225
+ ? { duration: 0 }
226
+ : STATE_LAYER_SPRING;
227
+
228
+ return (
229
+ <div
230
+ className="relative"
231
+ style={{
232
+ width: SwitchTokens.trackWidth,
233
+ height: SwitchTokens.trackHeight,
234
+ }}
235
+ aria-hidden="true"
236
+ >
237
+ {/* Track */}
238
+ <m.div
239
+ className="absolute inset-0 rounded-full"
240
+ style={{ borderStyle: "solid", opacity: trackOpacity }}
241
+ animate={{
242
+ backgroundColor: trackBg,
243
+ borderColor: trackBorderColor,
244
+ borderWidth: trackBorderWidth,
245
+ }}
246
+ transition={colorTransition}
247
+ />
248
+
249
+ {/* State layer — 40dp circle centered on thumb */}
250
+ <m.div
251
+ className="absolute rounded-full pointer-events-none"
252
+ style={{
253
+ width: SwitchTokens.stateLayerSize,
254
+ height: SwitchTokens.stateLayerSize,
255
+ top: (SwitchTokens.trackHeight - SwitchTokens.stateLayerSize) / 2,
256
+ backgroundColor: stateLayerColor,
257
+ }}
258
+ animate={{ x: stateLayerX, opacity: stateLayerOpacity }}
259
+ transition={stateLayerTransition}
260
+ />
261
+
262
+ {/* Thumb */}
263
+ <m.div
264
+ className="absolute rounded-full flex items-center justify-center overflow-hidden"
265
+ style={{ top: "50%", left: 0, y: "-50%" }}
266
+ animate={{
267
+ x: thumbX,
268
+ width: thumbSize,
269
+ height: thumbSize,
270
+ backgroundColor: thumbBg,
271
+ }}
272
+ transition={
273
+ prefersReduced
274
+ ? { duration: 0 }
275
+ : isPressed
276
+ ? SNAP_TRANSITION
277
+ : {
278
+ x: FAST_SPATIAL_SPRING,
279
+ width: FAST_SPATIAL_SPRING,
280
+ height: FAST_SPATIAL_SPRING,
281
+ backgroundColor: colorTransition,
282
+ }
283
+ }
284
+ >
285
+ {/* Icon cross-fade */}
286
+ <AnimatePresence mode="wait">
287
+ {showIcon && (
288
+ <m.span
289
+ key={checked ? "icon-on" : "icon-off"}
290
+ className="flex items-center justify-center"
291
+ style={{
292
+ width: SwitchTokens.iconSize,
293
+ height: SwitchTokens.iconSize,
294
+ color: iconColor,
295
+ fontSize: SwitchTokens.iconSize,
296
+ }}
297
+ initial={prefersReduced ? false : { opacity: 0, scale: 0.5 }}
298
+ animate={{ opacity: 1, scale: 1 }}
299
+ exit={prefersReduced ? {} : { opacity: 0, scale: 0.5 }}
300
+ transition={prefersReduced ? { duration: 0 } : { duration: 0.15 }}
301
+ >
302
+ {thumbContent}
303
+ </m.span>
304
+ )}
305
+ </AnimatePresence>
306
+ </m.div>
307
+ </div>
308
+ );
309
+ });
310
+
311
+ // ─── Switch ────────────────────────────────────────────────────────────────────
312
+
313
+ const SwitchComponent = React.forwardRef<HTMLButtonElement, SwitchProps>(
314
+ (
315
+ {
316
+ checked,
317
+ onCheckedChange,
318
+ disabled = false,
319
+ thumbContent,
320
+ icons = false,
321
+ showOnlySelectedIcon = false,
322
+ label,
323
+ ariaLabel,
324
+ className,
325
+ checkedTrackColor,
326
+ uncheckedTrackColor,
327
+ checkedThumbColor,
328
+ uncheckedThumbColor,
329
+ },
330
+ ref,
331
+ ) => {
332
+ const prefersReduced = useReducedMotion() ?? false;
333
+
334
+ const [isPressed, setIsPressed] = React.useState(false);
335
+ const [isHovered, setIsHovered] = React.useState(false);
336
+ const [isFocused, setIsFocused] = React.useState(false);
337
+ const [ripples, setRipples] = React.useState<RippleOrigin[]>([]);
338
+
339
+ const generatedId = React.useId();
340
+ const switchId = label ? `switch-${generatedId}` : undefined;
341
+
342
+ // ── Event handlers ────────────────────────────────────────────────────
343
+ const handleClick = React.useCallback(() => {
344
+ if (!disabled) onCheckedChange(!checked);
345
+ }, [disabled, checked, onCheckedChange]);
346
+
347
+ const handleKeyDown = React.useCallback(
348
+ (e: React.KeyboardEvent<HTMLButtonElement>) => {
349
+ if (disabled) return;
350
+ if (e.key === " " || e.key === "Enter") {
351
+ e.preventDefault();
352
+ onCheckedChange(!checked);
353
+ }
354
+ },
355
+ [disabled, checked, onCheckedChange],
356
+ );
357
+
358
+ const handlePointerDown = React.useCallback(
359
+ (e: React.PointerEvent<HTMLButtonElement>) => {
360
+ if (disabled) return;
361
+ setIsPressed(true);
362
+ // Ripple
363
+ const rect = e.currentTarget.getBoundingClientRect();
364
+ const x = e.clientX - rect.left;
365
+ const y = e.clientY - rect.top;
366
+ const rippleSize = Math.hypot(rect.width, rect.height) * 2;
367
+ setRipples((prev) => [
368
+ ...prev,
369
+ { id: Date.now(), x, y, size: rippleSize },
370
+ ]);
371
+ },
372
+ [disabled],
373
+ );
374
+
375
+ const handlePointerUp = React.useCallback(() => {
376
+ setIsPressed(false);
377
+ }, []);
378
+
379
+ const handlePointerEnter = React.useCallback(() => {
380
+ if (!disabled) setIsHovered(true);
381
+ }, [disabled]);
382
+
383
+ const handlePointerLeave = React.useCallback(() => {
384
+ setIsHovered(false);
385
+ setIsPressed(false);
386
+ }, []);
387
+
388
+ const handleFocus = React.useCallback(() => setIsFocused(true), []);
389
+ const handleBlur = React.useCallback(() => setIsFocused(false), []);
390
+
391
+ const removeRipple = React.useCallback(
392
+ (id: number) => setRipples((prev) => prev.filter((r) => r.id !== id)),
393
+ [],
394
+ );
395
+
396
+ // ── Shared accessible label ───────────────────────────────────────────
397
+ // When visible label wraps, aria-label is redundant (label text is
398
+ // announced via htmlFor linkage). Apply aria-label only when no label.
399
+ const buttonAriaLabel = label ? undefined : ariaLabel;
400
+
401
+ // ── Switch button ─────────────────────────────────────────────────────
402
+ const switchButton = (
403
+ <button
404
+ ref={ref}
405
+ id={switchId}
406
+ type="button"
407
+ role="switch"
408
+ aria-checked={checked}
409
+ aria-disabled={disabled || undefined}
410
+ aria-label={buttonAriaLabel}
411
+ tabIndex={disabled ? -1 : 0}
412
+ disabled={disabled}
413
+ onClick={handleClick}
414
+ onKeyDown={handleKeyDown}
415
+ onPointerDown={handlePointerDown}
416
+ onPointerUp={handlePointerUp}
417
+ onPointerLeave={handlePointerLeave}
418
+ onPointerEnter={handlePointerEnter}
419
+ onFocus={handleFocus}
420
+ onBlur={handleBlur}
421
+ className={cn(
422
+ "relative inline-flex items-center justify-center cursor-pointer select-none",
423
+ // Touch target: 48×48 minimum (pad around 32px track)
424
+ "min-w-12 min-h-12",
425
+ // Focus ring — MD3 FocusIndicatorColor = secondary
426
+ "focus-visible:outline-2 focus-visible:outline-offset-2 rounded-full",
427
+ "focus-visible:outline-(--md-sys-color-secondary)",
428
+ // Disabled
429
+ disabled && "pointer-events-none cursor-not-allowed",
430
+ !label && className,
431
+ )}
432
+ >
433
+ {/* Overflow clip wrapper for ripple */}
434
+ <div className="relative overflow-hidden rounded-full">
435
+ <SwitchVisual
436
+ checked={checked}
437
+ disabled={disabled}
438
+ isPressed={isPressed}
439
+ isHovered={isHovered}
440
+ isFocused={isFocused}
441
+ thumbContent={thumbContent}
442
+ icons={icons}
443
+ showOnlySelectedIcon={showOnlySelectedIcon}
444
+ prefersReduced={prefersReduced}
445
+ checkedTrackColor={checkedTrackColor}
446
+ uncheckedTrackColor={uncheckedTrackColor}
447
+ checkedThumbColor={checkedThumbColor}
448
+ uncheckedThumbColor={uncheckedThumbColor}
449
+ />
450
+ <Ripple
451
+ ripples={ripples}
452
+ onRippleDone={removeRipple}
453
+ disabled={disabled}
454
+ />
455
+ </div>
456
+ </button>
457
+ );
458
+
459
+ // ── With label ────────────────────────────────────────────────────────
460
+ const content = label ? (
461
+ <label
462
+ htmlFor={switchId}
463
+ className={cn(
464
+ "inline-flex items-center gap-3 cursor-pointer select-none",
465
+ disabled && "cursor-not-allowed pointer-events-none opacity-[0.38]",
466
+ className,
467
+ )}
468
+ >
469
+ {switchButton}
470
+ <span className="text-sm leading-none text-(--md-sys-color-on-surface)">
471
+ {label}
472
+ </span>
473
+ </label>
474
+ ) : (
475
+ switchButton
476
+ );
477
+
478
+ return (
479
+ <LazyMotion features={domMax} strict>
480
+ {content}
481
+ </LazyMotion>
482
+ );
483
+ },
484
+ );
485
+
486
+ SwitchComponent.displayName = "Switch";
487
+
488
+ /**
489
+ * MD3 Expressive Switch component.
490
+ *
491
+ * Toggles a single item on or off. Implements the ARIA switch pattern
492
+ * (`role="switch"`) without `<input>`. Fully animated per MD3 spec:
493
+ * thumb translation, size morph (16→24→28px), state layer, and icon cross-fade.
494
+ *
495
+ * @example
496
+ * ```tsx
497
+ * <Switch checked={isOn} onCheckedChange={setIsOn} label="Wi-Fi" />
498
+ * <Switch checked={isOn} onCheckedChange={setIsOn} icons thumbContent={<CheckIcon />} />
499
+ * <Switch checked={isOn} onCheckedChange={setIsOn} disabled />
500
+ * ```
501
+ *
502
+ * @see https://m3.material.io/components/switch/overview
503
+ */
504
+ export const Switch = React.memo(SwitchComponent);
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @file switch.types.ts
3
+ * MD3 Expressive Switch — TypeScript prop definitions.
4
+ * Spec: https://m3.material.io/components/switch/overview
5
+ */
6
+
7
+ import type * as React from "react";
8
+
9
+ /**
10
+ * Props for the `Switch` component.
11
+ *
12
+ * @example
13
+ * ```tsx
14
+ * <Switch checked={isOn} onCheckedChange={setIsOn} label="Wi-Fi" />
15
+ * ```
16
+ */
17
+ export interface SwitchProps {
18
+ /** Controlled checked (on) state. */
19
+ checked: boolean;
20
+ /** Called when the switch is toggled. Not called when disabled. */
21
+ onCheckedChange: (checked: boolean) => void;
22
+ /** Disables interaction and applies disabled visual state. @default false */
23
+ disabled?: boolean;
24
+ /**
25
+ * Optional icon content rendered inside the thumb.
26
+ * Expected to measure 16dp (SwitchTokens.iconSize).
27
+ */
28
+ thumbContent?: React.ReactNode;
29
+ /**
30
+ * When true, shows thumb icons in both selected and unselected states.
31
+ * Requires `thumbContent` to be provided.
32
+ * @default false
33
+ */
34
+ icons?: boolean;
35
+ /**
36
+ * When true, shows the icon only in the selected/checked state.
37
+ * Requires `thumbContent` to be provided.
38
+ * @default false
39
+ */
40
+ showOnlySelectedIcon?: boolean;
41
+ /**
42
+ * Visible label text rendered adjacent to the switch.
43
+ * When provided, wraps the switch in a `<label>` for accessibility.
44
+ */
45
+ label?: string;
46
+ /**
47
+ * Overrides the accessible name. Used when no visible `label` is provided.
48
+ * Maps to the `aria-label` attribute.
49
+ */
50
+ ariaLabel?: string;
51
+ /** Additional CSS class names applied to the outermost wrapper. */
52
+ className?: string;
53
+ // ── Advanced color overrides ──────────────────────────────────────────────
54
+ /** Override track background color when checked. Defaults to MD3 primary. */
55
+ checkedTrackColor?: string;
56
+ /** Override track background color when unchecked. Defaults to MD3 surface-container-highest. */
57
+ uncheckedTrackColor?: string;
58
+ /** Override thumb color when checked. Defaults to MD3 on-primary. */
59
+ checkedThumbColor?: string;
60
+ /** Override thumb color when unchecked. Defaults to MD3 outline. */
61
+ uncheckedThumbColor?: string;
62
+ }
@@ -2,9 +2,16 @@
2
2
  * @file tabs/index.ts
3
3
  * Public exports for the MD3 Expressive Tabs component system.
4
4
  */
5
+
5
6
  export { Tab } from "./tab";
6
7
  export { Tabs } from "./tabs";
7
8
  export { TabsColors, TabsTokens } from "./tabs.tokens";
8
- export type { TabProps, TabsContentProps, TabsListProps, TabsProps, TabsVariant, } from "./tabs.types";
9
+ export type {
10
+ TabProps,
11
+ TabsContentProps,
12
+ TabsListProps,
13
+ TabsProps,
14
+ TabsVariant,
15
+ } from "./tabs.types";
9
16
  export { TabsContent } from "./tabs-content";
10
17
  export { TabsList } from "./tabs-list";