@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,912 @@
1
+ /**
2
+ * @file slider-track.tsx
3
+ * MD3 Expressive Slider — Track with asymmetric corner radii, 6px thumb gaps,
4
+ * discrete tick marks, and centered-mode support.
5
+ *
6
+ * Design decisions:
7
+ * 1. GAP MATH: The 6px gap between track and thumb is calculated mathematically
8
+ * using CSS calc() — NOT using margin/padding which would break layout.
9
+ * 2. ASYMMETRIC RADII: Inner corners (facing thumb) = 2px; outer ends = size/2 (pill cap).
10
+ * 3. CENTERED MODE: Active segment spans from 50% outward to thumb, not from min.
11
+ * 4. TICKS: 4×4px dots positioned absolutely along track center axis.
12
+ * Color differs on active vs inactive portions of the track.
13
+ * 5. VERTICAL: Uses height/top instead of width/left, with inverted axis
14
+ * (bottom=0%, top=100%) per MD3 vertical spec.
15
+ *
16
+ * @see docs/m3/sliders/Slider.kt#SliderDefaults.Track
17
+ */
18
+
19
+ import { AnimatePresence, m, useReducedMotion } from "motion/react";
20
+ import * as React from "react";
21
+ import { cn } from "../../lib/utils";
22
+ import { SliderColors, SliderTokens } from "./slider.tokens";
23
+ import type {
24
+ SliderOrientation,
25
+ SliderTrackProps,
26
+ SliderTrackSize,
27
+ SliderVariant,
28
+ } from "./slider.types";
29
+
30
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Returns border-radius for a horizontal track segment.
34
+ * @param isLeading - whether this segment is on the leading (left) side of the thumb
35
+ */
36
+ function getHorizontalRadius(
37
+ isLeading: boolean,
38
+ innerR: number,
39
+ outerR: number,
40
+ ): React.CSSProperties {
41
+ if (isLeading) {
42
+ return {
43
+ borderTopLeftRadius: outerR,
44
+ borderBottomLeftRadius: outerR,
45
+ borderTopRightRadius: innerR,
46
+ borderBottomRightRadius: innerR,
47
+ };
48
+ }
49
+ return {
50
+ borderTopLeftRadius: innerR,
51
+ borderBottomLeftRadius: innerR,
52
+ borderTopRightRadius: outerR,
53
+ borderBottomRightRadius: outerR,
54
+ };
55
+ }
56
+
57
+ /** Border-radius for a vertical track segment. */
58
+ function getVerticalRadius(
59
+ isLeading: boolean,
60
+ innerR: number,
61
+ outerR: number,
62
+ ): React.CSSProperties {
63
+ if (isLeading) {
64
+ return {
65
+ borderBottomLeftRadius: outerR,
66
+ borderBottomRightRadius: outerR,
67
+ borderTopLeftRadius: innerR,
68
+ borderTopRightRadius: innerR,
69
+ };
70
+ }
71
+ return {
72
+ borderTopLeftRadius: outerR,
73
+ borderTopRightRadius: outerR,
74
+ borderBottomLeftRadius: innerR,
75
+ borderBottomRightRadius: innerR,
76
+ };
77
+ }
78
+
79
+ /** All-inner-radius shorthand for centered active segments (no pill caps). */
80
+ const allInnerRadius = (innerR: number): React.CSSProperties => ({
81
+ borderTopLeftRadius: innerR,
82
+ borderBottomLeftRadius: innerR,
83
+ borderTopRightRadius: innerR,
84
+ borderBottomRightRadius: innerR,
85
+ });
86
+
87
+ // ─── Inset Icon ───────────────────────────────────────────────────────────────
88
+
89
+ interface InsetIconProps {
90
+ /** The icon node to render. */
91
+ icon: React.ReactNode;
92
+ /** Whether the icon is currently positioned on the active (filled) segment. */
93
+ isOnActiveSegment: boolean;
94
+ /** The computed 'left' (horizontal) or 'bottom' (vertical) CSS value. */
95
+ position: string;
96
+ orientation: SliderOrientation;
97
+ /** Physical track height/width in px. */
98
+ trackSize: number;
99
+ /** Size token for looking up tokens (e.g. 'xl') */
100
+ trackSizeToken: SliderTrackSize;
101
+ disabled: boolean;
102
+ /** Color variant to match leading/trailing track. */
103
+ variant: SliderVariant;
104
+ /** Suppresses motion when user prefers reduced motion. */
105
+ prefersReduced: boolean;
106
+ }
107
+
108
+ /**
109
+ * Icon rendered inside the slider track.
110
+ *
111
+ * When the key changes (active ↔ inactive swap), AnimatePresence unmounts this
112
+ * with a fade-out, then mounts the new instance with a fade-in at its target
113
+ * position — producing the MD3 "hop" effect without any position cross-over.
114
+ * When the key stays the same (e.g. trailing-inactive tracking the thumb),
115
+ * the spring on `left`/`bottom` keeps it smoothly glued to the thumb.
116
+ */
117
+ const InsetIcon = React.memo(function InsetIcon({
118
+ icon,
119
+ isOnActiveSegment,
120
+ position,
121
+ orientation,
122
+ trackSize,
123
+ trackSizeToken,
124
+ disabled,
125
+ variant,
126
+ prefersReduced,
127
+ }: InsetIconProps) {
128
+ const iconSize = Math.min(
129
+ SliderTokens.insetIconSizes[trackSizeToken],
130
+ Math.max(4, trackSize - SliderTokens.insetIconPadding * 2),
131
+ );
132
+
133
+ const activeColor = `var(--md-sys-color-on-${variant})`;
134
+ const inactiveColor = `var(--md-sys-color-${variant})`;
135
+ const isHorizontal = orientation === "horizontal";
136
+ const fastFade = prefersReduced ? { duration: 0 } : { duration: 0.12 };
137
+
138
+ return (
139
+ <m.div
140
+ aria-hidden="true"
141
+ className="[&_svg]:w-full [&_svg]:h-full"
142
+ initial={{
143
+ opacity: 0,
144
+ ...(isHorizontal ? { left: position } : { bottom: position }),
145
+ }}
146
+ animate={{
147
+ [isHorizontal ? "left" : "bottom"]: position,
148
+ opacity: disabled ? 0.38 : 1,
149
+ color: isOnActiveSegment ? activeColor : inactiveColor,
150
+ }}
151
+ exit={{ opacity: 0, transition: fastFade }}
152
+ transition={
153
+ prefersReduced
154
+ ? { duration: 0 }
155
+ : {
156
+ left: { type: "spring", stiffness: 500, damping: 40 },
157
+ bottom: { type: "spring", stiffness: 500, damping: 40 },
158
+ opacity: fastFade,
159
+ color: fastFade,
160
+ }
161
+ }
162
+ style={{
163
+ position: "absolute",
164
+ width: iconSize,
165
+ height: iconSize,
166
+ display: "flex",
167
+ alignItems: "center",
168
+ justifyContent: "center",
169
+ pointerEvents: "none",
170
+ zIndex: 1,
171
+ willChange: isHorizontal ? "left" : "bottom",
172
+ ...(isHorizontal
173
+ ? { top: "50%", transform: "translateY(-50%)" }
174
+ : { left: "50%", transform: "translateX(-50%)" }),
175
+ }}
176
+ >
177
+ {icon}
178
+ </m.div>
179
+ );
180
+ });
181
+
182
+ // ─── Tick Marks ───────────────────────────────────────────────────────────────
183
+
184
+ interface TicksProps {
185
+ ticks: number[];
186
+ min: number;
187
+ max: number;
188
+ percent: number;
189
+ orientation: "horizontal" | "vertical";
190
+ variant: SliderVariant;
191
+ disabled: boolean;
192
+ /** Pre-computed track inset — avoids recalculating in every tick render. */
193
+ trackInset: number;
194
+ }
195
+
196
+ /** Renders tick dot markers for discrete slider mode. */
197
+ function Ticks({
198
+ ticks,
199
+ min,
200
+ max,
201
+ percent,
202
+ orientation,
203
+ variant,
204
+ isCentered,
205
+ disabled,
206
+ trackInset,
207
+ }: TicksProps & { isCentered?: boolean }) {
208
+ if (ticks.length === 0) return null;
209
+ const { thumbGap, thumbWidthDefault, tickSize } = SliderTokens;
210
+
211
+ return (
212
+ <>
213
+ {ticks.map((tick) => {
214
+ const tickPercent = (tick - min) / (max - min);
215
+ const isOnActive = isCentered
216
+ ? percent >= 0.5
217
+ ? tickPercent >= 0.5 && tickPercent <= percent
218
+ : tickPercent <= 0.5 && tickPercent >= percent
219
+ : tickPercent <= percent;
220
+
221
+ // Skip ticks that would be visually hidden inside the thumb gap
222
+ const thumbStart = percent - (thumbGap + thumbWidthDefault / 2) / 100;
223
+ const thumbEnd = percent + (thumbGap + thumbWidthDefault / 2) / 100;
224
+ if (tickPercent > thumbStart && tickPercent < thumbEnd) return null;
225
+
226
+ const color = disabled
227
+ ? SliderColors.disabledTick
228
+ : isOnActive
229
+ ? `var(--md-sys-color-${variant}-container)`
230
+ : `var(--md-sys-color-${variant})`;
231
+
232
+ const style: React.CSSProperties = {
233
+ position: "absolute",
234
+ width: tickSize,
235
+ height: tickSize,
236
+ borderRadius: "50%",
237
+ backgroundColor: color,
238
+ opacity: disabled ? 0.38 : 1,
239
+ ...(orientation === "horizontal"
240
+ ? {
241
+ left: `calc(${trackInset}px + ${tickPercent} * (100% - ${trackInset * 2}px) - ${tickSize / 2}px)`,
242
+ top: "50%",
243
+ transform: "translateY(-50%)",
244
+ }
245
+ : {
246
+ // Vertical: bottom=0%, top=100% (inverted Y-axis)
247
+ bottom: `calc(${trackInset}px + ${tickPercent} * (100% - ${trackInset * 2}px) - ${tickSize / 2}px)`,
248
+ left: "50%",
249
+ transform: "translateX(-50%)",
250
+ }),
251
+ };
252
+
253
+ return <div key={tick} style={style} aria-hidden="true" />;
254
+ })}
255
+ </>
256
+ );
257
+ }
258
+
259
+ // ─── SliderTrack ──────────────────────────────────────────────────────────────
260
+
261
+ /**
262
+ * MD3 Expressive Slider Track.
263
+ */
264
+ export const SliderTrack = React.memo(function SliderTrack({
265
+ percent,
266
+ trackSize,
267
+ orientation,
268
+ variant,
269
+ isCentered,
270
+ min,
271
+ max,
272
+ disabled,
273
+ trackRef,
274
+ onTrackPointerDown,
275
+ ticks = [],
276
+ insetIcon,
277
+ insetIconAtMin,
278
+ insetIconTrailing,
279
+ insetIconAtMax,
280
+ value,
281
+ trackShape = "md3",
282
+ }: Omit<SliderTrackProps, "step"> & { ticks?: number[] }) {
283
+ const isHorizontal = orientation === "horizontal";
284
+ const size = SliderTokens.trackSizes[trackSize];
285
+ const thumbHeight = SliderTokens.thumbHeights[trackSize];
286
+ const { thumbGap, thumbWidthDefault, trackInnerRadius } = SliderTokens;
287
+ const innerR = trackInnerRadius;
288
+
289
+ let outerR = size / 2;
290
+ if (trackShape === "md3") {
291
+ outerR = Math.min(SliderTokens.trackShapes[trackSize], size / 2);
292
+ } else if (typeof trackShape === "number") {
293
+ outerR = Math.min(trackShape, size / 2);
294
+ }
295
+
296
+ const thumbHalfWidth = thumbWidthDefault / 2;
297
+ const gapWithThumbStr = `${thumbGap + thumbHalfWidth}px`;
298
+
299
+ // ── Inset icon state ─────────────────────────────────────────────────────
300
+ const hasAnyInsetIcon = Boolean(insetIcon || insetIconTrailing);
301
+ const prefersReduced = useReducedMotion() ?? false;
302
+
303
+ // Measure actual track width to compute placement threshold.
304
+ const [trackWidth, setTrackWidth] = React.useState(0);
305
+ React.useLayoutEffect(() => {
306
+ const el = trackRef.current;
307
+ if (!el || !hasAnyInsetIcon) return;
308
+ // Initial measure
309
+ setTrackWidth(isHorizontal ? el.clientWidth : el.clientHeight);
310
+ // Update on resize
311
+ const ro = new ResizeObserver(() => {
312
+ setTrackWidth(isHorizontal ? el.clientWidth : el.clientHeight);
313
+ });
314
+ ro.observe(el);
315
+ return () => ro.disconnect();
316
+ }, [hasAnyInsetIcon, isHorizontal, trackRef]);
317
+
318
+ // Minimum percent required to keep icon on the active segment.
319
+ // = (iconSize + 2*padding + gap + halfThumb) / trackWidth
320
+ const activeIconSize = Math.min(
321
+ SliderTokens.insetIconSizes[trackSize],
322
+ Math.max(4, size - SliderTokens.insetIconPadding * 2),
323
+ );
324
+
325
+ const iconTotalWidth =
326
+ activeIconSize +
327
+ SliderTokens.insetIconPadding * 2 +
328
+ thumbGap +
329
+ thumbHalfWidth;
330
+ const iconThreshold = trackWidth > 0 ? iconTotalWidth / trackWidth : 0.15;
331
+
332
+ // Ref-based hysteresis — avoids a setState re-render cycle (which caused a
333
+ // 1-frame lag flicker). A dead-zone of 4% prevents rapid toggling when the
334
+ // thumb is near the switch boundary during fast drags.
335
+ // Enter active when: remaining space < threshold
336
+ // Exit active when: remaining space > threshold + 4% (dead-zone)
337
+ const HYSTERESIS_GAP = 0.04;
338
+ const trailingActiveRef = React.useRef(1 - percent <= iconThreshold);
339
+ const leadingActiveRef = React.useRef(percent > iconThreshold);
340
+
341
+ // Trailing icon hysteresis
342
+ const trailingPercent = 1 - percent;
343
+ if (trailingActiveRef.current) {
344
+ if (trailingPercent > iconThreshold + HYSTERESIS_GAP) {
345
+ trailingActiveRef.current = false;
346
+ }
347
+ } else {
348
+ if (trailingPercent <= iconThreshold) {
349
+ trailingActiveRef.current = true;
350
+ }
351
+ }
352
+ const trailingOnActive = trailingActiveRef.current;
353
+
354
+ // Leading icon hysteresis
355
+ if (leadingActiveRef.current) {
356
+ if (percent <= iconThreshold - HYSTERESIS_GAP) {
357
+ leadingActiveRef.current = false;
358
+ }
359
+ } else {
360
+ if (percent > iconThreshold) {
361
+ leadingActiveRef.current = true;
362
+ }
363
+ }
364
+ const leadingOnActive = leadingActiveRef.current;
365
+
366
+ // Resolve which icon to show (swap at min)
367
+ const isAtMin = value !== undefined && value <= min;
368
+ const resolvedLeadingIcon =
369
+ isAtMin && insetIconAtMin ? insetIconAtMin : insetIcon;
370
+
371
+ const isAtMax = value !== undefined && value >= max;
372
+ const resolvedTrailingIcon =
373
+ isAtMax && insetIconAtMax ? insetIconAtMax : insetIconTrailing;
374
+
375
+ // ── Colors ───────────────────────────────────────────────────────────────
376
+ const activeColor = disabled
377
+ ? SliderColors.disabledActiveTrack
378
+ : `var(--md-sys-color-${variant})`;
379
+ const inactiveColor = disabled
380
+ ? SliderColors.disabledInactiveTrack
381
+ : `var(--md-sys-color-${variant}-container)`;
382
+ const insetLimit = SliderTokens.thumbGap + SliderTokens.thumbWidthDefault / 2;
383
+ const trackInset = Math.min(size / 2, insetLimit);
384
+
385
+ // Icon positions in CSS (computed after trackInset is available)
386
+ const gapTotal = thumbGap + thumbHalfWidth;
387
+ const thumbCenter = `calc(${trackInset}px + ${percent} * (100% - ${trackInset * 2}px))`;
388
+
389
+ // Leading Icon positions
390
+ const leadingActiveLeft = `${trackInset + SliderTokens.insetIconPadding}px`;
391
+ const leadingInactiveLeft = `calc(${thumbCenter} + ${gapTotal}px + ${SliderTokens.insetIconPadding}px)`;
392
+
393
+ // Trailing Icon positions
394
+ const trailingInactiveLeft = `calc(100% - ${trackInset}px - ${activeIconSize}px - ${SliderTokens.insetIconPadding}px)`;
395
+ const trailingActiveLeft = `calc(${thumbCenter} - ${gapTotal}px - ${activeIconSize}px - ${SliderTokens.insetIconPadding}px)`;
396
+
397
+ // ── Horizontal layout ────────────────────────────────────────────────────
398
+ if (isHorizontal) {
399
+ const cxStr = `calc(${trackInset}px + ${percent} * (100% - ${trackInset * 2}px))`;
400
+
401
+ const segments: React.ReactNode[] = [];
402
+
403
+ if (!isCentered) {
404
+ const leftSegmentWidth = `max(0px, calc(${cxStr} - ${gapWithThumbStr}))`;
405
+ const rightSegmentLeft = `calc(${cxStr} + ${gapWithThumbStr})`;
406
+ const rightSegmentWidth = `max(0px, calc(100% - (${cxStr} + ${gapWithThumbStr})))`;
407
+
408
+ // Leading Segment
409
+ segments.push(
410
+ <div
411
+ key="left"
412
+ aria-hidden="true"
413
+ style={{
414
+ position: "absolute",
415
+ left: 0,
416
+ top: "50%",
417
+ transform: "translateY(-50%)",
418
+ width: leftSegmentWidth,
419
+ height: size,
420
+ backgroundColor: activeColor,
421
+ opacity: disabled ? 0.38 : 1,
422
+ ...getHorizontalRadius(true, innerR, outerR),
423
+ }}
424
+ />,
425
+ );
426
+
427
+ // Trailing Segment
428
+ segments.push(
429
+ <div
430
+ key="right"
431
+ aria-hidden="true"
432
+ style={{
433
+ position: "absolute",
434
+ left: rightSegmentLeft,
435
+ top: "50%",
436
+ transform: "translateY(-50%)",
437
+ width: rightSegmentWidth,
438
+ height: size,
439
+ backgroundColor: inactiveColor,
440
+ opacity: disabled ? 0.38 : 1,
441
+ ...getHorizontalRadius(false, innerR, outerR),
442
+ }}
443
+ />,
444
+ );
445
+ } else {
446
+ // Centered mode
447
+ const halfCenterGap = SliderTokens.thumbGap / 2;
448
+
449
+ if (percent >= 0.5) {
450
+ // Left base segment (Inactive)
451
+ const leftBaseWidth = `max(0px, min(calc(50% - ${halfCenterGap}px), calc(${cxStr} - ${gapWithThumbStr})))`;
452
+ segments.push(
453
+ <div
454
+ key="left-base"
455
+ aria-hidden="true"
456
+ style={{
457
+ position: "absolute",
458
+ left: 0,
459
+ top: "50%",
460
+ transform: "translateY(-50%)",
461
+ width: leftBaseWidth,
462
+ height: size,
463
+ backgroundColor: inactiveColor,
464
+ opacity: disabled ? 0.38 : 1,
465
+ ...getHorizontalRadius(true, innerR, outerR),
466
+ }}
467
+ />,
468
+ );
469
+
470
+ // Center active segment
471
+ const centerActiveLeft = `calc(50% + ${halfCenterGap}px)`;
472
+ const centerActiveWidth = `max(0px, calc(${cxStr} - ${gapWithThumbStr} - (50% + ${halfCenterGap}px)))`;
473
+ segments.push(
474
+ <div
475
+ key="center-active"
476
+ aria-hidden="true"
477
+ style={{
478
+ position: "absolute",
479
+ left: centerActiveLeft,
480
+ top: "50%",
481
+ transform: "translateY(-50%)",
482
+ width: centerActiveWidth,
483
+ height: size,
484
+ backgroundColor: activeColor,
485
+ opacity: disabled ? 0.38 : 1,
486
+ ...allInnerRadius(innerR),
487
+ }}
488
+ />,
489
+ );
490
+
491
+ // Right base segment (Inactive)
492
+ const rightBaseLeft = `calc(${cxStr} + ${gapWithThumbStr})`;
493
+ const rightBaseWidth = `max(0px, calc(100% - (${cxStr} + ${gapWithThumbStr})))`;
494
+ segments.push(
495
+ <div
496
+ key="right-base"
497
+ aria-hidden="true"
498
+ style={{
499
+ position: "absolute",
500
+ left: rightBaseLeft,
501
+ top: "50%",
502
+ transform: "translateY(-50%)",
503
+ width: rightBaseWidth,
504
+ height: size,
505
+ backgroundColor: inactiveColor,
506
+ opacity: disabled ? 0.38 : 1,
507
+ ...getHorizontalRadius(false, innerR, outerR),
508
+ }}
509
+ />,
510
+ );
511
+ } else {
512
+ // Left base segment (Inactive)
513
+ const leftBaseWidth = `max(0px, calc(${cxStr} - ${gapWithThumbStr}))`;
514
+ segments.push(
515
+ <div
516
+ key="left-base"
517
+ aria-hidden="true"
518
+ style={{
519
+ position: "absolute",
520
+ left: 0,
521
+ top: "50%",
522
+ transform: "translateY(-50%)",
523
+ width: leftBaseWidth,
524
+ height: size,
525
+ backgroundColor: inactiveColor,
526
+ opacity: disabled ? 0.38 : 1,
527
+ ...getHorizontalRadius(true, innerR, outerR),
528
+ }}
529
+ />,
530
+ );
531
+
532
+ // Center active segment
533
+ const centerActiveLeft = `calc(${cxStr} + ${gapWithThumbStr})`;
534
+ const centerActiveWidth = `max(0px, calc(50% - ${halfCenterGap}px - (${cxStr} + ${gapWithThumbStr})))`;
535
+ segments.push(
536
+ <div
537
+ key="center-active"
538
+ aria-hidden="true"
539
+ style={{
540
+ position: "absolute",
541
+ left: centerActiveLeft,
542
+ top: "50%",
543
+ transform: "translateY(-50%)",
544
+ width: centerActiveWidth,
545
+ height: size,
546
+ backgroundColor: activeColor,
547
+ opacity: disabled ? 0.38 : 1,
548
+ ...allInnerRadius(innerR),
549
+ }}
550
+ />,
551
+ );
552
+
553
+ // Right base segment (Inactive)
554
+ const rightBaseLeft = `max(calc(50% + ${halfCenterGap}px), calc(${cxStr} + ${gapWithThumbStr}))`;
555
+ const rightBaseWidth = `max(0px, calc(100% - max(calc(50% + ${halfCenterGap}px), calc(${cxStr} + ${gapWithThumbStr}))))`;
556
+ segments.push(
557
+ <div
558
+ key="right-base"
559
+ aria-hidden="true"
560
+ style={{
561
+ position: "absolute",
562
+ left: rightBaseLeft,
563
+ top: "50%",
564
+ transform: "translateY(-50%)",
565
+ width: rightBaseWidth,
566
+ height: size,
567
+ backgroundColor: inactiveColor,
568
+ opacity: disabled ? 0.38 : 1,
569
+ ...getHorizontalRadius(false, innerR, outerR),
570
+ }}
571
+ />,
572
+ );
573
+ }
574
+ }
575
+
576
+ return (
577
+ <div
578
+ ref={trackRef}
579
+ className={cn(
580
+ "relative w-full",
581
+ disabled ? "cursor-not-allowed" : "cursor-pointer",
582
+ )}
583
+ style={{ height: thumbHeight }}
584
+ onPointerDown={onTrackPointerDown}
585
+ aria-hidden="true"
586
+ >
587
+ {segments}
588
+ {ticks.length > 0 && (
589
+ <Ticks
590
+ ticks={ticks}
591
+ min={min}
592
+ max={max}
593
+ percent={percent}
594
+ orientation={orientation}
595
+ disabled={disabled}
596
+ variant={variant}
597
+ isCentered={isCentered}
598
+ trackInset={trackInset}
599
+ />
600
+ )}
601
+ {/* Inset Icons (Leading & Trailing) */}
602
+ {/* Leading icon: key changes on min-swap OR active↔inactive swap → fade transition */}
603
+ <AnimatePresence mode="wait">
604
+ {resolvedLeadingIcon && (
605
+ <InsetIcon
606
+ key={
607
+ isAtMin
608
+ ? "lead-min"
609
+ : leadingOnActive
610
+ ? "lead-active"
611
+ : "lead-inactive"
612
+ }
613
+ icon={resolvedLeadingIcon}
614
+ isOnActiveSegment={leadingOnActive}
615
+ position={
616
+ leadingOnActive ? leadingActiveLeft : leadingInactiveLeft
617
+ }
618
+ orientation={orientation}
619
+ trackSize={size}
620
+ trackSizeToken={trackSize}
621
+ disabled={disabled}
622
+ variant={variant}
623
+ prefersReduced={prefersReduced}
624
+ />
625
+ )}
626
+ </AnimatePresence>
627
+
628
+ {/* Trailing icon: key changes on max-swap OR active↔inactive swap → fade transition */}
629
+ <AnimatePresence mode="wait">
630
+ {resolvedTrailingIcon && (
631
+ <InsetIcon
632
+ key={
633
+ isAtMax
634
+ ? "trail-max"
635
+ : trailingOnActive
636
+ ? "trail-active"
637
+ : "trail-inactive"
638
+ }
639
+ icon={resolvedTrailingIcon}
640
+ isOnActiveSegment={trailingOnActive}
641
+ position={
642
+ trailingOnActive ? trailingActiveLeft : trailingInactiveLeft
643
+ }
644
+ orientation={orientation}
645
+ trackSize={size}
646
+ trackSizeToken={trackSize}
647
+ disabled={disabled}
648
+ variant={variant}
649
+ prefersReduced={prefersReduced}
650
+ />
651
+ )}
652
+ </AnimatePresence>
653
+ </div>
654
+ );
655
+ }
656
+
657
+ // ── Vertical layout ───────────────────────────────────────────────────────
658
+ const cyStr = `calc(${trackInset}px + ${percent} * (100% - ${trackInset * 2}px))`;
659
+
660
+ const segments: React.ReactNode[] = [];
661
+
662
+ if (!isCentered) {
663
+ const bottomSegmentHeight = `max(0px, calc(${cyStr} - ${gapWithThumbStr}))`;
664
+ const topSegmentBottom = `calc(${cyStr} + ${gapWithThumbStr})`;
665
+ const topSegmentHeight = `max(0px, calc(100% - (${cyStr} + ${gapWithThumbStr})))`;
666
+
667
+ // Bottom segment
668
+ segments.push(
669
+ <div
670
+ key="bottom"
671
+ aria-hidden="true"
672
+ style={{
673
+ position: "absolute",
674
+ bottom: 0,
675
+ left: "50%",
676
+ transform: "translateX(-50%)",
677
+ height: bottomSegmentHeight,
678
+ width: size,
679
+ backgroundColor: activeColor,
680
+ opacity: disabled ? 0.38 : 1,
681
+ ...getVerticalRadius(true, innerR, outerR),
682
+ }}
683
+ />,
684
+ );
685
+
686
+ // Top segment
687
+ segments.push(
688
+ <div
689
+ key="top"
690
+ aria-hidden="true"
691
+ style={{
692
+ position: "absolute",
693
+ bottom: topSegmentBottom,
694
+ left: "50%",
695
+ transform: "translateX(-50%)",
696
+ height: topSegmentHeight,
697
+ width: size,
698
+ backgroundColor: inactiveColor,
699
+ opacity: disabled ? 0.38 : 1,
700
+ ...getVerticalRadius(false, innerR, outerR),
701
+ }}
702
+ />,
703
+ );
704
+ } else {
705
+ // Centered mode (Vertical is inverted: bottom=0%, top=100%)
706
+ const halfCenterGap = SliderTokens.thumbGap / 2;
707
+
708
+ if (percent >= 0.5) {
709
+ // Bottom base segment (Inactive)
710
+ const bottomBaseHeight = `max(0px, min(calc(50% - ${halfCenterGap}px), calc(${cyStr} - ${gapWithThumbStr})))`;
711
+ segments.push(
712
+ <div
713
+ key="bottom-base"
714
+ aria-hidden="true"
715
+ style={{
716
+ position: "absolute",
717
+ bottom: 0,
718
+ left: "50%",
719
+ transform: "translateX(-50%)",
720
+ height: bottomBaseHeight,
721
+ width: size,
722
+ backgroundColor: inactiveColor,
723
+ opacity: disabled ? 0.38 : 1,
724
+ ...getVerticalRadius(true, innerR, outerR),
725
+ }}
726
+ />,
727
+ );
728
+
729
+ // Center active segment
730
+ const centerActiveBottom = `calc(50% + ${halfCenterGap}px)`;
731
+ const centerActiveHeight = `max(0px, calc(${cyStr} - ${gapWithThumbStr} - (50% + ${halfCenterGap}px)))`;
732
+ segments.push(
733
+ <div
734
+ key="center-active"
735
+ aria-hidden="true"
736
+ style={{
737
+ position: "absolute",
738
+ bottom: centerActiveBottom,
739
+ left: "50%",
740
+ transform: "translateX(-50%)",
741
+ height: centerActiveHeight,
742
+ width: size,
743
+ backgroundColor: activeColor,
744
+ opacity: disabled ? 0.38 : 1,
745
+ ...allInnerRadius(innerR),
746
+ }}
747
+ />,
748
+ );
749
+
750
+ // Top base segment (Inactive)
751
+ const topBaseBottom = `calc(${cyStr} + ${gapWithThumbStr})`;
752
+ const topBaseHeight = `max(0px, calc(100% - (${cyStr} + ${gapWithThumbStr})))`;
753
+ segments.push(
754
+ <div
755
+ key="top-base"
756
+ aria-hidden="true"
757
+ style={{
758
+ position: "absolute",
759
+ bottom: topBaseBottom,
760
+ left: "50%",
761
+ transform: "translateX(-50%)",
762
+ height: topBaseHeight,
763
+ width: size,
764
+ backgroundColor: inactiveColor,
765
+ opacity: disabled ? 0.38 : 1,
766
+ ...getVerticalRadius(false, innerR, outerR),
767
+ }}
768
+ />,
769
+ );
770
+ } else {
771
+ // Bottom base segment (Inactive)
772
+ const bottomBaseHeight = `max(0px, calc(${cyStr} - ${gapWithThumbStr}))`;
773
+ segments.push(
774
+ <div
775
+ key="bottom-base"
776
+ aria-hidden="true"
777
+ style={{
778
+ position: "absolute",
779
+ bottom: 0,
780
+ left: "50%",
781
+ transform: "translateX(-50%)",
782
+ height: bottomBaseHeight,
783
+ width: size,
784
+ backgroundColor: inactiveColor,
785
+ opacity: disabled ? 0.38 : 1,
786
+ ...getVerticalRadius(true, innerR, outerR),
787
+ }}
788
+ />,
789
+ );
790
+
791
+ // Center active segment
792
+ const centerActiveBottom = `calc(${cyStr} + ${gapWithThumbStr})`;
793
+ const centerActiveHeight = `max(0px, calc(50% - ${halfCenterGap}px - (${cyStr} + ${gapWithThumbStr})))`;
794
+ segments.push(
795
+ <div
796
+ key="center-active"
797
+ aria-hidden="true"
798
+ style={{
799
+ position: "absolute",
800
+ bottom: centerActiveBottom,
801
+ left: "50%",
802
+ transform: "translateX(-50%)",
803
+ height: centerActiveHeight,
804
+ width: size,
805
+ backgroundColor: activeColor,
806
+ opacity: disabled ? 0.38 : 1,
807
+ ...allInnerRadius(innerR),
808
+ }}
809
+ />,
810
+ );
811
+
812
+ // Top base segment (Inactive)
813
+ const topBaseBottom = `max(calc(50% + ${halfCenterGap}px), calc(${cyStr} + ${gapWithThumbStr}))`;
814
+ const topBaseHeight = `max(0px, calc(100% - max(calc(50% + ${halfCenterGap}px), calc(${cyStr} + ${gapWithThumbStr}))))`;
815
+ segments.push(
816
+ <div
817
+ key="top-base"
818
+ aria-hidden="true"
819
+ style={{
820
+ position: "absolute",
821
+ bottom: topBaseBottom,
822
+ left: "50%",
823
+ transform: "translateX(-50%)",
824
+ height: topBaseHeight,
825
+ width: size,
826
+ backgroundColor: inactiveColor,
827
+ opacity: disabled ? 0.38 : 1,
828
+ ...getVerticalRadius(false, innerR, outerR),
829
+ }}
830
+ />,
831
+ );
832
+ }
833
+ }
834
+
835
+ return (
836
+ <div
837
+ ref={trackRef}
838
+ className={cn(
839
+ "relative h-full",
840
+ disabled ? "cursor-not-allowed" : "cursor-pointer",
841
+ )}
842
+ style={{ width: thumbHeight }}
843
+ onPointerDown={onTrackPointerDown}
844
+ aria-hidden="true"
845
+ >
846
+ {segments}
847
+ {ticks.length > 0 && (
848
+ <Ticks
849
+ ticks={ticks}
850
+ min={min}
851
+ max={max}
852
+ percent={percent}
853
+ orientation={orientation}
854
+ disabled={disabled}
855
+ variant={variant}
856
+ isCentered={isCentered}
857
+ trackInset={trackInset}
858
+ />
859
+ )}
860
+ {/* Inset Icons (Leading & Trailing) */}
861
+ {/* Leading icon: key changes on min-swap OR active↔inactive swap → fade transition */}
862
+ <AnimatePresence mode="wait">
863
+ {resolvedLeadingIcon && (
864
+ <InsetIcon
865
+ key={
866
+ isAtMin
867
+ ? "lead-min"
868
+ : leadingOnActive
869
+ ? "lead-active"
870
+ : "lead-inactive"
871
+ }
872
+ icon={resolvedLeadingIcon}
873
+ isOnActiveSegment={leadingOnActive}
874
+ position={leadingOnActive ? leadingActiveLeft : leadingInactiveLeft}
875
+ orientation={orientation}
876
+ trackSize={size}
877
+ trackSizeToken={trackSize}
878
+ disabled={disabled}
879
+ variant={variant}
880
+ prefersReduced={prefersReduced}
881
+ />
882
+ )}
883
+ </AnimatePresence>
884
+
885
+ {/* Trailing icon: key changes on max-swap OR active↔inactive swap → fade transition */}
886
+ <AnimatePresence mode="wait">
887
+ {resolvedTrailingIcon && (
888
+ <InsetIcon
889
+ key={
890
+ isAtMax
891
+ ? "trail-max"
892
+ : trailingOnActive
893
+ ? "trail-active"
894
+ : "trail-inactive"
895
+ }
896
+ icon={resolvedTrailingIcon}
897
+ isOnActiveSegment={trailingOnActive}
898
+ position={
899
+ trailingOnActive ? trailingActiveLeft : trailingInactiveLeft
900
+ }
901
+ orientation={orientation}
902
+ trackSize={size}
903
+ trackSizeToken={trackSize}
904
+ disabled={disabled}
905
+ variant={variant}
906
+ prefersReduced={prefersReduced}
907
+ />
908
+ )}
909
+ </AnimatePresence>
910
+ </div>
911
+ );
912
+ });