@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,561 @@
1
+ /**
2
+ * @file range-slider.tsx
3
+ * MD3 Expressive RangeSlider — Two-thumb slider with crossover prevention.
4
+ *
5
+ * Extends the core Slider architecture with:
6
+ * - Two SliderThumb instances (start + end)
7
+ * - Crossover prevention: start cannot exceed end and vice versa
8
+ * - Z-index management: last-dragged thumb always on top
9
+ * - Active track spans from startPercent to endPercent
10
+ *
11
+ * Design decisions:
12
+ * - The track renders a custom "range" segment between the two thumbs.
13
+ * - Each thumb has independent onValueChange callbacks + crossover clamping.
14
+ * - Last-active thumb gets zIndex=2 to appear on top when thumbs are at same position.
15
+ *
16
+ * @see https://m3.material.io/components/sliders/overview
17
+ * @see docs/m3/sliders/Slider.kt#RangeSlider
18
+ */
19
+
20
+ import { domMax, LazyMotion } from "motion/react";
21
+ import * as React from "react";
22
+ import { cn } from "../../lib/utils";
23
+ import { useSliderMath } from "./hooks/useSliderMath";
24
+ import { SliderColors, SliderTokens } from "./slider.tokens";
25
+ import type {
26
+ RangeSliderProps,
27
+ SliderTrackShape,
28
+ SliderTrackSize,
29
+ SliderVariant,
30
+ } from "./slider.types";
31
+ import { SliderThumb } from "./slider-thumb";
32
+
33
+ // ─── Range Track ─────────────────────────────────────────────────────────────
34
+
35
+ interface RangeTrackProps {
36
+ startPercent: number;
37
+ endPercent: number;
38
+ trackSize: SliderTrackSize;
39
+ orientation: "horizontal" | "vertical";
40
+ variant: SliderVariant;
41
+ disabled: boolean;
42
+ ticks: number[];
43
+ min: number;
44
+ max: number;
45
+ trackShape?: SliderTrackShape;
46
+ trackRef: React.RefObject<HTMLDivElement | null>;
47
+ onTrackPointerDown?: (e: React.PointerEvent<HTMLDivElement>) => void;
48
+ }
49
+
50
+ /**
51
+ * Track for RangeSlider: renders three segments.
52
+ * Inactive | Gap | Active (range) | Gap | Inactive
53
+ */
54
+ const RangeTrack = React.memo(function RangeTrack({
55
+ startPercent,
56
+ endPercent,
57
+ trackSize,
58
+ orientation,
59
+ variant,
60
+ disabled,
61
+ ticks,
62
+ min,
63
+ max,
64
+ trackShape = "md3",
65
+ trackRef,
66
+ onTrackPointerDown,
67
+ }: RangeTrackProps) {
68
+ const isHorizontal = orientation === "horizontal";
69
+ const size = SliderTokens.trackSizes[trackSize];
70
+ const thumbHeight = SliderTokens.thumbHeights[trackSize];
71
+ const { thumbGap, thumbWidthDefault, tickSize } = SliderTokens;
72
+ const thumbHalfWidth = thumbWidthDefault / 2;
73
+
74
+ const activeTrack = disabled
75
+ ? SliderColors.disabledActiveTrack
76
+ : `var(--md-sys-color-${variant})`;
77
+ const inactiveTrack = disabled
78
+ ? SliderColors.disabledInactiveTrack
79
+ : `var(--md-sys-color-${variant}-container)`;
80
+
81
+ const innerR = SliderTokens.trackInnerRadius;
82
+
83
+ let outerR = size / 2;
84
+ if (trackShape === "md3") {
85
+ outerR = Math.min(SliderTokens.trackShapes[trackSize], size / 2);
86
+ } else if (typeof trackShape === "number") {
87
+ outerR = Math.min(trackShape, size / 2);
88
+ }
89
+
90
+ if (isHorizontal) {
91
+ const insetLimit = thumbGap + thumbWidthDefault / 2;
92
+ const trackInset = Math.min(size / 2, insetLimit);
93
+ const cxStart = `calc(${trackInset}px + ${startPercent} * (100% - ${trackInset * 2}px))`;
94
+ const cxEnd = `calc(${trackInset}px + ${endPercent} * (100% - ${trackInset * 2}px))`;
95
+ const gapWithThumbStr = `${thumbGap + thumbHalfWidth}px`;
96
+
97
+ const leadingWidth = `max(0px, calc(${cxStart} - ${gapWithThumbStr}))`;
98
+ const activeLeft = `calc(${cxStart} + ${gapWithThumbStr})`;
99
+ const activeWidth = `max(0px, calc(${cxEnd} - ${cxStart} - ${gapWithThumbStr} * 2))`;
100
+ const trailingLeft = `calc(${cxEnd} + ${gapWithThumbStr})`;
101
+ const trailingWidth = `max(0px, calc(100% - (${cxEnd} + ${gapWithThumbStr})))`;
102
+
103
+ return (
104
+ <div
105
+ ref={trackRef}
106
+ className={cn(
107
+ "relative w-full",
108
+ disabled ? "cursor-not-allowed" : "cursor-pointer",
109
+ )}
110
+ style={{ height: thumbHeight }}
111
+ onPointerDown={onTrackPointerDown}
112
+ aria-hidden="true"
113
+ >
114
+ {/* Leading inactive */}
115
+ <div
116
+ aria-hidden="true"
117
+ style={{
118
+ position: "absolute",
119
+ left: 0,
120
+ top: "50%",
121
+ transform: "translateY(-50%)",
122
+ width: leadingWidth,
123
+ height: size,
124
+ backgroundColor: inactiveTrack,
125
+ opacity: disabled ? 0.38 : 1,
126
+ borderTopLeftRadius: outerR,
127
+ borderBottomLeftRadius: outerR,
128
+ borderTopRightRadius: innerR,
129
+ borderBottomRightRadius: innerR,
130
+ }}
131
+ />
132
+ {/* Active middle segment */}
133
+ <div
134
+ aria-hidden="true"
135
+ style={{
136
+ position: "absolute",
137
+ left: activeLeft,
138
+ top: "50%",
139
+ transform: "translateY(-50%)",
140
+ width: activeWidth,
141
+ height: size,
142
+ backgroundColor: activeTrack,
143
+ opacity: disabled ? 0.38 : 1,
144
+ borderTopLeftRadius: startPercent <= 0 ? outerR : innerR,
145
+ borderBottomLeftRadius: startPercent <= 0 ? outerR : innerR,
146
+ borderTopRightRadius: endPercent >= 1 ? outerR : innerR,
147
+ borderBottomRightRadius: endPercent >= 1 ? outerR : innerR,
148
+ }}
149
+ />
150
+ {/* Trailing inactive */}
151
+ <div
152
+ aria-hidden="true"
153
+ style={{
154
+ position: "absolute",
155
+ left: trailingLeft,
156
+ top: "50%",
157
+ transform: "translateY(-50%)",
158
+ width: trailingWidth,
159
+ height: size,
160
+ backgroundColor: inactiveTrack,
161
+ opacity: disabled ? 0.38 : 1,
162
+ borderTopLeftRadius: innerR,
163
+ borderBottomLeftRadius: innerR,
164
+ borderTopRightRadius: outerR,
165
+ borderBottomRightRadius: outerR,
166
+ }}
167
+ />
168
+ {/* Ticks for discrete mode */}
169
+ {ticks.map((tick) => {
170
+ const tickPct = (tick - min) / (max - min);
171
+ const isActive = tickPct > startPercent && tickPct < endPercent;
172
+ return (
173
+ <div
174
+ key={tick}
175
+ aria-hidden="true"
176
+ style={{
177
+ position: "absolute",
178
+ width: tickSize,
179
+ height: tickSize,
180
+ borderRadius: "50%",
181
+ backgroundColor: disabled
182
+ ? SliderColors.disabledTick
183
+ : isActive
184
+ ? `var(--md-sys-color-${variant}-container)`
185
+ : `var(--md-sys-color-${variant})`,
186
+ opacity: disabled ? 0.38 : 1,
187
+ left: `calc(${trackInset}px + ${tickPct} * (100% - ${trackInset * 2}px) - ${tickSize / 2}px)`,
188
+ top: "50%",
189
+ transform: "translateY(-50%)",
190
+ }}
191
+ />
192
+ );
193
+ })}
194
+ </div>
195
+ );
196
+ }
197
+
198
+ // ── Vertical range track ──────────────────────────────────────────────────
199
+ const insetLimit = thumbGap + thumbWidthDefault / 2;
200
+ const trackInset = Math.min(size / 2, insetLimit);
201
+ const cyStart = `calc(${trackInset}px + ${startPercent} * (100% - ${trackInset * 2}px))`;
202
+ const cyEnd = `calc(${trackInset}px + ${endPercent} * (100% - ${trackInset * 2}px))`;
203
+ const gapWithThumbStr = `${thumbGap + thumbHalfWidth}px`;
204
+
205
+ const leadingHeight = `max(0px, calc(${cyStart} - ${gapWithThumbStr}))`;
206
+ const activeBottom = `calc(${cyStart} + ${gapWithThumbStr})`;
207
+ const activeHeight = `max(0px, calc(${cyEnd} - ${cyStart} - ${gapWithThumbStr} * 2))`;
208
+ const trailingBottom = `calc(${cyEnd} + ${gapWithThumbStr})`;
209
+ const trailingHeight = `max(0px, calc(100% - (${cyEnd} + ${gapWithThumbStr})))`;
210
+
211
+ return (
212
+ <div
213
+ ref={trackRef}
214
+ className={cn(
215
+ "relative h-full",
216
+ disabled ? "cursor-not-allowed" : "cursor-pointer",
217
+ )}
218
+ style={{ width: thumbHeight }}
219
+ onPointerDown={onTrackPointerDown}
220
+ aria-hidden="true"
221
+ >
222
+ {/* Bottom inactive */}
223
+ <div
224
+ aria-hidden="true"
225
+ style={{
226
+ position: "absolute",
227
+ bottom: 0,
228
+ left: "50%",
229
+ transform: "translateX(-50%)",
230
+ height: leadingHeight,
231
+ width: size,
232
+ backgroundColor: inactiveTrack,
233
+ opacity: disabled ? 0.38 : 1,
234
+ borderBottomLeftRadius: outerR,
235
+ borderBottomRightRadius: outerR,
236
+ borderTopLeftRadius: innerR,
237
+ borderTopRightRadius: innerR,
238
+ }}
239
+ />
240
+ {/* Active middle */}
241
+ <div
242
+ aria-hidden="true"
243
+ style={{
244
+ position: "absolute",
245
+ bottom: activeBottom,
246
+ left: "50%",
247
+ transform: "translateX(-50%)",
248
+ height: activeHeight,
249
+ width: size,
250
+ backgroundColor: activeTrack,
251
+ opacity: disabled ? 0.38 : 1,
252
+ borderBottomLeftRadius: startPercent <= 0 ? outerR : innerR,
253
+ borderBottomRightRadius: startPercent <= 0 ? outerR : innerR,
254
+ borderTopLeftRadius: endPercent >= 1 ? outerR : innerR,
255
+ borderTopRightRadius: endPercent >= 1 ? outerR : innerR,
256
+ }}
257
+ />
258
+ {/* Top inactive */}
259
+ <div
260
+ aria-hidden="true"
261
+ style={{
262
+ position: "absolute",
263
+ bottom: trailingBottom,
264
+ left: "50%",
265
+ transform: "translateX(-50%)",
266
+ height: trailingHeight,
267
+ width: size,
268
+ backgroundColor: inactiveTrack,
269
+ opacity: disabled ? 0.38 : 1,
270
+ borderTopLeftRadius: outerR,
271
+ borderTopRightRadius: outerR,
272
+ borderBottomLeftRadius: innerR,
273
+ borderBottomRightRadius: innerR,
274
+ }}
275
+ />
276
+ {/* Ticks for discrete mode */}
277
+ {ticks.map((tick) => {
278
+ const tickPct = (tick - min) / (max - min);
279
+ const isActive = tickPct > startPercent && tickPct < endPercent;
280
+ return (
281
+ <div
282
+ key={tick}
283
+ aria-hidden="true"
284
+ style={{
285
+ position: "absolute",
286
+ width: tickSize,
287
+ height: tickSize,
288
+ borderRadius: "50%",
289
+ backgroundColor: disabled
290
+ ? SliderColors.disabledTick
291
+ : isActive
292
+ ? `var(--md-sys-color-${variant}-container)`
293
+ : `var(--md-sys-color-${variant})`,
294
+ opacity: disabled ? 0.38 : 1,
295
+ bottom: `calc(${trackInset}px + ${tickPct} * (100% - ${trackInset * 2}px) - ${tickSize / 2}px)`,
296
+ left: "50%",
297
+ transform: "translateX(-50%)",
298
+ }}
299
+ />
300
+ );
301
+ })}
302
+ </div>
303
+ );
304
+ });
305
+
306
+ // ─── RangeSlider ──────────────────────────────────────────────────────────────
307
+
308
+ const RangeSliderComponent = React.forwardRef<HTMLDivElement, RangeSliderProps>(
309
+ (
310
+ {
311
+ value: controlledValue,
312
+ defaultValue,
313
+ onValueChange,
314
+ onValueChangeEnd,
315
+ min = 0,
316
+ max = 100,
317
+ step = 0,
318
+ orientation = "horizontal",
319
+ trackSize = "m",
320
+ trackShape = "md3",
321
+ variant = "primary",
322
+ disabled = false,
323
+ showValueIndicator = false,
324
+ showTicks = false,
325
+ formatValue,
326
+ className,
327
+ "aria-label": ariaLabel,
328
+ },
329
+ ref,
330
+ ) => {
331
+ const isHorizontal = orientation === "horizontal";
332
+
333
+ // ── Controlled / Uncontrolled ────────────────────────────────────────
334
+ const defaultStart = defaultValue?.[0] ?? min;
335
+ const defaultEnd = defaultValue?.[1] ?? max;
336
+ const [internalValue, setInternalValue] = React.useState<[number, number]>([
337
+ defaultStart,
338
+ defaultEnd,
339
+ ]);
340
+
341
+ const resolvedValue: [number, number] =
342
+ controlledValue !== undefined ? controlledValue : internalValue;
343
+
344
+ const { coerce, snap, toPercent, ticks } = useSliderMath({
345
+ min,
346
+ max,
347
+ step,
348
+ });
349
+
350
+ // Safe, snapped values
351
+ const startValue = snap(coerce(resolvedValue[0]));
352
+ const endValue = snap(coerce(resolvedValue[1]));
353
+ const startPercent = toPercent(startValue);
354
+ const endPercent = toPercent(endValue);
355
+
356
+ // ── Last-active thumb z-index management ────────────────────────────
357
+ // Whichever thumb was most recently interacted with gets zIndex=2
358
+ const [topThumb, setTopThumb] = React.useState<"start" | "end">("end");
359
+
360
+ // ── Track ref ───────────────────────────────────────────────────────
361
+ const trackRef = React.useRef<HTMLDivElement>(null);
362
+
363
+ // ── Crossover-safe setters ──────────────────────────────────────────
364
+ // Minimum gap: 1 step (or 1 unit for continuous)
365
+ const minGap = step > 0 ? step : (max - min) / 1000;
366
+
367
+ const handleStartChange = React.useCallback(
368
+ (newStart: number) => {
369
+ setTopThumb("start");
370
+ // Cannot exceed end thumb
371
+ const clamped = Math.min(newStart, endValue - minGap);
372
+ const final = snap(coerce(clamped));
373
+ if (controlledValue === undefined) {
374
+ setInternalValue([final, endValue]);
375
+ }
376
+ onValueChange?.([final, endValue]);
377
+ },
378
+ [controlledValue, coerce, endValue, minGap, onValueChange, snap],
379
+ );
380
+
381
+ const handleEndChange = React.useCallback(
382
+ (newEnd: number) => {
383
+ setTopThumb("end");
384
+ // Cannot go below start thumb
385
+ const clamped = Math.max(newEnd, startValue + minGap);
386
+ const final = snap(coerce(clamped));
387
+ if (controlledValue === undefined) {
388
+ setInternalValue([startValue, final]);
389
+ }
390
+ onValueChange?.([startValue, final]);
391
+ },
392
+ [controlledValue, coerce, minGap, onValueChange, snap, startValue],
393
+ );
394
+
395
+ const handleStartChangeEnd = React.useCallback(
396
+ (v: number) => onValueChangeEnd?.([v, endValue]),
397
+ [endValue, onValueChangeEnd],
398
+ );
399
+
400
+ const handleEndChangeEnd = React.useCallback(
401
+ (v: number) => onValueChangeEnd?.([startValue, v]),
402
+ [onValueChangeEnd, startValue],
403
+ );
404
+
405
+ // ── Click-to-jump on track ──────────────────────────────────────────
406
+ const handleTrackPointerDown = React.useCallback(
407
+ (e: React.PointerEvent<HTMLDivElement>) => {
408
+ if (disabled) return;
409
+ const trackEl = trackRef.current;
410
+ if (!trackEl) return;
411
+
412
+ const rect = trackEl.getBoundingClientRect();
413
+ let clickPercent: number;
414
+
415
+ const insetLimit =
416
+ SliderTokens.thumbGap + SliderTokens.thumbWidthDefault / 2;
417
+ const trackInset = Math.min(
418
+ SliderTokens.trackSizes[trackSize] / 2,
419
+ insetLimit,
420
+ );
421
+ if (isHorizontal) {
422
+ const x = e.clientX - rect.left;
423
+ clickPercent = (x - trackInset) / (rect.width - trackInset * 2);
424
+ } else {
425
+ const y = rect.bottom - e.clientY;
426
+ clickPercent = (y - trackInset) / (rect.height - trackInset * 2);
427
+ }
428
+
429
+ const clamped = Math.max(0, Math.min(1, clickPercent));
430
+ const rawValue = min + clamped * (max - min);
431
+ const clickValue = snap(coerce(rawValue));
432
+
433
+ // Move whichever thumb is closer to click point
434
+ const distStart = Math.abs(clickValue - startValue);
435
+ const distEnd = Math.abs(clickValue - endValue);
436
+
437
+ if (distStart <= distEnd) {
438
+ handleStartChange(clickValue);
439
+ } else {
440
+ handleEndChange(clickValue);
441
+ }
442
+ },
443
+ [
444
+ coerce,
445
+ disabled,
446
+ endValue,
447
+ handleEndChange,
448
+ handleStartChange,
449
+ isHorizontal,
450
+ max,
451
+ min,
452
+ snap,
453
+ startValue,
454
+ trackSize,
455
+ ],
456
+ );
457
+
458
+ // ── IDs ─────────────────────────────────────────────────────────────
459
+ const id = React.useId();
460
+
461
+ return (
462
+ <LazyMotion features={domMax} strict>
463
+ <div
464
+ ref={ref}
465
+ className={cn(
466
+ "relative flex items-center",
467
+ isHorizontal ? "w-full flex-row" : "h-full flex-col",
468
+ disabled && "pointer-events-none",
469
+ className,
470
+ )}
471
+ style={isHorizontal ? { minWidth: 0 } : { minHeight: 0 }}
472
+ >
473
+ <RangeTrack
474
+ startPercent={startPercent}
475
+ endPercent={endPercent}
476
+ trackSize={trackSize}
477
+ trackShape={trackShape}
478
+ orientation={orientation}
479
+ variant={variant}
480
+ disabled={disabled}
481
+ ticks={showTicks ? ticks : []}
482
+ min={min}
483
+ max={max}
484
+ trackRef={trackRef}
485
+ onTrackPointerDown={handleTrackPointerDown}
486
+ />
487
+
488
+ <SliderThumb
489
+ value={startValue}
490
+ percent={startPercent}
491
+ min={min}
492
+ max={max}
493
+ step={step}
494
+ disabled={disabled}
495
+ orientation={orientation}
496
+ trackSize={trackSize}
497
+ variant={variant}
498
+ showValueIndicator={showValueIndicator}
499
+ trackRef={trackRef}
500
+ onValueChange={handleStartChange}
501
+ onValueChangeEnd={handleStartChangeEnd}
502
+ formatValue={formatValue}
503
+ thumbId={`${id}-start`}
504
+ zIndex={topThumb === "start" ? 2 : 1}
505
+ aria-label={ariaLabel ? `${ariaLabel} start` : "Range start"}
506
+ />
507
+
508
+ <SliderThumb
509
+ value={endValue}
510
+ percent={endPercent}
511
+ min={min}
512
+ max={max}
513
+ step={step}
514
+ disabled={disabled}
515
+ orientation={orientation}
516
+ trackSize={trackSize}
517
+ variant={variant}
518
+ showValueIndicator={showValueIndicator}
519
+ trackRef={trackRef}
520
+ onValueChange={handleEndChange}
521
+ onValueChangeEnd={handleEndChangeEnd}
522
+ formatValue={formatValue}
523
+ thumbId={`${id}-end`}
524
+ zIndex={topThumb === "end" ? 2 : 1}
525
+ aria-label={ariaLabel ? `${ariaLabel} end` : "Range end"}
526
+ />
527
+ </div>
528
+ </LazyMotion>
529
+ );
530
+ },
531
+ );
532
+
533
+ RangeSliderComponent.displayName = "RangeSlider";
534
+
535
+ /**
536
+ * MD3 Expressive RangeSlider component.
537
+ *
538
+ * Two-thumb slider where the active track spans between the two thumbs.
539
+ * Thumbs cannot cross each other (crossover prevention built in).
540
+ *
541
+ * @example
542
+ * ```tsx
543
+ * // Controlled
544
+ * <RangeSlider
545
+ * value={[20, 80]}
546
+ * onValueChange={([start, end]) => setRange([start, end])}
547
+ * aria-label="Price range"
548
+ * />
549
+ *
550
+ * // Discrete
551
+ * <RangeSlider defaultValue={[0, 100]} step={10} />
552
+ *
553
+ * // Vertical
554
+ * <div className="h-64">
555
+ * <RangeSlider defaultValue={[25, 75]} orientation="vertical" />
556
+ * </div>
557
+ * ```
558
+ *
559
+ * @see https://m3.material.io/components/sliders/overview
560
+ */
561
+ export const RangeSlider = React.memo(RangeSliderComponent);