@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,234 @@
1
+ import { AnimatePresence, m, useReducedMotion } from "motion/react";
2
+ import * as React from "react";
3
+
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+ // MD3 Expressive Ripple
6
+ //
7
+ // - Origin: pointer-down coordinates (x, y) relative to container
8
+ // - Shape: perfectly round circle, expands from origin to diagonally fill btn
9
+ // - Color: currentColor at 12% opacity (matches MD3 state layer spec)
10
+ // - A11y: disabled when `prefers-reduced-motion` is active (configurable)
11
+ // - Clipping: caller wraps in overflow-hidden; border-radius handled on parent
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+
14
+ /**
15
+ * Represents a single ripple wave instance with position and size metadata.
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * const ripple: RippleOrigin = { id: Date.now(), x: 50, y: 30, size: 200 };
20
+ * ```
21
+ */
22
+ export interface RippleOrigin {
23
+ /** Unique identifier used as React key and for removal. */
24
+ id: number;
25
+ /** X coordinate of the pointer event relative to the container's left edge (px). */
26
+ x: number;
27
+ /** Y coordinate of the pointer event relative to the container's top edge (px). */
28
+ y: number;
29
+ /**
30
+ * Diameter of the ripple circle (px).
31
+ * Typically `Math.hypot(width, height) * 2` to ensure it fills the container.
32
+ */
33
+ size: number;
34
+ }
35
+
36
+ /** @internal Props for a single animated ripple element. */
37
+ interface RippleItemProps {
38
+ ripple: RippleOrigin;
39
+ onDone: (id: number) => void;
40
+ }
41
+
42
+ /** @internal Memoized single ripple wave — minimises re-renders. */
43
+ const RippleItem = React.memo(function RippleItem({
44
+ ripple,
45
+ onDone,
46
+ }: RippleItemProps) {
47
+ return (
48
+ <m.span
49
+ key={ripple.id}
50
+ aria-hidden="true"
51
+ style={{
52
+ position: "absolute",
53
+ left: ripple.x - ripple.size / 2,
54
+ top: ripple.y - ripple.size / 2,
55
+ width: ripple.size,
56
+ height: ripple.size,
57
+ borderRadius: "50%",
58
+ backgroundColor: "currentColor",
59
+ pointerEvents: "none",
60
+ transformOrigin: "center",
61
+ }}
62
+ initial={{ scale: 0, opacity: 0.12 }}
63
+ animate={{ scale: 1, opacity: 0 }}
64
+ exit={{ opacity: 0 }}
65
+ transition={{
66
+ scale: { duration: 0.5, ease: [0.2, 0, 0, 1] },
67
+ opacity: { duration: 0.4, ease: "easeOut", delay: 0.1 },
68
+ }}
69
+ onAnimationComplete={() => onDone(ripple.id)}
70
+ />
71
+ );
72
+ });
73
+
74
+ // ─────────────────────────────────────────────────────────────────────────────
75
+ // Ripple Component
76
+ // ─────────────────────────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Props for the `Ripple` presentation component.
80
+ */
81
+ export interface RippleProps {
82
+ /** Active ripple instances to render. Managed by the parent via `useRipple`. */
83
+ ripples: RippleOrigin[];
84
+ /** Called when a ripple's exit animation completes — remove it from state. */
85
+ onRippleDone: (id: number) => void;
86
+ /**
87
+ * Completely disables the ripple effect.
88
+ * Use this when the parent element is disabled or interaction is not desired.
89
+ * @default false
90
+ */
91
+ disabled?: boolean;
92
+ /**
93
+ * When `true`, the ripple respects the user's OS-level
94
+ * `prefers-reduced-motion` accessibility setting and renders nothing if active.
95
+ *
96
+ * Set to `false` to always show ripples regardless of system preference.
97
+ * @default true
98
+ */
99
+ respectSystemMotion?: boolean;
100
+ }
101
+
102
+ /**
103
+ * MD3 Expressive Ripple — animated touch-feedback wave layer.
104
+ *
105
+ * Renders absolutely-positioned ripple circles inside an `overflow-hidden`
106
+ * container. Must be placed as a direct child of the interactive element.
107
+ *
108
+ * @remarks
109
+ * - The parent element **must** have `overflow: hidden` and `position: relative`
110
+ * (or equivalent) for clipping to work correctly.
111
+ * - Set `disabled` to `true` on parent's disabled state to avoid stale ripples.
112
+ * - The ripple color is `currentColor` at 12% opacity — matching MD3 state layer spec.
113
+ *
114
+ * @example
115
+ * ```tsx
116
+ * const { ripples, onPointerDown, removeRipple } = useRippleState();
117
+ *
118
+ * <button onPointerDown={onPointerDown} className="relative overflow-hidden">
119
+ * <Ripple ripples={ripples} onRippleDone={removeRipple} />
120
+ * Click me
121
+ * </button>
122
+ * ```
123
+ *
124
+ * @see {@link useRippleState} for the state management hook
125
+ * @see https://m3.material.io/foundations/interaction/states/overview
126
+ */
127
+ export function Ripple({
128
+ ripples,
129
+ onRippleDone,
130
+ disabled = false,
131
+ respectSystemMotion = true,
132
+ }: RippleProps) {
133
+ const prefersReduced = useReducedMotion();
134
+
135
+ // Disabled prop: explicitly turned off by consumer
136
+ if (disabled) return null;
137
+
138
+ // Respect system prefers-reduced-motion when opted-in
139
+ if (respectSystemMotion && prefersReduced) return null;
140
+
141
+ return (
142
+ <AnimatePresence>
143
+ {ripples.map((r) => (
144
+ <RippleItem key={r.id} ripple={r} onDone={onRippleDone} />
145
+ ))}
146
+ </AnimatePresence>
147
+ );
148
+ }
149
+
150
+ // ─────────────────────────────────────────────────────────────────────────────
151
+ // useRippleState Hook
152
+ // ─────────────────────────────────────────────────────────────────────────────
153
+
154
+ /**
155
+ * Options for configuring `useRippleState` behaviour.
156
+ */
157
+ export interface UseRippleStateOptions {
158
+ /**
159
+ * When `true`, the ripple is suppressed — `onPointerDown` becomes a no-op.
160
+ * Use this to sync the ripple with the parent element's `disabled` state.
161
+ * @default false
162
+ */
163
+ disabled?: boolean;
164
+ }
165
+
166
+ /**
167
+ * `useRippleState` — state manager for MD3 Expressive ripple waves.
168
+ *
169
+ * Tracks active ripple instances and provides pointer event handlers.
170
+ * Pair with the `<Ripple>` component for rendering.
171
+ *
172
+ * @remarks
173
+ * This hook only manages ripple *state* (coordinates, size, lifecycle).
174
+ * The actual animation is handled by `<Ripple>` via Framer Motion.
175
+ * Respecting `prefers-reduced-motion` is handled by `<Ripple>` itself.
176
+ *
177
+ * @param options - Configuration options. See {@link UseRippleStateOptions}.
178
+ * @returns `{ ripples, onPointerDown, removeRipple }` — bind to the interactive element.
179
+ *
180
+ * @example
181
+ * ```tsx
182
+ * function MyButton({ disabled, children }) {
183
+ * const { ripples, onPointerDown, removeRipple } = useRippleState({ disabled });
184
+ *
185
+ * return (
186
+ * <button
187
+ * disabled={disabled}
188
+ * onPointerDown={onPointerDown}
189
+ * className="relative overflow-hidden"
190
+ * >
191
+ * <Ripple ripples={ripples} onRippleDone={removeRipple} disabled={disabled} />
192
+ * {children}
193
+ * </button>
194
+ * );
195
+ * }
196
+ * ```
197
+ *
198
+ * @see {@link Ripple} for the rendering component
199
+ */
200
+ export function useRippleState(options: UseRippleStateOptions = {}) {
201
+ const { disabled = false } = options;
202
+ const [ripples, setRipples] = React.useState<RippleOrigin[]>([]);
203
+
204
+ const onPointerDown = React.useCallback(
205
+ (e: React.PointerEvent<HTMLElement>) => {
206
+ if (disabled) return;
207
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
208
+ const x = e.clientX - rect.left;
209
+ const y = e.clientY - rect.top;
210
+ const rippleSize = Math.hypot(rect.width, rect.height) * 2;
211
+ setRipples((prev) => [
212
+ ...prev,
213
+ { id: Date.now(), x, y, size: rippleSize },
214
+ ]);
215
+ },
216
+ [disabled],
217
+ );
218
+
219
+ const removeRipple = React.useCallback((id: number) => {
220
+ setRipples((prev) => prev.filter((r) => r.id !== id));
221
+ }, []);
222
+
223
+ return { ripples, onPointerDown, removeRipple };
224
+ }
225
+
226
+ // ─────────────────────────────────────────────────────────────────────────────
227
+ // Legacy alias — preserved for backward-compatibility
228
+ // ─────────────────────────────────────────────────────────────────────────────
229
+
230
+ /**
231
+ * @deprecated Use `useRippleState` instead. This alias will be removed in a future version.
232
+ * @see {@link useRippleState}
233
+ */
234
+ export const useRipple = useRippleState;
@@ -0,0 +1,58 @@
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { ScrollArea } from "./scroll-area";
4
+
5
+ describe("ScrollArea", () => {
6
+ it("renders correctly with children", () => {
7
+ render(
8
+ <ScrollArea className="h-40 w-40">
9
+ <div data-testid="content">Long content here</div>
10
+ </ScrollArea>,
11
+ );
12
+
13
+ expect(screen.getByTestId("content")).toBeInTheDocument();
14
+ });
15
+
16
+ it("renders scrollbars when type is 'always'", async () => {
17
+ const { container } = render(
18
+ <ScrollArea type="always" className="h-40 w-40">
19
+ <div style={{ height: "1000px" }}>Overflowing content</div>
20
+ </ScrollArea>,
21
+ );
22
+
23
+ await waitFor(() => {
24
+ const scrollbars = container.querySelectorAll("[data-orientation]");
25
+ expect(scrollbars.length).toBeGreaterThan(0);
26
+ });
27
+ });
28
+
29
+ it("respects orientation prop", async () => {
30
+ const { container } = render(
31
+ <ScrollArea type="always" orientation="horizontal" className="h-40 w-40">
32
+ <div style={{ width: "1000px" }}>Overflowing content</div>
33
+ </ScrollArea>,
34
+ );
35
+
36
+ await waitFor(() => {
37
+ const scrollbar = container.querySelector(
38
+ '[data-orientation="horizontal"]',
39
+ );
40
+ expect(scrollbar).toBeInTheDocument();
41
+ });
42
+ });
43
+
44
+ it("hides scrollbar when type is 'none'", async () => {
45
+ const { container } = render(
46
+ <ScrollArea type="none" className="h-40 w-40">
47
+ <div style={{ height: "1000px" }}>Overflowing content</div>
48
+ </ScrollArea>,
49
+ );
50
+
51
+ await waitFor(() => {
52
+ const scrollbar = container.querySelector(
53
+ '[data-orientation="vertical"]',
54
+ );
55
+ expect(scrollbar).toHaveClass("hidden");
56
+ });
57
+ });
58
+ });
@@ -0,0 +1,139 @@
1
+ "use client";
2
+
3
+ import * as RadixScrollArea from "@radix-ui/react-scroll-area";
4
+ import * as React from "react";
5
+ import { cn } from "../lib/utils";
6
+
7
+ // ─── Types ────────────────────────────────────────────────────────────────────
8
+ /** Radix accepts hover/scroll/always/auto. We add 'none' as a UI-only hide. */
9
+ export type ScrollAreaType = "hover" | "scroll" | "always" | "none";
10
+ type RadixScrollAreaType = "hover" | "scroll" | "always" | "auto";
11
+ export type ScrollAreaOrientation = "vertical" | "horizontal" | "both";
12
+
13
+ export interface ScrollAreaProps
14
+ extends Omit<
15
+ React.ComponentPropsWithoutRef<typeof RadixScrollArea.Root>,
16
+ "type"
17
+ > {
18
+ /**
19
+ * Controls when the scrollbars are visible.
20
+ * - `hover`: Show on hover (default, recommended for desktop)
21
+ * - `scroll`: Show only while scrolling (recommended for mobile)
22
+ * - `always`: Always visible
23
+ * - `none`: Never visible
24
+ */
25
+ type?: ScrollAreaType;
26
+ /**
27
+ * The scrollbar orientation to render.
28
+ * @default "vertical"
29
+ */
30
+ orientation?: ScrollAreaOrientation;
31
+ /** Delay in ms before scrollbars hide when `type` is `hover` or `scroll`. */
32
+ scrollHideDelay?: number;
33
+ /** Extra classes applied to the inner viewport element. */
34
+ viewportClassName?: string;
35
+ }
36
+
37
+ // ─── Root ─────────────────────────────────────────────────────────────────────
38
+ const ScrollArea = React.forwardRef<
39
+ React.ElementRef<typeof RadixScrollArea.Root>,
40
+ ScrollAreaProps
41
+ >(
42
+ (
43
+ {
44
+ className,
45
+ viewportClassName,
46
+ children,
47
+ type = "hover",
48
+ orientation = "vertical",
49
+ scrollHideDelay = 600,
50
+ ...props
51
+ },
52
+ ref,
53
+ ) => {
54
+ const radixType: RadixScrollAreaType = type === "none" ? "always" : type;
55
+
56
+ return (
57
+ <RadixScrollArea.Root
58
+ ref={ref}
59
+ type={radixType}
60
+ scrollHideDelay={scrollHideDelay}
61
+ className={cn("relative overflow-hidden flex flex-col", className)}
62
+ {...props}
63
+ >
64
+ <RadixScrollArea.Viewport
65
+ className={cn(
66
+ "h-full w-full flex-1 min-h-0 min-w-0 rounded-[inherit]",
67
+ "outline-none focus-visible:ring-2 focus-visible:ring-m3-primary focus-visible:ring-offset-1",
68
+ viewportClassName,
69
+ )}
70
+ >
71
+ {children}
72
+ </RadixScrollArea.Viewport>
73
+
74
+ {(orientation === "vertical" || orientation === "both") && (
75
+ <ScrollAreaScrollbar
76
+ orientation="vertical"
77
+ className={type === "none" ? "hidden" : undefined}
78
+ />
79
+ )}
80
+
81
+ {(orientation === "horizontal" || orientation === "both") && (
82
+ <ScrollAreaScrollbar
83
+ orientation="horizontal"
84
+ className={type === "none" ? "hidden" : undefined}
85
+ />
86
+ )}
87
+
88
+ <RadixScrollArea.Corner className="bg-m3-surface-container" />
89
+ </RadixScrollArea.Root>
90
+ );
91
+ },
92
+ );
93
+ ScrollArea.displayName = "ScrollArea";
94
+
95
+ // ─── Scrollbar ────────────────────────────────────────────────────────────────
96
+ const ScrollAreaScrollbar = React.forwardRef<
97
+ React.ElementRef<typeof RadixScrollArea.Scrollbar>,
98
+ React.ComponentPropsWithoutRef<typeof RadixScrollArea.Scrollbar>
99
+ >(({ className, orientation = "vertical", ...props }, ref) => (
100
+ <RadixScrollArea.Scrollbar
101
+ ref={ref}
102
+ orientation={orientation}
103
+ className={cn(
104
+ "flex touch-none select-none transition-all duration-300 ease-in-out",
105
+ "absolute z-50",
106
+ orientation === "vertical" &&
107
+ "right-0 top-0 bottom-0 w-2.5 border-l border-l-transparent p-px",
108
+ orientation === "horizontal" &&
109
+ "bottom-0 left-0 right-0 h-2.5 flex-col border-t border-t-transparent p-px",
110
+ "data-[state=hidden]:opacity-0 data-[state=visible]:opacity-100",
111
+ className,
112
+ )}
113
+ {...props}
114
+ >
115
+ <RadixScrollArea.Thumb
116
+ className={cn(
117
+ "relative flex-1 rounded-full bg-m3-on-surface/25 transition-colors duration-200",
118
+ "hover:bg-m3-on-surface/40 active:bg-m3-on-surface/55",
119
+ "before:absolute before:left-1/2 before:top-1/2 before:min-h-11 before:min-w-11 before:-translate-x-1/2 before:-translate-y-1/2",
120
+ )}
121
+ />
122
+ </RadixScrollArea.Scrollbar>
123
+ ));
124
+ ScrollAreaScrollbar.displayName = RadixScrollArea.Scrollbar.displayName;
125
+
126
+ // ─── Corner ───────────────────────────────────────────────────────────────────
127
+ const ScrollAreaCorner = React.forwardRef<
128
+ React.ComponentRef<typeof RadixScrollArea.Corner>,
129
+ React.ComponentPropsWithoutRef<typeof RadixScrollArea.Corner>
130
+ >(({ className, ...props }, ref) => (
131
+ <RadixScrollArea.Corner
132
+ ref={ref}
133
+ className={cn("bg-m3-surface-container", className)}
134
+ {...props}
135
+ />
136
+ ));
137
+ ScrollAreaCorner.displayName = "ScrollAreaCorner";
138
+
139
+ export { ScrollArea, ScrollAreaScrollbar };
@@ -0,0 +1,145 @@
1
+ /**
2
+ * @file animated-placeholder.tsx
3
+ * MD3 Expressive Search — Animated placeholder overlay.
4
+ *
5
+ * Replaces the native `::placeholder` with a GPU-accelerated `translateX`
6
+ * animation so the placeholder text can smoothly slide from its alignment
7
+ * position (center / right) to left when the search input is focused.
8
+ *
9
+ * Implementation notes:
10
+ * - Only `transform: translateX` is animated → no layout triggers, no paint.
11
+ * - Container width is measured once via `useLayoutEffect` (before paint) to
12
+ * avoid a first-render flash, then kept fresh via `ResizeObserver`.
13
+ * - xOffset is stored in `useState` so Framer Motion picks up changes and
14
+ * re-animates smoothly on container resize.
15
+ * - The span is never unmounted — only opacity-toggled — to preserve the
16
+ * measurement ref between renders.
17
+ */
18
+
19
+ import { m, useReducedMotion } from "motion/react";
20
+ import * as React from "react";
21
+ import { cn } from "../../lib/utils";
22
+ import { SEARCH_COLORS, SEARCH_TYPOGRAPHY } from "./search.tokens";
23
+
24
+ /** Spring tuned to match MD3 Standard Decelerate curve. */
25
+ const PLACEHOLDER_SPRING = {
26
+ type: "spring" as const,
27
+ stiffness: 350,
28
+ damping: 30,
29
+ mass: 0.8,
30
+ };
31
+
32
+ interface AnimatedPlaceholderProps {
33
+ /** Placeholder text to display. */
34
+ text: string;
35
+ /** Alignment of the placeholder when idle (not focused). @default "left" */
36
+ textAlign: "left" | "center" | "right";
37
+ /**
38
+ * Whether the placeholder should be visible.
39
+ * Pass `!query` — hide when the user has typed something.
40
+ */
41
+ visible: boolean;
42
+ /**
43
+ * Whether the search is in an active/focused state.
44
+ * When `true`, the placeholder snaps to `left` regardless of `textAlign`.
45
+ */
46
+ focused: boolean;
47
+ /**
48
+ * The `<input>` element that this component wraps.
49
+ * It should have `w-full` instead of `flex-1` since this wrapper
50
+ * takes over the `flex-1` role in the parent flex layout.
51
+ */
52
+ children: React.ReactNode;
53
+ /** Extra className forwarded to the wrapper div. */
54
+ className?: string;
55
+ }
56
+
57
+ /**
58
+ * Wraps a search `<input>` with an animated placeholder overlay.
59
+ *
60
+ * The wrapper div occupies `flex-1` so it fits seamlessly in the
61
+ * horizontal flex layout used by search bar headers. The children
62
+ * (the `<input>`) should use `w-full` to fill the wrapper.
63
+ *
64
+ * Accessibility: `aria-label` on the `<input>` carries the placeholder
65
+ * text for screen readers; this span is `aria-hidden="true"`.
66
+ */
67
+ export function AnimatedPlaceholder({
68
+ text,
69
+ textAlign,
70
+ visible,
71
+ focused,
72
+ children,
73
+ className,
74
+ }: AnimatedPlaceholderProps) {
75
+ const shouldReduceMotion = useReducedMotion();
76
+ const containerRef = React.useRef<HTMLDivElement>(null);
77
+ const spanRef = React.useRef<HTMLSpanElement | null>(null);
78
+
79
+ // Offset in pixels from left when idle. 0 means no animation (left-aligned).
80
+ const [xOffset, setXOffset] = React.useState(0);
81
+
82
+ const recalculate = React.useCallback(() => {
83
+ const container = containerRef.current;
84
+ const span = spanRef.current;
85
+ if (!container || !span || textAlign === "left") {
86
+ setXOffset(0);
87
+ return;
88
+ }
89
+ const containerWidth = container.offsetWidth;
90
+ const textWidth = span.offsetWidth;
91
+ if (textAlign === "center") {
92
+ setXOffset(Math.max(0, (containerWidth - textWidth) / 2));
93
+ } else {
94
+ // right
95
+ setXOffset(Math.max(0, containerWidth - textWidth));
96
+ }
97
+ }, [textAlign]);
98
+
99
+ // Measure synchronously before first paint to prevent a position flash.
100
+ React.useLayoutEffect(() => {
101
+ recalculate();
102
+ }, [recalculate]);
103
+
104
+ // Keep measurement fresh when the container is resized.
105
+ React.useEffect(() => {
106
+ const container = containerRef.current;
107
+ if (!container) return;
108
+ const observer = new ResizeObserver(recalculate);
109
+ observer.observe(container);
110
+ return () => observer.disconnect();
111
+ }, [recalculate]);
112
+
113
+ // When focused or no longer visible (user typed), snap to left (x=0).
114
+ const targetX = focused || !visible ? 0 : xOffset;
115
+
116
+ return (
117
+ <div
118
+ ref={containerRef}
119
+ className={cn("relative flex-1 min-w-0", className)}
120
+ >
121
+ {children}
122
+
123
+ {/* Animated placeholder — decorative only, aria-hidden */}
124
+ <m.span
125
+ ref={(el) => {
126
+ spanRef.current = el;
127
+ }}
128
+ aria-hidden="true"
129
+ className={cn(
130
+ "pointer-events-none absolute inset-y-0 left-0",
131
+ "flex items-center whitespace-nowrap select-none",
132
+ SEARCH_TYPOGRAPHY.bodyLarge,
133
+ )}
134
+ style={{ color: SEARCH_COLORS.supportingText }}
135
+ animate={{
136
+ x: targetX,
137
+ opacity: visible ? 1 : 0,
138
+ }}
139
+ transition={shouldReduceMotion ? { duration: 0 } : PLACEHOLDER_SPRING}
140
+ >
141
+ {text}
142
+ </m.span>
143
+ </div>
144
+ );
145
+ }