@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,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";