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