@bug-on/md3-react 2.0.3 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (316) hide show
  1. package/.turbo/turbo-build.log +42 -0
  2. package/CHANGELOG.md +69 -0
  3. package/dist/index.css +178 -0
  4. package/dist/index.css.d.ts +2 -0
  5. package/dist/index.d.mts +6135 -0
  6. package/dist/index.d.ts +6135 -71
  7. package/dist/index.js +1688 -631
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +1600 -564
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/material-symbols-cdn.css.d.ts +2 -0
  12. package/dist/material-symbols-self-hosted.css.d.ts +2 -0
  13. package/dist/plugin.d.mts +1 -0
  14. package/dist/plugin.d.ts +1 -0
  15. package/dist/plugin.js +13 -0
  16. package/dist/plugin.js.map +1 -0
  17. package/dist/plugin.mjs +3 -0
  18. package/dist/plugin.mjs.map +1 -0
  19. package/dist/typography.css.d.ts +2 -0
  20. package/package.json +28 -19
  21. package/scripts/copy-assets.js +115 -0
  22. package/src/assets/fonts/GoogleSansFlex-VariableFont.woff2 +0 -0
  23. package/src/assets/fonts/MaterialSymbolsOutlined-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  24. package/src/assets/fonts/MaterialSymbolsRounded-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  25. package/src/assets/fonts/MaterialSymbolsSharp-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  26. package/src/assets/loading-indicator.svg +19 -0
  27. package/src/assets/material-symbols-cdn.css +65 -0
  28. package/src/assets/material-symbols-self-hosted.css +90 -0
  29. package/src/css.d.ts +20 -0
  30. package/src/hooks/useClickOutside.ts +37 -0
  31. package/src/hooks/useMediaQuery.ts +28 -0
  32. package/src/hooks/useRipple.ts +88 -0
  33. package/src/index.css +23 -0
  34. package/src/index.ts +349 -0
  35. package/src/lib/material-symbols-preconnect.tsx +82 -0
  36. package/src/lib/theme-utils.ts +195 -0
  37. package/src/lib/utils.ts +6 -0
  38. package/src/plugin.ts +12 -0
  39. package/src/test/button.test.tsx +59 -0
  40. package/src/test/icon.test.tsx +91 -0
  41. package/src/test/loading-indicator.test.tsx +128 -0
  42. package/src/test/progress-indicator.test.tsx +306 -0
  43. package/src/test/setup.ts +80 -0
  44. package/src/test/typography.test.tsx +206 -0
  45. package/src/types/index.ts +7 -0
  46. package/src/types/md3.ts +31 -0
  47. package/src/ui/Text.tsx +60 -0
  48. package/src/ui/__snapshots__/divider.test.tsx.snap +63 -0
  49. package/src/ui/app-bar/app-bar-column.tsx +99 -0
  50. package/src/ui/app-bar/app-bar-item-button.tsx +71 -0
  51. package/src/ui/app-bar/app-bar-items.test.tsx +89 -0
  52. package/src/ui/app-bar/app-bar-overflow-indicator.tsx +108 -0
  53. package/src/ui/app-bar/app-bar-row.tsx +104 -0
  54. package/src/ui/app-bar/app-bar.test.tsx +87 -0
  55. package/src/ui/app-bar/app-bar.tokens.ts +223 -0
  56. package/src/ui/app-bar/app-bar.types.ts +441 -0
  57. package/src/ui/app-bar/bottom-app-bar.test.tsx +42 -0
  58. package/src/ui/app-bar/bottom-app-bar.tsx +84 -0
  59. package/src/ui/app-bar/docked-toolbar.test.tsx +34 -0
  60. package/src/ui/app-bar/docked-toolbar.tsx +54 -0
  61. package/src/ui/app-bar/flexible-app-bar.test.tsx +75 -0
  62. package/src/ui/app-bar/hooks/use-app-bar-scroll.ts +110 -0
  63. package/src/ui/app-bar/hooks/use-flexible-app-bar.ts +123 -0
  64. package/{dist/ui/app-bar/index.d.ts → src/ui/app-bar/index.ts} +35 -2
  65. package/src/ui/app-bar/large-flexible-app-bar.tsx +165 -0
  66. package/src/ui/app-bar/medium-flexible-app-bar.tsx +167 -0
  67. package/src/ui/app-bar/search-app-bar.test.tsx +49 -0
  68. package/src/ui/app-bar/search-app-bar.tsx +176 -0
  69. package/src/ui/app-bar/search-view.tsx +227 -0
  70. package/src/ui/app-bar/small-app-bar.test.tsx +48 -0
  71. package/src/ui/app-bar/small-app-bar.tsx +203 -0
  72. package/src/ui/badge.test.tsx +345 -0
  73. package/src/ui/badge.tsx +282 -0
  74. package/src/ui/button-group.test.tsx +71 -0
  75. package/src/ui/button-group.tsx +350 -0
  76. package/src/ui/button.test.tsx +306 -0
  77. package/src/ui/button.tsx +665 -0
  78. package/src/ui/card.test.tsx +187 -0
  79. package/src/ui/card.tsx +259 -0
  80. package/src/ui/checkbox.test.tsx +423 -0
  81. package/src/ui/checkbox.tsx +525 -0
  82. package/src/ui/chip.test.tsx +292 -0
  83. package/src/ui/chip.tsx +548 -0
  84. package/src/ui/code-block.tsx +219 -0
  85. package/src/ui/dialog.test.tsx +300 -0
  86. package/src/ui/dialog.tsx +384 -0
  87. package/src/ui/divider.test.tsx +314 -0
  88. package/src/ui/divider.tsx +412 -0
  89. package/src/ui/drawer.tsx +240 -0
  90. package/src/ui/fab-menu.test.tsx +494 -0
  91. package/src/ui/fab-menu.tsx +739 -0
  92. package/src/ui/fab.test.tsx +232 -0
  93. package/src/ui/fab.tsx +505 -0
  94. package/src/ui/icon-button.test.tsx +515 -0
  95. package/src/ui/icon-button.tsx +525 -0
  96. package/src/ui/icon.test.tsx +197 -0
  97. package/src/ui/icon.tsx +179 -0
  98. package/src/ui/loading-indicator.test.tsx +73 -0
  99. package/src/ui/loading-indicator.tsx +312 -0
  100. package/src/ui/menu/context-menu.tsx +275 -0
  101. package/src/ui/menu/index.ts +77 -0
  102. package/src/ui/menu/menu-animations.ts +102 -0
  103. package/src/ui/menu/menu-context.tsx +99 -0
  104. package/src/ui/menu/menu-divider.tsx +47 -0
  105. package/src/ui/menu/menu-group.tsx +200 -0
  106. package/src/ui/menu/menu-item.tsx +294 -0
  107. package/src/ui/menu/menu-tokens.ts +208 -0
  108. package/src/ui/menu/menu-types.ts +313 -0
  109. package/src/ui/menu/menu.test.tsx +624 -0
  110. package/src/ui/menu/menu.tsx +289 -0
  111. package/src/ui/menu/sub-menu.tsx +223 -0
  112. package/src/ui/menu/vertical-menu.tsx +382 -0
  113. package/src/ui/navigation-rail.test.tsx +404 -0
  114. package/src/ui/navigation-rail.tsx +607 -0
  115. package/src/ui/progress-indicator/circular.tsx +248 -0
  116. package/src/ui/progress-indicator/hooks.ts +51 -0
  117. package/{dist/ui/progress-indicator/index.d.ts → src/ui/progress-indicator/index.tsx} +20 -2
  118. package/src/ui/progress-indicator/linear-flat.tsx +83 -0
  119. package/src/ui/progress-indicator/linear-wavy.tsx +243 -0
  120. package/src/ui/progress-indicator/linear.tsx +143 -0
  121. package/src/ui/progress-indicator/types.ts +158 -0
  122. package/src/ui/progress-indicator/utils.ts +73 -0
  123. package/src/ui/radio-button.test.tsx +407 -0
  124. package/src/ui/radio-button.tsx +551 -0
  125. package/src/ui/ripple.test.tsx +72 -0
  126. package/src/ui/ripple.tsx +234 -0
  127. package/src/ui/scroll-area.test.tsx +58 -0
  128. package/src/ui/scroll-area.tsx +139 -0
  129. package/src/ui/search/animated-placeholder.tsx +145 -0
  130. package/src/ui/search/hooks/use-search-keyboard.test.ts +202 -0
  131. package/src/ui/search/hooks/use-search-keyboard.ts +104 -0
  132. package/src/ui/search/hooks/use-search-view-focus.test.ts +96 -0
  133. package/src/ui/search/hooks/use-search-view-focus.ts +24 -0
  134. package/src/ui/search/index.ts +44 -0
  135. package/src/ui/search/search-bar.tsx +220 -0
  136. package/src/ui/search/search-context.tsx +42 -0
  137. package/src/ui/search/search-view-docked.tsx +194 -0
  138. package/src/ui/search/search-view-fullscreen.tsx +247 -0
  139. package/src/ui/search/search.test.tsx +233 -0
  140. package/src/ui/search/search.tokens.ts +134 -0
  141. package/src/ui/search/search.tsx +131 -0
  142. package/src/ui/search/search.types.ts +154 -0
  143. package/src/ui/search/trailing-action.tsx +49 -0
  144. package/src/ui/shared/constants.ts +135 -0
  145. package/{dist/ui/shared/touch-target.d.ts → src/ui/shared/touch-target.tsx} +13 -1
  146. package/src/ui/slider/hooks/useSliderMath.ts +195 -0
  147. package/{dist/ui/slider/index.d.ts → src/ui/slider/index.ts} +12 -1
  148. package/src/ui/slider/range-slider.tsx +561 -0
  149. package/src/ui/slider/slider-thumb.tsx +379 -0
  150. package/src/ui/slider/slider-track.tsx +912 -0
  151. package/src/ui/slider/slider.tokens.ts +189 -0
  152. package/src/ui/slider/slider.tsx +259 -0
  153. package/src/ui/slider/slider.types.ts +288 -0
  154. package/src/ui/snackbar/index.ts +20 -0
  155. package/src/ui/snackbar/snackbar.test.tsx +338 -0
  156. package/src/ui/snackbar/snackbar.tsx +476 -0
  157. package/{dist/ui/switch/index.d.ts → src/ui/switch/index.ts} +1 -0
  158. package/src/ui/switch/switch.stories.tsx +309 -0
  159. package/src/ui/switch/switch.test.tsx +243 -0
  160. package/src/ui/switch/switch.tokens.ts +89 -0
  161. package/src/ui/switch/switch.tsx +504 -0
  162. package/src/ui/switch/switch.types.ts +62 -0
  163. package/{dist/ui/tabs/index.d.ts → src/ui/tabs/index.ts} +8 -1
  164. package/src/ui/tabs/tab.tsx +407 -0
  165. package/src/ui/tabs/tabs-content.tsx +89 -0
  166. package/src/ui/tabs/tabs-list.tsx +146 -0
  167. package/src/ui/tabs/tabs.test.tsx +290 -0
  168. package/src/ui/tabs/tabs.tokens.ts +121 -0
  169. package/src/ui/tabs/tabs.tsx +229 -0
  170. package/src/ui/tabs/tabs.types.ts +185 -0
  171. package/{dist/ui/text-field/index.d.ts → src/ui/text-field/index.ts} +8 -1
  172. package/src/ui/text-field/subcomponents/active-indicator.tsx +67 -0
  173. package/src/ui/text-field/subcomponents/floating-label.tsx +161 -0
  174. package/src/ui/text-field/subcomponents/leading-icon.tsx +46 -0
  175. package/src/ui/text-field/subcomponents/outline-container.tsx +170 -0
  176. package/src/ui/text-field/subcomponents/prefix-suffix.tsx +59 -0
  177. package/src/ui/text-field/subcomponents/supporting-text.tsx +145 -0
  178. package/src/ui/text-field/subcomponents/trailing-icon.tsx +199 -0
  179. package/src/ui/text-field/text-field.test.tsx +454 -0
  180. package/src/ui/text-field/text-field.tokens.ts +104 -0
  181. package/src/ui/text-field/text-field.tsx +548 -0
  182. package/src/ui/text-field/text-field.types.ts +180 -0
  183. package/src/ui/theme-provider/index.tsx +215 -0
  184. package/src/ui/toc.test.tsx +108 -0
  185. package/src/ui/toc.tsx +172 -0
  186. package/src/ui/tooltip/plain-tooltip.tsx +63 -0
  187. package/src/ui/tooltip/rich-tooltip.tsx +94 -0
  188. package/src/ui/tooltip/tooltip-box.tsx +266 -0
  189. package/src/ui/tooltip/tooltip-caret-shape.tsx +68 -0
  190. package/src/ui/tooltip/tooltip.tokens.ts +26 -0
  191. package/src/ui/tooltip/tooltip.types.ts +70 -0
  192. package/src/ui/tooltip/use-tooltip-position.ts +208 -0
  193. package/src/ui/tooltip/use-tooltip-state.ts +41 -0
  194. package/src/ui/typography/__tests__/typography.test.tsx +170 -0
  195. package/{dist/ui/typography/index.d.ts → src/ui/typography/index.ts} +21 -3
  196. package/src/ui/typography/type-scale-tokens.ts +205 -0
  197. package/src/ui/typography/typography-key-tokens.ts +43 -0
  198. package/src/ui/typography/typography-tokens.ts +360 -0
  199. package/src/ui/typography/typography.css +22 -0
  200. package/src/ui/typography/typography.tsx +559 -0
  201. package/test-render.tsx +4 -0
  202. package/test-shadow.html +26 -0
  203. package/test_output.txt +164 -0
  204. package/test_output_v2.txt +5 -0
  205. package/tsconfig.build.json +10 -0
  206. package/tsconfig.json +18 -0
  207. package/tsup.config.ts +20 -0
  208. package/vitest.config.ts +11 -0
  209. package/dist/hooks/useClickOutside.d.ts +0 -8
  210. package/dist/hooks/useMediaQuery.d.ts +0 -11
  211. package/dist/hooks/useRipple.d.ts +0 -26
  212. package/dist/lib/material-symbols-preconnect.d.ts +0 -42
  213. package/dist/lib/theme-utils.d.ts +0 -63
  214. package/dist/lib/utils.d.ts +0 -2
  215. package/dist/types/index.d.ts +0 -1
  216. package/dist/types/md3.d.ts +0 -14
  217. package/dist/ui/app-bar/app-bar-column.d.ts +0 -28
  218. package/dist/ui/app-bar/app-bar-item-button.d.ts +0 -16
  219. package/dist/ui/app-bar/app-bar-overflow-indicator.d.ts +0 -18
  220. package/dist/ui/app-bar/app-bar-row.d.ts +0 -36
  221. package/dist/ui/app-bar/app-bar.tokens.d.ts +0 -184
  222. package/dist/ui/app-bar/app-bar.types.d.ts +0 -392
  223. package/dist/ui/app-bar/bottom-app-bar.d.ts +0 -31
  224. package/dist/ui/app-bar/docked-toolbar.d.ts +0 -25
  225. package/dist/ui/app-bar/hooks/use-app-bar-scroll.d.ts +0 -42
  226. package/dist/ui/app-bar/hooks/use-flexible-app-bar.d.ts +0 -37
  227. package/dist/ui/app-bar/large-flexible-app-bar.d.ts +0 -26
  228. package/dist/ui/app-bar/medium-flexible-app-bar.d.ts +0 -28
  229. package/dist/ui/app-bar/search-app-bar.d.ts +0 -43
  230. package/dist/ui/app-bar/search-view.d.ts +0 -54
  231. package/dist/ui/app-bar/small-app-bar.d.ts +0 -37
  232. package/dist/ui/badge.d.ts +0 -125
  233. package/dist/ui/button-group.d.ts +0 -59
  234. package/dist/ui/button.d.ts +0 -148
  235. package/dist/ui/card.d.ts +0 -62
  236. package/dist/ui/checkbox.d.ts +0 -82
  237. package/dist/ui/chip.d.ts +0 -110
  238. package/dist/ui/code-block.d.ts +0 -14
  239. package/dist/ui/dialog.d.ts +0 -111
  240. package/dist/ui/divider.d.ts +0 -164
  241. package/dist/ui/drawer.d.ts +0 -39
  242. package/dist/ui/dropdown.d.ts +0 -29
  243. package/dist/ui/fab-menu.d.ts +0 -204
  244. package/dist/ui/fab.d.ts +0 -162
  245. package/dist/ui/icon-button.d.ts +0 -131
  246. package/dist/ui/icon.d.ts +0 -88
  247. package/dist/ui/loading-indicator.d.ts +0 -42
  248. package/dist/ui/navigation-rail.d.ts +0 -29
  249. package/dist/ui/progress-indicator/circular.d.ts +0 -3
  250. package/dist/ui/progress-indicator/hooks.d.ts +0 -3
  251. package/dist/ui/progress-indicator/linear-flat.d.ts +0 -10
  252. package/dist/ui/progress-indicator/linear-wavy.d.ts +0 -18
  253. package/dist/ui/progress-indicator/linear.d.ts +0 -3
  254. package/dist/ui/progress-indicator/types.d.ts +0 -151
  255. package/dist/ui/progress-indicator/utils.d.ts +0 -3
  256. package/dist/ui/radio-button.d.ts +0 -106
  257. package/dist/ui/ripple.d.ts +0 -126
  258. package/dist/ui/scroll-area.d.ts +0 -27
  259. package/dist/ui/search/animated-placeholder.d.ts +0 -54
  260. package/dist/ui/search/hooks/use-search-keyboard.d.ts +0 -32
  261. package/dist/ui/search/hooks/use-search-view-focus.d.ts +0 -6
  262. package/dist/ui/search/index.d.ts +0 -27
  263. package/dist/ui/search/search-bar.d.ts +0 -32
  264. package/dist/ui/search/search-context.d.ts +0 -24
  265. package/dist/ui/search/search-view-docked.d.ts +0 -25
  266. package/dist/ui/search/search-view-fullscreen.d.ts +0 -36
  267. package/dist/ui/search/search.d.ts +0 -50
  268. package/dist/ui/search/search.tokens.d.ts +0 -112
  269. package/dist/ui/search/search.types.d.ts +0 -131
  270. package/dist/ui/search/trailing-action.d.ts +0 -9
  271. package/dist/ui/shared/constants.d.ts +0 -86
  272. package/dist/ui/slider/hooks/useSliderMath.d.ts +0 -101
  273. package/dist/ui/slider/range-slider.d.ts +0 -47
  274. package/dist/ui/slider/slider-thumb.d.ts +0 -33
  275. package/dist/ui/slider/slider-track.d.ts +0 -25
  276. package/dist/ui/slider/slider.d.ts +0 -60
  277. package/dist/ui/slider/slider.tokens.d.ts +0 -151
  278. package/dist/ui/slider/slider.types.d.ts +0 -259
  279. package/dist/ui/snackbar/index.d.ts +0 -6
  280. package/dist/ui/snackbar/snackbar.d.ts +0 -197
  281. package/dist/ui/switch/switch.d.ts +0 -30
  282. package/dist/ui/switch/switch.stories.d.ts +0 -48
  283. package/dist/ui/switch/switch.tokens.d.ts +0 -67
  284. package/dist/ui/switch/switch.types.d.ts +0 -59
  285. package/dist/ui/tabs/tab.d.ts +0 -43
  286. package/dist/ui/tabs/tabs-content.d.ts +0 -36
  287. package/dist/ui/tabs/tabs-list.d.ts +0 -40
  288. package/dist/ui/tabs/tabs.d.ts +0 -60
  289. package/dist/ui/tabs/tabs.tokens.d.ts +0 -94
  290. package/dist/ui/tabs/tabs.types.d.ts +0 -172
  291. package/dist/ui/text-field/subcomponents/active-indicator.d.ts +0 -24
  292. package/dist/ui/text-field/subcomponents/floating-label.d.ts +0 -43
  293. package/dist/ui/text-field/subcomponents/leading-icon.d.ts +0 -23
  294. package/dist/ui/text-field/subcomponents/outline-container.d.ts +0 -42
  295. package/dist/ui/text-field/subcomponents/prefix-suffix.d.ts +0 -24
  296. package/dist/ui/text-field/subcomponents/supporting-text.d.ts +0 -37
  297. package/dist/ui/text-field/subcomponents/trailing-icon.d.ts +0 -41
  298. package/dist/ui/text-field/text-field.d.ts +0 -49
  299. package/dist/ui/text-field/text-field.tokens.d.ts +0 -76
  300. package/dist/ui/text-field/text-field.types.d.ts +0 -126
  301. package/dist/ui/theme-provider/index.d.ts +0 -48
  302. package/dist/ui/toc.d.ts +0 -80
  303. package/dist/ui/tooltip/plain-tooltip.d.ts +0 -2
  304. package/dist/ui/tooltip/rich-tooltip.d.ts +0 -2
  305. package/dist/ui/tooltip/tooltip-box.d.ts +0 -2
  306. package/dist/ui/tooltip/tooltip-caret-shape.d.ts +0 -9
  307. package/dist/ui/tooltip/tooltip.tokens.d.ts +0 -26
  308. package/dist/ui/tooltip/tooltip.types.d.ts +0 -56
  309. package/dist/ui/tooltip/use-tooltip-position.d.ts +0 -8
  310. package/dist/ui/tooltip/use-tooltip-state.d.ts +0 -2
  311. package/dist/ui/typography/type-scale-tokens.d.ts +0 -162
  312. package/dist/ui/typography/typography-key-tokens.d.ts +0 -40
  313. package/dist/ui/typography/typography-tokens.d.ts +0 -220
  314. package/dist/ui/typography/typography.d.ts +0 -265
  315. /package/{dist/hooks/index.d.ts → src/hooks/index.ts} +0 -0
  316. /package/{dist/ui/tooltip/index.d.ts → src/ui/tooltip/index.ts} +0 -0
