@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,607 @@
1
+ import { cva } from "class-variance-authority";
2
+ import { AnimatePresence, domMax, LazyMotion, m } from "motion/react";
3
+ import * as React from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { cn } from "../lib/utils";
6
+ import { Icon } from "./icon";
7
+ import { Ripple, useRippleState } from "./ripple";
8
+ import {
9
+ SPRING_TRANSITION,
10
+ SPRING_TRANSITION_EXPRESSIVE,
11
+ } from "./shared/constants";
12
+ import { TouchTarget } from "./shared/touch-target";
13
+
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+ // Types & Constants
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+
18
+ export type NavigationRailVariant = "collapsed" | "expanded" | "modal";
19
+ export type NavigationRailLabelVisibility = "labeled" | "auto" | "unlabeled";
20
+
21
+ export interface NavigationRailItemProps {
22
+ selected: boolean;
23
+ icon: React.ReactNode;
24
+ label?: React.ReactNode;
25
+ onClick?: () => void;
26
+ disabled?: boolean;
27
+ badge?: React.ReactNode;
28
+ "aria-label"?: string;
29
+ className?: string;
30
+ }
31
+
32
+ export interface NavigationRailProps {
33
+ variant?: NavigationRailVariant;
34
+ labelVisibility?: NavigationRailLabelVisibility;
35
+ header?: React.ReactNode;
36
+ fab?: React.ReactNode;
37
+ footer?: React.ReactNode;
38
+ narrow?: boolean;
39
+ open?: boolean;
40
+ xr?: boolean | "contained" | "spatialized";
41
+ onClose?: () => void;
42
+ children: React.ReactNode;
43
+ className?: string;
44
+ style?: React.CSSProperties;
45
+ }
46
+
47
+ const NavigationRailContext = React.createContext<{
48
+ variant: NavigationRailVariant;
49
+ labelVisibility: NavigationRailLabelVisibility;
50
+ xr: boolean;
51
+ }>({ variant: "collapsed", labelVisibility: "labeled", xr: false });
52
+
53
+ const MD3_MODAL_TRANSITION = {
54
+ type: "tween",
55
+ ease: [0.05, 0.7, 0.1, 1],
56
+ duration: 0.3,
57
+ } as const;
58
+
59
+ // ─────────────────────────────────────────────────────────────────────────────
60
+ // CVA Variants
61
+ // ─────────────────────────────────────────────────────────────────────────────
62
+
63
+ const railContainerVariants = cva(
64
+ "flex flex-col overflow-y-auto overflow-x-hidden select-none transition-colors duration-300",
65
+ {
66
+ variants: {
67
+ variant: {
68
+ collapsed: "items-center",
69
+ expanded: "items-start",
70
+ modal:
71
+ "bg-m3-surface shadow-lg rounded-r-[var(--m3-shape-corner-large)]",
72
+ },
73
+ narrow: {
74
+ true: "w-20",
75
+ false: "w-24",
76
+ },
77
+ xr: {
78
+ true: "h-fit py-5 rounded-[48px] shadow-xl bg-m3-surface border border-white/5",
79
+ false: "h-full pt-11 pb-4 shadow-none bg-m3-surface rounded-none",
80
+ },
81
+ },
82
+ compoundVariants: [
83
+ { variant: "expanded", className: "min-w-[13.75rem] max-w-[22.5rem]" },
84
+ { variant: "modal", className: "min-w-[13.75rem] max-w-[22.5rem]" },
85
+ ],
86
+ defaultVariants: {
87
+ variant: "collapsed",
88
+ narrow: false,
89
+ xr: false,
90
+ },
91
+ },
92
+ );
93
+
94
+ // ─────────────────────────────────────────────────────────────────────────────
95
+ // Helpers
96
+ // ─────────────────────────────────────────────────────────────────────────────
97
+
98
+ function cloneIconWithFill(
99
+ icon: React.ReactNode,
100
+ selected: boolean,
101
+ ): React.ReactNode {
102
+ if (!React.isValidElement(icon)) return icon;
103
+ if ((icon.type as unknown) === Icon) {
104
+ return React.cloneElement(
105
+ icon as React.ReactElement<{ fill?: 0 | 1; animateFill?: boolean }>,
106
+ { fill: selected ? 1 : 0, animateFill: true },
107
+ );
108
+ }
109
+ return icon;
110
+ }
111
+
112
+ function getMenuItems(container: HTMLElement): HTMLElement[] {
113
+ return Array.from(
114
+ container.querySelectorAll<HTMLElement>(
115
+ '[role="menuitem"]:not([aria-disabled="true"])',
116
+ ),
117
+ );
118
+ }
119
+
120
+ function setFocusedItem(items: HTMLElement[], index: number) {
121
+ for (const item of items) item.tabIndex = -1;
122
+ const target = items[index];
123
+ if (target) {
124
+ target.tabIndex = 0;
125
+ target.focus();
126
+ }
127
+ }
128
+
129
+ // ─────────────────────────────────────────────────────────────────────────────
130
+ // NavigationRailItem Sub-components
131
+ // ─────────────────────────────────────────────────────────────────────────────
132
+
133
+ interface ActivePillProps {
134
+ layoutId: string;
135
+ disableInitial?: boolean;
136
+ }
137
+
138
+ function ActivePill({ layoutId, disableInitial = false }: ActivePillProps) {
139
+ return (
140
+ <m.div
141
+ layoutId={layoutId}
142
+ className="absolute inset-0 bg-m3-secondary-container pointer-events-none"
143
+ style={{ borderRadius: 9999, zIndex: 0, originX: 0.5, originY: 0.5 }}
144
+ initial={disableInitial ? false : { opacity: 0, scale: 0.5 }}
145
+ animate={{ opacity: 1, scale: 1 }}
146
+ exit={{ opacity: 0, scale: 0.5, transition: { duration: 0.15 } }}
147
+ transition={SPRING_TRANSITION_EXPRESSIVE}
148
+ />
149
+ );
150
+ }
151
+
152
+ function HoverStateLayer() {
153
+ return (
154
+ <div className="absolute inset-0 rounded-full bg-m3-on-surface opacity-0 group-hover:opacity-[0.08] transition-opacity duration-200 pointer-events-none z-0" />
155
+ );
156
+ }
157
+
158
+ interface RippleLayerProps {
159
+ ripples: ReturnType<typeof useRippleState>["ripples"];
160
+ onRippleDone: ReturnType<typeof useRippleState>["removeRipple"];
161
+ }
162
+
163
+ function RippleLayer({ ripples, onRippleDone }: RippleLayerProps) {
164
+ return (
165
+ <div className="absolute inset-0 rounded-full overflow-hidden pointer-events-none z-0">
166
+ <Ripple ripples={ripples} onRippleDone={onRippleDone} />
167
+ </div>
168
+ );
169
+ }
170
+
171
+ interface IconContainerProps {
172
+ selected: boolean;
173
+ badge?: React.ReactNode;
174
+ children: React.ReactNode;
175
+ }
176
+
177
+ function IconContainer({ selected, badge, children }: IconContainerProps) {
178
+ return (
179
+ <div
180
+ aria-hidden="true"
181
+ className={cn(
182
+ "relative flex items-center justify-center size-6 transition-colors duration-200",
183
+ selected
184
+ ? "text-m3-on-secondary-container"
185
+ : "text-m3-on-surface-variant",
186
+ )}
187
+ >
188
+ {children}
189
+ {badge && (
190
+ <span className="absolute -top-1 -right-1 flex min-w-3 h-3 items-center justify-center rounded-full bg-m3-error px-1 text-[10px] font-medium leading-none tracking-normal text-m3-on-error ring-[1.5px] ring-m3-surface">
191
+ {badge}
192
+ </span>
193
+ )}
194
+ </div>
195
+ );
196
+ }
197
+
198
+ // ─────────────────────────────────────────────────────────────────────────────
199
+ // NavigationRailItem
200
+ // ─────────────────────────────────────────────────────────────────────────────
201
+
202
+ const NavigationRailItemComponent = React.forwardRef<
203
+ HTMLButtonElement,
204
+ NavigationRailItemProps
205
+ >(
206
+ (
207
+ {
208
+ selected,
209
+ icon,
210
+ label,
211
+ onClick,
212
+ disabled = false,
213
+ badge,
214
+ className,
215
+ "aria-label": ariaLabelProp,
216
+ },
217
+ ref,
218
+ ) => {
219
+ const { variant, labelVisibility } = React.useContext(
220
+ NavigationRailContext,
221
+ );
222
+ const isExpanded = variant === "expanded" || variant === "modal";
223
+ const isModal = variant === "modal";
224
+ const enableLayout = !isModal;
225
+
226
+ const activePillId = `rail-pill-${React.useId()}`;
227
+ const { ripples, onPointerDown, removeRipple } = useRippleState({
228
+ disabled,
229
+ });
230
+
231
+ const showLabel =
232
+ isExpanded ||
233
+ labelVisibility === "labeled" ||
234
+ (labelVisibility === "auto" && selected);
235
+
236
+ const handleClick = React.useCallback(
237
+ (e: React.MouseEvent<HTMLButtonElement>) => {
238
+ if (disabled) {
239
+ e.preventDefault();
240
+ return;
241
+ }
242
+ onClick?.();
243
+ },
244
+ [disabled, onClick],
245
+ );
246
+
247
+ const filledIcon = cloneIconWithFill(icon, selected);
248
+
249
+ const labelInitial = isModal
250
+ ? false
251
+ : { opacity: 0, x: isExpanded ? -12 : 0, y: isExpanded ? 0 : -8 };
252
+
253
+ return (
254
+ <LazyMotion features={domMax} strict>
255
+ <m.button
256
+ layout={enableLayout}
257
+ ref={ref}
258
+ type="button"
259
+ role="menuitem"
260
+ aria-current={selected ? "page" : undefined}
261
+ aria-disabled={disabled ? true : undefined}
262
+ aria-label={
263
+ ariaLabelProp || (typeof label === "string" ? label : undefined)
264
+ }
265
+ onClick={handleClick}
266
+ onPointerDown={onPointerDown}
267
+ className={cn(
268
+ "group relative flex cursor-pointer transition-colors duration-200 outline-none select-none",
269
+ "focus-visible:ring-2 focus-visible:ring-m3-primary focus-visible:ring-offset-2 rounded-full",
270
+ disabled && "pointer-events-none opacity-[0.38]",
271
+ isExpanded
272
+ ? "w-full flex-row items-center px-3 h-14"
273
+ : "w-full flex-col justify-center h-14",
274
+ className,
275
+ )}
276
+ tabIndex={-1}
277
+ >
278
+ {/* Pill container - adapts layout for expanded vs collapsed */}
279
+ <m.div
280
+ layout={enableLayout}
281
+ className={cn(
282
+ "relative flex z-10",
283
+ isExpanded
284
+ ? "flex-row items-center w-fit h-14 px-4 gap-x-3 rounded-full"
285
+ : "flex-col items-center justify-center w-full gap-y-1 rounded-full",
286
+ )}
287
+ >
288
+ {isExpanded && (
289
+ <AnimatePresence initial={false}>
290
+ {selected && (
291
+ <ActivePill
292
+ layoutId={activePillId}
293
+ disableInitial={isModal}
294
+ />
295
+ )}
296
+ </AnimatePresence>
297
+ )}
298
+ {isExpanded && <HoverStateLayer />}
299
+ {isExpanded && (
300
+ <RippleLayer ripples={ripples} onRippleDone={removeRipple} />
301
+ )}
302
+
303
+ {/* Icon pill - collapsed mode */}
304
+ <m.div
305
+ layout={enableLayout}
306
+ className={cn(
307
+ "relative flex items-center justify-center shrink-0 z-10",
308
+ isExpanded ? "size-6" : "h-8 w-14 mx-auto",
309
+ )}
310
+ style={{ borderRadius: 9999 }}
311
+ >
312
+ {!isExpanded && (
313
+ <AnimatePresence initial={false}>
314
+ {selected && <ActivePill layoutId={activePillId} />}
315
+ </AnimatePresence>
316
+ )}
317
+ {!isExpanded && <HoverStateLayer />}
318
+ {!isExpanded && (
319
+ <RippleLayer ripples={ripples} onRippleDone={removeRipple} />
320
+ )}
321
+
322
+ <m.div
323
+ layout={enableLayout ? "position" : false}
324
+ className="relative z-10 flex size-6 items-center justify-center text-current"
325
+ >
326
+ <IconContainer selected={selected} badge={badge}>
327
+ {filledIcon}
328
+ </IconContainer>
329
+ </m.div>
330
+ </m.div>
331
+
332
+ <AnimatePresence mode="popLayout">
333
+ {showLabel && label && (
334
+ <m.span
335
+ key="rail-label"
336
+ layout={enableLayout ? "position" : false}
337
+ initial={labelInitial}
338
+ animate={{ opacity: 1, x: 0, y: 0 }}
339
+ exit={{ opacity: 0, transition: { duration: 0.1 } }}
340
+ transition={SPRING_TRANSITION}
341
+ className={cn(
342
+ "z-10 transition-colors duration-200 whitespace-nowrap",
343
+ selected
344
+ ? "text-m3-on-surface"
345
+ : "text-m3-on-surface-variant",
346
+ isExpanded
347
+ ? "text-sm font-medium tracking-wide text-left"
348
+ : "text-xs font-medium tracking-wide",
349
+ )}
350
+ >
351
+ {label}
352
+ </m.span>
353
+ )}
354
+ </AnimatePresence>
355
+ </m.div>
356
+
357
+ <TouchTarget />
358
+ </m.button>
359
+ </LazyMotion>
360
+ );
361
+ },
362
+ );
363
+
364
+ NavigationRailItemComponent.displayName = "NavigationRailItem";
365
+ export const NavigationRailItem = React.memo(NavigationRailItemComponent);
366
+
367
+ // ─────────────────────────────────────────────────────────────────────────────
368
+ // NavigationRail Container
369
+ // ─────────────────────────────────────────────────────────────────────────────
370
+
371
+ function useRoving(navRef: React.RefObject<HTMLElement | null>) {
372
+ React.useEffect(() => {
373
+ if (!navRef.current) return;
374
+ const items = getMenuItems(navRef.current);
375
+ const selected = items.find(
376
+ (el) => el.getAttribute("aria-current") === "page",
377
+ );
378
+
379
+ for (const item of items) item.tabIndex = -1;
380
+ const firstFocusable = selected ?? items[0];
381
+ if (firstFocusable) firstFocusable.tabIndex = 0;
382
+ }, [navRef]);
383
+
384
+ return React.useCallback(
385
+ (e: React.KeyboardEvent<HTMLElement>) => {
386
+ if (!navRef.current) return;
387
+ const items = getMenuItems(navRef.current);
388
+ if (items.length === 0) return;
389
+
390
+ const currentIndex = items.indexOf(document.activeElement as HTMLElement);
391
+
392
+ const keyMap: Record<string, () => number> = {
393
+ ArrowDown: () =>
394
+ currentIndex < items.length - 1 ? currentIndex + 1 : 0,
395
+ ArrowRight: () =>
396
+ currentIndex < items.length - 1 ? currentIndex + 1 : 0,
397
+ ArrowUp: () => (currentIndex > 0 ? currentIndex - 1 : items.length - 1),
398
+ ArrowLeft: () =>
399
+ currentIndex > 0 ? currentIndex - 1 : items.length - 1,
400
+ Home: () => 0,
401
+ End: () => items.length - 1,
402
+ };
403
+
404
+ const getNextIndex = keyMap[e.key];
405
+
406
+ if (getNextIndex) {
407
+ e.preventDefault();
408
+ setFocusedItem(items, getNextIndex());
409
+ return;
410
+ }
411
+
412
+ if (
413
+ (e.key === " " || e.key === "Enter") &&
414
+ items.includes(document.activeElement as HTMLElement)
415
+ ) {
416
+ e.preventDefault();
417
+ (document.activeElement as HTMLElement).click();
418
+ }
419
+ },
420
+ [navRef],
421
+ );
422
+ }
423
+
424
+ const NavigationRailComponent = React.forwardRef<
425
+ HTMLElement,
426
+ NavigationRailProps
427
+ >(
428
+ (
429
+ {
430
+ variant = "collapsed",
431
+ labelVisibility = "labeled",
432
+ header,
433
+ fab,
434
+ footer,
435
+ narrow = false,
436
+ open = false,
437
+ xr = false,
438
+ onClose,
439
+ children,
440
+ className,
441
+ style,
442
+ },
443
+ ref,
444
+ ) => {
445
+ const isModal = variant === "modal";
446
+ const isXr = xr === true || xr === "contained" || xr === "spatialized";
447
+ const xrMode = xr === "spatialized" ? "spatialized" : "contained";
448
+ const isSpatial = isXr && xrMode === "spatialized";
449
+ const applyAnimation = !isXr || !isSpatial;
450
+
451
+ const navRef = React.useRef<HTMLElement>(null);
452
+ const handleKeyDown = useRoving(navRef);
453
+
454
+ const setRefs = React.useCallback(
455
+ (node: HTMLElement | null) => {
456
+ navRef.current = node;
457
+ if (typeof ref === "function") ref(node);
458
+ else if (ref) ref.current = node;
459
+ },
460
+ [ref],
461
+ );
462
+
463
+ const navBaseClasses = cn(
464
+ railContainerVariants({ variant, narrow, xr: isXr }),
465
+ );
466
+ const modalPositioning = isModal ? "fixed left-0 top-0 z-[100]" : "";
467
+
468
+ const navHeaderSpacing = (() => {
469
+ if (!isXr) return "mb-6 min-h-10";
470
+ if (xrMode === "contained") return fab ? "mb-10" : "mb-5";
471
+ return "mb-5";
472
+ })();
473
+
474
+ const navElement = (
475
+ <m.nav
476
+ key="md3-nav-rail"
477
+ layout={!isModal}
478
+ ref={isSpatial ? undefined : setRefs}
479
+ role="navigation"
480
+ aria-label="Main navigation"
481
+ className={cn(
482
+ navBaseClasses,
483
+ !isSpatial && modalPositioning,
484
+ !isSpatial && isModal && applyAnimation && "will-change-transform",
485
+ !isSpatial && className,
486
+ )}
487
+ style={isSpatial ? undefined : style}
488
+ onKeyDown={handleKeyDown}
489
+ initial={isModal && applyAnimation ? { x: "-100%" } : false}
490
+ animate={isModal && applyAnimation ? { x: 0 } : false}
491
+ exit={isModal && applyAnimation ? { x: "-100%" } : undefined}
492
+ transition={isModal ? MD3_MODAL_TRANSITION : SPRING_TRANSITION}
493
+ >
494
+ {(header || (fab && !isSpatial)) && (
495
+ <div
496
+ className={cn(
497
+ "flex w-full flex-col items-center justify-start shrink-0 empty:hidden",
498
+ navHeaderSpacing,
499
+ )}
500
+ >
501
+ {header}
502
+ {header && fab && !isSpatial && (
503
+ <div className={isXr ? "h-1" : "h-4"} />
504
+ )}
505
+ {!isSpatial && fab}
506
+ </div>
507
+ )}
508
+
509
+ <div
510
+ role="menubar"
511
+ aria-orientation="vertical"
512
+ className="flex flex-col flex-1 w-full gap-y-5"
513
+ >
514
+ {children}
515
+ </div>
516
+
517
+ {footer && (
518
+ <div className="flex w-full flex-col items-center justify-end mt-auto shrink-0 pt-4 empty:hidden">
519
+ {footer}
520
+ </div>
521
+ )}
522
+ </m.nav>
523
+ );
524
+
525
+ const spatialFabSize = narrow
526
+ ? "size-20 rounded-[40px]"
527
+ : "size-24 rounded-[48px]";
528
+
529
+ const spatialWrapper = (
530
+ <m.div
531
+ key="md3-nav-wrapper"
532
+ ref={setRefs}
533
+ className={cn(
534
+ "flex flex-col items-center gap-y-5 pointer-events-none",
535
+ modalPositioning,
536
+ "m-6",
537
+ isModal && "will-change-transform",
538
+ className,
539
+ )}
540
+ style={style}
541
+ initial={isModal ? { x: "-100%" } : false}
542
+ animate={isModal ? { x: 0 } : false}
543
+ exit={isModal ? { x: "-100%" } : undefined}
544
+ transition={isModal ? MD3_MODAL_TRANSITION : SPRING_TRANSITION}
545
+ >
546
+ {fab && (
547
+ <div
548
+ className={cn(
549
+ "flex shrink-0 items-center justify-center pointer-events-auto",
550
+ spatialFabSize,
551
+ )}
552
+ >
553
+ {fab}
554
+ </div>
555
+ )}
556
+ {React.cloneElement(
557
+ navElement as React.ReactElement<{ className?: string }>,
558
+ {
559
+ className: cn(navBaseClasses, "pointer-events-auto"),
560
+ },
561
+ )}
562
+ </m.div>
563
+ );
564
+
565
+ const finalNavElement = isSpatial ? spatialWrapper : navElement;
566
+
567
+ const contextValue = { variant, labelVisibility, xr: isXr };
568
+
569
+ if (isModal) {
570
+ if (typeof document === "undefined") return null;
571
+
572
+ return createPortal(
573
+ <LazyMotion features={domMax} strict>
574
+ <NavigationRailContext.Provider value={contextValue}>
575
+ <AnimatePresence>
576
+ {open && (
577
+ <m.div
578
+ key="md3-nav-backdrop"
579
+ initial={{ opacity: 0 }}
580
+ animate={{ opacity: 1 }}
581
+ exit={{ opacity: 0 }}
582
+ transition={{ duration: 0.2, ease: "linear" }}
583
+ className="fixed inset-0 bg-black/40 z-40 will-change-[opacity]"
584
+ onClick={onClose}
585
+ aria-hidden="true"
586
+ />
587
+ )}
588
+ {open && finalNavElement}
589
+ </AnimatePresence>
590
+ </NavigationRailContext.Provider>
591
+ </LazyMotion>,
592
+ document.body,
593
+ );
594
+ }
595
+
596
+ return (
597
+ <LazyMotion features={domMax} strict>
598
+ <NavigationRailContext.Provider value={contextValue}>
599
+ {finalNavElement}
600
+ </NavigationRailContext.Provider>
601
+ </LazyMotion>
602
+ );
603
+ },
604
+ );
605
+
606
+ NavigationRailComponent.displayName = "NavigationRail";
607
+ export const NavigationRail = React.memo(NavigationRailComponent);