@@ -0,0 +1,289 @@
1
+ // ─── MD3 Expressive Menu — Root (Menu, MenuTrigger, MenuContent) ─────────────
2
+ import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
3
+ import { AnimatePresence, m } from "motion/react";
4
+ import * as React from "react";
5
+ import { cn } from "../../lib/utils";
6
+ import { MENU_CONTAINER_VARIANTS } from "./menu-animations";
7
+ import { MenuProvider, useMenuContext } from "./menu-context";
8
+ import {
9
+ BASELINE_COLORS,
10
+ MENU_CONTAINER_SHAPE,
11
+ MENU_GROUP_GAP,
12
+ MENU_MAX_WIDTH,
13
+ MENU_MIN_WIDTH,
14
+ MENU_POPUP_PADDING_Y,
15
+ STANDARD_COLORS,
16
+ VIBRANT_COLORS,
17
+ } from "./menu-tokens";
18
+ import type {
19
+ MenuContentProps,
20
+ MenuGroupProps,
21
+ MenuProps,
22
+ MenuTriggerProps,
23
+ } from "./menu-types";
24
+
25
+ // ─── Menu (Root) ──────────────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * MD3 Expressive Menu root component.
29
+ *
30
+ * Wraps Radix `DropdownMenu.Root` and provides `MenuContext` with `colorVariant`
31
+ * and `open` state to all descendant MenuItem and MenuGroup components.
32
+ *
33
+ * @example
34
+ * <Menu colorVariant="standard">
35
+ * <MenuTrigger asChild>
36
+ * <IconButton name="more_vert" />
37
+ * </MenuTrigger>
38
+ * <MenuContent>
39
+ * <MenuGroup index={0} count={2}>
40
+ * <MenuItem>Cut</MenuItem>
41
+ * <MenuItem>Copy</MenuItem>
42
+ * </MenuGroup>
43
+ * <MenuGroup index={1} count={2}>
44
+ * <MenuItem>Paste</MenuItem>
45
+ * </MenuGroup>
46
+ * </MenuContent>
47
+ * </Menu>
48
+ */
49
+ export function Menu({
50
+ children,
51
+ variant,
52
+ menuVariant,
53
+ colorVariant = "standard",
54
+ open: controlledOpen,
55
+ onOpenChange: controlledOnOpenChange,
56
+ defaultOpen,
57
+ ...props
58
+ }: MenuProps &
59
+ Omit<React.ComponentPropsWithoutRef<typeof DropdownMenu.Root>, "children">) {
60
+ // Support deprecated menuVariant prop
61
+ const resolvedVariant = variant ?? menuVariant ?? "baseline";
62
+
63
+ // Support both controlled and uncontrolled open state.
64
+ // Initialize internalOpen from defaultOpen so that `defaultOpen={true}` works.
65
+ const [internalOpen, setInternalOpen] = React.useState(
66
+ () => defaultOpen ?? false,
67
+ );
68
+ const isControlled = controlledOpen !== undefined;
69
+ const open = isControlled ? controlledOpen : internalOpen;
70
+
71
+ const handleOpenChange = React.useCallback(
72
+ (next: boolean) => {
73
+ if (!isControlled) setInternalOpen(next);
74
+ controlledOnOpenChange?.(next);
75
+ },
76
+ [isControlled, controlledOnOpenChange],
77
+ );
78
+
79
+ return (
80
+ <MenuProvider
81
+ variant={resolvedVariant}
82
+ colorVariant={colorVariant}
83
+ open={open}
84
+ onOpenChange={handleOpenChange}
85
+ >
86
+ <DropdownMenu.Root
87
+ {...props}
88
+ defaultOpen={defaultOpen}
89
+ open={isControlled ? open : undefined}
90
+ onOpenChange={handleOpenChange}
91
+ >
92
+ {children}
93
+ </DropdownMenu.Root>
94
+ </MenuProvider>
95
+ );
96
+ }
97
+ Menu.displayName = "Menu";
98
+
99
+ // ─── MenuTrigger ──────────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * The trigger element that opens/closes the Menu.
103
+ *
104
+ * Use `asChild` to merge trigger behavior with your own element (e.g. a Button or IconButton).
105
+ */
106
+ export const MenuTrigger = React.forwardRef<
107
+ React.ComponentRef<typeof DropdownMenu.Trigger>,
108
+ MenuTriggerProps & React.ComponentPropsWithoutRef<typeof DropdownMenu.Trigger>
109
+ >(({ children, asChild = true, ...props }, ref) => (
110
+ <DropdownMenu.Trigger ref={ref} asChild={asChild} {...props}>
111
+ {children}
112
+ </DropdownMenu.Trigger>
113
+ ));
114
+ MenuTrigger.displayName = "MenuTrigger";
115
+
116
+ // ─── MenuContent (popup panel) ────────────────────────────────────────────────
117
+
118
+ /**
119
+ * The popup container for the menu's contents.
120
+ *
121
+ * Renders into a portal. Uses Radix `forceMount` + Framer Motion `AnimatePresence`
122
+ * so the exit animation (scale + opacity via FastEffects) plays before the portal
123
+ * unmounts. The `open` state is read from `MenuContext`.
124
+ *
125
+ * Transform-origin is automatically set via the Radix CSS variable
126
+ * `--radix-dropdown-menu-content-transform-origin`.
127
+ *
128
+ * @param hasOverflow - Set true when using SubMenu to prevent clipping
129
+ */
130
+ export const MenuContent = React.forwardRef<
131
+ React.ComponentRef<typeof DropdownMenu.Content>,
132
+ MenuContentProps &
133
+ Omit<React.ComponentPropsWithoutRef<typeof DropdownMenu.Content>, "asChild">
134
+ >(
135
+ (
136
+ {
137
+ children,
138
+ sideOffset = 6,
139
+ side = "bottom",
140
+ align = "start",
141
+ hasOverflow = false,
142
+ colorVariant: propColorVariant,
143
+ separatorStyle = "gap",
144
+ className,
145
+ ...props
146
+ },
147
+ ref,
148
+ ) => {
149
+ const {
150
+ open,
151
+ variant,
152
+ colorVariant: contextColorVariant,
153
+ } = useMenuContext();
154
+ const colorVariant = propColorVariant ?? contextColorVariant;
155
+
156
+ // Baseline always uses baseline colors; expressive uses colorVariant
157
+ const colors =
158
+ variant === "baseline"
159
+ ? BASELINE_COLORS
160
+ : colorVariant === "vibrant"
161
+ ? VIBRANT_COLORS
162
+ : STANDARD_COLORS;
163
+
164
+ const isExpressiveGap =
165
+ variant === "expressive" && separatorStyle === "gap";
166
+
167
+ // Expressive variant: large rounded container with elevation (unless gap variant)
168
+ // Baseline variant: CornerExtraSmall (4px) container
169
+ const containerClassName =
170
+ variant === "expressive"
171
+ ? cn(
172
+ "z-50 flex flex-col",
173
+ MENU_MIN_WIDTH,
174
+ MENU_MAX_WIDTH,
175
+ isExpressiveGap ? MENU_GROUP_GAP : "",
176
+ isExpressiveGap ? "bg-transparent" : colors.containerBg,
177
+ isExpressiveGap ? "" : "rounded-2xl",
178
+ isExpressiveGap ? "" : "elevation-2",
179
+ hasOverflow || isExpressiveGap
180
+ ? "overflow-visible"
181
+ : "overflow-hidden",
182
+ "outline-none",
183
+ className,
184
+ )
185
+ : cn(
186
+ "z-50 flex flex-col",
187
+ MENU_MIN_WIDTH,
188
+ MENU_MAX_WIDTH,
189
+ MENU_POPUP_PADDING_Y,
190
+ MENU_GROUP_GAP,
191
+ colors.containerBg,
192
+ MENU_CONTAINER_SHAPE,
193
+ "elevation-2",
194
+ hasOverflow ? "overflow-visible" : "overflow-hidden",
195
+ "outline-none",
196
+ className,
197
+ );
198
+
199
+ // Helper to recursively flatten fragments
200
+ const flattenChildren = (nodes: React.ReactNode): React.ReactElement[] => {
201
+ return React.Children.toArray(nodes).reduce(
202
+ (acc: React.ReactElement[], child) => {
203
+ if (React.isValidElement(child)) {
204
+ if (child.type === React.Fragment) {
205
+ return acc.concat(
206
+ flattenChildren(
207
+ (child as React.ReactElement<{ children?: React.ReactNode }>)
208
+ .props.children,
209
+ ),
210
+ );
211
+ }
212
+ acc.push(child as React.ReactElement);
213
+ }
214
+ return acc;
215
+ },
216
+ [],
217
+ );
218
+ };
219
+
220
+ let renderedChildren: React.ReactNode = children;
221
+
222
+ if (variant === "expressive") {
223
+ const validChildren = flattenChildren(children);
224
+ const groupCount = validChildren.length;
225
+
226
+ const enhancedChildren = validChildren.map((child, i) =>
227
+ React.cloneElement(child as React.ReactElement<MenuGroupProps>, {
228
+ index: i,
229
+ count: groupCount,
230
+ isGapVariant: isExpressiveGap,
231
+ }),
232
+ );
233
+
234
+ renderedChildren =
235
+ separatorStyle === "divider"
236
+ ? enhancedChildren.reduce<React.ReactNode[]>((acc, child, i) => {
237
+ if (i > 0) {
238
+ acc.push(
239
+ <hr
240
+ key={`divider-${(child as React.ReactElement).key || i}`}
241
+ className={cn(
242
+ "mx-3 my-0.5 h-px border-0 bg-m3-outline-variant",
243
+ )}
244
+ />,
245
+ );
246
+ }
247
+ acc.push(child);
248
+ return acc;
249
+ }, [])
250
+ : enhancedChildren;
251
+ }
252
+
253
+ return (
254
+ <AnimatePresence>
255
+ {open && (
256
+ <DropdownMenu.Portal forceMount>
257
+ <DropdownMenu.Content
258
+ ref={ref}
259
+ sideOffset={sideOffset}
260
+ side={side}
261
+ align={align}
262
+ asChild
263
+ forceMount
264
+ {...props}
265
+ >
266
+ <m.div
267
+ role="menu"
268
+ aria-orientation="vertical"
269
+ className={containerClassName}
270
+ variants={MENU_CONTAINER_VARIANTS}
271
+ initial="hidden"
272
+ animate="visible"
273
+ exit="exit"
274
+ style={{
275
+ ...(props.style as React.CSSProperties),
276
+ transformOrigin:
277
+ "var(--radix-dropdown-menu-content-transform-origin)",
278
+ }}
279
+ >
280
+ {renderedChildren}
281
+ </m.div>
282
+ </DropdownMenu.Content>
283
+ </DropdownMenu.Portal>
284
+ )}
285
+ </AnimatePresence>
286
+ );
287
+ },
288
+ );
289
+ MenuContent.displayName = "MenuContent";
@@ -0,0 +1,223 @@
1
+ // ─── MD3 Expressive Menu — SubMenu ───────────────────────────────────────────
2
+ // Nested sub-menu triggered by hover/keyboard on a MenuItem
3
+ import * as ContextMenu from "@radix-ui/react-context-menu";
4
+ import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
5
+ import { AnimatePresence, m } from "motion/react";
6
+ import * as React from "react";
7
+ import { cn } from "../../lib/utils";
8
+ import { Icon } from "../icon";
9
+ import { SUBMENU_CONTAINER_VARIANTS } from "./menu-animations";
10
+ import { useMenuContext } from "./menu-context";
11
+ import {
12
+ BASELINE_COLORS,
13
+ MENU_CONTAINER_SHAPE,
14
+ MENU_GROUP_GAP,
15
+ MENU_MAX_WIDTH,
16
+ MENU_MIN_WIDTH,
17
+ MENU_POPUP_PADDING_Y,
18
+ STANDARD_COLORS,
19
+ VIBRANT_COLORS,
20
+ } from "./menu-tokens";
21
+ import type {
22
+ MenuColorVariant,
23
+ MenuItemProps,
24
+ SubMenuProps,
25
+ } from "./menu-types";
26
+
27
+ /**
28
+ * A nested SubMenu that opens from a trigger MenuItem.
29
+ *
30
+ * Keyboard: ArrowRight opens, ArrowLeft/Escape closes (handled by Radix).
31
+ * The parent MenuContent should set `hasOverflow={true}` when SubMenus are used.
32
+ *
33
+ * ### Hover delays
34
+ * `hoverOpenDelay` (default: 200ms) — time before the submenu opens on hover.
35
+ * `hoverCloseDelay` (default: 300ms) — time before the submenu closes after pointer-leave.
36
+ * These delays allow the user to safely move diagonally from trigger to submenu content
37
+ * without accidental close (safe polygon behavior from Radix still applies).
38
+ *
39
+ * @example
40
+ * <MenuContent hasOverflow>
41
+ * <SubMenu
42
+ * trigger={
43
+ * <MenuItem trailingIcon={<Icon name="chevron_right" size={20} />}>
44
+ * Share
45
+ * </MenuItem>
46
+ * }
47
+ * >
48
+ * <MenuItem>Via Email</MenuItem>
49
+ * <MenuItem>Via Link</MenuItem>
50
+ * </SubMenu>
51
+ * </MenuContent>
52
+ */
53
+ export function SubMenu({
54
+ children,
55
+ trigger,
56
+ side = "right",
57
+ colorVariant: propColorVariant,
58
+ hoverOpenDelay = 200,
59
+ hoverCloseDelay = 300,
60
+ }: SubMenuProps) {
61
+ const { colorVariant: contextColorVariant, menuPrimitive } = useMenuContext();
62
+ const colorVariant = propColorVariant ?? contextColorVariant;
63
+
64
+ // Controlled open state for hover delay support.
65
+ // Note: We use Radix's controlled mode carefully — Radix still handles keyboard
66
+ // and the safe polygon logic internally when we pass open/onOpenChange.
67
+ const [open, setOpen] = React.useState(false);
68
+ const openTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
69
+ const closeTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
70
+ null,
71
+ );
72
+
73
+ const clearTimers = React.useCallback(() => {
74
+ if (openTimerRef.current) clearTimeout(openTimerRef.current);
75
+ if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
76
+ }, []);
77
+
78
+ const handleTriggerPointerEnter = React.useCallback(() => {
79
+ clearTimers();
80
+ openTimerRef.current = setTimeout(() => setOpen(true), hoverOpenDelay);
81
+ }, [hoverOpenDelay, clearTimers]);
82
+
83
+ const handleTriggerPointerLeave = React.useCallback(() => {
84
+ clearTimers();
85
+ closeTimerRef.current = setTimeout(() => setOpen(false), hoverCloseDelay);
86
+ }, [hoverCloseDelay, clearTimers]);
87
+
88
+ const handleContentPointerEnter = React.useCallback(() => {
89
+ // Keep open when pointer moves into the submenu content
90
+ clearTimers();
91
+ }, [clearTimers]);
92
+
93
+ const handleContentPointerLeave = React.useCallback(() => {
94
+ clearTimers();
95
+ closeTimerRef.current = setTimeout(() => setOpen(false), hoverCloseDelay);
96
+ }, [hoverCloseDelay, clearTimers]);
97
+
98
+ // Cleanup timers on unmount
99
+ React.useEffect(() => () => clearTimers(), [clearTimers]);
100
+
101
+ // Select the correct Radix Sub primitives based on which menu family is active.
102
+ const Sub = menuPrimitive === "context" ? ContextMenu.Sub : DropdownMenu.Sub;
103
+ const SubTrigger =
104
+ menuPrimitive === "context"
105
+ ? ContextMenu.SubTrigger
106
+ : DropdownMenu.SubTrigger;
107
+ const SubContent =
108
+ menuPrimitive === "context"
109
+ ? ContextMenu.SubContent
110
+ : DropdownMenu.SubContent;
111
+ const Portal =
112
+ menuPrimitive === "context" ? ContextMenu.Portal : DropdownMenu.Portal;
113
+
114
+ return (
115
+ <Sub open={open} onOpenChange={setOpen}>
116
+ {/* SubTrigger renders its own element (no asChild) so it can correctly
117
+ * compute the bounding box for SubContent positioning. */}
118
+ <SubTrigger
119
+ className="w-full outline-none"
120
+ onPointerEnter={handleTriggerPointerEnter}
121
+ onPointerLeave={handleTriggerPointerLeave}
122
+ >
123
+ {React.isValidElement(trigger)
124
+ ? React.cloneElement(trigger as React.ReactElement<MenuItemProps>, {
125
+ isSubTrigger: true,
126
+ // Auto-add chevron if missing
127
+ trailingIcon: (trigger.props as MenuItemProps).trailingIcon || (
128
+ <Icon name="chevron_right" size={20} />
129
+ ),
130
+ })
131
+ : trigger}
132
+ </SubTrigger>
133
+
134
+ {/* SubMenu popup */}
135
+ <AnimatePresence>
136
+ {open && (
137
+ <Portal forceMount>
138
+ <SubContent
139
+ sideOffset={4}
140
+ alignOffset={-4}
141
+ forceMount
142
+ className="outline-none"
143
+ >
144
+ <SubMenuContent
145
+ side={side}
146
+ colorVariant={colorVariant}
147
+ onPointerEnter={handleContentPointerEnter}
148
+ onPointerLeave={handleContentPointerLeave}
149
+ >
150
+ {children}
151
+ </SubMenuContent>
152
+ </SubContent>
153
+ </Portal>
154
+ )}
155
+ </AnimatePresence>
156
+ </Sub>
157
+ );
158
+ }
159
+ SubMenu.displayName = "SubMenu";
160
+
161
+ /**
162
+ * Inner wrapper to handle animations.
163
+ */
164
+ function SubMenuContent({
165
+ children,
166
+ side,
167
+ colorVariant: propColorVariant,
168
+ onPointerEnter,
169
+ onPointerLeave,
170
+ }: {
171
+ children: React.ReactNode;
172
+ side: "left" | "right";
173
+ colorVariant?: MenuColorVariant;
174
+ onPointerEnter?: React.PointerEventHandler<HTMLDivElement>;
175
+ onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
176
+ }) {
177
+ const { menuVariant, colorVariant: contextColorVariant } = useMenuContext();
178
+ const colorVariant = propColorVariant ?? contextColorVariant;
179
+ const colors =
180
+ menuVariant === "baseline"
181
+ ? BASELINE_COLORS
182
+ : colorVariant === "vibrant"
183
+ ? VIBRANT_COLORS
184
+ : STANDARD_COLORS;
185
+
186
+ return (
187
+ <m.div
188
+ role="menu"
189
+ aria-orientation="vertical"
190
+ onPointerEnter={onPointerEnter}
191
+ onPointerLeave={onPointerLeave}
192
+ className={cn(
193
+ "z-50 flex flex-col",
194
+ // Width constraints
195
+ MENU_MIN_WIDTH,
196
+ MENU_MAX_WIDTH,
197
+ // Vertical padding: 8dp
198
+ MENU_POPUP_PADDING_Y,
199
+ // Gap between groups: 2dp
200
+ MENU_GROUP_GAP,
201
+ // Container background
202
+ colors.containerBg,
203
+ // Container shape: CornerExtraSmall (4px)
204
+ MENU_CONTAINER_SHAPE,
205
+ // Elevation-2 shadow
206
+ "elevation-2",
207
+ // Overflow clip
208
+ "overflow-hidden",
209
+ "outline-none",
210
+ )}
211
+ variants={SUBMENU_CONTAINER_VARIANTS}
212
+ initial="hidden"
213
+ animate="visible"
214
+ exit="exit"
215
+ style={{
216
+ transformOrigin: side === "right" ? "top left" : "top right",
217
+ }}
218
+ >
219
+ {children}
220
+ </m.div>
221
+ );
222
+ }
223
+ SubMenuContent.displayName = "SubMenuContent";